"use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { BookOpen, ChevronDown, ChevronUp, Code2, Copy, Cpu, Eye, EyeOff, Flag, GitBranch, Globe, GripVertical, Heart, Layers, Loader2, Lock, MapPin, MoreHorizontal, Pencil, Plus, Rocket, Save, 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 { 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"; import { Switch } from "@/components/ui/switch"; import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar"; import { RichEditor } from "@/components/ui/rich-editor"; 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 { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav"; 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 { BotPanel } from "@/components/admin/bot-panel"; import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel"; import { DEFAULT_PAGE_SIZE, ListPagination, buildPageQuery, type Paginated, } from "@/components/admin/list-pagination"; import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields"; 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 ? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت." : MENTOR_HINT}

({ title: group.title, items: group.items.map((item) => ({ id: item.id, label: item.label })), }))} activeId={tab} onSelect={setTab} /> {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 === "bots" && 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 && (u.isFrozen || u.isClosed) && ( )}
); })} {users.length === 0 && !isLoading && (

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

)}
setDialogUser(null)} onSaved={invalidate} /> ); } export 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 [saving, setSaving] = useState(false); useEffect(() => { if (user) { setForm(user); setPassword(""); setSecurityAnswer(""); } }, [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); } }; return ( !o && onClose()}> {isNew ? "کاربر جدید" : `ویرایش «${form.name}»`} {isNew ? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر." : "اطلاعات کاربر را به‌روزرسانی کنید."}
setForm({ ...form, name: value })} onEmail={(value) => setForm({ ...form, email: value })} onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })} phoneMaxLength={PHONE_INPUT_MAX_LEN} /> setForm({ ...form, username: value })} />
{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" />
)}
); } 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 [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++) { 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 publishLesson = async (l: Lesson, publish: boolean) => { try { await http.post(`/api/admin/lessons/${l.id}/publish`, { publish }); qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); toast.success(publish ? "درس منتشر شد" : "درس به پیش‌نویس برگشت"); } catch (e) { toast.error(toUserError(e)); } }; const publishChapter = async (ch: Chapter, publish: boolean) => { try { await http.post(`/api/admin/chapters/${ch.id}/publish`, { publish }); qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); toast.success(publish ? "فصل منتشر شد" : "فصل به پیش‌نویس برگشت"); } catch (e) { toast.error(toUserError(e)); } }; 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 { 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}`); if (activeChapter === ch.id) setActiveChapter(null); qc.invalidateQueries({ queryKey: ["admin", "lessons"] }); qc.invalidateQueries({ queryKey: ["admin", "stats"] }); toast.success("فصل حذف شد"); } catch (e) { toast.error(toUserError(e)); } }; const publishedChapters = chapters.filter((c) => c.isPublished).length; const publishedLessons = lessons.filter((l) => l.isPublished).length; return ( <> {dialog} {/* Course overview header */}

دوره آموزشی

{chapters.length} فصل {lessons.length} درس {publishedChapters} فصل منتشر {publishedLessons} درس منتشر
{/* 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 */} {/* 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 collision = ls.some( (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"; }} onDrop={() => { if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) { const next = reorderArray(ls, dragIdx.from, li); persistLessonOrder(next).catch(() => {}); } setDragIdx(null); }} onDragEnd={() => setDragIdx(null)} className={cn( "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", )} > {/* Grip handle */} {/* Lesson identity */}

{l.title}

{formatDur(l.durationSeconds)} +{formatFaNumber(l.xpReward)} {l.videoKey ? ( ) : ( بدون ویدیو )} {collision && ( تداخل موقعیت )}
{/* Publish badge */} {l.isPublished ? "منتشر" : "پیش‌نویس"} {/* 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 && (

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

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

)} 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, isPublished: false }); 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 کاراکتر

set({ description: v })} placeholder="چرا این درس مهم است و چه چیزی می‌آموزد؟" />
set({ videoKey })} /> {draft.videoKey ? ( ) : (

)}
{/* ساختار */}
} text="ساختار و گره‌های درخت" />
{cycle && (

این پیش‌نیاز زنجیرهٔ چرخه‌ای می‌سازد و هرگز باز نمی‌شود.

)} {prereq && !cycle && (

تنها پس از قبولی در «{prereq.title}» (L{prereq.id})، این درس برای هنرجو باز می‌شود.

)}
set({ x: v })} /> set({ y: v })} /> set({ sortOrder: v })} />
{collision && (

جای {draft.x},{draft.y} همراستا با درس دیگری است — روی درخت روی هم می‌افتند.

)}
{/* امتیاز و آزمون */}
} text="امتیاز و آزمون" />
set({ durationSeconds: v })} /> set({ completionThresholdPct: Math.min(100, Math.max(1, v)) })} /> set({ xpReward: v })} />
{thrOk && (

آزمون با {formatDur(requiredSeconds)} تماشای تأییدشده از میان {formatDur(draft.durationSeconds)} باز می‌شود؛ قبولیِ تک‌تک پرسش‌ها، شاخه‌های بعدی را آزاد می‌کند. روی هر پاسخ اشتباه یک جان کسر می‌شود.

)}
{/* پیش‌نمایش زنده */}
); } function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { return (
onChange(Number(e.target.value))} />
); } function SectionTitle({ icon, text }: { icon: React.ReactNode; text: string }) { return (

{icon} {text}

); } function AdminEventsPanel() { const qc = useQueryClient(); const { confirm, dialog } = useConfirm(); const [modal, setModal] = useState(null); const { data, isLoading } = useQuery({ queryKey: ["admin", "events"], queryFn: () => http.get<{ events: AdminEvent[] }>("/api/admin/events"), }); const save = async (ev: AdminEvent) => { if (!isValidIsoRange(ev.startsAt, ev.endsAt)) { toast.error("زمان پایان باید پس از زمان شروع باشد"); return; } try { const { createdAt: _c, rsvped: _r, ...body } = ev; if (ev.id) await http.put(`/api/admin/events/${ev.id}`, body); else await http.post("/api/admin/events", body); setModal(null); qc.invalidateQueries({ queryKey: ["admin", "events"] }); toast.success(ev.id ? "رویداد به‌روزرسانی شد" : "رویداد ساخته شد"); } catch (e) { toast.error(toUserError(e)); } }; const del = async (ev: AdminEvent) => { if (!(await confirm("حذف رویداد", `رویداد «${ev.title}» حذف شود؟`))) return; try { await http.del(`/api/admin/events/${ev.id}`); qc.invalidateQueries({ queryKey: ["admin", "events"] }); toast.success("رویداد حذف شد"); } catch (e) { toast.error(toUserError(e)); } }; const fmt = (iso: string) => new Intl.DateTimeFormat("fa-IR", { weekday: "short", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(iso)); return ( <> {dialog}
فهرست رویدادها

در مرکز هنرجو نمایش داده می‌شوند.

{isLoading && }
{(data?.events ?? []).map((e) => (
{e.eventType === "workshop" ? "کارگاه" : e.eventType === "meet" ? "جلسه" : "آنلاین"} {e.isActive ? "فعال" : "پنهان"}
{e.externalUrl && ( )}

{e.title}

{e.description || "—"}

{fmt(e.startsAt)} تا {new Date(e.endsAt).toLocaleTimeString("fa-IR")}

))}
{(data?.events ?? []).length === 0 && !isLoading && (

هنوز رویدادی نیست.

)}
setModal(null)} onSave={save} />
); } function EventDialog({ event, onClose, onSave }: { event: AdminEvent | null; onClose: () => void; onSave: (e: AdminEvent) => void }) { const [draft, setDraft] = useState(event); useEffect(() => setDraft(event), [event]); const set = (patch: Partial) => setDraft((d) => (d ? { ...d, ...patch } : d)); const valid = !!draft?.title.trim() && !!draft?.startsAt && !!draft?.endsAt && isValidIsoRange(draft.startsAt, draft.endsAt); return ( !o && onClose()}> {draft?.id ? "ویرایش رویداد" : "رویداد جدید"} زمان‌ها با تقویم جلالی انتخاب می‌شوند و در مرکز هنرجو نمایش داده می‌شوند. {draft && (
set({ title: e.target.value })} placeholder="مثلاً کارگاه Git و گیت‌هاب" />