واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-* بههمراه .anim-stagger برای ورود آبشاری. همهٔ مدتزمانها پشت custom property هستند تا یک override کل اپ را تنظیم کند. - packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar، Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer - ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد میشود - AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up - prefers-reduced-motion مدتزمانها را به ۰.۰۱ms میرساند تا وضعیت پایانی حفظ شود - قرارداد motion در src/test/motion.test.tsx تست میشود تا بازنویسی بعدی آن را بیصدا حذف نکند - تستهای فرانتاند: ۳۵ ← ۴۵
840 lines
24 KiB
TypeScript
840 lines
24 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Base design system for Smart Organization.
|
|
*
|
|
* Rules encoded here (from PLAN.md §12):
|
|
* - dark-first, flat surfaces, generous whitespace
|
|
* - no gradients, no drop shadows
|
|
* - every async surface has empty / loading / error states
|
|
* - the customer never sees a provider or model name
|
|
*/
|
|
|
|
import * as React from "react";
|
|
|
|
export function cx(...parts: Array<string | false | null | undefined>): string {
|
|
return parts.filter(Boolean).join(" ");
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Button */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
|
type ButtonSize = "sm" | "md";
|
|
|
|
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
|
primary: "bg-accent text-white hover:bg-accent-strong border-transparent",
|
|
secondary: "bg-surface-overlay text-slate-100 hover:bg-surface-border border-surface-border",
|
|
ghost: "bg-transparent text-slate-300 hover:bg-surface-overlay border-transparent",
|
|
danger: "bg-danger text-white hover:opacity-90 border-transparent",
|
|
};
|
|
|
|
const BUTTON_SIZES: Record<ButtonSize, string> = {
|
|
sm: "h-8 px-3 text-xs",
|
|
md: "h-10 px-4 text-sm",
|
|
};
|
|
|
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant;
|
|
size?: ButtonSize;
|
|
loading?: boolean;
|
|
}
|
|
|
|
export function Button({
|
|
variant = "primary",
|
|
size = "md",
|
|
loading = false,
|
|
className,
|
|
children,
|
|
disabled,
|
|
...rest
|
|
}: ButtonProps) {
|
|
return (
|
|
<button
|
|
{...rest}
|
|
disabled={disabled || loading}
|
|
className={cx(
|
|
"inline-flex items-center justify-center gap-2 rounded-lg border font-medium",
|
|
"transition-[color,background-color,border-color,transform] duration-150",
|
|
"active:scale-[0.97]",
|
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
BUTTON_VARIANTS[variant],
|
|
BUTTON_SIZES[size],
|
|
className,
|
|
)}
|
|
>
|
|
{loading && (
|
|
<span
|
|
aria-hidden
|
|
className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent"
|
|
/>
|
|
)}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Card / Badge / Stat */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export function Card({
|
|
className,
|
|
children,
|
|
...rest
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
return (
|
|
<div
|
|
{...rest}
|
|
className={cx(
|
|
"rounded-xl border border-surface-border bg-surface-raised p-5",
|
|
"transition-colors duration-200",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CardTitle({ children }: { children: React.ReactNode }) {
|
|
return <h3 className="mb-1 text-sm font-medium text-slate-200">{children}</h3>;
|
|
}
|
|
|
|
export function CardHint({ children }: { children: React.ReactNode }) {
|
|
return <p className="text-xs leading-relaxed text-slate-400">{children}</p>;
|
|
}
|
|
|
|
type Tone = "neutral" | "info" | "success" | "warning" | "danger";
|
|
|
|
const TONES: Record<Tone, string> = {
|
|
neutral: "bg-surface-overlay text-slate-300 border-surface-border",
|
|
info: "bg-accent-soft text-accent border-accent/40",
|
|
success: "bg-positive/10 text-positive border-positive/40",
|
|
warning: "bg-warning/10 text-warning border-warning/40",
|
|
danger: "bg-danger/10 text-danger border-danger/40",
|
|
};
|
|
|
|
export function Badge({
|
|
tone = "neutral",
|
|
children,
|
|
className,
|
|
}: {
|
|
tone?: Tone;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cx(
|
|
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
|
TONES[tone],
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Stat({
|
|
label,
|
|
value,
|
|
hint,
|
|
tone = "neutral",
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
hint?: string;
|
|
tone?: Tone;
|
|
}) {
|
|
return (
|
|
<div className="anim-rise rounded-xl border border-surface-border bg-surface-raised p-4">
|
|
<p className="text-xs text-slate-400">{label}</p>
|
|
<p
|
|
className={cx(
|
|
"mt-1 text-2xl font-medium",
|
|
tone === "danger" ? "text-danger" : "text-slate-100",
|
|
)}
|
|
>
|
|
{value}
|
|
</p>
|
|
{hint && <p className="mt-1 text-xs text-slate-500">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Form fields */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface FieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
label: string;
|
|
hint?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export function Field({ label, hint, error, className, id, ...rest }: FieldProps) {
|
|
const inputId = id ?? `field-${label.replace(/\s+/g, "-")}`;
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor={inputId} className="text-xs font-medium text-slate-300">
|
|
{label}
|
|
</label>
|
|
<input
|
|
id={inputId}
|
|
{...rest}
|
|
className={cx(
|
|
"h-10 rounded-lg border bg-surface-overlay px-3 text-sm text-slate-100 transition-colors duration-150",
|
|
"placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
|
|
error ? "border-danger" : "border-surface-border",
|
|
className,
|
|
)}
|
|
/>
|
|
{error ? (
|
|
<p className="text-xs text-danger">{error}</p>
|
|
) : hint ? (
|
|
<p className="text-xs text-slate-500">{hint}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
label: string;
|
|
hint?: string;
|
|
error?: string;
|
|
options: { value: string; label: string }[];
|
|
}
|
|
|
|
export function Select({ label, hint, error, options, className, id, ...rest }: SelectProps) {
|
|
const selectId = id ?? `select-${label.replace(/\s+/g, "-")}`;
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
{label ? (
|
|
<label htmlFor={selectId} className="text-xs font-medium text-slate-300">
|
|
{label}
|
|
</label>
|
|
) : null}
|
|
<select
|
|
id={selectId}
|
|
{...rest}
|
|
className={cx(
|
|
"h-10 rounded-lg border bg-surface-overlay px-3 text-sm text-slate-100 transition-colors duration-150",
|
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
|
|
error ? "border-danger" : "border-surface-border",
|
|
className,
|
|
)}
|
|
>
|
|
{options.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{error ? (
|
|
<p className="text-xs text-danger">{error}</p>
|
|
) : hint ? (
|
|
<p className="text-xs text-slate-500">{hint}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Checkbox({
|
|
label,
|
|
className,
|
|
id,
|
|
...rest
|
|
}: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
|
const boxId = id ?? `check-${label.replace(/\s+/g, "-")}`;
|
|
return (
|
|
<label htmlFor={boxId} className={cx("flex items-center gap-2 text-sm text-slate-300", className)}>
|
|
<input
|
|
id={boxId}
|
|
type="checkbox"
|
|
{...rest}
|
|
className="h-4 w-4 rounded border-surface-border bg-surface-overlay accent-accent"
|
|
/>
|
|
{label}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Async states */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export function LoadingState({ label = "در حال بارگذاری…" }: { label?: string }) {
|
|
return (
|
|
<div className="anim-fade-in flex flex-col gap-3" role="status" aria-live="polite">
|
|
<p className="text-xs text-slate-400">{label}</p>
|
|
<div className="h-4 w-1/3 animate-pulse rounded bg-surface-overlay" />
|
|
<div className="h-4 w-2/3 animate-pulse rounded bg-surface-overlay" />
|
|
<div className="h-4 w-1/2 animate-pulse rounded bg-surface-overlay" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
action?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="anim-rise flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-surface-border bg-surface-raised/50 px-6 py-12 text-center">
|
|
<p className="text-sm font-medium text-slate-200">{title}</p>
|
|
{description && <p className="max-w-md text-xs text-slate-400">{description}</p>}
|
|
{action}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ErrorState({
|
|
title = "خطا در دریافت داده",
|
|
message,
|
|
onRetry,
|
|
}: {
|
|
title?: string;
|
|
message?: string;
|
|
onRetry?: () => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="anim-rise flex flex-col items-start gap-3 rounded-xl border border-danger/40 bg-danger/5 px-5 py-4"
|
|
>
|
|
<p className="text-sm font-medium text-danger">{title}</p>
|
|
{message && <p className="text-xs text-slate-300">{message}</p>}
|
|
{onRetry && (
|
|
<Button variant="secondary" size="sm" onClick={onRetry}>
|
|
تلاش دوباره
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Domain-specific surfaces */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export function UsageBar({
|
|
used,
|
|
total,
|
|
label,
|
|
}: {
|
|
used: number;
|
|
total: number;
|
|
label?: string;
|
|
}) {
|
|
const pct = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0;
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
{label && (
|
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
<span>{label}</span>
|
|
<span>٪{pct}</span>
|
|
</div>
|
|
)}
|
|
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-overlay">
|
|
<div
|
|
className={cx(
|
|
"h-full rounded-full transition-[width] duration-500 ease-out",
|
|
pct > 90 ? "bg-danger" : pct > 70 ? "bg-warning" : "bg-accent",
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface TimelineItem {
|
|
id: string;
|
|
title: string;
|
|
meta?: string;
|
|
tone?: Tone;
|
|
}
|
|
|
|
export function Timeline({ items }: { items: TimelineItem[] }) {
|
|
if (items.length === 0) {
|
|
return <p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>;
|
|
}
|
|
return (
|
|
<ol className="anim-stagger flex flex-col gap-3">
|
|
{items.map((item) => (
|
|
<li key={item.id} className="flex gap-3">
|
|
<span
|
|
aria-hidden
|
|
className={cx(
|
|
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
|
|
item.tone === "danger"
|
|
? "bg-danger"
|
|
: item.tone === "success"
|
|
? "bg-positive"
|
|
: "bg-accent",
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs text-slate-200">{item.title}</span>
|
|
{item.meta && <span className="text-xs text-slate-500">{item.meta}</span>}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
export function EmployeeCard({
|
|
name,
|
|
role,
|
|
status,
|
|
activeTasks,
|
|
todayUsage,
|
|
onOpen,
|
|
}: {
|
|
name: string;
|
|
role: string;
|
|
status: string;
|
|
activeTasks?: number;
|
|
todayUsage?: string;
|
|
onOpen?: () => void;
|
|
}) {
|
|
const tone: Tone = status === "active" ? "success" : status === "paused" ? "warning" : "neutral";
|
|
return (
|
|
<Card className="flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-100">{name}</p>
|
|
<p className="text-xs text-slate-400">{role}</p>
|
|
</div>
|
|
<Badge tone={tone}>{status === "active" ? "فعال" : status === "paused" ? "متوقف" : "بایگانی"}</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
<span>وظایف فعال: {activeTasks ?? 0}</span>
|
|
<span>مصرف امروز: {todayUsage ?? "—"}</span>
|
|
</div>
|
|
{onOpen && (
|
|
<Button variant="secondary" size="sm" onClick={onOpen}>
|
|
تنظیمات
|
|
</Button>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function TaskCard({
|
|
title,
|
|
status,
|
|
employeeName,
|
|
onOpen,
|
|
}: {
|
|
title: string;
|
|
status: string;
|
|
employeeName?: string;
|
|
onOpen?: () => void;
|
|
}) {
|
|
const tone: Tone =
|
|
status === "done"
|
|
? "success"
|
|
: status === "failed"
|
|
? "danger"
|
|
: status === "running"
|
|
? "info"
|
|
: status === "waiting_approval"
|
|
? "warning"
|
|
: "neutral";
|
|
return (
|
|
<Card className="flex flex-col gap-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<p className="text-sm text-slate-100">{title}</p>
|
|
<Badge tone={tone}>{status}</Badge>
|
|
</div>
|
|
{employeeName && <p className="text-xs text-slate-400">{employeeName}</p>}
|
|
{onOpen && (
|
|
<Button variant="ghost" size="sm" onClick={onOpen}>
|
|
مشاهده
|
|
</Button>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ConfirmDialog({
|
|
open,
|
|
title,
|
|
description,
|
|
confirmLabel = "تأیید",
|
|
onConfirm,
|
|
onCancel,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
description?: string;
|
|
confirmLabel?: string;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
if (!open) return null;
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
className="anim-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
|
>
|
|
<div className="anim-scale-in w-full max-w-md rounded-xl border border-surface-border bg-surface-raised p-5">
|
|
<p className="text-sm font-medium text-slate-100">{title}</p>
|
|
{description && <p className="mt-2 text-xs text-slate-400">{description}</p>}
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button variant="secondary" size="sm" onClick={onCancel}>
|
|
انصراف
|
|
</Button>
|
|
<Button variant="danger" size="sm" onClick={onConfirm}>
|
|
{confirmLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Modal / Drawer */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const MODAL_SIZES: Record<"sm" | "md" | "lg", string> = {
|
|
sm: "max-w-sm",
|
|
md: "max-w-lg",
|
|
lg: "max-w-3xl",
|
|
};
|
|
|
|
export function Modal({
|
|
open,
|
|
title,
|
|
description,
|
|
onClose,
|
|
footer,
|
|
size = "md",
|
|
children,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
description?: string;
|
|
onClose: () => void;
|
|
footer?: React.ReactNode;
|
|
size?: "sm" | "md" | "lg";
|
|
children?: React.ReactNode;
|
|
}) {
|
|
if (!open) return null;
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
className="anim-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
// Clicks inside must not bubble up and close the dialog.
|
|
onClick={(event) => event.stopPropagation()}
|
|
className={cx(
|
|
"anim-scale-in w-full rounded-xl border border-surface-border bg-surface-raised p-5",
|
|
MODAL_SIZES[size],
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-sm font-medium text-slate-100">{title}</p>
|
|
{description && <p className="text-xs text-slate-400">{description}</p>}
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={onClose} aria-label="بستن">
|
|
✕
|
|
</Button>
|
|
</div>
|
|
{children && <div className="mt-4">{children}</div>}
|
|
{footer && <div className="mt-5 flex justify-end gap-2">{footer}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Drawer({
|
|
open,
|
|
title,
|
|
onClose,
|
|
side = "start",
|
|
children,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
onClose: () => void;
|
|
/** ``start`` is the right-hand edge in an RTL layout. */
|
|
side?: "start" | "end";
|
|
children: React.ReactNode;
|
|
}) {
|
|
if (!open) return null;
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
className="anim-fade-in fixed inset-0 z-50 flex bg-black/60"
|
|
onClick={onClose}
|
|
>
|
|
<aside
|
|
onClick={(event) => event.stopPropagation()}
|
|
className={cx(
|
|
"anim-drawer-start flex h-full w-full max-w-md flex-col border-surface-border bg-surface-raised",
|
|
side === "start" ? "border-l" : "border-r",
|
|
)}
|
|
>
|
|
<header className="flex items-center justify-between border-b border-surface-border px-4 py-3">
|
|
<p className="text-sm font-medium text-slate-100">{title}</p>
|
|
<Button variant="ghost" size="sm" onClick={onClose} aria-label="بستن">
|
|
✕
|
|
</Button>
|
|
</header>
|
|
<div className="flex-1 overflow-y-auto p-4">{children}</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Table */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface TableColumn<T> {
|
|
key: string;
|
|
header: string;
|
|
render: (row: T) => React.ReactNode;
|
|
align?: "start" | "center" | "end";
|
|
className?: string;
|
|
}
|
|
|
|
const ALIGN: Record<"start" | "center" | "end", string> = {
|
|
start: "text-start",
|
|
center: "text-center",
|
|
end: "text-end",
|
|
};
|
|
|
|
/**
|
|
* A table that owns its own empty / loading / error presentation.
|
|
*
|
|
* Passing those states in as props rather than leaving every caller to
|
|
* remember them is what keeps the three states consistent across the app.
|
|
*/
|
|
export function Table<T>({
|
|
columns,
|
|
rows,
|
|
rowKey,
|
|
loading = false,
|
|
error,
|
|
onRetry,
|
|
empty,
|
|
caption,
|
|
}: {
|
|
columns: Array<TableColumn<T>>;
|
|
rows: T[];
|
|
rowKey: (row: T) => string;
|
|
loading?: boolean;
|
|
error?: string | null;
|
|
onRetry?: () => void;
|
|
empty?: React.ReactNode;
|
|
caption?: string;
|
|
}) {
|
|
if (loading) return <LoadingState />;
|
|
if (error) return <ErrorState message={error} onRetry={onRetry} />;
|
|
if (rows.length === 0) {
|
|
return (
|
|
<>
|
|
{empty ?? <EmptyState title="موردی برای نمایش نیست." />}
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<div className="anim-fade-in overflow-x-auto rounded-xl border border-surface-border">
|
|
<table className="w-full border-collapse text-sm">
|
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
<thead>
|
|
<tr className="bg-surface-overlay/60">
|
|
{columns.map((column) => (
|
|
<th
|
|
key={column.key}
|
|
scope="col"
|
|
className={cx(
|
|
"whitespace-nowrap px-4 py-2 text-xs font-medium text-slate-400",
|
|
ALIGN[column.align ?? "start"],
|
|
)}
|
|
>
|
|
{column.header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="anim-stagger">
|
|
{rows.map((row) => (
|
|
<tr key={rowKey(row)} className="border-t border-surface-border">
|
|
{columns.map((column) => (
|
|
<td
|
|
key={column.key}
|
|
className={cx(
|
|
"px-4 py-3 text-slate-200",
|
|
ALIGN[column.align ?? "start"],
|
|
column.className,
|
|
)}
|
|
>
|
|
{column.render(row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Tabs */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface TabItem {
|
|
key: string;
|
|
label: string;
|
|
badge?: React.ReactNode;
|
|
}
|
|
|
|
export function Tabs({
|
|
items,
|
|
active,
|
|
onChange,
|
|
}: {
|
|
items: TabItem[];
|
|
active: string;
|
|
onChange: (key: string) => void;
|
|
}) {
|
|
return (
|
|
<div role="tablist" className="flex flex-wrap gap-1 border-b border-surface-border">
|
|
{items.map((item) => {
|
|
const selected = item.key === active;
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
role="tab"
|
|
type="button"
|
|
aria-selected={selected}
|
|
onClick={() => onChange(item.key)}
|
|
className={cx(
|
|
"-mb-px flex items-center gap-2 border-b-2 px-3 py-2 text-xs font-medium transition-colors",
|
|
selected
|
|
? "border-accent text-slate-100"
|
|
: "border-transparent text-slate-400 hover:text-slate-200",
|
|
)}
|
|
>
|
|
{item.label}
|
|
{item.badge}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Toast */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface ToastMessage {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
tone?: Tone;
|
|
}
|
|
|
|
export function ToastStack({
|
|
toasts,
|
|
onDismiss,
|
|
}: {
|
|
toasts: ToastMessage[];
|
|
onDismiss: (id: string) => void;
|
|
}) {
|
|
if (toasts.length === 0) return null;
|
|
return (
|
|
<div
|
|
aria-live="polite"
|
|
className="pointer-events-none fixed bottom-4 start-4 z-[60] flex w-full max-w-sm flex-col gap-2"
|
|
>
|
|
{toasts.map((toast) => (
|
|
<div
|
|
key={toast.id}
|
|
role="status"
|
|
className={cx(
|
|
"anim-slide-up pointer-events-auto flex items-start justify-between gap-3 rounded-lg border px-4 py-3",
|
|
TONES[toast.tone ?? "neutral"],
|
|
)}
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium">{toast.title}</span>
|
|
{toast.description && (
|
|
<span className="text-xs opacity-80">{toast.description}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="بستن"
|
|
onClick={() => onDismiss(toast.id)}
|
|
className="text-xs opacity-70 hover:opacity-100"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* ArtifactViewer */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export function ArtifactViewer({
|
|
name,
|
|
kind,
|
|
content,
|
|
sizeBytes,
|
|
createdAt,
|
|
}: {
|
|
name: string;
|
|
kind: string;
|
|
content: string;
|
|
sizeBytes?: number;
|
|
createdAt?: string;
|
|
}) {
|
|
return (
|
|
<div className="anim-fade-in flex flex-col gap-2 rounded-xl border border-surface-border bg-surface-raised">
|
|
<div className="flex items-center justify-between gap-3 border-b border-surface-border px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium text-slate-200">{name}</span>
|
|
<Badge tone="info">{kind}</Badge>
|
|
</div>
|
|
<span className="text-xs text-slate-500">
|
|
{sizeBytes !== undefined ? `${sizeBytes} بایت` : ""}
|
|
{createdAt ? ` · ${createdAt}` : ""}
|
|
</span>
|
|
</div>
|
|
<pre className="max-h-96 overflow-auto whitespace-pre-wrap px-4 py-3 text-xs leading-6 text-slate-300">
|
|
{content || "محتوایی ثبت نشده است."}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|