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

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ 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
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@agy/contracts",
"version": "0.1.0",
"private": true,
"description": "Shared API contracts and types between @agy/web and the FastAPI backend.",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
+617
View File
@@ -0,0 +1,617 @@
/**
* Shared API contracts.
*
* These types mirror the FastAPI response schemas. They are intentionally
* hand-written and small: the backend is the source of truth, and a drift here
* surfaces immediately as a TypeScript error at the call site.
*/
export type Role = "owner" | "admin" | "manager" | "member" | "viewer";
export type MembershipStatus = "active" | "invited" | "suspended";
/** Provider kinds the AI Control Plane can talk to. */
export type ProviderType =
| "mock"
| "openai_compatible"
| "anthropic"
| "gemini"
| "openrouter";
/** What a model can do. Routing picks models by required capability. */
export type Capability = "text" | "vision" | "tools" | "reasoning" | "long_context" | "code";
/** Routing intents. Customers never see these names — only the platform owner does. */
export type RoutingMode = "fast" | "cheap" | "balanced" | "reasoning" | "vision" | "coding";
export type TaskStatus =
| "new"
| "triaged"
| "waiting_approval"
| "running"
| "done"
| "failed"
| "cancelled";
export type RunStatus =
| "queued"
| "planning"
| "waiting_approval"
| "running"
| "tool_calling"
| "sandbox_running"
| "completed"
| "failed"
| "cancelled"
| "timed_out";
export type RoleKey =
| "manager"
| "planner"
| "developer"
| "reviewer"
| "tester"
| "researcher"
| "content_creator"
| "support";
export type EmployeeStatus = "active" | "paused" | "archived";
export type PreviewDomainStatus = "active" | "disabled" | "failed";
export type RateUnit =
| "model_input_tokens"
| "model_output_tokens"
| "sandbox_cpu_second"
| "sandbox_memory_gb_second"
| "storage_gb_day"
| "tool_call"
| "premium_employee_run";
export interface ApiErrorBody {
error: {
code: string;
message: string;
details?: unknown;
/**
* Correlates this response with the row the API wrote to its error console
* (and with the `X-Request-ID` response header). Surfacing it lets a user
* quote one string that points at exactly one server-side record.
*/
request_id?: string;
};
}
export interface User {
id: string;
email: string;
name: string;
avatar_url?: string | null;
is_platform_owner: boolean;
}
export interface Organization {
id: string;
name: string;
slug: string;
owner_user_id: string;
}
export interface OrganizationWithRole extends Organization {
role: Role;
}
export interface Member {
id: string;
user_id: string;
email: string;
name: string;
role: Role;
status: MembershipStatus;
created_at: string;
}
export interface Invite {
id: string;
email: string;
role: Role;
expires_at: string;
invite_token: string;
invite_url: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
user: User;
}
export interface MeResponse {
user: User;
organizations: OrganizationWithRole[];
}
export interface Project {
id: string;
organization_id: string;
name: string;
slug: string;
description: string;
default_branch: string;
created_at: string;
}
export interface RepoStatus {
exists: boolean;
private: boolean;
default_branch: string;
branches: string[];
open_pull_requests: number;
}
export interface Repository {
id: string;
provider: string;
owner: string;
repo_name: string;
clone_url: string;
web_url: string;
default_branch: string;
private: boolean;
}
export interface AdminProvider {
id: string;
name: string;
type: string;
base_url: string;
is_active: boolean;
created_at: string;
}
export interface AdminModel {
id: string;
provider_id: string;
internal_name: string;
display_name_for_admin: string;
capabilities: string[];
input_price_per_1m: number;
output_price_per_1m: number;
is_active: boolean;
}
export interface AdminRoutingPolicy {
id: string;
name: string;
description: string;
mode: string;
is_default: boolean;
is_active: boolean;
}
export interface AdminRateCard {
id: string;
name: string;
unit: RateUnit;
customer_price: number;
internal_cost_estimate: number;
is_active: boolean;
}
export interface AdminOrganization {
id: string;
name: string;
slug: string;
member_count: number;
created_at: string;
}
/**
* A stored provider credential.
*
* There is no field here that can carry a plaintext key — the API only ever
* returns a masked form, and on list it returns a fixed mask because the
* plaintext is unrecoverable by design.
*/
export interface AdminCredential {
id: string;
provider_id: string;
name: string;
masked_key: string;
is_active: boolean;
}
/** Result of a routing dry-run: which model a mode would pick, and why. */
export interface ModelChoice {
mode: string;
model_id: string;
internal_name: string;
capabilities: string[];
estimated_input_price_per_1m: number;
estimated_output_price_per_1m: number;
}
export interface AdminUsageRow {
organization_id: string;
quantity: number;
total_price: number;
}
export interface PreviewDomain {
id: string;
host: string;
port: number;
status: PreviewDomainStatus;
protected: boolean;
url: string;
}
export interface Employee {
id: string;
organization_id: string;
project_id?: string | null;
template_id?: string | null;
name: string;
role_key: RoleKey;
status: EmployeeStatus;
routing_policy_id?: string | null;
created_at: string;
}
export interface Task {
id: string;
organization_id: string;
project_id?: string | null;
thread_id?: string | null;
title: string;
description: string;
status: TaskStatus;
assigned_employee_id?: string | null;
created_at: string;
updated_at: string;
}
export interface Run {
id: string;
task_id: string;
employee_id?: string | null;
status: RunStatus;
error?: string | null;
started_at?: string | null;
finished_at?: string | null;
created_at: string;
}
export interface Thread {
id: string;
organization_id: string;
project_id?: string | null;
title: string;
created_at: string;
}
export interface Message {
id: string;
thread_id: string;
role: "user" | "assistant" | "system";
content: string;
employee_id?: string | null;
created_at: string;
}
export interface ThreadDetail extends Thread {
messages: Message[];
}
export interface RunStep {
id: string;
index: number;
kind: string;
title: string;
payload_json: Record<string, unknown>;
created_at: string;
}
export interface ToolCall {
id: string;
tool_key: string;
status: string;
requires_approval: boolean;
arguments_json: Record<string, unknown>;
result_json: Record<string, unknown>;
created_at: string;
}
export interface ArtifactDetail extends Artifact {
content_ref: string;
run_id?: string | null;
}
export interface RunDetail extends Run {
steps: RunStep[];
tool_calls: ToolCall[];
artifacts: ArtifactDetail[];
}
export interface TaskDetail extends Task {
runs: Run[];
}
export interface AgentTemplate {
id: string;
name: string;
role_key: RoleKey;
description: string;
default_tools_json: string[];
}
/** A routing policy as a customer may see it — no mode, no model hints. */
export interface RoutingChoice {
id: string;
name: string;
description: string;
}
export interface PromptVersion {
id: string;
version: number;
content: string;
created_at: string;
}
export interface ToolPermission {
tool_key: string;
allowed: boolean;
}
/** One entry in the tool catalog an employee can be granted. */
export interface ToolSpec {
key: string;
label: string;
description: string;
requires_approval: boolean;
destructive: boolean;
category: string;
}
export interface EmployeeDetail extends Employee {
prompt_versions: PromptVersion[];
tool_permissions: ToolPermission[];
open_task_count: number;
}
export interface PostMessageResponse {
user_message: Message;
assistant_message: Message;
}
export interface Artifact {
id: string;
name: string;
kind: string;
size_bytes: number;
created_at: string;
}
export interface BillingSummary {
balance: number;
currency: string;
low_balance: boolean;
low_balance_threshold: number;
total_spend: number;
by_unit: { unit: RateUnit; quantity: number; total_price: number }[];
by_employee: Record<string, number>;
by_project: Record<string, number>;
}
export interface UsageEvent {
id: string;
unit: RateUnit;
quantity: number;
unit_price: number;
total_price: number;
project_id?: string | null;
employee_id?: string | null;
run_id?: string | null;
created_at: string;
}
export interface Invoice {
id: string;
amount: number;
currency: string;
status: string;
period_start?: string | null;
period_end?: string | null;
created_at: string;
}
export interface Payment {
id: string;
amount: number;
currency: string;
status: string;
provider: string;
created_at: string;
}
export interface Wallet {
balance: number;
currency: string;
low_balance: boolean;
}
export interface AuditEvent {
id: string;
action: string;
actor_user_id?: string | null;
target_type: string;
target_id: string;
created_at: string;
}
// --- sandbox ----------------------------------------------------------------
export type NetworkPolicy = "deny_all" | "allowlist" | "open";
export interface SandboxTemplate {
id: string;
name: string;
provider: string;
image: string;
description: string;
default_cpu: number;
default_memory_mb: number;
default_disk_mb: number;
}
export interface SandboxWorkspace {
id: string;
project_id: string;
template_id: string;
provider: string;
status: string;
created_at: string;
}
export interface SandboxSession {
id: string;
workspace_id: string;
status: string;
started_at: string;
last_active_at: string;
}
export interface SandboxCommandRun {
id: string;
command: string;
exit_code: number | null;
stdout: string;
stderr: string;
duration_ms: number;
truncated: boolean;
created_at: string;
}
export interface SandboxFileRead {
path: string;
content: string;
}
export interface SandboxSnapshot {
id: string;
name: string;
created_at: string;
}
export interface SandboxPreviewUrl {
port: number;
url: string;
}
/**
* Effective limits are resolved server-side (project -> organization ->
* platform) and clamped. The UI only ever displays them.
*/
export interface SandboxResourceLimit {
cpu: number;
memory_mb: number;
disk_mb: number;
timeout_seconds: number;
max_output_bytes: number;
network_policy: NetworkPolicy;
}
export interface HealthResponse {
status: string;
app: string;
env: string;
}
export interface ReadinessResponse {
status: string;
checks: {
database: string;
model_provider: string;
adapters: Record<string, string>;
};
}
/* -------------------------------------------------------------------------- */
/* Error console (platform owner) */
/* -------------------------------------------------------------------------- */
export type ErrorSeverity = "warning" | "error" | "critical";
export type ErrorSource = "http" | "worker" | "startup";
export type ErrorStatusFilter = "open" | "resolved";
/**
* One persisted failure. Deliberately wide: a record that lacks the request
* path or the exception class forces whoever is debugging to reproduce the bug
* just to learn what it was.
*/
export interface ErrorEvent {
id: string;
kind: string;
message: string;
stack: string;
severity: ErrorSeverity;
source: ErrorSource;
error_type: string;
fingerprint: string;
status_code: number | null;
method: string;
path: string;
organization_id: string | null;
user_id: string | null;
run_id: string | null;
request_id: string;
ip: string;
user_agent: string;
metadata_json: Record<string, unknown>;
resolved_at: string | null;
resolved_note: string;
created_at: string;
}
/** Every occurrence of one distinct bug, collapsed into a single row. */
export interface ErrorGroup {
fingerprint: string;
occurrences: number;
open_count: number;
first_seen: string;
last_seen: string;
kind: string;
error_type: string;
severity: ErrorSeverity;
source: ErrorSource;
path: string;
sample_message: string;
}
export interface ErrorSummary {
open_by_severity: Record<string, number>;
total_open: number;
}
export interface ErrorListResponse {
items: ErrorEvent[];
total: number;
limit: number;
offset: number;
}
export interface ResolveErrorResponse {
fingerprint: string;
resolved: number;
}
export interface ErrorQuery {
status?: ErrorStatusFilter;
severity?: ErrorSeverity;
source?: ErrorSource;
organization_id?: string;
search?: string;
limit?: number;
offset?: number;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"declaration": true
},
"include": ["src/**/*.ts"]
}
+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"]
}