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

231
src/components/Rating.tsx Normal file
View File

@@ -0,0 +1,231 @@
import React, { useState, useCallback } from 'react';
export interface RatingProps {
/** Current value (controlled) */
value?: number;
/** Default value */
defaultValue?: number;
/** Maximum rating value */
max?: number;
/** Callback when value changes */
onChange?: (value: number) => void;
/** Allow half values */
allowHalf?: boolean;
/** Allow clearing (clicking same value) */
allowClear?: boolean;
/** Disabled state */
disabled?: boolean;
/** Read-only state */
readOnly?: boolean;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Color */
color?: string;
/** Empty color */
emptyColor?: string;
/** Custom icon */
icon?: React.ReactNode;
/** Custom empty icon */
emptyIcon?: React.ReactNode;
/** Custom half icon */
halfIcon?: React.ReactNode;
/** Character to display (alternative to icon) */
character?: React.ReactNode;
/** Show value text */
showValue?: boolean;
/** Custom value formatter */
formatValue?: (value: number) => string;
/** Tooltips for each value */
tooltips?: string[];
/** Additional CSS classes */
className?: string;
}
const StarIcon: React.FC<{ filled?: boolean; half?: boolean }> = ({ filled, half }) => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill={filled || half ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
{half ? (
<>
<defs>
<linearGradient id="half-star">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
<path fill="url(#half-star)" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</>
) : (
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
)}
</svg>
);
export const Rating: React.FC<RatingProps> = ({
value: controlledValue,
defaultValue = 0,
max = 5,
onChange,
allowHalf = false,
allowClear = true,
disabled = false,
readOnly = false,
size = 'md',
color = '#ffc107',
emptyColor = '#e0e0e0',
icon,
emptyIcon,
halfIcon,
character,
showValue = false,
formatValue = (v) => v.toFixed(allowHalf ? 1 : 0),
tooltips,
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [hoverValue, setHoverValue] = useState<number | null>(null);
const value = controlledValue ?? internalValue;
const displayValue = hoverValue ?? value;
const handleClick = useCallback((newValue: number) => {
if (disabled || readOnly) return;
// Allow clear if clicking same value
const finalValue = allowClear && newValue === value ? 0 : newValue;
if (controlledValue === undefined) {
setInternalValue(finalValue);
}
onChange?.(finalValue);
}, [disabled, readOnly, allowClear, value, controlledValue, onChange]);
const handleMouseMove = useCallback((e: React.MouseEvent, index: number) => {
if (disabled || readOnly) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const isHalf = allowHalf && x < rect.width / 2;
setHoverValue(isHalf ? index + 0.5 : index + 1);
}, [disabled, readOnly, allowHalf]);
const handleMouseLeave = () => {
setHoverValue(null);
};
const renderStar = (index: number) => {
const starValue = index + 1;
const isFilled = displayValue >= starValue;
const isHalf = allowHalf && displayValue >= index + 0.5 && displayValue < starValue;
const isActive = !disabled && !readOnly;
let starIcon: React.ReactNode;
if (character) {
starIcon = character;
} else if (isHalf && halfIcon) {
starIcon = halfIcon;
} else if (isFilled && icon) {
starIcon = icon;
} else if (!isFilled && emptyIcon) {
starIcon = emptyIcon;
} else {
starIcon = <StarIcon filled={isFilled} half={isHalf} />;
}
const tooltip = tooltips?.[index];
return (
<span
key={index}
className={`ll-rating-star ${isFilled || isHalf ? 'll-rating-star-filled' : 'll-rating-star-empty'}`}
style={{
color: isFilled || isHalf ? color : emptyColor,
cursor: isActive ? 'pointer' : 'default',
}}
onClick={() => handleClick(allowHalf && isHalf ? index + 0.5 : starValue)}
onMouseMove={(e) => handleMouseMove(e, index)}
title={tooltip}
role="radio"
aria-checked={value === starValue}
>
{starIcon}
</span>
);
};
const classes = [
'll-rating',
`ll-rating-${size}`,
disabled && 'll-rating-disabled',
readOnly && 'll-rating-readonly',
className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
onMouseLeave={handleMouseLeave}
role="radiogroup"
aria-label="Rating"
>
<div className="ll-rating-stars">
{Array.from({ length: max }, (_, i) => renderStar(i))}
</div>
{showValue && (
<span className="ll-rating-value">{formatValue(displayValue)}</span>
)}
</div>
);
};
// Heart Rating variant
export const HeartRating: React.FC<Omit<RatingProps, 'icon' | 'emptyIcon'>> = (props) => {
const HeartIcon = ({ filled }: { filled: boolean }) => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
return (
<Rating
{...props}
color={props.color || '#e91e63'}
icon={<HeartIcon filled />}
emptyIcon={<HeartIcon filled={false} />}
/>
);
};
// Emoji Rating variant
export interface EmojiRatingProps extends Omit<RatingProps, 'icon' | 'emptyIcon' | 'character' | 'max'> {
emojis?: string[];
}
export const EmojiRating: React.FC<EmojiRatingProps> = ({
emojis = ['😞', '😕', '😐', '🙂', '😄'],
...props
}) => {
const max = emojis.length;
return (
<div className="ll-emoji-rating">
<Rating
{...props}
max={max}
allowHalf={false}
icon={null}
emptyIcon={null}
/>
<div className="ll-emoji-rating-faces">
{emojis.map((emoji, index) => (
<span
key={index}
className={`ll-emoji-rating-face ${(props.value ?? props.defaultValue ?? 0) >= index + 1 ? 'active' : ''}`}
>
{emoji}
</span>
))}
</div>
</div>
);
};