import { useState } from "react"; import { cn } from "~/utils/cn"; import type { StatusState } from "~/lib/types"; const AVATAR_COLORS = [ "bg-orange-700", "bg-sky-700", "bg-emerald-700", "bg-rose-700", "bg-violet-700", "bg-amber-700", "bg-teal-700", "bg-fuchsia-700", ]; const SECONDS = 1000; const MINUTE = 60 * SECONDS; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const MONTH = 30 * DAY; const YEAR = 365 * DAY; const KB = 1024; function hash(value: string): number { let out = 0; for (const char of value) out = (out * 31 + char.charCodeAt(0)) | 0; return Math.abs(out); } const AVATAR_PIXELS = { sm: 40, md: 64, lg: 160 } as const; /** * Shows an account's uploaded picture (looked up by name unless `src` is given) and falls back to * colored initials when there is none or it fails to load. */ export function Avatar({ name, size = "md", src }: { name: string; size?: "sm" | "md" | "lg"; src?: string | null }) { const [failedUrl, setFailedUrl] = useState(null); const sizeClass = size === "sm" ? "size-5 text-[10px]" : size === "lg" ? "size-20 text-3xl" : "size-8 text-sm"; const url = src === undefined ? `/avatars/u/${encodeURIComponent(name)}?s=${AVATAR_PIXELS[size]}` : src; if (url && url !== failedUrl) { return ( setFailedUrl(url)} ref={(node) => { if (node?.complete && node.naturalWidth === 0) setFailedUrl(url); }} className={cn("shrink-0 rounded-md bg-gray-900 object-cover", sizeClass)} /> ); } return ( {name.slice(0, 1)} ); } export function formatRelative(timestamp: number, now = Date.now()): string { const delta = now - timestamp; const units: Array<[number, string]> = [ [YEAR, "year"], [MONTH, "month"], [DAY, "day"], [HOUR, "hour"], [MINUTE, "minute"], ]; for (const [size, label] of units) { if (delta >= size) { const count = Math.floor(delta / size); return `${count} ${label}${count === 1 ? "" : "s"} ago`; } } return "just now"; } export function RelativeTime({ timestamp }: { timestamp: number }) { return ( ); } export function formatDuration(start: number | null, end: number | null): string { if (!start) return ""; const total = Math.max(0, Math.round(((end ?? Date.now()) - start) / SECONDS)); const minutes = Math.floor(total / 60); return minutes > 0 ? `${minutes}m ${total % 60}s` : `${total}s`; } export function formatBytes(bytes: number): string { if (bytes < KB) return `${bytes} B`; if (bytes < KB * KB) return `${(bytes / KB).toFixed(1)} KB`; if (bytes < KB * KB * KB) return `${(bytes / KB / KB).toFixed(1)} MB`; return `${(bytes / KB / KB / KB).toFixed(1)} GB`; } const BADGE_TONES = { neutral: "border-gray-700 text-gray-400", accent: "border-accent-600/60 bg-accent-950 text-accent-300", green: "border-emerald-800 bg-emerald-950/50 text-emerald-300", red: "border-red-900 bg-red-950/50 text-red-300", purple: "border-violet-800 bg-violet-950/50 text-violet-300", amber: "border-amber-800 bg-amber-950/40 text-amber-300", } as const; export function Badge({ tone = "neutral", children, className, }: { tone?: keyof typeof BADGE_TONES; children: React.ReactNode; className?: string; }) { return ( {children} ); } export function LabelChip({ name, color }: { name: string; color: string }) { return ( {name} ); } const STATUS_STYLES: Record = { success: { className: "bg-emerald-500", label: "Passing" }, failure: { className: "bg-red-500", label: "Failing" }, error: { className: "bg-red-500", label: "Errored" }, pending: { className: "bg-amber-400 animate-pulse-dot", label: "Pending" }, in_progress: { className: "bg-amber-400 animate-pulse-dot", label: "Running" }, queued: { className: "bg-gray-500 animate-pulse-dot", label: "Queued" }, cancelled: { className: "bg-gray-500", label: "Cancelled" }, skipped: { className: "bg-gray-600", label: "Skipped" }, }; export function StatusDot({ state, className }: { state: StatusState | string | null; className?: string }) { if (!state) return null; const style = STATUS_STYLES[state] ?? STATUS_STYLES.queued; return ( {style.label} ); } export function runState(status: string, conclusion: string | null): string { return status === "completed" ? (conclusion ?? "skipped") : status; } export function Panel({ title, actions, children, className, }: { title?: React.ReactNode; actions?: React.ReactNode; children: React.ReactNode; className?: string; }) { return (
{(title || actions) && (
{title}
{actions &&
{actions}
}
)} {children}
); } export function EmptyState({ title, children }: { title: string; children?: React.ReactNode }) { return (
∅

{title}

{children &&
{children}
}
); } export function Markdown({ html, className }: { html: string; className?: string }) { return (
); } export function PageContainer({ children, className }: { children: React.ReactNode; className?: string }) { return
{children}
; }