chore: initialize @limitless/ui git repo + add AppShell

This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.

Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.

Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
  sidebar, footer) backed by gsc-shell-api. Public surface:
    AppShell, ShellProvider, useShell, ShellConfig types
  Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
  getToken; AppShell composes the existing PageShell / Navbar /
  Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
  `tsc` command was a no-op because tsconfig.json sets noEmit:true
  for typecheck speed. Existing dist/ only existed because of an
  earlier emit; clean rebuilds were silently broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-10 09:42:57 +02:00
commit cf068ce4ec
115 changed files with 36542 additions and 0 deletions

213
src/components/Carousel.tsx Normal file
View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
export interface CarouselItem {
/** Image source */
src?: string;
/** Alt text for image */
alt?: string;
/** Caption title */
title?: string;
/** Caption description */
description?: string;
/** Custom content instead of image */
content?: React.ReactNode;
}
export interface CarouselProps {
/** Carousel items */
items: CarouselItem[];
/** Show indicators */
indicators?: boolean;
/** Show controls (prev/next) */
controls?: boolean;
/** Auto play */
autoPlay?: boolean;
/** Interval in ms */
interval?: number;
/** Pause on hover */
pauseOnHover?: boolean;
/** Enable keyboard navigation */
keyboard?: boolean;
/** Enable touch/swipe */
touch?: boolean;
/** Crossfade animation */
fade?: boolean;
/** Dark variant */
dark?: boolean;
/** Active index (controlled) */
activeIndex?: number;
/** Callback when slide changes */
onSlide?: (index: number) => void;
/** Additional CSS classes */
className?: string;
}
export const Carousel: React.FC<CarouselProps> = ({
items,
indicators = true,
controls = true,
autoPlay = false,
interval = 5000,
pauseOnHover = true,
keyboard = true,
touch = true,
fade = false,
dark = false,
activeIndex: controlledIndex,
onSlide,
className = '',
}) => {
const [currentIndex, setCurrentIndex] = useState(controlledIndex ?? 0);
const [isPaused, setIsPaused] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);
const carouselRef = useRef<HTMLDivElement>(null);
const activeIndex = controlledIndex ?? currentIndex;
const goToSlide = useCallback((index: number) => {
const newIndex = index < 0 ? items.length - 1 : index >= items.length ? 0 : index;
if (controlledIndex === undefined) {
setCurrentIndex(newIndex);
}
onSlide?.(newIndex);
}, [items.length, controlledIndex, onSlide]);
const goToPrev = useCallback(() => {
goToSlide(activeIndex - 1);
}, [activeIndex, goToSlide]);
const goToNext = useCallback(() => {
goToSlide(activeIndex + 1);
}, [activeIndex, goToSlide]);
// Auto play
useEffect(() => {
if (!autoPlay || isPaused) return;
const timer = setInterval(() => {
goToNext();
}, interval);
return () => clearInterval(timer);
}, [autoPlay, isPaused, interval, goToNext]);
// Keyboard navigation
useEffect(() => {
if (!keyboard) return;
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
goToPrev();
} else if (e.key === 'ArrowRight') {
goToNext();
}
};
const carousel = carouselRef.current;
carousel?.addEventListener('keydown', handleKeydown);
return () => carousel?.removeEventListener('keydown', handleKeydown);
}, [keyboard, goToPrev, goToNext]);
// Touch handling
const handleTouchStart = (e: React.TouchEvent) => {
if (!touch) return;
setTouchStart(e.touches[0].clientX);
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (!touch || touchStart === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStart - touchEnd;
if (Math.abs(diff) > 50) {
if (diff > 0) {
goToNext();
} else {
goToPrev();
}
}
setTouchStart(null);
};
const classes = [
'll-carousel',
fade && 'll-carousel-fade',
dark && 'll-carousel-dark',
className,
].filter(Boolean).join(' ');
return (
<div
ref={carouselRef}
className={classes}
tabIndex={keyboard ? 0 : undefined}
onMouseEnter={() => pauseOnHover && setIsPaused(true)}
onMouseLeave={() => pauseOnHover && setIsPaused(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Indicators */}
{indicators && items.length > 1 && (
<div className="ll-carousel-indicators">
{items.map((_, index) => (
<button
key={index}
type="button"
className={`ll-carousel-indicator ${index === activeIndex ? 'active' : ''}`}
onClick={() => goToSlide(index)}
aria-current={index === activeIndex}
aria-label={`Slide ${index + 1}`}
/>
))}
</div>
)}
{/* Slides */}
<div className="ll-carousel-inner">
{items.map((item, index) => (
<div
key={index}
className={`ll-carousel-item ${index === activeIndex ? 'active' : ''}`}
>
{item.content || (
<img
src={item.src}
alt={item.alt || ''}
className="ll-carousel-image"
/>
)}
{(item.title || item.description) && (
<div className="ll-carousel-caption">
{item.title && <h5>{item.title}</h5>}
{item.description && <p>{item.description}</p>}
</div>
)}
</div>
))}
</div>
{/* Controls */}
{controls && items.length > 1 && (
<>
<button
type="button"
className="ll-carousel-control ll-carousel-control-prev"
onClick={goToPrev}
aria-label="Previous"
>
<span className="ll-carousel-control-icon ll-carousel-control-prev-icon" aria-hidden="true" />
</button>
<button
type="button"
className="ll-carousel-control ll-carousel-control-next"
onClick={goToNext}
aria-label="Next"
>
<span className="ll-carousel-control-icon ll-carousel-control-next-icon" aria-hidden="true" />
</button>
</>
)}
</div>
);
};