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:
213
src/components/Carousel.tsx
Normal file
213
src/components/Carousel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user