نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@agy/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Base UI components / design system for AGY Web (RTL, dark-first).",
|
||||
"type": "module",
|
||||
"main": "./src/index.tsx",
|
||||
"types": "./src/index.tsx",
|
||||
"exports": {
|
||||
".": "./src/index.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.7",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Base design system for AGY Web.
|
||||
*
|
||||
* 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-colors",
|
||||
"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",
|
||||
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="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",
|
||||
"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",
|
||||
"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="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="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="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",
|
||||
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="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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
>
|
||||
<div className="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="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(
|
||||
"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="fixed inset-0 z-50 flex bg-black/60"
|
||||
onClick={onClose}
|
||||
>
|
||||
<aside
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={cx(
|
||||
"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="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>
|
||||
{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(
|
||||
"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="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user