diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5851c0..b6c570d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^22.10.0", "@types/react": "^19.0.0", @@ -796,6 +797,22 @@ "node": ">= 10" } }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", @@ -2973,6 +2990,35 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1306a2b..8cca361 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^22.10.0", "@types/react": "^19.0.0", diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..68f02f9 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 194480e..c1ea4f4 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..f8ab2ad Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..9c52165 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png index 47c3d57..17069a6 100644 Binary files a/frontend/public/logo.png and b/frontend/public/logo.png differ diff --git a/frontend/src/app/cap/page.tsx b/frontend/src/app/cap/page.tsx index 68c6525..748ef26 100644 --- a/frontend/src/app/cap/page.tsx +++ b/frontend/src/app/cap/page.tsx @@ -73,13 +73,6 @@ function CapPageInner() { enabled: !!user && curriculumDone, }); - useEffect(() => { - if (!curriculumDone || !user) return; - void http - .post("/api/me/certificate/issue", {}) - .then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] })) - .catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"))); - }, [curriculumDone, user, qc]); if (isLoading) { return ; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 109e9c9..ac77aca 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -35,6 +35,14 @@ const vazirmatnLatin = localFont({ export const metadata: Metadata = { title: "پرگار", description: "مسیر یادگیری واقعی پرگار", + icons: { + icon: [ + { url: "/favicon.ico" }, + { url: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, + }, }; export const viewport: Viewport = { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 76e7f27..8a1ace3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -93,6 +93,7 @@ export default function Home() { const goCap = () => router.push("/cap"); const goLogin = () => router.push("/login"); + const goRegister = () => router.push("/register"); return (
@@ -134,7 +135,7 @@ export default function Home() { - @@ -288,7 +289,7 @@ export default function Home() { ورود diff --git a/frontend/src/components/admin-studio.tsx b/frontend/src/components/admin-studio.tsx index 43c06b3..7b6f852 100644 --- a/frontend/src/components/admin-studio.tsx +++ b/frontend/src/components/admin-studio.tsx @@ -12,6 +12,7 @@ import { Copy, Cpu, Eye, + EyeOff, Flag, GitBranch, Globe, @@ -21,6 +22,7 @@ import { Loader2, Lock, MapPin, + MoreHorizontal, Pencil, Plus, Rocket, @@ -59,6 +61,15 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { PasswordInput } from "@/components/ui/password-input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; @@ -685,7 +696,11 @@ function CurriculumPanel() { const [lessonModal, setLessonModal] = useState(null); const [chapterModal, setChapterModal] = useState(null); - const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number; over?: number } | null>(null); + const [activeChapter, setActiveChapter] = useState(null); + const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number } | null>(null); + + const toggleChapter = (id: number) => + setActiveChapter((prev) => (prev === id ? null : id)); const persistChapterOrder = async (list: Chapter[]) => { for (let i = 0; i < list.length; i++) { @@ -772,12 +787,14 @@ function CurriculumPanel() { } }; - const newLesson = (chapterId: number) => + const newLesson = (chapterId: number) => { + setActiveChapter(chapterId); setLessonModal({ id: 0, chapterId, title: "", description: "", videoKey: "", durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined, x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false, }); + }; const duplicateLesson = async (l: Lesson) => { try { @@ -812,6 +829,7 @@ function CurriculumPanel() { } try { await http.del(`/api/admin/chapters/${ch.id}`); + if (activeChapter === ch.id) setActiveChapter(null); qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); qc.invalidateQueries({ queryKey: ["admin", "stats"] }); toast.success("فصل حذف شد"); @@ -820,70 +838,159 @@ function CurriculumPanel() { } }; + const publishedChapters = chapters.filter((c) => c.isPublished).length; + const publishedLessons = lessons.filter((l) => l.isPublished).length; + return ( <> {dialog} - - -
- فصل‌ها و درخت مهارت -

- {chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y -

+ + {/* Course overview header */} +
+
+

دوره آموزشی

+
+ {chapters.length} فصل + {lessons.length} درس + {publishedChapters} فصل منتشر + {publishedLessons} درس منتشر
- - - - {isLoading && } - {chapters.map((ch, ci) => { - const ls = lessons.filter((l) => l.chapterId === ch.id); - const ChIcon = chapterIconOf(ch.icon); - return ( -
-
- +
+ +
+ + {/* Chapter list */} + {isLoading && } + +
+ {chapters.map((ch, ci) => { + const ls = lessons.filter((l) => l.chapterId === ch.id); + const ChIcon = chapterIconOf(ch.icon); + const isOpen = activeChapter === ch.id; + + return ( +
+ {/* Chapter header row */} +
+ {/* Expand toggle + identity — takes full remaining width */} + - - - -
- {ls.length === 0 ? ( -

هنوز درسی نیست. «+ درس» را بزنید.

- ) : ( -
-
+ + + {/* Status badges */} + + {ls.length} درس + + + {ch.isPublished ? "منتشر" : "پیش‌نویس"} + + + {/* Chapter actions menu */} + + + + + + فصل {ci + 1} + setChapterModal(ch)}> + ویرایش فصل + + { + setActiveChapter(ch.id); + newLesson(ch.id); + }} + > + درس جدید + + + {ci > 0 && ( + moveChapter(ci, -1)}> + جابجا به بالا + + )} + {ci < chapters.length - 1 && ( + moveChapter(ci, 1)}> + جابجا به پایین + + )} + publishChapter(ch, !ch.isPublished)}> + {ch.isPublished ? ( + <> برگشت به پیش‌نویس + ) : ( + <> انتشار فصل + )} + + + delChapter(ch)} + > + حذف فصل + + + +
+ + {/* Lessons panel (progressive disclosure) */} + {isOpen && ( +
+ {ls.length === 0 ? ( +
+ هنوز درسی نیست — از دکمه زیر درس اضافه کنید. +
+ ) : ( +
{ls.map((l, li) => { - const cols = ls.filter((o) => o.x === l.x && o.y === l.y && o.id !== l.id); + const collision = ls.some( + (o) => o.x === l.x && o.y === l.y && o.id !== l.id, + ); return (
{ e.preventDefault(); e.dataTransfer.dropEffect = "move"; - setDragIdx((d) => (d ? { ...d, over: li, chapter: ch.id } : d)); }} onDrop={() => { if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) { - const idx = dragIdx.from; - const next = reorderArray(ls, idx, li); + const next = reorderArray(ls, dragIdx.from, li); persistLessonOrder(next).catch(() => {}); } setDragIdx(null); }} onDragEnd={() => setDragIdx(null)} className={cn( - "group flex cursor-grab items-center gap-2 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors active:cursor-grabbing", + "flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-muted/40", dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40", - !l.videoKey && "border-dashed" )} > - - L{l.id} + {/* Grip handle */} + + + {/* Lesson identity */}
-

{l.title}

-
- {formatDur(l.durationSeconds)} - +{formatFaNumber(l.xpReward)} - {l.x},{l.y} +

+ {l.title} +

+
+ + + {formatDur(l.durationSeconds)} + + + + +{formatFaNumber(l.xpReward)} + {l.videoKey ? ( - + + ) : ( بدون ویدیو )} - {cols.length > 0 && تداخل} + {collision && ( + + تداخل موقعیت + + )}
- + + {/* Publish badge */} + {l.isPublished ? "منتشر" : "پیش‌نویس"} -
- 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact /> - - - - -
+ + {/* Up/down reorder (accessible) */} + 0 ? () => moveLesson(ch.id, li, -1) : undefined} + onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} + compact + /> + + {/* Edit (primary action) */} + + + {/* Secondary actions menu */} + + + + + + L{l.id} + duplicateLesson(l)}> + تکثیر + + publishLesson(l, !l.isPublished)}> + {l.isPublished ? ( + <> برگشت به پیش‌نویس + ) : ( + <> انتشار درس + )} + + + delLesson(l)} + > + حذف درس + + +
); })}
+ )} + + {/* Add lesson footer */} +
+
- )} -
- ); - })} - {chapters.length === 0 && !isLoading && ( -
-

هنوز فصلی نیست

-

با «فصل جدید» شروع کنید؛ درس‌های هر فصل روی درخت مهارت هنرجو نمایش داده می‌شود.

+
+ )}
- )} - - + ); + })} +
+ + {chapters.length === 0 && !isLoading && ( +
+

هنوز فصلی نیست

+

+ با «فصل جدید» شروع کنید؛ درس‌های هر فصل روی درخت مهارت هنرجو نمایش داده می‌شود. +

+ +
+ )} setChapterModal(null)} onSave={saveChapter} />
-