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