Files
ersatztv/web/src/components/dataDisplay.tsx
T

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>
);
}