نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
+24
View File
@@ -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"
}
}
+836
View File
@@ -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>
);
}
+15
View File
@@ -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"]
}