328 lines
7.5 KiB
TypeScript
328 lines
7.5 KiB
TypeScript
import type { CSSProperties, ReactNode } from 'react';
|
|
import { X } from 'lucide-react';
|
|
|
|
function classNames(...values: Array<string | false | null | undefined>): string {
|
|
return values.filter(Boolean).join(' ');
|
|
}
|
|
|
|
export interface BadgeProps {
|
|
children?: ReactNode;
|
|
tone?: 'neutral' | 'accent' | 'ok' | 'warn' | 'error';
|
|
solid?: boolean;
|
|
dot?: boolean;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
export function Badge({
|
|
children,
|
|
tone = 'neutral',
|
|
solid = false,
|
|
dot = false,
|
|
style
|
|
}: BadgeProps) {
|
|
return (
|
|
<span
|
|
className={classNames(
|
|
'ctv-badge',
|
|
tone !== 'neutral' && `ctv-tone-${tone}`,
|
|
solid && 'ctv-badge-solid',
|
|
dot && 'ctv-badge-dot'
|
|
)}
|
|
style={style}
|
|
>
|
|
{dot && <span className="ctv-badge-mark" />}
|
|
<span>{children}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export interface StatusDotProps {
|
|
status?: 'live' | 'ok' | 'warn' | 'error' | 'idle';
|
|
pulse?: boolean;
|
|
size?: number;
|
|
label?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const statusColors: Record<NonNullable<StatusDotProps['status']>, string> = {
|
|
live: 'var(--status-live)',
|
|
ok: 'var(--status-ok)',
|
|
warn: 'var(--status-warn)',
|
|
error: 'var(--status-error)',
|
|
idle: 'var(--text-disabled)'
|
|
};
|
|
|
|
export function StatusDot({
|
|
status = 'idle',
|
|
pulse = false,
|
|
size = 8,
|
|
label,
|
|
style
|
|
}: StatusDotProps) {
|
|
const color = statusColors[status];
|
|
const shouldPulse = pulse || status === 'live';
|
|
|
|
return (
|
|
<span
|
|
className="ctv-status-dot"
|
|
style={
|
|
{
|
|
'--ctv-dot-color': color,
|
|
'--ctv-dot-size': `${size}px`,
|
|
'--ctv-dot-shadow': shouldPulse ? `0 0 6px ${color}` : 'none',
|
|
...style
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<span className="ctv-status-dot-mark" aria-hidden="true">
|
|
{shouldPulse && <span className="ctv-status-dot-ping" />}
|
|
<span className="ctv-status-dot-core" />
|
|
</span>
|
|
{label && <span>{label}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export interface TagProps {
|
|
children?: ReactNode;
|
|
onRemove?: () => void;
|
|
icon?: ReactNode;
|
|
tone?: 'neutral' | 'accent';
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
export function Tag({ children, onRemove, icon, tone = 'neutral', style }: TagProps) {
|
|
return (
|
|
<span
|
|
className={classNames(
|
|
'ctv-tag',
|
|
tone === 'accent' && 'ctv-tag-accent',
|
|
onRemove && 'ctv-tag-removable'
|
|
)}
|
|
style={style}
|
|
>
|
|
{icon && <span className="ctv-tag-icon">{icon}</span>}
|
|
<span>{children}</span>
|
|
{onRemove && (
|
|
<button type="button" className="ctv-tag-remove" aria-label="Remove" onClick={onRemove}>
|
|
<X aria-hidden="true" size={10} strokeWidth={2.4} />
|
|
</button>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export interface ChannelLogoProps {
|
|
name?: string;
|
|
src?: string | null;
|
|
size?: number;
|
|
radius?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const logoPalette = [
|
|
['#5B7CFA', '#2E3A66'],
|
|
['#3FB984', '#1E4536'],
|
|
['#E0A83D', '#4A3818'],
|
|
['#E5484D', '#4A1F21'],
|
|
['#B06CF0', '#38235A'],
|
|
['#48B0C8', '#193E47']
|
|
] as const;
|
|
|
|
function hashIndex(value: string): number {
|
|
let hash = 0;
|
|
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
}
|
|
|
|
return hash % logoPalette.length;
|
|
}
|
|
|
|
function initialsFor(name: string): string {
|
|
const initials = name
|
|
.split(/[\s\-|:]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0])
|
|
.join('')
|
|
.toUpperCase();
|
|
|
|
return initials || '?';
|
|
}
|
|
|
|
export function ChannelLogo({
|
|
name = '',
|
|
src = null,
|
|
size = 40,
|
|
radius = 'var(--radius-sm)',
|
|
style
|
|
}: ChannelLogoProps) {
|
|
const [foreground, background] = logoPalette[hashIndex(name)];
|
|
|
|
return (
|
|
<span
|
|
className="ctv-channel-logo"
|
|
style={{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: radius,
|
|
background: src ? 'var(--ctv-bg-sunken)' : background,
|
|
...style
|
|
}}
|
|
>
|
|
{src ? (
|
|
<img src={src} alt={name} />
|
|
) : (
|
|
<span
|
|
className="ctv-channel-logo-initials"
|
|
style={{
|
|
color: foreground,
|
|
fontSize: Math.round(size * 0.34)
|
|
}}
|
|
>
|
|
{initialsFor(name)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export interface CardProps {
|
|
title?: ReactNode;
|
|
subtitle?: ReactNode;
|
|
actions?: ReactNode;
|
|
children?: ReactNode;
|
|
padded?: boolean;
|
|
inset?: boolean;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
export function Card({
|
|
title,
|
|
subtitle,
|
|
actions,
|
|
children,
|
|
padded = true,
|
|
inset = false,
|
|
style
|
|
}: CardProps) {
|
|
return (
|
|
<section className={classNames('ctv-card', inset && 'ctv-card-inset')} style={style}>
|
|
{(title || subtitle || actions) && (
|
|
<div className="ctv-card-header">
|
|
<div>
|
|
{title && <div className="ctv-card-title">{title}</div>}
|
|
{subtitle && <div className="ctv-card-subtitle">{subtitle}</div>}
|
|
</div>
|
|
{actions && <div className="ctv-card-actions">{actions}</div>}
|
|
</div>
|
|
)}
|
|
<div className={classNames('ctv-card-body', !padded && 'ctv-card-body-flush')}>
|
|
{children}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export interface StatProps {
|
|
label: string;
|
|
value: ReactNode;
|
|
unit?: string;
|
|
delta?: ReactNode;
|
|
deltaTone?: 'up' | 'down' | 'neutral';
|
|
icon?: ReactNode;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
export function Stat({
|
|
label,
|
|
value,
|
|
unit,
|
|
delta = null,
|
|
deltaTone = 'neutral',
|
|
icon,
|
|
style
|
|
}: StatProps) {
|
|
return (
|
|
<div className="ctv-stat" style={style}>
|
|
<div className="ctv-stat-label">
|
|
{icon && <span className="ctv-stat-icon">{icon}</span>}
|
|
<span>{label}</span>
|
|
</div>
|
|
<div className="ctv-stat-value-row">
|
|
<span className="ctv-stat-value">{value}</span>
|
|
{unit && <span className="ctv-stat-unit">{unit}</span>}
|
|
{delta != null && (
|
|
<span
|
|
className={classNames(
|
|
'ctv-stat-delta',
|
|
deltaTone === 'up' && 'ctv-stat-delta-up',
|
|
deltaTone === 'down' && 'ctv-stat-delta-down'
|
|
)}
|
|
>
|
|
{delta}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface ProgressBarProps {
|
|
value?: number | null;
|
|
tone?: 'accent' | 'ok' | 'warn' | 'error';
|
|
height?: number;
|
|
showLabel?: boolean;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const progressColors: Record<NonNullable<ProgressBarProps['tone']>, string> = {
|
|
accent: 'var(--action-primary)',
|
|
ok: 'var(--status-ok)',
|
|
warn: 'var(--status-warn)',
|
|
error: 'var(--status-error)'
|
|
};
|
|
|
|
export function ProgressBar({
|
|
value = null,
|
|
tone = 'accent',
|
|
height = 6,
|
|
showLabel = false,
|
|
style
|
|
}: ProgressBarProps) {
|
|
const indeterminate = value == null;
|
|
const percent = Math.max(0, Math.min(100, value ?? 0));
|
|
|
|
return (
|
|
<div className="ctv-progress" style={style}>
|
|
<div
|
|
className="ctv-progress-track"
|
|
role="progressbar"
|
|
aria-valuemin={indeterminate ? undefined : 0}
|
|
aria-valuemax={indeterminate ? undefined : 100}
|
|
aria-valuenow={indeterminate ? undefined : percent}
|
|
style={
|
|
{
|
|
'--ctv-progress-height': `${height}px`,
|
|
'--ctv-progress-color': progressColors[tone]
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<span
|
|
className={classNames(
|
|
'ctv-progress-fill',
|
|
indeterminate && 'ctv-progress-fill-indeterminate'
|
|
)}
|
|
style={
|
|
{
|
|
'--ctv-progress-value': `${percent}%`
|
|
} as CSSProperties
|
|
}
|
|
/>
|
|
</div>
|
|
{showLabel && !indeterminate && <span className="ctv-progress-label">{Math.round(percent)}%</span>}
|
|
</div>
|
|
);
|
|
}
|