diff --git a/frontend/src/components/admin-studio.tsx b/frontend/src/components/admin-studio.tsx new file mode 100644 index 0000000..b092229 --- /dev/null +++ b/frontend/src/components/admin-studio.tsx @@ -0,0 +1,1624 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + BookOpen, + ChevronDown, + ChevronUp, + Code2, + Copy, + Cpu, + Eye, + Flag, + GitBranch, + Globe, + GripVertical, + Heart, + KeyRound, + Layers, + Loader2, + Lock, + MapPin, + Pencil, + Plus, + Rocket, + Save, + ShieldCheck, + Sparkles, + Target, + Terminal, + Timer, + TriangleAlert, + Trash2, + Unlock, + Video, + Zap, +} from "lucide-react"; +import { http, signedMediaUrl, toUserError } from "@/lib/api"; +import type { + AdminEvent, + Chapter, + Lesson, + Role, + User, +} from "@/lib/types"; +import { useAuth } from "@/lib/auth-store"; +import { + clampPhoneInput, + normalizePhone, + PHONE_INPUT_MAX_LEN, + validateEmail, + validateName, + validatePassword, + validatePhone, + validateSecurityQA, + validateUsername, +} from "@/lib/validation"; +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 { PasswordInput } from "@/components/ui/password-input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils"; +import { toast } from "@/components/providers"; +import { + ADMIN_NAV, + defaultAdminTab, + findAdminNavItem, + isValidAdminTab, + type AdminTabId, +} from "@/components/admin/nav"; +import { AdminPanelHeader } from "@/components/admin/panel-header"; +import { useConfirm } from "@/components/admin/use-confirm"; +import { StatsPanel } from "@/components/admin/stats-panel"; +import { LearningPanel } from "@/components/admin/learning-panel"; +import { QuestionsPanel } from "@/components/admin/questions-panel"; +import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel"; +import { AnnouncePanel } from "@/components/admin/announce-panel"; +import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel"; +import { + DEFAULT_PAGE_SIZE, + ListPagination, + buildPageQuery, + type Paginated, +} from "@/components/admin/list-pagination"; +import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar"; +import { SiteSettingsPanel } from "@/components/admin/site-settings-panel"; +import { RegistrationPanel } from "@/components/admin/registration-panel"; +import { InvitesPanel } from "@/components/admin/invites-panel"; +import { WhitelistPanel } from "@/components/admin/whitelist-panel"; +import { BlacklistPanel } from "@/components/admin/blacklist-panel"; +import { ChatExportPanel } from "@/components/admin/chat-export-panel"; +import { ChallengesPanel } from "@/components/admin/challenges-panel"; +import { JalaliDateTimePicker, isValidIsoRange } from "@/components/ui/jalali-datetime-picker"; + +const CHAPTER_ICON_OPTIONS: { key: string; icon: typeof Terminal; label: string }[] = [ + { key: "terminal", icon: Terminal, label: "ترمینال" }, + { key: "chip", icon: Cpu, label: "تراشه" }, + { key: "rocket", icon: Rocket, label: "موشک" }, + { key: "book", icon: BookOpen, label: "کتاب" }, + { key: "layers", icon: Layers, label: "لایه‌ها" }, + { key: "flag", icon: Flag, label: "پرچم" }, + { key: "target", icon: Target, label: "هدف" }, + { key: "zap", icon: Zap, label: "صاعقه" }, + { key: "code", icon: Code2, label: "کد" }, + { key: "globe", icon: Globe, label: "دنیا" }, +]; + +function chapterIconOf(key: string) { + return CHAPTER_ICON_OPTIONS.find((o) => o.key === key)?.icon ?? Layers; +} + +async function openSignedMedia(key: string) { + if (!key) return; + try { + const url = await signedMediaUrl(key); + window.open(url, "_blank", "noopener,noreferrer"); + } catch (e) { + toast.error(toUserError(e, "باز کردن فایل ممکن نشد")); + } +} + +async function copyToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text); + toast.success("در کلیپ‌بورد کپی شد"); + } catch { + toast.error("کپی ممکن نشد"); + } +} + +function formatDur(sec: number) { + if (!sec || sec < 60) return `${sec || 0} ث`; + const m = Math.floor(sec / 60); + const s = sec % 60; + return s ? `${m}د ${s}ث` : `${m} دقیقه`; +} + +const MENTOR_HINT = + "وضعیت کلی یادگیری هنرجویان، دروس، سوالات، ویدیوها و رویدادهای آموزشی."; + +export function AdminStudio() { + const user = useAuth((s) => s.user); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isAdmin = user?.role === "admin"; + + useEffect(() => { + if (user && user.role !== "admin" && user.role !== "mentor") { + router.replace("/cap"); + } + }, [user, router]); + + if (user && user.role !== "admin" && user.role !== "mentor") { + return null; + } + + const rawTab = searchParams.get("tab") ?? ""; + const tab: AdminTabId = isValidAdminTab(rawTab, !!isAdmin) + ? rawTab + : defaultAdminTab(!!isAdmin); + + const setTab = useCallback( + (next: AdminTabId) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", next); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [pathname, router, searchParams] + ); + + useEffect(() => { + if (!isValidAdminTab(rawTab, !!isAdmin)) { + setTab(defaultAdminTab(!!isAdmin)); + } + }, [rawTab, isAdmin, setTab]); + + const navItem = findAdminNavItem(tab); + const visibleGroups = ADMIN_NAV.map((group) => ({ + ...group, + items: group.items.filter((i) => !i.adminOnly || isAdmin), + })).filter((g) => g.items.length > 0); + + return ( +
+
+
+

{isAdmin ? "استودیو ادمین" : "استودیو منتور"}

+

+ {isAdmin ? ( + <> + پنل مدیریت + + ) : ( + <> + پنل منتور + + )} +

+

+ {isAdmin + ? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت — گروه‌بندی‌شده تا چیزی گم نشود." + : MENTOR_HINT} +

+
+ + {isAdmin ? "ادمین" : "منتور"} + +
+ + {/* Mobile: horizontal grouped chips */} + + +
+ + +
+ {navItem && ( + + )} + {tab === "stats" && isAdmin && } + {tab === "learning" && } + {tab === "users" && isAdmin && } + {tab === "orders" && isAdmin && } + {tab === "registration" && isAdmin && } + {tab === "invites" && isAdmin && } + {tab === "whitelist" && isAdmin && } + {tab === "blacklist" && isAdmin && } + {tab === "settings" && isAdmin && } + {tab === "curriculum" && } + {tab === "questions" && } + {tab === "videos" && } + {tab === "events" && } + {tab === "challenges" && isAdmin && } + {tab === "announce" && isAdmin && } + {tab === "chat-export" && isAdmin && } +
+
+
+ ); +} +function UsersPanel() { + const qc = useQueryClient(); + const currentUser = useAuth((s) => s.user); + const isStaff = currentUser?.role === "admin" || currentUser?.role === "mentor"; + const [q, setQ] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [page, setPage] = useState(1); + const [busy, setBusy] = useState(null); + const [dialogUser, setDialogUser] = useState(null); + const { confirm, dialog } = useConfirm(); + + useEffect(() => { + setPage(1); + }, [q, roleFilter]); + + const { data, isLoading } = useQuery({ + queryKey: ["admin", "users", q, roleFilter, page], + queryFn: () => { + const params = new URLSearchParams(buildPageQuery(page)); + if (q) params.set("q", q); + if (roleFilter) params.set("role", roleFilter); + return http.get>(`/api/admin/users?${params.toString()}`); + }, + }); + + const users = data?.items ?? []; + const total = data?.total ?? 0; + const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE; + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["admin", "users"] }); + qc.invalidateQueries({ queryKey: ["admin", "stats"] }); + }; + + const act = async (id: number, kind: "lock" | "unlock") => { + setBusy(id); + try { + if (kind === "lock") await http.post(`/api/admin/users/${id}/lock`); + else await http.post(`/api/admin/users/${id}/unlock`); + invalidate(); + } catch (e) { + toast.error(toUserError(e)); + } finally { + setBusy(null); + } + }; + + const del = async (u: User) => { + if ( + !(await confirm( + "حذف کاربر", + `حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.` + )) + ) { + return; + } + setBusy(u.id); + try { + await http.del(`/api/admin/users/${u.id}`); + toast.success("کاربر حذف شد"); + invalidate(); + } catch (e) { + toast.error(toUserError(e)); + } finally { + setBusy(null); + } + }; + + return ( + <> + {dialog} + + +
+ فهرست حساب‌ها + {isStaff && ( + + )} +
+ + setQ(e.target.value)} + className="max-w-xs" + /> + setRoleFilter(e.target.value)} + > + + + {isStaff && } + {currentUser?.role === "admin" && } + + {(q || roleFilter) && ( + + )} + +
+ +
+ {isLoading && ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ +
+ ))} +
+ )} + {(users).map((u) => { + const canMutate = + currentUser?.role === "admin" || + (currentUser?.role === "mentor" && u.role === "student"); + return ( +
+ +
+

+ {u.name} + {u.id === currentUser?.id && شما} + {u.isLocked && قفل شده} + {u.role === "admin" && ادمین} +

+

+ @{u.username} · {u.email} + {u.phone ? ` · ${u.phone}` : ""} +

+
+
+ {formatFaNumber(u.xp)} امتیاز + {formatStreakLabel(u.streakCurrent)} + + {" "} + {u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)} + +
+
+ {canMutate && u.id !== currentUser?.id && ( + u.isLocked ? ( + + ) : ( + + ) + )} + {canMutate && ( + + )} + {canMutate && u.id !== currentUser?.id && ( + + )} +
+
+ ); + })} + {users.length === 0 && !isLoading && ( +

کاربری یافت نشد.

+ )} +
+
+ +
+
+
+ setDialogUser(null)} onSaved={invalidate} /> + + ); +} + +function UserDialog({ + user, + onClose, + onSaved, +}: { + user: User | null; + onClose: () => void; + onSaved: () => void; +}) { + const currentUser = useAuth((s) => s.user); + const isNew = !!user && user.id === 0; + const [form, setForm] = useState(user ?? ({} as User)); + const [password, setPassword] = useState(""); + const [securityAnswer, setSecurityAnswer] = useState(""); + const [resetAnswer, setResetAnswer] = useState(""); + const [resetPassword, setResetPassword] = useState(""); + const [saving, setSaving] = useState(false); + const [resetting, setResetting] = useState(false); + + useEffect(() => { + if (user) { + setForm(user); + setPassword(""); + setSecurityAnswer(""); + setResetAnswer(""); + setResetPassword(""); + } + }, [user]); + + const isSelf = !isNew && !!user && user.id === currentUser?.id; + + const save = async () => { + if (!form) return; + const nameErr = validateName(form.name ?? ""); + if (nameErr) { + toast.error(nameErr); + return; + } + const userErr = validateUsername(form.username ?? ""); + if (userErr) { + toast.error(userErr); + return; + } + const emailErr = validateEmail(form.email ?? ""); + if (emailErr) { + toast.error(emailErr); + return; + } + const phoneErr = validatePhone(form.phone ?? "", false); + if (phoneErr) { + toast.error(phoneErr); + return; + } + if (isSelf && form.role !== currentUser?.role) { + toast.error("نمی‌توانید نقش حساب خودتان را تغییر دهید"); + return; + } + if (isNew) { + const pwErr = validatePassword(password); + if (pwErr) { + toast.error(pwErr); + return; + } + const secErr = validateSecurityQA(form.securityQuestion ?? "", securityAnswer); + if (secErr) { + toast.error(secErr); + return; + } + } else if (securityAnswer.trim()) { + const q = (form.securityQuestion ?? "").trim(); + if (q) { + const secErr = validateSecurityQA(q, securityAnswer); + if (secErr) { + toast.error(secErr); + return; + } + } else if ([...securityAnswer.trim()].length < 2) { + toast.error("جواب امنیتی خیلی کوتاه است"); + return; + } + } + setSaving(true); + try { + if (isNew) { + await http.post("/api/admin/users", { + name: (form.name ?? "").trim(), + email: (form.email ?? "").trim().toLowerCase(), + username: (form.username ?? "").trim().toLowerCase(), + password, + role: form.role, + phone: normalizePhone(form.phone ?? ""), + securityQuestion: (form.securityQuestion ?? "").trim(), + securityAnswer: securityAnswer.trim(), + }); + toast.success("کاربر ساخته شد"); + } else { + await http.put(`/api/admin/users/${form.id}`, { + name: (form.name ?? "").trim(), + email: (form.email ?? "").trim().toLowerCase(), + username: (form.username ?? "").trim().toLowerCase(), + phone: normalizePhone(form.phone ?? ""), + ...(form.securityQuestion?.trim() + ? { securityQuestion: form.securityQuestion.trim() } + : {}), + ...(securityAnswer.trim() ? { securityAnswer: securityAnswer.trim() } : {}), + ...(isSelf ? {} : { role: form.role, hearts: form.hearts, xp: form.xp }), + }); + toast.success("کاربر به‌روزرسانی شد"); + } + onSaved(); + onClose(); + } catch (e) { + toast.error(toUserError(e)); + } finally { + setSaving(false); + } + }; + + const doReset = async () => { + if (!form?.id) return; + const pwErr = validatePassword(resetPassword); + if (!resetAnswer.trim() || pwErr) { + toast.error(pwErr ?? "جواب امنیتی و رمز جدید (حداقل ۸ کاراکتر) لازم است"); + return; + } + setResetting(true); + try { + await http.post(`/api/admin/users/${form.id}/reset-password`, { + securityAnswer: resetAnswer, + newPassword: resetPassword, + }); + toast.success("رمز عبور ریست شد"); + setResetAnswer(""); + setResetPassword(""); + } catch (e) { + toast.error(toUserError(e)); + } finally { + setResetting(false); + } + }; + + return ( + !o && onClose()}> + + + {isNew ? "کاربر جدید" : `ویرایش «${form.name}»`} + + {isNew + ? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر." + : "اطلاعات کاربر را به‌روزرسانی کنید. ریست رمز فقط با جواب امنیتی."} + + +
+
+ + setForm({ ...form, name: e.target.value })} /> +
+
+ + setForm({ ...form, username: e.target.value.toLowerCase() })} + dir="ltr" + className="text-left" + placeholder="مثلاً ahp" + /> +
+
+ + setForm({ ...form, email: e.target.value })} /> +
+
+ + setForm({ ...form, phone: clampPhoneInput(e.target.value) })} + dir="ltr" + className="text-left" + placeholder="09xxxxxxxxx" + inputMode="tel" + maxLength={PHONE_INPUT_MAX_LEN} + /> +
+
+ + + {isSelf && ( +

نقش حساب خودتان قابل تغییر نیست.

+ )} + {!isSelf && currentUser?.role === "mentor" && ( +

منتور فقط می‌تواند نقش هنرجو را تنظیم کند.

+ )} +
+ {!isNew && !isSelf && ( +
+
+ + + setForm({ ...form, hearts: Math.max(0, Math.min(3, Number(e.target.value) || 0)) }) + } + /> +
+
+ + setForm({ ...form, xp: Number(e.target.value) })} /> +
+
+ )} +
+ + setForm({ ...form, securityQuestion: e.target.value })} + placeholder="مثلاً نام اولین مدرسه؟" + /> +
+
+ + setSecurityAnswer(e.target.value)} + placeholder={isNew ? "الزامی" : "فقط در صورت تغییر"} + dir="rtl" + autoComplete="off" + /> +
+ {isNew && ( +
+ + setPassword(e.target.value)} + minLength={8} + placeholder="حداقل ۸ کاراکتر" + required + autoComplete="new-password" + /> +
+ )} + {!isNew && ( +
+

ریست رمز با سوال امنیتی

+ {form.securityQuestion ? ( +

سوال: {form.securityQuestion}

+ ) : ( +

سوال امنیتی تعریف نشده است.

+ )} + setResetAnswer(e.target.value)} + placeholder="جواب امنیتی" + dir="rtl" + autoComplete="off" + /> + setResetPassword(e.target.value)} + placeholder="رمز جدید (حداقل ۸ کاراکتر)" + minLength={8} + autoComplete="new-password" + /> + +
+ )} +
+ + + + +
+
+ ); +} + +function CurriculumPanel() { + const qc = useQueryClient(); + const { confirm, dialog } = useConfirm(); + const { data, isLoading } = useQuery({ + queryKey: ["admin", "lessons"], + queryFn: async () => { + const [c, l] = await Promise.all([ + http.get<{ chapters: Chapter[] }>("/api/admin/chapters"), + http.get<{ lessons: Lesson[] }>("/api/admin/lessons"), + ]); + return { chapters: c.chapters, lessons: l.lessons }; + }, + }); + const chapters = [...(data?.chapters ?? [])].sort((a, b) => a.sortOrder - b.sortOrder); + const lessons = (data?.lessons ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder); + + const [lessonModal, setLessonModal] = useState(null); + const [chapterModal, setChapterModal] = useState(null); + const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number; over?: number } | null>(null); + + const persistChapterOrder = async (list: Chapter[]) => { + for (let i = 0; i < list.length; i++) { + const ch = list[i]; + if (ch.sortOrder !== i + 1) await http.put(`/api/admin/chapters/${ch.id}`, { ...ch, sortOrder: i + 1 }); + } + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + }; + + const persistLessonOrder = async (list: Lesson[]) => { + for (let i = 0; i < list.length; i++) { + const l = list[i]; + if (l.sortOrder !== i + 1) await http.put(`/api/admin/lessons/${l.id}`, { ...l, sortOrder: i + 1 }); + } + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + }; + + const moveChapter = async (idx: number, delta: number) => { + const to = idx + delta; + if (to < 0 || to >= chapters.length) return; + const next = reorderArray(chapters, idx, to); + try { + await persistChapterOrder(next); + toast.success("ترتیب فصل‌ها به‌روزرسانی شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + const moveLesson = async (chapterId: number, idx: number, delta: number) => { + const ls = lessons.filter((l) => l.chapterId === chapterId); + const to = idx + delta; + if (to < 0 || to >= ls.length) return; + const next = reorderArray(ls, idx, to); + try { + await persistLessonOrder(next); + toast.success("ترتیب درس‌ها به‌روزرسانی شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + const saveChapter = async (ch: Chapter) => { + try { + if (ch.id) await http.put(`/api/admin/chapters/${ch.id}`, ch); + else await http.post("/api/admin/chapters", { ...ch, sortOrder: chapters.length + 1, id: 0 }); + setChapterModal(null); + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + toast.success(ch.id ? "فصل به‌روزرسانی شد" : "فصل ساخته شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + const saveLesson = async (lv: Lesson) => { + try { + if (lv.id) await http.put(`/api/admin/lessons/${lv.id}`, lv); + else await http.post("/api/admin/lessons", lv); + setLessonModal(null); + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + toast.success(lv.id ? "درس به‌روزرسانی شد" : "درس ساخته شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + const newLesson = (chapterId: number) => + 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, + }); + + const duplicateLesson = async (l: Lesson) => { + try { + await http.post("/api/admin/lessons", { ...l, id: 0, title: `${l.title} (کپی)`, sortOrder: 1 }); + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + toast.success("کپی درس ساخته شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + const delLesson = async (l: Lesson) => { + if (!(await confirm("حذف درس", `درس «${l.title}» (L${l.id}) با سوالاتش حذف شود؟`))) return; + try { + await http.del(`/api/admin/lessons/${l.id}`); + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + qc.invalidateQueries({ queryKey: ["admin", "stats"] }); + toast.success("درس حذف شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + const delChapter = async (ch: Chapter) => { + if ( + !(await confirm( + "حذف فصل", + `فصل «${ch.title}» با همه درس‌هایش حذف شود؟ این عمل بازگشت‌ناپذیر است.` + )) + ) { + return; + } + try { + await http.del(`/api/admin/chapters/${ch.id}`); + qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); + qc.invalidateQueries({ queryKey: ["admin", "stats"] }); + toast.success("فصل حذف شد"); + } catch (e) { + toast.error(toUserError(e)); + } + }; + + return ( + <> + {dialog} + + +
+ فصل‌ها و درخت مهارت +

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

+
+ +
+ + {isLoading && } + {chapters.map((ch, ci) => { + const ls = lessons.filter((l) => l.chapterId === ch.id); + const ChIcon = chapterIconOf(ch.icon); + return ( +
+
+ + + +
+

{ch.title}

+ {ch.description &&

{ch.description}

} +
+ {ls.length} درس + + {ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو + + 0 ? () => moveChapter(ci, -1) : undefined} + onDown={ci < chapters.length - 1 ? () => moveChapter(ci, 1) : undefined} + /> + + + +
+ {ls.length === 0 ? ( +

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

+ ) : ( +
+
+ {ls.map((l, li) => { + const cols = ls.filter((o) => o.x === l.x && o.y === l.y && o.id !== l.id); + return ( +
setDragIdx({ chapter: ch.id, from: li })} + onDragOver={(e) => { + 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); + 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", + dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40", + !l.videoKey && "border-dashed" + )} + > + + L{l.id} +
+

{l.title}

+
+ {formatDur(l.durationSeconds)} + +{formatFaNumber(l.xpReward)} + {l.x},{l.y} + {l.videoKey ? ( + + ) : ( + بدون ویدیو + )} + {cols.length > 0 && تداخل} +
+
+ + {l.isActive ? "فعال" : "پیش‌نویس"} + +
+ 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact /> + + + +
+
+ ); + })} +
+
+ )} +
+ ); + })} + {chapters.length === 0 && !isLoading && ( +
+

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

+

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

+
+ )} +
+
+ + setChapterModal(null)} onSave={saveChapter} /> + setLessonModal(null)} + onSave={saveLesson} + /> + + ); +} + +function reorderArray(arr: T[], from: number, to: number): T[] { + if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr; + const next = [...arr]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +} + +function GroupNav({ + onUp, + onDown, + compact, +}: { + onUp?: () => void; + onDown?: () => void; + compact?: boolean; +}) { + return ( +
+ {onUp && ( + + )} + {onDown && ( + + )} +
+ ); +} + +function ChapterDialog({ + chapter, + onClose, + onSave, +}: { + chapter: Chapter | null; + onClose: () => void; + onSave: (c: Chapter) => void; +}) { + const isNew = !!chapter && chapter.id === 0; + const [draft, setDraft] = useState(chapter ?? ({} as Chapter)); + useEffect(() => { + if (chapter) setDraft(chapter); + }, [chapter]); + const ChIcon = chapterIconOf(draft.icon); + + return ( + !o && onClose()}> + + + {isNew ? "فصل جدید" : "ویرایش فصل"} + فصل‌ها گروه‌های دروس درخت مهارت هستند و نمادشان روی نقشهٔ مسیر دیده می‌شود. + +
+
+ + setDraft({ ...draft, title: e.target.value })} autoFocus /> +
+
+ + setDraft({ ...draft, description: e.target.value })} placeholder="مثلاً مبانی ترمینال و گیت" /> +
+
+ +
+ {CHAPTER_ICON_OPTIONS.map((o) => { + const active = (draft.icon ?? "") === o.key; + return ( + + ); + })} +
+
+
+ + + + +

+ در نقشهٔ مسیر با این نشان نمایش داده می‌شود. +

+
+
+ ); +} + +function LessonDialog({ + lesson, + chapters, + lessons, + onClose, + onSave, +}: { + lesson: Lesson | null; + chapters: Chapter[]; + lessons: Lesson[]; + onClose: () => void; + onSave: (l: Lesson) => void; +}) { + const [draft, setDraft] = useState(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true }); + useEffect(() => { + if (lesson) setDraft(lesson); + }, [lesson]); + const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + const isNew = draft.id === 0; + + const others = lessons.filter((l) => l.id !== draft.id); + const titleOk = draft.title.trim().length > 0; + const durOk = draft.durationSeconds > 0; + const thrOk = draft.completionThresholdPct >= 1 && draft.completionThresholdPct <= 100; + const valid = titleOk && durOk && thrOk; + + const cycle = useMemo(() => { + if (!draft.requiresLessonId) return false; + let node: number | null = draft.requiresLessonId; + const seen = new Set(); + for (let i = 0; i < lessons.length + 1 && node; i++) { + if (node === draft.id) return true; + if (seen.has(node)) break; + seen.add(node); + node = lessons.find((l) => l.id === node)?.requiresLessonId ?? null; + } + return false; + }, [draft.requiresLessonId, draft.id, lessons]); + + const collision = useMemo( + () => lessons.some((l) => l.id !== draft.id && l.chapterId === draft.chapterId && l.x === draft.x && l.y === draft.y), + [draft.chapterId, draft.id, draft.x, draft.y, lessons] + ); + + const prereq = lessons.find((l) => l.id === draft.requiresLessonId); + const requiredSeconds = Math.round((draft.durationSeconds * draft.completionThresholdPct) / 100); + + return ( + !o && onClose()}> + + + + {isNew ? "درس جدید" : `ویرایش درس L${draft.id}`} + {!isNew && ( + set({ isActive: v })} aria-label="وضعیت درس" /> + )} + + {draft.isActive ? "فعال" : "پیش‌نویس"} + + + برای فعال شدن آزمون، هنرجو باید {thrOk ? formatDur(requiredSeconds) : "—"} تماشای تاییدشده داشته باشد. + + +
+
+
+ {/* محتوا */} +
+ } text="محتوا" /> +
+ + set({ title: e.target.value })} + placeholder="مثلاً HTTP در عمل" + className={cn(!titleOk && "border-destructive")} + autoFocus + /> +

{draft.title.length}/120 کاراکتر

+
+
+ +