واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-* بههمراه .anim-stagger برای ورود آبشاری. همهٔ مدتزمانها پشت custom property هستند تا یک override کل اپ را تنظیم کند. - packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar، Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer - ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد میشود - AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up - prefers-reduced-motion مدتزمانها را به ۰.۰۱ms میرساند تا وضعیت پایانی حفظ شود - قرارداد motion در src/test/motion.test.tsx تست میشود تا بازنویسی بعدی آن را بیصدا حذف نکند - تستهای فرانتاند: ۳۵ ← ۴۵
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import * as React from "react";
|
|
import type { Project } from "@agy/contracts";
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardHint,
|
|
CardTitle,
|
|
EmptyState,
|
|
ErrorState,
|
|
Field,
|
|
LoadingState,
|
|
} from "@agy/ui";
|
|
import { ApiError, api } from "@/lib/api";
|
|
import { useSession } from "@/lib/session";
|
|
|
|
export default function ProjectsPage() {
|
|
const { activeOrganization } = useSession();
|
|
const [projects, setProjects] = React.useState<Project[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [name, setName] = React.useState("");
|
|
const [description, setDescription] = React.useState("");
|
|
const [creating, setCreating] = React.useState(false);
|
|
const [formError, setFormError] = React.useState<string | null>(null);
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!activeOrganization) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setProjects(await api.listProjects(activeOrganization.id));
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "دریافت پروژهها ناموفق بود.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [activeOrganization]);
|
|
|
|
React.useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const onCreate = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (!activeOrganization) return;
|
|
setFormError(null);
|
|
setCreating(true);
|
|
try {
|
|
const created = await api.createProject(activeOrganization.id, {
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
});
|
|
setProjects((current) => [created, ...current]);
|
|
setName("");
|
|
setDescription("");
|
|
} catch (err) {
|
|
setFormError(err instanceof ApiError ? err.message : "ساخت پروژه ناموفق بود.");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
if (!activeOrganization) {
|
|
return (
|
|
<EmptyState
|
|
title="سازمانی انتخاب نشده است"
|
|
description="برای دیدن پروژهها ابتدا یک سازمان بسازید."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="anim-stagger flex flex-col gap-5">
|
|
<header className="flex flex-col gap-1">
|
|
<h1 className="text-lg font-medium text-slate-100">پروژهها</h1>
|
|
<p className="text-sm text-slate-400">
|
|
هر پروژه میتواند یک مخزن خصوصی، یک محیط اجرای ایزوله و یک دامنهٔ تست داشته باشد.
|
|
</p>
|
|
</header>
|
|
|
|
<Card>
|
|
<CardTitle>ساخت پروژه</CardTitle>
|
|
<CardHint>نام پروژه به یک slug یکتا در سازمان تبدیل میشود.</CardHint>
|
|
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
|
<div className="flex-1">
|
|
<Field
|
|
label="نام پروژه"
|
|
required
|
|
minLength={2}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="مثلاً فروشگاه آنلاین"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<Field
|
|
label="توضیح (اختیاری)"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button type="submit" loading={creating}>
|
|
ساخت پروژه
|
|
</Button>
|
|
</form>
|
|
{formError && (
|
|
<div className="mt-3">
|
|
<ErrorState title="ساخت پروژه ناموفق" message={formError} />
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{loading ? (
|
|
<LoadingState label="در حال دریافت پروژهها…" />
|
|
) : error ? (
|
|
<ErrorState message={error} onRetry={load} />
|
|
) : projects.length === 0 ? (
|
|
<EmptyState
|
|
title="هنوز پروژهای نساختهاید"
|
|
description="اولین پروژه را با فرم بالا بسازید؛ سپس میتوانید مخزن Git و دامنهٔ تست بسازید."
|
|
/>
|
|
) : (
|
|
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{projects.map((project) => (
|
|
<Card key={project.id} className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-sm font-medium text-slate-100">{project.name}</p>
|
|
<p className="text-xs text-slate-500 ltr-inline">{project.slug}</p>
|
|
</div>
|
|
{project.description && (
|
|
<p className="text-xs text-slate-400">{project.description}</p>
|
|
)}
|
|
<Link href={`/projects/${project.id}`} className="mt-auto">
|
|
<Button variant="secondary" size="sm" className="w-full">
|
|
جزئیات پروژه
|
|
</Button>
|
|
</Link>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|