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

518
src/components/Slider.tsx Normal file
View File

@@ -0,0 +1,518 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
export interface SliderProps {
/** Current value (controlled) */
value?: number;
/** Default value */
defaultValue?: number;
/** Minimum value */
min?: number;
/** Maximum value */
max?: number;
/** Step increment */
step?: number;
/** Callback when value changes */
onChange?: (value: number) => void;
/** Callback when sliding ends */
onChangeEnd?: (value: number) => void;
/** Show current value tooltip */
showTooltip?: boolean | 'always' | 'hover' | 'drag';
/** Format tooltip value */
formatTooltip?: (value: number) => string;
/** Show tick marks */
showTicks?: boolean;
/** Custom tick values */
ticks?: number[];
/** Show tick labels */
showTickLabels?: boolean;
/** Format tick labels */
formatTickLabel?: (value: number) => string;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Disabled state */
disabled?: boolean;
/** Orientation */
orientation?: 'horizontal' | 'vertical';
/** Additional CSS classes */
className?: string;
}
export const Slider: React.FC<SliderProps> = ({
value: controlledValue,
defaultValue = 0,
min = 0,
max = 100,
step = 1,
onChange,
onChangeEnd,
showTooltip = 'drag',
formatTooltip = (v) => String(v),
showTicks = false,
ticks,
showTickLabels = false,
formatTickLabel = (v) => String(v),
variant = 'primary',
size = 'md',
disabled = false,
orientation = 'horizontal',
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [isDragging, setIsDragging] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const value = controlledValue ?? internalValue;
// Calculate percentage
const percentage = ((value - min) / (max - min)) * 100;
// Get value from position
const getValueFromPosition = useCallback((clientX: number, clientY: number): number => {
if (!trackRef.current) return value;
const rect = trackRef.current.getBoundingClientRect();
let ratio: number;
if (orientation === 'horizontal') {
ratio = (clientX - rect.left) / rect.width;
} else {
ratio = 1 - (clientY - rect.top) / rect.height;
}
ratio = Math.max(0, Math.min(1, ratio));
let newValue = min + ratio * (max - min);
// Snap to step
newValue = Math.round(newValue / step) * step;
newValue = Math.max(min, Math.min(max, newValue));
return newValue;
}, [min, max, step, value, orientation]);
// Handle mouse/touch move
const handleMove = useCallback((clientX: number, clientY: number) => {
if (disabled) return;
const newValue = getValueFromPosition(clientX, clientY);
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onChange?.(newValue);
}, [disabled, getValueFromPosition, controlledValue, onChange]);
// Handle mouse down
const handleMouseDown = (e: React.MouseEvent) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
handleMove(e.clientX, e.clientY);
};
// Handle touch start
const handleTouchStart = (e: React.TouchEvent) => {
if (disabled) return;
setIsDragging(true);
const touch = e.touches[0];
handleMove(touch.clientX, touch.clientY);
};
// Handle mouse/touch events
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX, e.clientY);
};
const handleTouchMove = (e: TouchEvent) => {
const touch = e.touches[0];
handleMove(touch.clientX, touch.clientY);
};
const handleEnd = () => {
setIsDragging(false);
onChangeEnd?.(value);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleEnd);
};
}, [isDragging, handleMove, onChangeEnd, value]);
// Handle keyboard
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
let newValue = value;
const largeStep = step * 10;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(max, value + step);
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(min, value - step);
break;
case 'PageUp':
newValue = Math.min(max, value + largeStep);
break;
case 'PageDown':
newValue = Math.max(min, value - largeStep);
break;
case 'Home':
newValue = min;
break;
case 'End':
newValue = max;
break;
default:
return;
}
e.preventDefault();
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onChange?.(newValue);
onChangeEnd?.(newValue);
};
// Generate tick marks
const tickMarks = ticks || (showTicks ?
Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) :
[]
);
// Determine tooltip visibility
const shouldShowTooltip =
showTooltip === 'always' ||
(showTooltip === 'hover' && (isHovering || isDragging)) ||
(showTooltip === 'drag' && isDragging) ||
(showTooltip === true && (isHovering || isDragging));
const classes = [
'll-slider',
`ll-slider-${orientation}`,
`ll-slider-${variant}`,
`ll-slider-${size}`,
disabled && 'll-slider-disabled',
className,
].filter(Boolean).join(' ');
const trackStyle = orientation === 'horizontal'
? { width: `${percentage}%` }
: { height: `${percentage}%` };
const thumbStyle = orientation === 'horizontal'
? { left: `${percentage}%` }
: { bottom: `${percentage}%` };
return (
<div className={classes}>
<div
ref={trackRef}
className="ll-slider-track-container"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="ll-slider-track">
<div className="ll-slider-track-fill" style={trackStyle} />
</div>
{/* Tick marks */}
{tickMarks.length > 0 && (
<div className="ll-slider-ticks">
{tickMarks.map((tick) => {
const tickPercent = ((tick - min) / (max - min)) * 100;
const tickStyle = orientation === 'horizontal'
? { left: `${tickPercent}%` }
: { bottom: `${tickPercent}%` };
return (
<div key={tick} className="ll-slider-tick" style={tickStyle}>
<div className="ll-slider-tick-mark" />
{showTickLabels && (
<div className="ll-slider-tick-label">
{formatTickLabel(tick)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Thumb */}
<div
className="ll-slider-thumb"
style={thumbStyle}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-orientation={orientation}
aria-disabled={disabled}
onKeyDown={handleKeyDown}
>
{shouldShowTooltip && (
<div className="ll-slider-tooltip">
{formatTooltip(value)}
</div>
)}
</div>
</div>
</div>
);
};
// Range Slider
export interface RangeSliderProps extends Omit<SliderProps, 'value' | 'defaultValue' | 'onChange' | 'onChangeEnd'> {
/** Current values [min, max] (controlled) */
value?: [number, number];
/** Default values [min, max] */
defaultValue?: [number, number];
/** Callback when values change */
onChange?: (value: [number, number]) => void;
/** Callback when sliding ends */
onChangeEnd?: (value: [number, number]) => void;
/** Minimum gap between handles */
minGap?: number;
}
export const RangeSlider: React.FC<RangeSliderProps> = ({
value: controlledValue,
defaultValue = [25, 75],
min = 0,
max = 100,
step = 1,
onChange,
onChangeEnd,
minGap = 0,
showTooltip = 'drag',
formatTooltip = (v) => String(v),
showTicks = false,
ticks,
showTickLabels = false,
formatTickLabel = (v) => String(v),
variant = 'primary',
size = 'md',
disabled = false,
orientation = 'horizontal',
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [activeHandle, setActiveHandle] = useState<0 | 1 | null>(null);
const [isHovering, setIsHovering] = useState<0 | 1 | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const value = controlledValue ?? internalValue;
// Calculate percentages
const lowPercent = ((value[0] - min) / (max - min)) * 100;
const highPercent = ((value[1] - min) / (max - min)) * 100;
// Get value from position
const getValueFromPosition = useCallback((clientX: number, clientY: number): number => {
if (!trackRef.current) return 0;
const rect = trackRef.current.getBoundingClientRect();
let ratio: number;
if (orientation === 'horizontal') {
ratio = (clientX - rect.left) / rect.width;
} else {
ratio = 1 - (clientY - rect.top) / rect.height;
}
ratio = Math.max(0, Math.min(1, ratio));
let newValue = min + ratio * (max - min);
newValue = Math.round(newValue / step) * step;
return newValue;
}, [min, max, step, orientation]);
// Handle move
const handleMove = useCallback((clientX: number, clientY: number) => {
if (disabled || activeHandle === null) return;
const newValue = getValueFromPosition(clientX, clientY);
let newValues: [number, number] = [...value];
if (activeHandle === 0) {
newValues[0] = Math.min(newValue, value[1] - minGap);
newValues[0] = Math.max(min, newValues[0]);
} else {
newValues[1] = Math.max(newValue, value[0] + minGap);
newValues[1] = Math.min(max, newValues[1]);
}
if (controlledValue === undefined) {
setInternalValue(newValues);
}
onChange?.(newValues);
}, [disabled, activeHandle, getValueFromPosition, value, minGap, min, max, controlledValue, onChange]);
// Handle track click
const handleTrackClick = (e: React.MouseEvent) => {
if (disabled) return;
const clickValue = getValueFromPosition(e.clientX, e.clientY);
const distToLow = Math.abs(clickValue - value[0]);
const distToHigh = Math.abs(clickValue - value[1]);
const handle = distToLow <= distToHigh ? 0 : 1;
setActiveHandle(handle);
let newValues: [number, number] = [...value];
if (handle === 0) {
newValues[0] = Math.min(clickValue, value[1] - minGap);
newValues[0] = Math.max(min, newValues[0]);
} else {
newValues[1] = Math.max(clickValue, value[0] + minGap);
newValues[1] = Math.min(max, newValues[1]);
}
if (controlledValue === undefined) {
setInternalValue(newValues);
}
onChange?.(newValues);
};
// Handle mouse/touch events
useEffect(() => {
if (activeHandle === null) return;
const handleMouseMove = (e: MouseEvent) => handleMove(e.clientX, e.clientY);
const handleTouchMove = (e: TouchEvent) => handleMove(e.touches[0].clientX, e.touches[0].clientY);
const handleEnd = () => {
setActiveHandle(null);
onChangeEnd?.(value);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleEnd);
};
}, [activeHandle, handleMove, onChangeEnd, value]);
// Tick marks
const tickMarks = ticks || (showTicks ?
Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) :
[]
);
const classes = [
'll-slider',
'll-range-slider',
`ll-slider-${orientation}`,
`ll-slider-${variant}`,
`ll-slider-${size}`,
disabled && 'll-slider-disabled',
className,
].filter(Boolean).join(' ');
const fillStyle = orientation === 'horizontal'
? { left: `${lowPercent}%`, width: `${highPercent - lowPercent}%` }
: { bottom: `${lowPercent}%`, height: `${highPercent - lowPercent}%` };
const thumbStyles = [
orientation === 'horizontal' ? { left: `${lowPercent}%` } : { bottom: `${lowPercent}%` },
orientation === 'horizontal' ? { left: `${highPercent}%` } : { bottom: `${highPercent}%` },
];
const shouldShowTooltip = (handle: 0 | 1) =>
showTooltip === 'always' ||
(showTooltip === 'hover' && (isHovering === handle || activeHandle === handle)) ||
(showTooltip === 'drag' && activeHandle === handle) ||
(showTooltip === true && (isHovering === handle || activeHandle === handle));
return (
<div className={classes}>
<div
ref={trackRef}
className="ll-slider-track-container"
onClick={handleTrackClick}
>
<div className="ll-slider-track">
<div className="ll-slider-track-fill" style={fillStyle} />
</div>
{/* Tick marks */}
{tickMarks.length > 0 && (
<div className="ll-slider-ticks">
{tickMarks.map((tick) => {
const tickPercent = ((tick - min) / (max - min)) * 100;
const tickStyle = orientation === 'horizontal'
? { left: `${tickPercent}%` }
: { bottom: `${tickPercent}%` };
return (
<div key={tick} className="ll-slider-tick" style={tickStyle}>
<div className="ll-slider-tick-mark" />
{showTickLabels && (
<div className="ll-slider-tick-label">
{formatTickLabel(tick)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Thumbs */}
{[0, 1].map((handle) => (
<div
key={handle}
className={`ll-slider-thumb ${activeHandle === handle ? 'active' : ''}`}
style={thumbStyles[handle]}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-valuemin={handle === 0 ? min : value[0]}
aria-valuemax={handle === 0 ? value[1] : max}
aria-valuenow={value[handle as 0 | 1]}
aria-orientation={orientation}
aria-disabled={disabled}
onMouseDown={(e) => { e.stopPropagation(); setActiveHandle(handle as 0 | 1); }}
onTouchStart={() => setActiveHandle(handle as 0 | 1)}
onMouseEnter={() => setIsHovering(handle as 0 | 1)}
onMouseLeave={() => setIsHovering(null)}
>
{shouldShowTooltip(handle as 0 | 1) && (
<div className="ll-slider-tooltip">
{formatTooltip(value[handle as 0 | 1])}
</div>
)}
</div>
))}
</div>
</div>
);
};