نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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