"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 (
({
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 (
);
}
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 (
);
}
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 (
);
}
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 (
);
}
function Loader({ className }: { className?: string }) {
return (
{[0, 1, 2, 3].map((i) => (
))}
);
}
function LoaderRow() {
return (
);
}