import { useEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { createPortal } from 'react-dom';
import { AlertTriangle, CircleCheck, CircleX, LoaderCircle, RefreshCcw, WifiOff, X } from 'lucide-react';
import { useI18n } from '../lib/i18n';
import { useServer } from '../context/server-context';
export function Button({ className = '', variant = 'primary', ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
}) {
return <button className={`button button--${variant} ${className}`} {...props} />;
}
export function Input({ className = '', ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={`input ${className}`} {...props} />;
}
export function Select({ className = '', children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={`input select ${className}`} {...props}>{children}</select>;
}
export function Textarea({ className = '', ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={`input textarea ${className}`} {...props} />;
}
export function Field({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint && <span className="field__hint">{hint}</span>}
</label>
);
}
export function Panel({ children, className = '', title, action }: {
children: ReactNode;
className?: string;
title?: ReactNode;
action?: ReactNode;
}) {
return (
<section className={`panel ${className}`}>
{(title || action) && <div className="panel__header"><h2>{title}</h2>{action}</div>}
{children}
</section>
);
}
export function Badge({ children, tone = 'neutral' }: {
children: ReactNode;
tone?: 'neutral' | 'good' | 'warn' | 'danger' | 'info';
}) {
return <span className={`badge badge--${tone}`}>{children}</span>;
}
export function PageHeader({ eyebrow, title, description, actions }: {
eyebrow?: ReactNode;
title: ReactNode;
description: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow && <div className="page-header__eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
<p>{description}</p>
</div>
{actions && <div className="page-header__actions">{actions}</div>}
</header>
);
}
export function EmptyState({ title, detail, icon }: { title: ReactNode; detail?: ReactNode; icon?: ReactNode }) {
return (
<div className="empty-state">
<div className="empty-state__icon">{icon ?? <span className="pixel-mark" />}</div>
<strong>{title}</strong>
{detail && <p>{detail}</p>}
</div>
);
}
export function ResourceState({ loading, error, empty, onRetry, children }: {
loading: boolean;
error?: string;
empty?: boolean;
onRetry?: () => void;
children: ReactNode;
}) {
const { t } = useI18n();
const { connection } = useServer();
if (connection === 'offline') {
return <EmptyState icon={<WifiOff />} title={t('connection.offline')} detail={t('connection.offlineDetail')} />;
}
if (loading) return <div className="resource-state"><LoaderCircle className="spin" /> {t('common.loading')}</div>;
if (error) {
return (
<div className="resource-state resource-state--error">
<AlertTriangle />
<div><strong>{t('common.error')}</strong><span>{error}</span></div>
{onRetry && <Button variant="secondary" onClick={onRetry}><RefreshCcw size={15} />{t('common.refresh')}</Button>}
</div>
);
}
if (empty) return <EmptyState title={t('common.empty')} />;
return <>{children}</>;
}
export function FeatureUnavailable({ children }: { children: ReactNode }) {
return <div className="feature-unavailable"><AlertTriangle size={18} /><span>{children}</span></div>;
}
export function TableShell({ children }: { children: ReactNode }) {
return <div className="table-shell"><table>{children}</table></div>;
}
function toastViewport(): HTMLElement | undefined {
if (typeof document === 'undefined') return undefined;
const existing = document.getElementById('xfesm-toast-viewport');
if (existing) return existing;
const viewport = document.createElement('div');
viewport.id = 'xfesm-toast-viewport';
viewport.className = 'toast-viewport';
viewport.setAttribute('aria-live', 'polite');
viewport.setAttribute('aria-atomic', 'false');
document.body.append(viewport);
return viewport;
}
export function Toast({ message, title, tone = 'good', duration = 5_000, onClose }: {
message: string;
title?: string;
tone?: 'good' | 'danger' | 'warn';
/** Set to 0 to keep the notification visible until it is closed. */
duration?: number;
onClose?: () => void;
}) {
const { t } = useI18n();
const [visible, setVisible] = useState(true);
const closeHandler = useRef(onClose);
closeHandler.current = onClose;
useEffect(() => {
setVisible(true);
if (duration <= 0) return undefined;
const timer = globalThis.setTimeout(() => {
setVisible(false);
closeHandler.current?.();
}, duration);
return () => globalThis.clearTimeout(timer);
}, [duration, message, title, tone]);
if (!visible) return null;
const viewport = toastViewport();
const dismiss = () => {
setVisible(false);
closeHandler.current?.();
};
const Icon = tone === 'good' ? CircleCheck : tone === 'danger' ? CircleX : AlertTriangle;
const content = (
<article
className={`toast toast--${tone}`}
role={tone === 'danger' ? 'alert' : 'status'}
style={{ '--toast-duration': `${duration}ms` } as CSSProperties}
>
<Icon className="toast__icon" aria-hidden="true" />
<div className="toast__content">
<strong>{title ?? message}</strong>
{title && <span>{message}</span>}
</div>
<button className="toast__close" type="button" aria-label={t('common.close')} onClick={dismiss}><X aria-hidden="true" /></button>
{duration > 0 && <span className="toast__countdown" aria-hidden="true" />}
</article>
);
return viewport ? createPortal(content, viewport) : content;
}
export function MetricCard({ label, value, unit, detail, tone }: {
label: ReactNode;
value: ReactNode;
unit?: ReactNode;
detail?: ReactNode;
tone?: 'good' | 'warn' | 'danger';
}) {
return (
<div className={`metric-card ${tone ? `metric-card--${tone}` : ''}`}>
<span className="metric-card__label">{label}</span>
<div className="metric-card__value">{value}<small>{unit}</small></div>
{detail && <span className="metric-card__detail">{detail}</span>}
</div>
);
}
export function formatBytes(value?: number): string {
if (value === undefined || !Number.isFinite(value)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = value;
let unit = 0;
while (Math.abs(amount) >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${amount.toFixed(unit < 2 ? 0 : 1)} ${units[unit]}`;
}
export function formatDuration(seconds?: number): string {
if (seconds === undefined || !Number.isFinite(seconds)) return '—';
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
export function formatDate(value?: string, locale = 'zh-CN'): string {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(date);
}
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { createPortal } from 'react-dom';
import { AlertTriangle, CircleCheck, CircleX, LoaderCircle, RefreshCcw, WifiOff, X } from 'lucide-react';
import { useI18n } from '../lib/i18n';
import { useServer } from '../context/server-context';
export function Button({ className = '', variant = 'primary', ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
}) {
return <button className={`button button--${variant} ${className}`} {...props} />;
}
export function Input({ className = '', ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={`input ${className}`} {...props} />;
}
export function Select({ className = '', children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={`input select ${className}`} {...props}>{children}</select>;
}
export function Textarea({ className = '', ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={`input textarea ${className}`} {...props} />;
}
export function Field({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint && <span className="field__hint">{hint}</span>}
</label>
);
}
export function Panel({ children, className = '', title, action }: {
children: ReactNode;
className?: string;
title?: ReactNode;
action?: ReactNode;
}) {
return (
<section className={`panel ${className}`}>
{(title || action) && <div className="panel__header"><h2>{title}</h2>{action}</div>}
{children}
</section>
);
}
export function Badge({ children, tone = 'neutral' }: {
children: ReactNode;
tone?: 'neutral' | 'good' | 'warn' | 'danger' | 'info';
}) {
return <span className={`badge badge--${tone}`}>{children}</span>;
}
export function PageHeader({ eyebrow, title, description, actions }: {
eyebrow?: ReactNode;
title: ReactNode;
description: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow && <div className="page-header__eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
<p>{description}</p>
</div>
{actions && <div className="page-header__actions">{actions}</div>}
</header>
);
}
export function EmptyState({ title, detail, icon }: { title: ReactNode; detail?: ReactNode; icon?: ReactNode }) {
return (
<div className="empty-state">
<div className="empty-state__icon">{icon ?? <span className="pixel-mark" />}</div>
<strong>{title}</strong>
{detail && <p>{detail}</p>}
</div>
);
}
export function ResourceState({ loading, error, empty, onRetry, children }: {
loading: boolean;
error?: string;
empty?: boolean;
onRetry?: () => void;
children: ReactNode;
}) {
const { t } = useI18n();
const { connection } = useServer();
if (connection === 'offline') {
return <EmptyState icon={<WifiOff />} title={t('connection.offline')} detail={t('connection.offlineDetail')} />;
}
if (loading) return <div className="resource-state"><LoaderCircle className="spin" /> {t('common.loading')}</div>;
if (error) {
return (
<div className="resource-state resource-state--error">
<AlertTriangle />
<div><strong>{t('common.error')}</strong><span>{error}</span></div>
{onRetry && <Button variant="secondary" onClick={onRetry}><RefreshCcw size={15} />{t('common.refresh')}</Button>}
</div>
);
}
if (empty) return <EmptyState title={t('common.empty')} />;
return <>{children}</>;
}
export function FeatureUnavailable({ children }: { children: ReactNode }) {
return <div className="feature-unavailable"><AlertTriangle size={18} /><span>{children}</span></div>;
}
export function TableShell({ children }: { children: ReactNode }) {
return <div className="table-shell"><table>{children}</table></div>;
}
function toastViewport(): HTMLElement | undefined {
if (typeof document === 'undefined') return undefined;
const existing = document.getElementById('xfesm-toast-viewport');
if (existing) return existing;
const viewport = document.createElement('div');
viewport.id = 'xfesm-toast-viewport';
viewport.className = 'toast-viewport';
viewport.setAttribute('aria-live', 'polite');
viewport.setAttribute('aria-atomic', 'false');
document.body.append(viewport);
return viewport;
}
export function Toast({ message, title, tone = 'good', duration = 5_000, onClose }: {
message: string;
title?: string;
tone?: 'good' | 'danger' | 'warn';
/** Set to 0 to keep the notification visible until it is closed. */
duration?: number;
onClose?: () => void;
}) {
const { t } = useI18n();
const [visible, setVisible] = useState(true);
const closeHandler = useRef(onClose);
closeHandler.current = onClose;
useEffect(() => {
setVisible(true);
if (duration <= 0) return undefined;
const timer = globalThis.setTimeout(() => {
setVisible(false);
closeHandler.current?.();
}, duration);
return () => globalThis.clearTimeout(timer);
}, [duration, message, title, tone]);
if (!visible) return null;
const viewport = toastViewport();
const dismiss = () => {
setVisible(false);
closeHandler.current?.();
};
const Icon = tone === 'good' ? CircleCheck : tone === 'danger' ? CircleX : AlertTriangle;
const content = (
<article
className={`toast toast--${tone}`}
role={tone === 'danger' ? 'alert' : 'status'}
style={{ '--toast-duration': `${duration}ms` } as CSSProperties}
>
<Icon className="toast__icon" aria-hidden="true" />
<div className="toast__content">
<strong>{title ?? message}</strong>
{title && <span>{message}</span>}
</div>
<button className="toast__close" type="button" aria-label={t('common.close')} onClick={dismiss}><X aria-hidden="true" /></button>
{duration > 0 && <span className="toast__countdown" aria-hidden="true" />}
</article>
);
return viewport ? createPortal(content, viewport) : content;
}
export function MetricCard({ label, value, unit, detail, tone }: {
label: ReactNode;
value: ReactNode;
unit?: ReactNode;
detail?: ReactNode;
tone?: 'good' | 'warn' | 'danger';
}) {
return (
<div className={`metric-card ${tone ? `metric-card--${tone}` : ''}`}>
<span className="metric-card__label">{label}</span>
<div className="metric-card__value">{value}<small>{unit}</small></div>
{detail && <span className="metric-card__detail">{detail}</span>}
</div>
);
}
export function formatBytes(value?: number): string {
if (value === undefined || !Number.isFinite(value)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = value;
let unit = 0;
while (Math.abs(amount) >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${amount.toFixed(unit < 2 ? 0 : 1)} ${units[unit]}`;
}
export function formatDuration(seconds?: number): string {
if (seconds === undefined || !Number.isFinite(seconds)) return '—';
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
export function formatDate(value?: string, locale = 'zh-CN'): string {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(date);
}