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 */}
+
+
+ {visibleGroups.map((group) => (
+
+
{group.title}
+
+ {group.items.map((item) => {
+ const Icon = item.icon;
+ const active = tab === item.id;
+ return (
+ setTab(item.id)}
+ className={cn(
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-bold transition-colors",
+ active
+ ? "bg-primary text-primary-foreground shadow-offset-sm"
+ : "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
+ )}
+ >
+
+ {item.label}
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+
+
+ {visibleGroups.map((group) => (
+
+
+ {group.title}
+
+
+ {group.items.map((item) => {
+ const Icon = item.icon;
+ const active = tab === item.id;
+ return (
+
+ setTab(item.id)}
+ aria-current={active ? "page" : undefined}
+ className={cn(
+ "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-bold transition-colors",
+ active
+ ? "bg-primary text-primary-foreground shadow-offset-sm"
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
+ )}
+ >
+
+ {item.label}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+ {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 && (
+
setDialogUser({ id: 0, email: "", username: "", name: "", role: "student", xp: 0, hearts: 3, heartsUpdatedAt: "", streakCurrent: 0, streakLongest: 0, isLocked: false, isActive: true, createdAt: "" })}>
+ کاربر جدید
+
+ )}
+
+
+ setQ(e.target.value)}
+ className="max-w-xs"
+ />
+ setRoleFilter(e.target.value)}
+ >
+ همه نقشها
+ هنرجوها
+ {isStaff && منتورها }
+ {currentUser?.role === "admin" && ادمینها }
+
+ {(q || roleFilter) && (
+ {
+ setQ("");
+ setRoleFilter("");
+ setPage(1);
+ }}
+ >
+ پاک کردن
+
+ )}
+
+
+
+
+ {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 ? (
+
act(u.id, "unlock")}>
+ {busy === u.id ? : } فعالسازی
+
+ ) : (
+
act(u.id, "lock")}>
+ {busy === u.id ? : } قفل
+
+ )
+ )}
+ {canMutate && (
+
setDialogUser(u)} title="ویرایش">
+
+
+ )}
+ {canMutate && u.id !== currentUser?.id && (
+
del(u)} title="حذف">
+ {busy === u.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}
+ />
+
+
+
نقش
+
setForm({ ...form, role: e.target.value as Role })}
+ >
+ هنرجو
+ {currentUser?.role === "admin" && منتور }
+ {currentUser?.role === "admin" && ادمین }
+
+ {isSelf && (
+
نقش حساب خودتان قابل تغییر نیست.
+ )}
+ {!isSelf && currentUser?.role === "mentor" && (
+
منتور فقط میتواند نقش هنرجو را تنظیم کند.
+ )}
+
+ {!isNew && !isSelf && (
+
+ )}
+
+ سوال امنیتی{!isNew && form.securityQuestion ? " (فعلی نمایش داده میشود)" : ""}
+ setForm({ ...form, securityQuestion: e.target.value })}
+ placeholder="مثلاً نام اولین مدرسه؟"
+ />
+
+
+
{isNew ? "جواب امنیتی" : "جواب امنیتی جدید (خالی = بدون تغییر)"}
+
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"
+ />
+
+ {resetting ? : }
+ ریست رمز
+
+
+ )}
+
+
+ انصراف
+
+ {saving ? : } {isNew ? "ساخت" : "ذخیره"}
+
+
+
+
+ );
+}
+
+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
+
+
+ setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
+ فصل جدید
+
+
+
+ {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}
+ />
+ setChapterModal(ch)}>
+ فصل
+
+ newLesson(ch.id)}>
+ درس
+
+ delChapter(ch)} title="حذف فصل">
+
+
+
+ {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 />
+ duplicateLesson(l)}>
+
+
+ setLessonModal(l)} title="ویرایش">
+
+
+ delLesson(l)} title="حذف درس">
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ })}
+ {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 (
+ setDraft({ ...draft, icon: o.key })}
+ title={o.label}
+ className={cn(
+ "flex flex-col items-center gap-1 rounded-lg border px-1 py-2 transition-colors",
+ active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:border-primary/40"
+ )}
+ >
+
+ {o.label}
+
+ );
+ })}
+
+
+
+
+ انصراف
+ draft && onSave(draft)}>
+ {isNew ? "ساخت فصل" : "ذخیره فصل"}
+
+
+
+ در نقشهٔ مسیر با این نشان نمایش داده میشود.
+
+
+
+ );
+}
+
+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 کاراکتر
+
+
+ توضیحات
+
+
+
set({ videoKey })} />
+ {draft.videoKey ? (
+ void openSignedMedia(draft.videoKey)}
+ className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
+ >
+ پیشنمایش ویدیو
+
+ ) : (
+
+ بدون ویدیو — اول از تب ویدیوها آپلود کنید
+
+ )}
+
+
+
+ {/* ساختار */}
+
+ } text="ساختار و گرههای درخت" />
+
+
+ فصل
+ set({ chapterId: Number(e.target.value) })}
+ >
+ {chapters.map((c) => (
+ {c.title}
+ ))}
+
+
+
+ پیشنیاز (آزادکننده)
+ set({ requiresLessonId: e.target.value ? Number(e.target.value) : undefined })}
+ >
+ بدون پیشنیاز (ریشه)
+ {others.map((l) => (
+ L{l.id} · {l.title}
+ ))}
+
+
+
+ {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)} باز میشود؛ قبولیِ تکتک پرسشها، شاخههای بعدی را آزاد میکند. روی هر پاسخ اشتباه یک جان کسر میشود.
+
+ )}
+
+
+
+ {/* پیشنمایش زنده */}
+
+
+ پیشنمایش
+
+
+
+ {draft.isActive ? فعال : پیشنویس }
+ {!isNew && L{draft.id} }
+
+
{draft.title || "عنوان درس"}
+
+ {draft.description || "توضیحاتی که کنجکاوی هنرجو را برمیانگیزد."}
+
+
+ ▶ {formatDur(draft.durationSeconds)}
+ +{formatFaNumber(draft.xpReward)} امتیاز
+ آزمون {thrOk ? `${draft.completionThresholdPct}٪` : "؟"}
+
+ {prereq && !cycle && (
+
+ 🔓 پس از «{prereq.title}»
+
+ )}
+
+
+
ux= {draft.x}
+
uy= {draft.y}
+
ترتیب {draft.sortOrder}
+
+
+
+
+
+
+ انصراف
+ valid && onSave(draft)} disabled={!valid}>
+ {isNew ? "ساخت درس" : "ذخیره درس"}
+
+
+
+
+ );
+}
+
+function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
+ return (
+
+ {label}
+ 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}
+
+
+
+
فهرست رویدادها
+
در مرکز هنرجو نمایش داده میشوند.
+
+ setModal({ id: 0, title: "", description: "", eventType: "workshop", externalUrl: "", startsAt: "", endsAt: "", rsvped: false, isActive: true, createdAt: "" })}>
+ رویداد جدید
+
+
+
+ {isLoading && }
+
+ {(data?.events ?? []).map((e) => (
+
+
+
{e.eventType === "workshop" ? "کارگاه" : e.eventType === "meet" ? "جلسه" : "آنلاین"}
+
{e.isActive ? "فعال" : "پنهان"}
+
+ {e.externalUrl && (
+ copyToClipboard(e.externalUrl)}>
+
+
+ )}
+ setModal({ ...e })}>ویرایش
+ del(e)} title="حذف">
+
+
+
+
+
{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 و گیتهاب" />
+
+
+ توضیحات
+
+
+
+ نوع
+ set({ eventType: e.target.value as AdminEvent["eventType"] })}>
+ کارگاه
+ دیدار
+ آنلاین
+
+
+
+ لینک پیوستن
+ set({ externalUrl: e.target.value })} placeholder="https://…" />
+
+
+
+
+ شروع (جلالی)
+ set({ startsAt: iso })} aria-label="زمان شروع جلالی" />
+
+
+ پایان (جلالی)
+ set({ endsAt: iso })} aria-label="زمان پایان جلالی" />
+
+
+ {draft.startsAt && draft.endsAt && !isValidIsoRange(draft.startsAt, draft.endsAt) && (
+
زمان پایان باید پس از زمان شروع باشد.
+ )}
+
+
+
فعال / قابل نمایش
+
رویدادهای پنهان از پیشنهادات کنار میروند
+
+
set({ isActive: v })} />
+
+
+ )}
+
+ انصراف
+ draft && onSave(draft)}>ذخیره رویداد
+
+
+
+ );
+}
+function Loader({ className }: { className?: string }) {
+ return (
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
+
+function LoaderRow() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/admin/announce-panel.tsx b/frontend/src/components/admin/announce-panel.tsx
new file mode 100644
index 0000000..3fe5ac9
--- /dev/null
+++ b/frontend/src/components/admin/announce-panel.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Bell,
+ Calendar,
+ Eye,
+ Gamepad2,
+ Loader2,
+ Megaphone,
+ Route,
+ TrendingUp,
+ Users,
+} from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import { toast } from "@/components/providers";
+
+const CATEGORIES = [
+ { id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
+ { id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
+ { id: "gamification", label: "بازیسازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
+ { id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
+] as const;
+
+const ROUTES = [
+ { path: "/unwrap", label: "نقشه مسیر" },
+ { path: "/notifications", label: "اعلانها" },
+ { path: "/leaderboard", label: "لیدربورد" },
+ { path: "/cap", label: "داشبورد" },
+];
+
+function PreviewCard({
+ title,
+ body,
+ category,
+ route,
+}: {
+ title: string;
+ body: string;
+ category: string;
+ route: string;
+}) {
+ const cat = CATEGORIES.find((c) => c.id === category);
+ return (
+
+
+
+
+
+
+ {cat?.label ?? category}
+
+
+
{title.trim() || "عنوان اطلاعیه"}
+
+ {body.trim() || "متن پیام اینجا دیده میشود…"}
+
+
+ → {route || "/"}
+
+
+ );
+}
+
+export function AnnouncePanel() {
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+ const [category, setCategory] = useState("event");
+ const [route, setRoute] = useState("/unwrap");
+ const [busy, setBusy] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
+
+ const titleOk = title.trim().length >= 3;
+ const bodyOk = body.trim().length >= 8;
+ const valid = titleOk && bodyOk;
+
+ const send = async () => {
+ if (!valid) return;
+ setBusy(true);
+ try {
+ const res = await http.post<{ recipients: number }>("/api/admin/announce", {
+ title: title.trim(),
+ body: body.trim(),
+ category,
+ route,
+ });
+ toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
+ setLastSent({ title: title.trim(), recipients: res.recipients });
+ setTitle("");
+ setBody("");
+ setConfirmOpen(false);
+ } catch (e) {
+ toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ نگارش اعلان
+
+ به همه هنرجویان فعال میرسد و در مرکز اعلانها دیده میشود.
+
+
+
+
+ عنوان
+ setTitle(e.target.value)}
+ placeholder="مثلاً جلسه زنده هفته ۴ · پنجشنبه ۲۰:۰۰"
+ className={cn("h-11 rounded-2xl", !titleOk && title.length > 0 && "border-destructive")}
+ autoFocus
+ />
+
+
+
+
+ پیام
+ {body.length}/۵۰۰
+
+
+
+
+
دسته
+
+ {CATEGORIES.map((c) => {
+ const active = category === c.id;
+ const Icon = c.icon;
+ return (
+ setCategory(c.id)}
+ className={cn(
+ "grid h-[4.25rem] grid-cols-[2.25rem_minmax(0,1fr)] items-center gap-3 rounded-xl border px-3 text-right transition-colors",
+ active ? "border-primary bg-primary/10" : "border-border hover:border-primary/40"
+ )}
+ >
+
+
+
+
+ {c.label}
+ {c.hint}
+
+
+ );
+ })}
+
+
+
+
+
+ مقصد کلیک
+
+
+ {ROUTES.map((r) => (
+ setRoute(r.path)}
+ className={cn(
+ "inline-flex h-8 items-center rounded-full border px-3 text-xs font-bold transition-colors",
+ route === r.path
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border hover:border-primary/40"
+ )}
+ >
+ {r.label}
+
+ ))}
+
+
setRoute(e.target.value)}
+ dir="ltr"
+ className="h-10 rounded-2xl font-mono text-xs"
+ placeholder="/unwrap"
+ />
+
+
+
+
setConfirmOpen(true)}
+ >
+
+ پیشنمایش و ارسال
+
+ {!valid && (
+
عنوان و پیام را کامل کنید تا ادامه فعال شود.
+ )}
+
+
+
+
+
+
+
+
+ پیشنمایش زنده
+
+ همزمان با تایپ بهروز میشود.
+
+
+
+
+
+
+ {lastSent && (
+
+
+ آخرین ارسال موفق
+ {lastSent.title}
+ {lastSent.recipients} گیرنده
+
+
+ )}
+
+
+
+
+
+
+ تأیید ارسال
+ این پیشنمایش نهایی است؛ بعد از تأیید برای همه ارسال میشود.
+
+
+
+ setConfirmOpen(false)} disabled={busy}>
+ بازگشت
+
+ void send()}>
+ {busy ? : }
+ تأیید و ارسال
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/admin/blacklist-panel.tsx b/frontend/src/components/admin/blacklist-panel.tsx
new file mode 100644
index 0000000..1a48788
--- /dev/null
+++ b/frontend/src/components/admin/blacklist-panel.tsx
@@ -0,0 +1,401 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { Loader2, Trash2, Upload } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import type { RegistrationBlacklistAttempt, RegistrationPhoneBlacklist } from "@/lib/types";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { toast } from "@/components/providers";
+import { formatFaNumber } from "@/lib/utils";
+import {
+ DEFAULT_PAGE_SIZE,
+ ListPagination,
+ buildPageQuery,
+ type Paginated,
+} from "@/components/admin/list-pagination";
+import { ListToolbar } from "@/components/admin/list-toolbar";
+import { useConfirm } from "@/components/admin/use-confirm";
+
+const pathLabel: Record = {
+ public: "عمومی",
+ invite: "دعوت",
+};
+
+type ImportResult = {
+ inserted: number;
+ skipped: number;
+ invalid: number;
+ rejected: number;
+ rejectedPhones?: string[];
+ removedWhitelist?: number;
+ whitelistedConflicts?: string[];
+};
+
+export function BlacklistPanel() {
+ const qc = useQueryClient();
+ const { confirm, dialog } = useConfirm();
+ const [text, setText] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [deleteBusy, setDeleteBusy] = useState(null);
+ const [listQ, setListQ] = useState("");
+ const [historyQ, setHistoryQ] = useState("");
+ const [listPage, setListPage] = useState(1);
+ const [historyPage, setHistoryPage] = useState(1);
+ const [pendingConflicts, setPendingConflicts] = useState([]);
+
+ useEffect(() => {
+ setListPage(1);
+ }, [listQ]);
+
+ useEffect(() => {
+ setHistoryPage(1);
+ }, [historyQ]);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "phone-blacklist", listQ, listPage],
+ queryFn: () => {
+ const params = new URLSearchParams(buildPageQuery(listPage));
+ if (listQ.trim()) params.set("q", listQ.trim());
+ return http.get>(
+ `/api/admin/phone-blacklist?${params.toString()}`,
+ );
+ },
+ });
+
+ const { data: historyData, isLoading: historyLoading } = useQuery({
+ queryKey: ["admin", "phone-blacklist", "attempts", historyQ, historyPage],
+ queryFn: () => {
+ const params = new URLSearchParams(buildPageQuery(historyPage));
+ if (historyQ.trim()) params.set("q", historyQ.trim());
+ return http.get>(
+ `/api/admin/phone-blacklist/attempts?${params.toString()}`,
+ );
+ },
+ });
+
+ const entries = useMemo(() => data?.items ?? [], [data]);
+ const attempts = useMemo(() => historyData?.items ?? [], [historyData]);
+ const listTotal = data?.total ?? 0;
+ const listPageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
+ const historyTotal = historyData?.total ?? 0;
+ const historyPageSize = historyData?.pageSize ?? DEFAULT_PAGE_SIZE;
+
+ const summarizeImport = (res: ImportResult) => {
+ const parts = [
+ `${formatFaNumber(res.inserted)} افزوده`,
+ res.skipped ? `${formatFaNumber(res.skipped)} تکراری` : null,
+ res.removedWhitelist ? `${formatFaNumber(res.removedWhitelist)} از فهرست مجاز حذف` : null,
+ res.invalid ? `${formatFaNumber(res.invalid)} نامعتبر` : null,
+ ].filter(Boolean);
+ if (parts.length > 0) {
+ toast.success(parts.join(" · "));
+ }
+ if (res.rejected > 0) {
+ const phones = res.rejectedPhones ?? [];
+ const detail =
+ phones.length > 0
+ ? ` (${phones.slice(0, 3).join("، ")}${phones.length > 3 ? "…" : ""})`
+ : "";
+ toast.error(
+ `${formatFaNumber(res.rejected)} شماره قبلاً ثبتنام شده و قابل انتقال به فهرست سیاه نیست${detail}`,
+ );
+ }
+ };
+
+ const postImport = async (payload: {
+ text?: string;
+ phones?: string[];
+ resolveConflicts?: boolean;
+ }) => {
+ return http.post("/api/admin/phone-blacklist/import", payload);
+ };
+
+ const finishImport = (res: ImportResult) => {
+ summarizeImport(res);
+ setText("");
+ setPendingConflicts([]);
+ setListPage(1);
+ qc.invalidateQueries({ queryKey: ["admin", "phone-blacklist"] });
+ if (res.removedWhitelist) {
+ qc.invalidateQueries({ queryKey: ["admin", "phone-whitelist"] });
+ }
+ };
+
+ const importPhones = async () => {
+ if (!text.trim()) {
+ toast.error("حداقل یک شماره وارد کنید");
+ return;
+ }
+ setBusy(true);
+ setPendingConflicts([]);
+ try {
+ const res = await postImport({ text });
+ const conflicts = res.whitelistedConflicts ?? [];
+ if (conflicts.length > 0) {
+ summarizeImport(res);
+ if (conflicts.length === 1) {
+ const phone = conflicts[0];
+ const ok = await confirm(
+ "این شماره در فهرست مجاز است",
+ `شماره ${phone} در فهرست مجاز است. حذف از فهرست مجاز و افزودن به فهرست سیاه؟`,
+ "حذف از فهرست مجاز و افزودن",
+ );
+ if (!ok) {
+ setPendingConflicts(conflicts);
+ return;
+ }
+ const resolved = await postImport({ phones: conflicts, resolveConflicts: true });
+ finishImport(resolved);
+ return;
+ }
+ setPendingConflicts(conflicts);
+ toast.error(
+ `${formatFaNumber(conflicts.length)} شماره در فهرست مجاز است — برای ادامه تعارض را حل کنید`,
+ );
+ return;
+ }
+ finishImport(res);
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const resolveConflicts = async () => {
+ if (pendingConflicts.length === 0) {
+ return;
+ }
+ setBusy(true);
+ try {
+ const res = await postImport({ phones: pendingConflicts, resolveConflicts: true });
+ finishImport(res);
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const remove = async (id: number) => {
+ setDeleteBusy(id);
+ try {
+ await http.del(`/api/admin/phone-blacklist/${id}`);
+ toast.success("شماره از فهرست سیاه حذف شد");
+ qc.invalidateQueries({ queryKey: ["admin", "phone-blacklist"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setDeleteBusy(null);
+ }
+ };
+
+ return (
+
+ {dialog}
+
+
+ واردات شماره
+
+ این شمارهها همیشه رد میشوند — ثبتنام عمومی و دعوتنامه. اگر شماره در فهرست مجاز باشد، قبل
+ از انتقال تأیید میگیریم. فرمت: هر خط یا جدا با ویرگول.
+
+
+
+
+ شمارهها
+
+ void importPhones()}>
+ {busy ? : }
+ واردات
+
+ {pendingConflicts.length > 0 && (
+
+
این شماره در فهرست مجاز است
+
+ حذف از فهرست مجاز و افزودن به فهرست سیاه؟
+
+
+ {pendingConflicts.map((phone) => (
+ {phone}
+ ))}
+
+
+ void resolveConflicts()}
+ >
+ {busy ? : null}
+ حل تعارض و ادامه
+
+ setPendingConflicts([])}
+ >
+ انصراف
+
+
+
+ )}
+
+
+
+
+
+ فهرست سیاه
+ جستجو و مدیریت شمارههای مسدود.
+
+
+
+ setListQ(e.target.value)}
+ className="max-w-xs text-left tracking-wide"
+ dir="ltr"
+ />
+ {listQ && (
+ setListQ("")}>
+ پاک کردن
+
+ )}
+
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && entries.length === 0 && (
+
+ هنوز شمارهای در فهرست سیاه نیست.
+
+ )}
+
+ {entries.map((entry) => (
+
+
+
+ {entry.phone}
+
+
+ افزوده: {new Date(entry.createdAt).toLocaleString("fa-IR")}
+
+
+
void remove(entry.id)}
+ >
+ {deleteBusy === entry.id ? (
+
+ ) : (
+
+ )}
+ حذف
+
+
+ ))}
+
+
+
+
+
+
+
+ تاریخچه تلاش
+
+ تلاشهای ثبتنام با شمارهٔ مسدود — فقط برای ممیزی ثبت میشوند.
+
+
+
+
+ setHistoryQ(e.target.value)}
+ dir="ltr"
+ className="max-w-sm text-left tracking-wide"
+ placeholder="۰۹۱۲… یا نام کاربری"
+ />
+ {historyQ && (
+ setHistoryQ("")}>
+ پاک کردن
+
+ )}
+
+
+ {historyLoading && (
+
+
+
+ )}
+ {!historyLoading && attempts.length === 0 && (
+
+ هنوز تلاشی ثبت نشده است.
+
+ )}
+
+ {attempts.map((a) => (
+
+
+
+ {a.phone}
+
+
+ {pathLabel[a.path] ?? a.path}
+
+
+
+ {new Date(a.attemptedAt).toLocaleString("fa-IR")}
+ {a.ip ? ` · IP: ${a.ip}` : ""}
+
+ {(a.attemptedUsername || a.attemptedEmail) && (
+
+ {[a.attemptedUsername, a.attemptedEmail].filter(Boolean).join(" · ")}
+
+ )}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/challenges-panel.tsx b/frontend/src/components/admin/challenges-panel.tsx
new file mode 100644
index 0000000..2544e58
--- /dev/null
+++ b/frontend/src/components/admin/challenges-panel.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus, Trash2, Trophy } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+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 { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { JalaliDateTimePicker, isValidIsoRange } from "@/components/ui/jalali-datetime-picker";
+import { useConfirm } from "@/components/admin/use-confirm";
+import { toast } from "@/components/providers";
+import { formatFaNumber } from "@/lib/utils";
+
+export type AdminChallenge = {
+ id: number;
+ title: string;
+ description: string;
+ targetXp: number;
+ startsAt: string;
+ endsAt: string;
+ isActive: boolean;
+ createdAt: string;
+};
+
+export function ChallengesPanel() {
+ const qc = useQueryClient();
+ const { confirm, dialog } = useConfirm();
+ const [modal, setModal] = useState(null);
+ const { data, isLoading, isError, error, refetch } = useQuery({
+ queryKey: ["admin", "challenges"],
+ queryFn: () => http.get<{ challenges: AdminChallenge[] }>("/api/admin/challenges"),
+ });
+
+ const save = async (c: AdminChallenge) => {
+ if (!isValidIsoRange(c.startsAt, c.endsAt)) {
+ toast.error("زمان پایان باید پس از زمان شروع باشد");
+ return;
+ }
+ if (!c.targetXp || c.targetXp <= 0) {
+ toast.error("هدف امتیاز باید بزرگتر از صفر باشد");
+ return;
+ }
+ try {
+ const { createdAt: _c, ...body } = c;
+ if (c.id) await http.put(`/api/admin/challenges/${c.id}`, body);
+ else await http.post("/api/admin/challenges", body);
+ setModal(null);
+ qc.invalidateQueries({ queryKey: ["admin", "challenges"] });
+ qc.invalidateQueries({ queryKey: ["challenges"] });
+ toast.success(c.id ? "چالش بهروزرسانی شد" : "چالش ساخته شد");
+ } catch (e) {
+ toast.error(toUserError(e));
+ }
+ };
+
+ const del = async (c: AdminChallenge) => {
+ if (!(await confirm("حذف چالش", `چالش «${c.title}» حذف شود؟`))) return;
+ try {
+ await http.del(`/api/admin/challenges/${c.id}`);
+ qc.invalidateQueries({ queryKey: ["admin", "challenges"] });
+ 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}
+
+
+
+
چالشهای امتیاز
+
+ چالش ماهانه با هدف امتیاز؛ پیشرفت هنرجو با تکمیل آزمونها بهروز میشود.
+
+
+
+ setModal({
+ id: 0,
+ title: "",
+ description: "",
+ targetXp: 500,
+ startsAt: "",
+ endsAt: "",
+ isActive: true,
+ createdAt: "",
+ })
+ }
+ >
+ چالش جدید
+
+
+
+ {isLoading && (
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+ )}
+ {isError && (
+
+
{toUserError(error)}
+
refetch()}>
+ تلاش دوباره
+
+
+ )}
+
+ {(data?.challenges ?? []).map((c) => (
+
+
+
+
{c.isActive ? "فعال" : "خاموش"}
+
هدف {formatFaNumber(c.targetXp)} امتیاز
+
+ setModal({ ...c })}>
+ ویرایش
+
+ del(c)}>
+
+
+
+
+
{c.title}
+
{c.description || "—"}
+
+ {fmt(c.startsAt)} تا {fmt(c.endsAt)}
+
+
+ ))}
+
+ {!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
+ هنوز چالشی نیست.
+ )}
+
+ setModal(null)} onSave={save} />
+
+ >
+ );
+}
+
+function ChallengeDialog({
+ challenge,
+ onClose,
+ onSave,
+}: {
+ challenge: AdminChallenge | null;
+ onClose: () => void;
+ onSave: (c: AdminChallenge) => void;
+}) {
+ const [draft, setDraft] = useState(challenge);
+ useEffect(() => setDraft(challenge), [challenge]);
+ const set = (patch: Partial) => setDraft((d) => (d ? { ...d, ...patch } : d));
+ const valid =
+ !!draft?.title.trim() &&
+ !!draft?.startsAt &&
+ !!draft?.endsAt &&
+ (draft?.targetXp ?? 0) > 0 &&
+ isValidIsoRange(draft!.startsAt, draft!.endsAt);
+
+ return (
+ !o && onClose()}>
+
+
+ {draft?.id ? "ویرایش چالش" : "چالش جدید"}
+ بازه را با تقویم جلالی تنظیم کنید؛ هنرجویان پیشرفت تا هدف امتیاز را میبینند.
+
+ {draft && (
+
+
+ عنوان
+ set({ title: e.target.value })} placeholder="مثلاً چالش ماه مهر" />
+
+
+ توضیحات
+
+
+ هدف امتیاز
+ set({ targetXp: Number(e.target.value) || 0 })}
+ />
+
+
+
+ شروع (جلالی)
+ set({ startsAt: iso })} aria-label="شروع چالش" />
+
+
+ پایان (جلالی)
+ set({ endsAt: iso })} aria-label="پایان چالش" />
+
+
+
+
+
فعال
+
فقط چالشهای فعال در بازهٔ زمانی به هنرجو نشان داده میشوند
+
+
set({ isActive: v })} />
+
+
+ )}
+
+
+ انصراف
+
+ draft && onSave(draft)}>
+ ذخیره چالش
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/chat-export-panel.tsx b/frontend/src/components/admin/chat-export-panel.tsx
new file mode 100644
index 0000000..fd85e16
--- /dev/null
+++ b/frontend/src/components/admin/chat-export-panel.tsx
@@ -0,0 +1,379 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useRef, useState } from "react";
+import { Ban, Copy, Download, Link2, Loader2, Upload } from "lucide-react";
+import { API_URL, http, refreshSession, throwFromResponse, toUserError } from "@/lib/api";
+import { useAuth } from "@/lib/auth-store";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { toast } from "@/components/providers";
+import { AdminSelect } from "@/components/admin/list-toolbar";
+import { formatFaNumber } from "@/lib/utils";
+
+type ChatExportToken = {
+ id: number;
+ label: string;
+ expiresAt: string;
+ revokedAt?: string | null;
+ usedAt?: string | null;
+ createdAt: string;
+ lastUsedAt?: string | null;
+ active: boolean;
+ token?: string;
+ url?: string;
+};
+
+type ChatExportStats = {
+ conversationCount: number;
+ messageCount: number;
+ attachmentBytes: number;
+ bodyBytes: number;
+ estimatedBytes: number;
+};
+
+function formatFaBytes(n: number): string {
+ if (!Number.isFinite(n) || n < 0) n = 0;
+ if (n < 1024) return `${formatFaNumber(n)} بایت`;
+ if (n < 1024 * 1024) return `${formatFaNumber(Math.round(n / 1024))} کیلوبایت`;
+ const mb = n / (1024 * 1024);
+ if (mb < 1024) {
+ const rounded = mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1).replace(/\.0$/, ""));
+ return `${formatFaNumber(rounded, mb >= 10 ? 0 : 1)} مگابایت`;
+ }
+ const gb = mb / 1024;
+ const rounded = gb >= 10 ? Math.round(gb) : Number(gb.toFixed(1).replace(/\.0$/, ""));
+ return `${formatFaNumber(rounded, gb >= 10 ? 0 : 1)} گیگابایت`;
+}
+
+function formatFaDate(iso: string): string {
+ try {
+ return new Intl.DateTimeFormat("fa-IR", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(iso));
+ } catch {
+ return iso;
+ }
+}
+
+export function ChatExportPanel() {
+ const qc = useQueryClient();
+ const fileRef = useRef(null);
+ const [exportBusy, setExportBusy] = useState(false);
+ const [restoreBusy, setRestoreBusy] = useState(false);
+ const [createBusy, setCreateBusy] = useState(false);
+ const [revokeBusy, setRevokeBusy] = useState(null);
+ const [expiresInHours, setExpiresInHours] = useState<1 | 24>(24);
+ const [lastCreatedURL, setLastCreatedURL] = useState("");
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "chat-export", "tokens"],
+ queryFn: () => http.get<{ items: ChatExportToken[] }>("/api/admin/chat-export/tokens"),
+ });
+
+ const { data: statsData } = useQuery({
+ queryKey: ["admin", "chat-export", "stats"],
+ queryFn: () => http.get<{ stats: ChatExportStats }>("/api/admin/chat-export/stats"),
+ });
+
+ const tokens = data?.items ?? [];
+ const stats = statsData?.stats;
+
+ const copyText = async (text: string, okMsg: string) => {
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(okMsg);
+ } catch {
+ toast.error("کپی ممکن نشد");
+ }
+ };
+
+ const downloadExport = async () => {
+ setExportBusy(true);
+ try {
+ const doFetch = (token: string | null) =>
+ fetch(`${API_URL}/api/admin/chat-export`, {
+ credentials: "include",
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ let res = await doFetch(useAuth.getState().accessToken);
+ if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
+ await refreshSession();
+ res = await doFetch(useAuth.getState().accessToken);
+ }
+ if (!res.ok) {
+ await throwFromResponse(res, "دانلود خروجی گفتگوها ممکن نشد");
+ }
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.zip`;
+ a.click();
+ URL.revokeObjectURL(url);
+ toast.success("آرشیو ZIP گفتگوها دانلود شد");
+ } catch (e) {
+ toast.error(toUserError(e, "دانلود خروجی گفتگوها ممکن نشد"));
+ } finally {
+ setExportBusy(false);
+ }
+ };
+
+ const restoreExport = async (file: File) => {
+ setRestoreBusy(true);
+ try {
+ const isZip =
+ file.name.toLowerCase().endsWith(".zip") ||
+ file.type === "application/zip" ||
+ file.type === "application/x-zip-compressed";
+ const doFetch = async (token: string | null) => {
+ if (isZip) {
+ return fetch(`${API_URL}/api/admin/chat-export/restore`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ "Content-Type": "application/zip",
+ },
+ body: file,
+ });
+ }
+ const text = await file.text();
+ const payload = JSON.parse(text) as unknown;
+ return fetch(`${API_URL}/api/admin/chat-export/restore`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify(payload),
+ });
+ };
+ let res = await doFetch(useAuth.getState().accessToken);
+ if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
+ await refreshSession();
+ res = await doFetch(useAuth.getState().accessToken);
+ }
+ if (!res.ok) {
+ await throwFromResponse(res, "بازیابی خروجی گفتگوها ممکن نشد");
+ }
+ const data = (await res.json()) as { imported?: number };
+ toast.success(`${formatFaNumber(data.imported ?? 0)} پیام بازیابی شد`);
+ await qc.invalidateQueries({ queryKey: ["admin", "chat-export", "stats"] });
+ } catch (e) {
+ toast.error(toUserError(e, "بازیابی خروجی گفتگوها ممکن نشد"));
+ } finally {
+ setRestoreBusy(false);
+ }
+ };
+
+ const createLink = async () => {
+ setCreateBusy(true);
+ try {
+ const res = await http.post<{ token: ChatExportToken }>("/api/admin/chat-export/tokens", {
+ expiresInHours,
+ });
+ const url = res.token?.url ?? "";
+ setLastCreatedURL(url);
+ if (url) {
+ await copyText(url, "لینک دانلود ساخته و کپی شد");
+ } else {
+ toast.success("لینک دانلود ساخته شد");
+ }
+ await qc.invalidateQueries({ queryKey: ["admin", "chat-export", "tokens"] });
+ } catch (e) {
+ toast.error(toUserError(e, "ساخت لینک دانلود ممکن نشد"));
+ } finally {
+ setCreateBusy(false);
+ }
+ };
+
+ const revokeLink = async (id: number) => {
+ setRevokeBusy(id);
+ try {
+ await http.post(`/api/admin/chat-export/tokens/${id}/revoke`, {});
+ toast.success("لینک لغو شد");
+ await qc.invalidateQueries({ queryKey: ["admin", "chat-export", "tokens"] });
+ } catch (e) {
+ toast.error(toUserError(e, "لغو لینک ممکن نشد"));
+ } finally {
+ setRevokeBusy(null);
+ }
+ };
+
+ return (
+
+
+
+ آرشیو گفتگوها
+
+
+
+ آرشیو فشردهٔ ZIP شامل manifest.json و پوشهٔ{" "}
+ attachments/ برای همه گفتگوهای دانشجو↔منتور.
+
+ {stats ? (
+
+ کل تاریخچه تقریباً{" "}
+ {formatFaBytes(stats.estimatedBytes)} حجم دارد
+ {" "}
+
+ ({formatFaNumber(stats.conversationCount)} گفتگو، {formatFaNumber(stats.messageCount)} پیام
+ {stats.attachmentBytes > 0
+ ? `، حدود ${formatFaBytes(stats.attachmentBytes)} پیوست`
+ : ""}
+ ). حجم فایل ZIP نزدیک همین مقدار است.
+
+
+ ) : null}
+
+ void downloadExport()} disabled={exportBusy}>
+ {exportBusy ? : }
+ دانلود آرشیو ZIP
+
+ fileRef.current?.click()}
+ >
+ {restoreBusy ? : }
+ بازیابی از ZIP یا JSON
+
+ {
+ const f = e.target.files?.[0];
+ e.target.value = "";
+ if (f) void restoreExport(f);
+ }}
+ />
+
+
+
+
+
+
+ لینک دانلود خارجی
+
+
+
+ لینک یکبارمصرف با مهلت محدود بسازید؛ همان آرشیو ZIP را بدون ورود ادمین میدهد و پس از اولین دانلود باطل میشود (لغو دستی هم ممکن است).
+
+
+
+
مهلت اعتبار
+
setExpiresInHours(Number(e.target.value) === 1 ? 1 : 24)}
+ >
+ ۱ ساعت
+ ۲۴ ساعت
+
+
+
void createLink()} disabled={createBusy}>
+ {createBusy ? : }
+ ایجاد لینک دانلود
+
+
+ {lastCreatedURL ? (
+
+
+ {lastCreatedURL}
+
+ void copyText(lastCreatedURL, "لینک کپی شد")}
+ >
+
+ کپی
+
+
+ ) : null}
+
+
+
لینکهای فعال و اخیر
+ {isLoading ? (
+
در حال بارگذاری…
+ ) : tokens.length === 0 ? (
+
هنوز لینکی ساخته نشده است.
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/confirm-dialog.tsx b/frontend/src/components/admin/confirm-dialog.tsx
new file mode 100644
index 0000000..501936f
--- /dev/null
+++ b/frontend/src/components/admin/confirm-dialog.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+type ConfirmDialogProps = {
+ open: boolean;
+ title: string;
+ description: string;
+ confirmLabel?: string;
+ onConfirm: () => void;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmLabel = "حذف",
+ onConfirm,
+ onOpenChange,
+}: ConfirmDialogProps) {
+ return (
+
+
+
+ {title}
+ {description}
+
+
+ onOpenChange(false)}>
+ انصراف
+
+ {
+ onConfirm();
+ }}
+ >
+ {confirmLabel}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/invites-panel.tsx b/frontend/src/components/admin/invites-panel.tsx
new file mode 100644
index 0000000..116857c
--- /dev/null
+++ b/frontend/src/components/admin/invites-panel.tsx
@@ -0,0 +1,510 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { Copy, Loader2, Plus, Ban, Pause, Play, Pencil } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import type { RegistrationInvite } from "@/lib/types";
+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 { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "@/components/providers";
+import { formatFaNumber } from "@/lib/utils";
+import {
+ DEFAULT_PAGE_SIZE,
+ ListPagination,
+ buildPageQuery,
+ type Paginated,
+} from "@/components/admin/list-pagination";
+import { ListToolbar } from "@/components/admin/list-toolbar";
+import { validateInviteLabel } from "@/lib/validation";
+
+const STATUS_LABEL: Record = {
+ active: "فعال",
+ paused: "متوقف موقت",
+ revoked: "لغو شده",
+ exhausted: "تکمیل شده",
+};
+
+function inviteDisplayURL(inv: RegistrationInvite): string {
+ if (inv.url) return inv.url;
+ if (inv.token && typeof window !== "undefined") {
+ return `${window.location.origin}/register/invite/${inv.token}`;
+ }
+ return "";
+}
+
+function statusBadgeVariant(
+ status: RegistrationInvite["status"],
+): "secondary" | "destructive" | "outline" {
+ if (status === "active") return "secondary";
+ if (status === "revoked") return "destructive";
+ return "outline";
+}
+
+export function InvitesPanel() {
+ const qc = useQueryClient();
+ const [maxUses, setMaxUses] = useState(10);
+ const [label, setLabel] = useState("");
+ const [expiresInDays, setExpiresInDays] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [actionBusy, setActionBusy] = useState(null);
+ const [editing, setEditing] = useState(null);
+ const [editLabel, setEditLabel] = useState("");
+ const [editMaxUses, setEditMaxUses] = useState(1);
+ const [editExpiresInDays, setEditExpiresInDays] = useState("");
+ const [editClearExpires, setEditClearExpires] = useState(false);
+ const [editBusy, setEditBusy] = useState(false);
+ const [page, setPage] = useState(1);
+ const [q, setQ] = useState("");
+
+ useEffect(() => {
+ setPage(1);
+ }, [q]);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "invites", q, page],
+ queryFn: () => {
+ const params = new URLSearchParams(buildPageQuery(page));
+ if (q.trim()) params.set("q", q.trim());
+ return http.get>(`/api/admin/invites?${params.toString()}`);
+ },
+ });
+
+ const invites = useMemo(() => data?.items ?? [], [data]);
+ const total = data?.total ?? 0;
+ const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
+
+ const create = async () => {
+ if (maxUses < 1 || maxUses > 10_000) {
+ toast.error("ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد");
+ return;
+ }
+ const labelErr = validateInviteLabel(label);
+ if (labelErr) {
+ toast.error(labelErr);
+ return;
+ }
+ setBusy(true);
+ try {
+ const body: { maxUses: number; label?: string; expiresInDays?: number } = {
+ maxUses,
+ };
+ if (label.trim()) body.label = label.trim();
+ const days = Number(expiresInDays);
+ if (expiresInDays.trim() && Number.isFinite(days) && days > 0) {
+ body.expiresInDays = Math.floor(days);
+ }
+ const res = await http.post<{ invite: RegistrationInvite }>("/api/admin/invites", body);
+ const url = inviteDisplayURL(res.invite);
+ if (url) {
+ try {
+ await navigator.clipboard.writeText(url);
+ toast.success("لینک ساخته و کپی شد");
+ } catch {
+ toast.success("لینک عضویت ساخته شد");
+ }
+ } else {
+ toast.success("لینک عضویت ساخته شد");
+ }
+ setLabel("");
+ setExpiresInDays("");
+ setPage(1);
+ qc.invalidateQueries({ queryKey: ["admin", "invites"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const copy = async (inv: RegistrationInvite) => {
+ const url = inviteDisplayURL(inv);
+ if (!url) {
+ toast.error("آدرس لینک در دسترس نیست");
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(url);
+ toast.success("لینک کپی شد");
+ } catch {
+ toast.error("کپی ممکن نشد");
+ }
+ };
+
+ const pause = async (id: number) => {
+ setActionBusy(id);
+ try {
+ await http.post(`/api/admin/invites/${id}/pause`, {});
+ toast.success("لینک موقتاً متوقف شد");
+ qc.invalidateQueries({ queryKey: ["admin", "invites"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setActionBusy(null);
+ }
+ };
+
+ const resume = async (id: number) => {
+ setActionBusy(id);
+ try {
+ await http.post(`/api/admin/invites/${id}/resume`, {});
+ toast.success("لینک دوباره فعال شد");
+ qc.invalidateQueries({ queryKey: ["admin", "invites"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setActionBusy(null);
+ }
+ };
+
+ const revoke = async (id: number) => {
+ setActionBusy(id);
+ try {
+ await http.post(`/api/admin/invites/${id}/revoke`, {});
+ toast.success("لینک لغو شد");
+ qc.invalidateQueries({ queryKey: ["admin", "invites"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setActionBusy(null);
+ }
+ };
+
+ const openEdit = (inv: RegistrationInvite) => {
+ setEditing(inv);
+ setEditLabel(inv.label ?? "");
+ setEditMaxUses(inv.maxUses);
+ setEditExpiresInDays("");
+ setEditClearExpires(false);
+ };
+
+ const saveEdit = async () => {
+ if (!editing) return;
+ if (editMaxUses < editing.usedCount) {
+ toast.error(`ظرفیت نمیتواند کمتر از ${formatFaNumber(editing.usedCount)} (استفادهشده) باشد`);
+ return;
+ }
+ if (editMaxUses < 1 || editMaxUses > 10_000) {
+ toast.error("ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد");
+ return;
+ }
+ const labelErr = validateInviteLabel(editLabel);
+ if (labelErr) {
+ toast.error(labelErr);
+ return;
+ }
+ setEditBusy(true);
+ try {
+ const body: {
+ label: string;
+ maxUses: number;
+ expiresInDays?: number;
+ clearExpires?: boolean;
+ } = {
+ label: editLabel.trim(),
+ maxUses: editMaxUses,
+ };
+ if (editClearExpires) {
+ body.clearExpires = true;
+ } else {
+ const days = Number(editExpiresInDays);
+ if (editExpiresInDays.trim() && Number.isFinite(days) && days > 0) {
+ body.expiresInDays = Math.floor(days);
+ }
+ }
+ await http.put(`/api/admin/invites/${editing.id}`, body);
+ toast.success("لینک بهروز شد");
+ setEditing(null);
+ qc.invalidateQueries({ queryKey: ["admin", "invites"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setEditBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ ساخت لینک جدید
+
+ ظرفیت، برچسب و مهلت اختیاری — لینک را بعد از ساخت کپی کنید.
+
+
+
+
+ void create()}>
+ {busy ? : }
+ ساخت لینک
+
+
+
+
+
+
+ لینکهای عضویت
+ مدیریت، کپی و توقف لینکهای ساختهشده.
+
+
+
+ setQ(e.target.value)}
+ className="max-w-xs"
+ />
+ {q && (
+ setQ("")}>
+ پاک کردن
+
+ )}
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && invites.length === 0 && (
+
+ {q.trim() ? "نتیجهای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
+
+ )}
+
+ {invites.map((inv) => (
+
+
+
+
+ {inv.label || `لینک #${formatFaNumber(inv.id)}`}
+
+
{STATUS_LABEL[inv.status]}
+
+
+ استفاده: {formatFaNumber(inv.usedCount)} / {formatFaNumber(inv.maxUses)}
+ {inv.status === "active" || inv.status === "paused"
+ ? ` · باقیمانده: ${formatFaNumber(inv.remaining)}`
+ : null}
+
+ {inv.expiresAt && (
+
+ انقضا: {new Date(inv.expiresAt).toLocaleString("fa-IR")}
+
+ )}
+ {inv.token && (
+
+ {inviteDisplayURL(inv)}
+
+ )}
+
+
+
void copy(inv)}
+ >
+
+ کپی
+
+ {(inv.status === "active" || inv.status === "paused") && (
+
openEdit(inv)}
+ >
+
+ ویرایش
+
+ )}
+ {inv.status === "active" && (
+
void pause(inv.id)}
+ >
+ {actionBusy === inv.id ? (
+
+ ) : (
+
+ )}
+ توقف موقت
+
+ )}
+ {inv.status === "paused" && (
+
void resume(inv.id)}
+ >
+ {actionBusy === inv.id ? (
+
+ ) : (
+
+ )}
+ ازسرگیری
+
+ )}
+ {(inv.status === "active" || inv.status === "paused") && (
+
void revoke(inv.id)}
+ >
+ {actionBusy === inv.id ? (
+
+ ) : (
+
+ )}
+ لغو
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
!o && setEditing(null)}>
+
+
+ ویرایش لینک عضویت
+
+ ظرفیت و برچسب را تغییر دهید. ظرفیت نمیتواند کمتر از تعداد استفادهشده باشد.
+
+
+ {editing && (
+
+
+ استفادهشده: {formatFaNumber(editing.usedCount)} از {formatFaNumber(editing.maxUses)}
+
+
+ برچسب
+ setEditLabel(e.target.value)}
+ placeholder="مثلاً دوره بهار"
+ />
+
+
+ ظرفیت (نفر)
+ setEditMaxUses(Number(e.target.value) || 1)}
+ dir="ltr"
+ className="text-left"
+ />
+
+
+ مهلت جدید (روز از الان — اختیاری)
+ {
+ setEditExpiresInDays(e.target.value);
+ if (e.target.value.trim()) setEditClearExpires(false);
+ }}
+ dir="ltr"
+ className="text-left"
+ placeholder={editing.expiresAt ? "بدون تغییر" : "—"}
+ disabled={editClearExpires}
+ />
+
+ {editing.expiresAt && (
+
+ حذف مهلت انقضا
+ {
+ setEditClearExpires(v);
+ if (v) setEditExpiresInDays("");
+ }}
+ aria-label="حذف مهلت انقضا"
+ />
+
+ )}
+
+ )}
+
+ setEditing(null)} disabled={editBusy}>
+ انصراف
+
+ void saveEdit()}>
+ {editBusy ? : null}
+ ذخیره
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/learning-panel.tsx b/frontend/src/components/admin/learning-panel.tsx
new file mode 100644
index 0000000..0a96022
--- /dev/null
+++ b/frontend/src/components/admin/learning-panel.tsx
@@ -0,0 +1,468 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ Flame,
+ Heart,
+ Loader2,
+ Lock,
+ Search,
+ Sparkles,
+ Trophy,
+ Unlock,
+ Users,
+} from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import type {
+ LearningOverview,
+ StudentLearningRow,
+ UserLearningDetail,
+} from "@/lib/types";
+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 { Skeleton } from "@/components/ui/skeleton";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { toast } from "@/components/providers";
+import { cn, formatFaNumber } from "@/lib/utils";
+
+type SortKey = "xp" | "progress" | "activity" | "streak";
+type FilterKey = "all" | "active" | "locked";
+
+function pct(n: number) {
+ return `${formatFaNumber(Math.round(n))}٪`;
+}
+
+function fmtDate(v?: string) {
+ if (!v) return "—";
+ try {
+ return new Date(v).toLocaleString("fa-IR", { dateStyle: "medium", timeStyle: "short" });
+ } catch {
+ return v;
+ }
+}
+
+export function LearningPanel() {
+ const qc = useQueryClient();
+ const [q, setQ] = useState("");
+ const [filter, setFilter] = useState("all");
+ const [sort, setSort] = useState("xp");
+ const [selectedId, setSelectedId] = useState(null);
+ const [unlocking, setUnlocking] = useState(false);
+
+ const overviewQ = useQuery({
+ queryKey: ["admin", "learning", "overview"],
+ queryFn: () => http.get<{ overview: LearningOverview }>("/api/admin/learning/overview"),
+ retry: 1,
+ });
+
+ const studentsQ = useQuery({
+ queryKey: ["admin", "learning", "students", q],
+ queryFn: () =>
+ http.get<{ students: StudentLearningRow[] }>(
+ `/api/admin/learning/students?q=${encodeURIComponent(q)}`
+ ),
+ retry: 1,
+ });
+
+ const detailQ = useQuery({
+ queryKey: ["admin", "learning", "user", selectedId],
+ queryFn: () => http.get<{ learning: UserLearningDetail }>(`/api/admin/users/${selectedId}/learning`),
+ enabled: selectedId != null,
+ retry: 1,
+ });
+
+ const unlockStudent = async (id: number) => {
+ setUnlocking(true);
+ try {
+ await http.post(`/api/admin/users/${id}/unlock`);
+ toast.success("قفل حساب باز شد و جانها بازگردانی شدند");
+ await Promise.all([
+ qc.invalidateQueries({ queryKey: ["admin", "learning"] }),
+ qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] }),
+ ]);
+ } catch (e) {
+ toast.error(toUserError(e, "باز کردن قفل ممکن نشد"));
+ } finally {
+ setUnlocking(false);
+ }
+ };
+
+ const overview = overviewQ.data?.overview;
+ const students = useMemo(() => {
+ let rows = [...(studentsQ.data?.students ?? [])];
+ if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
+ if (filter === "locked") rows = rows.filter((r) => r.isLocked);
+ rows.sort((a, b) => {
+ switch (sort) {
+ case "progress":
+ return b.progressPct - a.progressPct;
+ case "streak":
+ return b.streakCurrent - a.streakCurrent;
+ case "activity": {
+ const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
+ const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
+ return bt - at;
+ }
+ default:
+ return b.xp - a.xp;
+ }
+ });
+ return rows;
+ }, [studentsQ.data?.students, filter, sort]);
+
+ const cards = overview
+ ? [
+ { label: "هنرجویان فعال", value: overview.studentsActive, hint: `از ${formatFaNumber(overview.studentsTotal)} نفر`, icon: Users },
+ { label: "قفلشده", value: overview.studentsLocked, hint: "نیاز به باز کردن قفل", icon: Lock },
+ { label: "تکمیل درس", value: overview.completions, hint: `${formatFaNumber(overview.lessonsTotal)} درس فعال`, icon: Trophy },
+ { label: "نرخ قبولی ۷روزه", value: Math.round(overview.passRate7d), hint: `${formatFaNumber(overview.passes7d)}/${formatFaNumber(overview.attempts7d)} تلاش`, icon: Sparkles, suffix: "٪" },
+ { label: "میانگین تماشا", value: Math.round(overview.avgWatchPct), hint: "درسهای ناتمام", icon: Flame, suffix: "٪" },
+ { label: "امتیاز ۷روزه", value: overview.xpAwarded7d, hint: `${formatFaNumber(overview.studentsStreaking)} با استریک`, icon: Sparkles },
+ ]
+ : [];
+
+ return (
+
+ {(overviewQ.isError || studentsQ.isError) && (
+
+
+
+ {toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
+
+
{
+ void overviewQ.refetch();
+ void studentsQ.refetch();
+ }}
+ >
+ تلاش دوباره
+
+
+ )}
+
+
+ {overviewQ.isLoading &&
+ [0, 1, 2, 3, 4, 5].map((i) =>
)}
+ {cards.map((c) => (
+
+
+
+
+
+
+ {formatFaNumber(c.value)}
+ {c.suffix ?? ""}
+
+ {c.label}
+ {c.hint}
+
+
+ ))}
+
+
+
+
+
+
جدول هنرجویان
+
+ {formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید
+
+
+
+
+
+ setQ(e.target.value)}
+ placeholder="جستجوی نام یا ایمیل…"
+ className="h-10 ps-9"
+ />
+
+
setFilter(e.target.value as FilterKey)}
+ >
+ همه
+ فعال
+ قفلشده
+
+
setSort(e.target.value as SortKey)}
+ >
+ مرتبسازی: امتیاز
+ مرتبسازی: پیشرفت
+ مرتبسازی: آخرین فعالیت
+ مرتبسازی: استریک
+
+
+
+
+ {studentsQ.isLoading && (
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+
+
+
+
+ هنرجو
+ پیشرفت
+ امتیاز
+ جان
+ استریک
+ ۷روزه
+ آخرین فعالیت
+
+
+
+ {students.map((s) => (
+ setSelectedId(s.id)}
+ >
+
+
+
+
+ {s.name}
+ {s.isLocked && (
+
+ قفل
+
+ )}
+
+
+ @{s.username}
+
+
+
+
+
+
+
+
+ {formatFaNumber(s.lessonsPassed)}/{formatFaNumber(s.lessonsTotal)}
+
+ {pct(s.progressPct)}
+
+
+
+
+ {formatFaNumber(s.xp)}
+
+
+
+ {formatFaNumber(s.hearts)}
+
+
+ {formatFaNumber(s.streakCurrent)}
+
+ {formatFaNumber(s.passes7d)}/{formatFaNumber(s.attempts7d)}
+ {s.attempts7d > 0 && (
+ ({pct(s.passRate7d)})
+ )}
+
+ {fmtDate(s.lastActivityAt)}
+
+ ))}
+
+
+ {students.length === 0 && !studentsQ.isLoading && (
+
هنرجویی یافت نشد.
+ )}
+
+
+
+
+
!o && setSelectedId(null)}>
+
+
+
+ {detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
+
+
+ {detailQ.data
+ ? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
+ : "در حال بارگذاری…"}
+
+
+
+ {detailQ.isLoading && (
+
+ بارگذاری جزئیات
+
+ )}
+ {detailQ.isError && (
+
{toUserError(detailQ.error)}
+ )}
+ {detailQ.data && (
+ <>
+ {detailQ.data.learning.user.isLocked && (
+
+
+
+
+
حساب قفل است
+
+ پس از باز کردن قفل، سه جان بازمیگردد و مسیر ادامه پیدا میکند.
+
+
+
+
unlockStudent(detailQ.data!.learning.user.id)}
+ className="gap-1.5"
+ >
+ {unlocking ? : }
+ باز کردن قفل
+
+
+ )}
+
+
+ {[
+ { l: "امتیاز", v: formatFaNumber(detailQ.data.learning.user.xp) },
+ { l: "جان", v: formatFaNumber(detailQ.data.learning.user.hearts) },
+ { l: "استریک", v: formatFaNumber(detailQ.data.learning.user.streakCurrent) },
+ {
+ l: "تکمیل",
+ v: `${formatFaNumber(detailQ.data.learning.user.lessonsPassed)}/${formatFaNumber(detailQ.data.learning.user.lessonsTotal)}`,
+ },
+ ].map((x) => (
+
+ ))}
+
+
+
+ دروس
+
+ {detailQ.data.learning.lessons.map((l) => (
+
+
+
+ L{l.lessonId} · {l.title}
+
+
{l.chapterTitle}
+
+
+
+ {pct(l.watchedPct)}
+
+ {l.passedQuiz ? (
+
+ قبول
+
+ ) : l.quizUnlocked ? (
+
+ آزمون باز
+
+ ) : (
+
+ در حال تماشا
+
+ )}
+
+
+ ))}
+
+
+
+
+ تلاشهای آزمون
+ {detailQ.data.learning.attempts.length === 0 ? (
+ هنوز تلاشی ثبت نشده.
+ ) : (
+
+ {detailQ.data.learning.attempts.map((a) => (
+
+ {a.lessonTitle}
+
+ {formatFaNumber(a.correctCount)}/{formatFaNumber(a.total)} · {pct(a.scorePct)}
+
+
+ {a.status === "passed" ? "قبول" : "رد"}
+
+ {fmtDate(a.createdAt)}
+
+ ))}
+
+ )}
+
+
+
+
+
امتیازها
+
+ {detailQ.data.learning.xpEvents.map((x) => (
+
+ +{formatFaNumber(x.amount)}
+ {x.source}
+ {fmtDate(x.createdAt)}
+
+ ))}
+ {detailQ.data.learning.xpEvents.length === 0 && (
+
رویداد امتیازی نیست.
+ )}
+
+
+
+
نشستهای تماشا
+
+ {detailQ.data.learning.sessions.map((s) => (
+
+
{s.lessonTitle}
+
آخرین ضربان: {fmtDate(s.lastHeartbeatAt)}
+
+ ))}
+ {detailQ.data.learning.sessions.length === 0 && (
+
نشست تماشایی نیست.
+ )}
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/list-pagination.tsx b/frontend/src/components/admin/list-pagination.tsx
new file mode 100644
index 0000000..f673801
--- /dev/null
+++ b/frontend/src/components/admin/list-pagination.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { formatFaNumber } from "@/lib/utils";
+
+export type Paginated = {
+ items: T[];
+ total: number;
+ page: number;
+ pageSize: number;
+};
+
+export const DEFAULT_PAGE_SIZE = 10;
+
+export function buildPageQuery(page: number, pageSize = DEFAULT_PAGE_SIZE): string {
+ const params = new URLSearchParams();
+ params.set("page", String(page));
+ params.set("pageSize", String(pageSize));
+ return params.toString();
+}
+
+export function ListPagination({
+ page,
+ pageSize,
+ total,
+ onPageChange,
+ className,
+}: {
+ page: number;
+ pageSize: number;
+ total: number;
+ onPageChange: (page: number) => void;
+ className?: string;
+}) {
+ if (total <= 0) {
+ return null;
+ }
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ if (totalPages <= 1) {
+ return (
+
+ {formatFaNumber(total)} مورد
+
+ );
+ }
+ return (
+
+
+ صفحه {formatFaNumber(page)} از {formatFaNumber(totalPages)}
+ {" · "}
+ {formatFaNumber(total)} مورد
+
+
+ onPageChange(page - 1)}
+ >
+
+ قبلی
+
+ = totalPages}
+ onClick={() => onPageChange(page + 1)}
+ >
+ بعدی
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/list-toolbar.tsx b/frontend/src/components/admin/list-toolbar.tsx
new file mode 100644
index 0000000..f95cd05
--- /dev/null
+++ b/frontend/src/components/admin/list-toolbar.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode, SelectHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+/** Shared chrome for admin list search / filter rows. */
+export function ListToolbar({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Native select styled to match Input height and radius. */
+export function AdminSelect({
+ className,
+ ...props
+}: SelectHTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/admin/nav.ts b/frontend/src/components/admin/nav.ts
new file mode 100644
index 0000000..1f4f444
--- /dev/null
+++ b/frontend/src/components/admin/nav.ts
@@ -0,0 +1,205 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ Activity,
+ BookOpen,
+ CalendarPlus,
+ DatabaseBackup,
+ FileVideo,
+ GraduationCap,
+ HelpCircle,
+ Link2,
+ Megaphone,
+ Package,
+ Phone,
+ PhoneOff,
+ Settings2,
+ Trophy,
+ UserPlus,
+ Users,
+} from "lucide-react";
+
+export type AdminTabId =
+ | "stats"
+ | "learning"
+ | "users"
+ | "orders"
+ | "registration"
+ | "invites"
+ | "whitelist"
+ | "blacklist"
+ | "curriculum"
+ | "questions"
+ | "videos"
+ | "events"
+ | "challenges"
+ | "announce"
+ | "chat-export"
+ | "settings";
+
+export type AdminNavItem = {
+ id: AdminTabId;
+ label: string;
+ description: string;
+ icon: LucideIcon;
+ adminOnly?: boolean;
+};
+
+export type AdminNavGroup = {
+ title: string;
+ items: AdminNavItem[];
+};
+
+export const ADMIN_NAV: AdminNavGroup[] = [
+ {
+ title: "نمای کلی",
+ items: [
+ {
+ id: "stats",
+ label: "نمای کلی",
+ description: "آمار سریع سامانه و یادداشتهای کاری ادمین.",
+ icon: Activity,
+ adminOnly: true,
+ },
+ ],
+ },
+ {
+ title: "افراد",
+ items: [
+ {
+ id: "learning",
+ label: "پیشرفت یادگیری",
+ description: "وضعیت پیشرفت، استریک و جزئیات مسیر هر هنرجو.",
+ icon: GraduationCap,
+ },
+ {
+ id: "users",
+ label: "کاربران",
+ description: "ساخت، ویرایش، قفل و مدیریت نقش حسابها.",
+ icon: Users,
+ adminOnly: true,
+ },
+ {
+ id: "orders",
+ label: "سفارش فیزیکی",
+ description: "پیگیری درخواست و ارسال گواهینامه فیزیکی.",
+ icon: Package,
+ adminOnly: true,
+ },
+ ],
+ },
+ {
+ title: "ثبتنام و دسترسی",
+ items: [
+ {
+ id: "registration",
+ label: "ثبتنام عمومی",
+ description: "روشن/خاموش کردن ثبتنام آزاد در /register و نمای کلی دسترسی.",
+ icon: UserPlus,
+ adminOnly: true,
+ },
+ {
+ id: "invites",
+ label: "لینک عضویت",
+ description: "ساخت و مدیریت لینکهای دعوت با ظرفیت و مهلت.",
+ icon: Link2,
+ adminOnly: true,
+ },
+ {
+ id: "whitelist",
+ label: "فهرست مجاز",
+ description: "شمارههایی که وقتی اجبار فهرست مجاز روشن است اجازه ثبتنام عمومی دارند.",
+ icon: Phone,
+ adminOnly: true,
+ },
+ {
+ id: "blacklist",
+ label: "فهرست سیاه",
+ description: "شمارههایی که همیشه رد میشوند — ثبتنام عمومی و دعوتنامه.",
+ icon: PhoneOff,
+ adminOnly: true,
+ },
+ ],
+ },
+ {
+ title: "محتوای دوره",
+ items: [
+ {
+ id: "curriculum",
+ label: "دروس",
+ description: "فصلها، درسها، ترتیب و درخت مهارت.",
+ icon: BookOpen,
+ },
+ {
+ id: "questions",
+ label: "سوالات",
+ description: "بانک سوالات چندگزینهای هر درس.",
+ icon: HelpCircle,
+ },
+ {
+ id: "videos",
+ label: "ویدیوها",
+ description: "آپلود و مدیریت فایلهای ویدیوی دروس.",
+ icon: FileVideo,
+ },
+ ],
+ },
+ {
+ title: "ارتباط",
+ items: [
+ {
+ id: "events",
+ label: "رویدادها",
+ description: "کارگاهها و جلسات قابلنمایش در مرکز هنرجو.",
+ icon: CalendarPlus,
+ },
+ {
+ id: "challenges",
+ label: "چالشها",
+ description: "چالش ماهانه با هدف امتیاز برای هنرجویان.",
+ icon: Trophy,
+ adminOnly: true,
+ },
+ {
+ id: "announce",
+ label: "اطلاعیه",
+ description: "ارسال اعلان گروهی به هنرجویان فعال.",
+ icon: Megaphone,
+ adminOnly: true,
+ },
+ {
+ id: "chat-export",
+ label: "آرشیو گفتگوها",
+ description: "خروجی ساختاریافته و لینک دانلود موقت برای همه گفتگوهای پروژه.",
+ icon: DatabaseBackup,
+ adminOnly: true,
+ },
+ ],
+ },
+ {
+ title: "سایت",
+ items: [
+ {
+ id: "settings",
+ label: "تنظیمات سایت",
+ description: "حمایت مالی، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
+ icon: Settings2,
+ adminOnly: true,
+ },
+ ],
+ },
+];
+
+export function findAdminNavItem(id: AdminTabId): AdminNavItem | undefined {
+ return ADMIN_NAV.flatMap((g) => g.items).find((i) => i.id === id);
+}
+
+export function defaultAdminTab(_isAdmin: boolean): AdminTabId {
+ return "learning";
+}
+
+export function isValidAdminTab(id: string, isAdmin: boolean): id is AdminTabId {
+ const item = findAdminNavItem(id as AdminTabId);
+ if (!item) return false;
+ if (item.adminOnly && !isAdmin) return false;
+ return true;
+}
diff --git a/frontend/src/components/admin/panel-header.tsx b/frontend/src/components/admin/panel-header.tsx
new file mode 100644
index 0000000..0c5e6ff
--- /dev/null
+++ b/frontend/src/components/admin/panel-header.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from "react";
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export function AdminPanelHeader({
+ title,
+ description,
+ icon: Icon,
+ actions,
+ className,
+}: {
+ title: string;
+ description?: string;
+ icon?: LucideIcon;
+ actions?: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
+ {Icon && (
+
+
+
+ )}
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/frontend/src/components/admin/physical-orders-panel.tsx b/frontend/src/components/admin/physical-orders-panel.tsx
new file mode 100644
index 0000000..c59e5ce
--- /dev/null
+++ b/frontend/src/components/admin/physical-orders-panel.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { Loader2 } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
+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 { toast } from "@/components/providers";
+import { formatFaNumber } from "@/lib/utils";
+
+const STATUS_LABEL: Record = {
+ requested: "درخواستشده",
+ paid: "پرداختشده",
+ shipped: "ارسالشده",
+ cancelled: "لغو",
+};
+
+const NEXT: Partial> = {
+ requested: "paid",
+ paid: "shipped",
+};
+
+export function PhysicalOrdersPanel() {
+ const qc = useQueryClient();
+ const [statusFilter, setStatusFilter] = useState("");
+ const [notes, setNotes] = useState>({});
+ const [tracking, setTracking] = useState>({});
+ const [busy, setBusy] = useState(null);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "physical-orders", statusFilter],
+ queryFn: () =>
+ http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
+ `/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`,
+ ),
+ });
+
+ const update = async (id: number, status: PhysicalOrderStatus) => {
+ setBusy(id);
+ try {
+ await http.put(`/api/admin/physical-orders/${id}`, {
+ status,
+ note: notes[id] ?? "",
+ trackingCode: tracking[id] ?? "",
+ });
+ toast.success("وضعیت بهروزرسانی شد — دانشجو مطلع میشود");
+ qc.invalidateQueries({ queryKey: ["admin", "physical-orders"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ return (
+
+
+ فهرست سفارشها
+
+ setStatusFilter(e.target.value)}
+ >
+ همه
+ درخواستشده
+ پرداختشده
+ ارسالشده
+ لغو
+
+ {data?.physical && (
+
+ قیمت: {formatFaNumber(data.physical.priceIrr)} ریال · پنجره:{" "}
+ {formatFaNumber(data.physical.windowDays)} روز
+
+ )}
+
+
+
+ {isLoading && (
+
+
+
+ )}
+ {(data?.orders ?? []).map((o) => (
+
+
+
+
{o.userName ?? `کاربر ${o.userId}`}
+
+ #{o.id} · cert {o.publicId}
+
+
+ مهلت پنجره: {new Date(o.windowEndsAt).toLocaleString("fa-IR")}
+
+ {(o.address || o.city) && (
+
+ {[o.recipientName, o.phone, o.city, o.postalCode].filter(Boolean).join(" · ")}
+
+ {o.address}
+
+ )}
+ {o.trackingCode && (
+
+ tracking: {o.trackingCode}
+
+ )}
+
+
+ {STATUS_LABEL[o.status]}
+
+
+ {o.note &&
یادداشت: {o.note}
}
+
setNotes((n) => ({ ...n, [o.id]: e.target.value }))}
+ />
+ {(o.status === "paid" || o.status === "shipped") && (
+
setTracking((t) => ({ ...t, [o.id]: e.target.value }))}
+ dir="ltr"
+ />
+ )}
+
+ {NEXT[o.status] && (
+ update(o.id, NEXT[o.status]!)}>
+ {busy === o.id ? : null}
+ انتقال به {STATUS_LABEL[NEXT[o.status]!]}
+
+ )}
+ {o.status !== "cancelled" && o.status !== "shipped" && (
+ update(o.id, "cancelled")}
+ >
+ لغو
+
+ )}
+
+
+ ))}
+ {!isLoading && (data?.orders ?? []).length === 0 && (
+ سفارشی نیست.
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/admin/questions-panel.tsx b/frontend/src/components/admin/questions-panel.tsx
new file mode 100644
index 0000000..9ef3670
--- /dev/null
+++ b/frontend/src/components/admin/questions-panel.tsx
@@ -0,0 +1,558 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, ChevronDown, ChevronUp, Eye, Plus, Save, Trash2 } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import type { Lesson, MCQQuestion } from "@/lib/types";
+import { useConfirm } from "@/components/admin/use-confirm";
+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 { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { cn, formatOptionLetter } from "@/lib/utils";
+import { toast } from "@/components/providers";
+import { Skeleton } from "@/components/ui/skeleton";
+
+function reorderArray(arr: T[], from: number, to: number): T[] {
+ const next = arr.slice();
+ const [item] = next.splice(from, 1);
+ next.splice(to, 0, item);
+ return next;
+}
+
+const STEPS = [
+ { id: 1 as const, label: "متن و گزینهها" },
+ { id: 2 as const, label: "پاسخ درست" },
+ { id: 3 as const, label: "پیشنمایش نهایی" },
+];
+
+export function QuestionsPanel() {
+ const qc = useQueryClient();
+ const { confirm, dialog } = useConfirm();
+ const [lessonId, setLessonId] = useState(null);
+ const [modal, setModal] = useState(null);
+
+ const { data: lessonsData } = useQuery({
+ queryKey: ["admin", "lessons"],
+ queryFn: () => http.get<{ lessons: Lesson[] }>("/api/admin/lessons"),
+ });
+ const lessons = [...(lessonsData?.lessons ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
+
+ useEffect(() => {
+ if (lessonId == null && lessons[0]) setLessonId(lessons[0].id);
+ }, [lessons, lessonId]);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "questions", lessonId],
+ queryFn: () => http.get<{ questions: MCQQuestion[] }>(`/api/admin/lessons/${lessonId}/questions`),
+ enabled: !!lessonId,
+ });
+ const qs = data?.questions ?? [];
+ const selected = lessons.find((l) => l.id === lessonId);
+
+ const save = async (qv: MCQQuestion) => {
+ try {
+ if (qv.id) await http.put(`/api/admin/questions/${qv.id}`, qv);
+ else await http.post(`/api/admin/lessons/${qv.lessonId}/questions`, qv);
+ setModal(null);
+ qc.invalidateQueries({ queryKey: ["admin", "questions"] });
+ qc.invalidateQueries({ queryKey: ["admin", "stats"] });
+ toast.success(qv.id ? "سوال ذخیره شد" : "سوال اضافه شد");
+ } catch (e) {
+ toast.error(toUserError(e, "ذخیرهٔ سوال ممکن نشد"));
+ }
+ };
+
+ const move = async (idx: number, delta: number) => {
+ const to = idx + delta;
+ if (to < 0 || to >= qs.length) return;
+ const next = reorderArray(qs, idx, to);
+ try {
+ for (let i = 0; i < next.length; i++) {
+ if (next[i].position !== i + 1) {
+ await http.put(`/api/admin/questions/${next[i].id}`, { ...next[i], position: i + 1 });
+ }
+ }
+ qc.invalidateQueries({ queryKey: ["admin", "questions"] });
+ } catch (e) {
+ toast.error(toUserError(e, "جابهجایی سوال ممکن نشد"));
+ }
+ };
+
+ const del = async (q: MCQQuestion) => {
+ if (!(await confirm("حذف سوال", `سوال «${q.question.slice(0, 60)}…» حذف شود؟`))) return;
+ try {
+ await http.del(`/api/admin/questions/${q.id}`);
+ qc.invalidateQueries({ queryKey: ["admin", "questions"] });
+ qc.invalidateQueries({ queryKey: ["admin", "stats"] });
+ } catch (e) {
+ toast.error(toUserError(e, "حذف سوال ممکن نشد"));
+ }
+ };
+
+ const openNew = () => {
+ if (!lessonId) return;
+ setModal({
+ id: 0,
+ lessonId,
+ position: qs.length + 1,
+ question: "",
+ options: ["", "", ""],
+ answerIndex: 0,
+ explanation: "",
+ });
+ };
+
+ return (
+ <>
+ {dialog}
+
+
+
+ انتخاب درس
+ سوالات آزمون متعلق به یک درساند؛ اول درس را انتخاب کنید.
+
+
+
+ {lessons.map((l) => {
+ const active = l.id === lessonId;
+ return (
+
setLessonId(l.id)}
+ className={cn(
+ "flex h-[4.25rem] w-40 shrink-0 flex-col justify-center rounded-xl border px-3 text-right transition-colors",
+ active
+ ? "border-primary bg-primary text-primary-foreground shadow-offset-sm"
+ : "border-border bg-card hover:border-primary/40"
+ )}
+ >
+ L{l.id}
+ {l.title}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ سوالات {selected ? `· ${selected.title}` : ""}
+
+
+ {qs.length} سوال · تکپاسخی · ترتیب با فلشها
+
+
+
+ سوال جدید
+
+
+
+ {isLoading && (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ )}
+ {qs.map((q, i) => (
+
+
+ {i + 1}
+
+
+
{q.question}
+
+ {q.options.map((o, oi) => {
+ const isAns = oi === q.answerIndex;
+ return (
+
+
+ {formatOptionLetter(oi)}
+
+ {o || "(خالی)"}
+ {isAns && }
+
+ );
+ })}
+
+
+
+ move(i, -1)}>
+
+
+ move(i, 1)}
+ >
+
+
+
+
+ setModal(q)}>
+ ویرایش
+
+ del(q)}
+ >
+
+
+
+
+ ))}
+ {qs.length === 0 && !isLoading && lessonId && (
+
+
هنوز سوالی نیست
+
+ حداقل دو گزینهٔ پر و یک پاسخ درست مشخص کنید تا آزمون این درس زنده شود.
+
+
+ ساخت اولین سوال
+
+
+ )}
+
+
+
+ setModal(null)} onSave={save} />
+ >
+ );
+}
+
+function QuestionDialog({
+ question,
+ onClose,
+ onSave,
+}: {
+ question: MCQQuestion | null;
+ onClose: () => void;
+ onSave: (q: MCQQuestion) => void;
+}) {
+ const [draft, setDraft] = useState(question);
+ const [step, setStep] = useState<1 | 2 | 3>(1);
+ useEffect(() => {
+ setDraft(question);
+ setStep(1);
+ }, [question]);
+ if (!draft) return null;
+ const set = (patch: Partial) => setDraft((d) => (d ? { ...d, ...patch } : d));
+ const setOpt = (i: number, v: string) => set({ options: draft.options.map((o, idx) => (idx === i ? v : o)) });
+ const addOpt = () => {
+ if (draft.options.length >= 6) return;
+ set({ options: [...draft.options, ""] });
+ };
+ const delOpt = (i: number) => {
+ const opts = draft.options.filter((_, idx) => idx !== i);
+ let answerIndex = draft.answerIndex;
+ if (i < draft.answerIndex) {
+ answerIndex = draft.answerIndex - 1;
+ } else if (i === draft.answerIndex) {
+ answerIndex = Math.min(draft.answerIndex, Math.max(0, opts.length - 1));
+ }
+ set({ options: opts, answerIndex });
+ };
+
+ const clean = draft.options.map((o) => o.trim());
+ const questionOk = draft.question.trim().length > 3;
+ const allOptsFilled = clean.every(Boolean) && clean.length >= 2;
+ const answerOk = !!clean[draft.answerIndex];
+ const valid = questionOk && allOptsFilled && answerOk;
+
+ const canGo = (target: 1 | 2 | 3) => {
+ if (target === 1) return true;
+ if (target === 2) return questionOk && allOptsFilled;
+ return valid;
+ };
+
+ return (
+ !o && onClose()}>
+
+
+
+ {draft.id ? "ویرایش سوال" : "سوال جدید"}
+
+ قبل از ذخیره، پیشنمایش نهایی را مثل هنرجو ببینید.
+
+ {STEPS.map((s) => {
+ const active = step === s.id;
+ const done = step > s.id;
+ return (
+ canGo(s.id) && setStep(s.id)}
+ className={cn(
+ "flex h-10 items-center justify-center gap-1.5 rounded-xl border px-2 text-[11px] font-bold transition-colors sm:text-xs",
+ active
+ ? "border-primary bg-primary text-primary-foreground"
+ : done
+ ? "border-success/40 bg-success/10 text-success"
+ : "border-border bg-muted/40 text-muted-foreground disabled:opacity-40"
+ )}
+ >
+ {s.id}
+ {s.label}
+
+ );
+ })}
+
+
+
+
+ {step === 1 && (
+
+
+
+
+
+
گزینهها ({draft.options.length}/۶)
+
= 6}
+ >
+ افزودن
+
+
+
+ {draft.options.map((opt, i) => (
+
+
+ {formatOptionLetter(i)}
+
+ setOpt(i, e.target.value)}
+ placeholder={`متن گزینه ${formatOptionLetter(i)}`}
+ className="h-10 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0"
+ />
+ delOpt(i)}
+ >
+
+
+
+ ))}
+
+ {!allOptsFilled && (
+
+ همهٔ گزینههای نمایشدادهشده باید پر باشند (حداقل ۲ تا).
+
+ )}
+
+
+
+ توضیح بعد از اشتباه (اختیاری)
+ set({ explanation: e.target.value })}
+ placeholder="چرا این پاسخ درست است؟"
+ className="h-11 rounded-2xl"
+ />
+
+
+ )}
+
+ {step === 2 && (
+
+
گزینهٔ درست را علامت بزنید.
+
+ {draft.question}
+
+
+ {draft.options.map((o, i) => {
+ const isAns = i === draft.answerIndex;
+ return (
+ set({ answerIndex: i })}
+ className={cn(
+ "grid w-full grid-cols-[2.75rem_minmax(0,1fr)_1.25rem] items-center gap-3 rounded-xl border px-4 py-3 text-right text-sm font-bold transition-colors",
+ isAns
+ ? "border-success bg-success/10 text-success"
+ : "border-border hover:border-primary/50"
+ )}
+ >
+
+ {isAns ? : formatOptionLetter(i)}
+
+ {o}
+
+ {isAns && }
+
+
+ );
+ })}
+
+ {!answerOk && (
+
پاسخ درست باید گزینهای با متن غیرخالی باشد.
+ )}
+
+ )}
+
+ {step === 3 && (
+
+
+
+ پیشنمایش نهایی — همان چیزی که هنرجو در آزمون میبیند
+
+
+
+
+
+ ?
+
+
{draft.question}
+
+
+ {draft.options.map((opt, oi) => {
+ const isAns = oi === draft.answerIndex;
+ return (
+
+
+ {formatOptionLetter(oi)}
+
+ {opt}
+ {isAns && (
+
+ پاسخ درست
+
+ )}
+
+ );
+ })}
+
+ {draft.explanation.trim() && (
+
+ توضیح اشتباه:
+ {draft.explanation}
+
+ )}
+
+
+
+
+ {draft.options.length} گزینه
+
+
+ پاسخ {formatOptionLetter(draft.answerIndex)}
+
+
+
+ {draft.explanation.trim() ? "توضیح دارد" : "بدون توضیح"}
+
+
+
+ )}
+
+
+
+
+ انصراف
+
+
+ {step > 1 && (
+ setStep((s) => (s - 1) as 1 | 2 | 3)}>
+ قبلی
+
+ )}
+ {step < 3 ? (
+ setStep((s) => (s + 1) as 1 | 2 | 3)}
+ >
+ {step === 1 ? "بعدی · پاسخ درست" : "بعدی · پیشنمایش"}
+
+ ) : (
+ onSave(draft)}>
+ تأیید و {draft.id ? "ذخیره" : "ساخت"}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/registration-panel.tsx b/frontend/src/components/admin/registration-panel.tsx
new file mode 100644
index 0000000..f1ecab5
--- /dev/null
+++ b/frontend/src/components/admin/registration-panel.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { ArrowLeft, Link2, Loader2, Phone, PhoneOff, Save, ShieldAlert, UserPlus } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "@/components/providers";
+import type { AdminTabId } from "@/components/admin/nav";
+
+type SettingsMap = Record;
+
+const RELATED: {
+ id: AdminTabId;
+ label: string;
+ hint: string;
+ icon: typeof Link2;
+}[] = [
+ {
+ id: "invites",
+ label: "لینک عضویت",
+ hint: "دعوت با ظرفیت و مهلت، بدون نیاز به فهرست مجاز",
+ icon: Link2,
+ },
+ {
+ id: "whitelist",
+ label: "فهرست مجاز",
+ hint: "وقتی اجبار فهرست مجاز روشن باشد، فقط این شمارهها ثبتنام عمومی میکنند",
+ icon: Phone,
+ },
+ {
+ id: "blacklist",
+ label: "فهرست سیاه",
+ hint: "همیشه رد — برای ثبتنام عمومی و دعوتنامه",
+ icon: PhoneOff,
+ },
+];
+
+export function RegistrationPanel({ onNavigate }: { onNavigate: (tab: AdminTabId) => void }) {
+ const qc = useQueryClient();
+ const [enabled, setEnabled] = useState(false);
+ const [requireWhitelist, setRequireWhitelist] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [dirty, setDirty] = useState(false);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "settings"],
+ queryFn: () => http.get<{ settings: SettingsMap }>("/api/admin/settings"),
+ });
+
+ useEffect(() => {
+ if (!data?.settings || dirty) return;
+ setEnabled((data.settings.registration_enabled ?? "false") === "true");
+ setRequireWhitelist((data.settings.registration_require_whitelist ?? "true") === "true");
+ }, [data, dirty]);
+
+ const save = async () => {
+ if (!data?.settings) return;
+ setBusy(true);
+ try {
+ await http.put("/api/admin/settings", {
+ settings: {
+ ...data.settings,
+ registration_enabled: enabled ? "true" : "false",
+ registration_require_whitelist: requireWhitelist ? "true" : "false",
+ },
+ });
+ toast.success("تنظیمات ثبتنام ذخیره شد");
+ setDirty(false);
+ qc.invalidateQueries({ queryKey: ["admin", "settings"] });
+ qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ دسترسی عمومی
+
+
+ ثبتنام در /register را روشن کنید و مشخص کنید آیا شماره باید حتماً در فهرست مجاز باشد.
+ فهرست سیاه همیشه رد میکند.
+
+
+ void save()}>
+ {busy ? : }
+ ذخیره
+
+
+
+
+
+
ثبتنام عمومی فعال باشد
+
+ {enabled
+ ? "صفحه ثبتنام عمومی در دسترس است"
+ : "فقط لینک عضویت یا ورود حسابهای موجود"}
+
+
+ {
+ setEnabled(v);
+ setDirty(true);
+ }}
+ aria-label="ثبتنام عمومی"
+ />
+
+
+
+
+
اجبار فهرست مجاز
+
+ {requireWhitelist
+ ? "فقط شمارههای آزاد در فهرست مجاز میتوانند ثبتنام عمومی کنند"
+ : "هر شمارهای (بهجز فهرست سیاه) میتواند ثبتنام کند"}
+
+
+ {
+ setRequireWhitelist(v);
+ setDirty(true);
+ }}
+ aria-label="اجبار فهرست مجاز"
+ disabled={!enabled}
+ />
+
+
+
+
+
+ فهرست سیاه همیشه اعمال میشود؛ حتی اگر اجبار
+ فهرست مجاز خاموش باشد یا ثبتنام از لینک دعوت باشد.
+
+
+
+
+
+
+
ابزارهای مرتبط
+
+ {RELATED.map((item) => {
+ const Icon = item.icon;
+ return (
+
onNavigate(item.id)}
+ className="group flex flex-col gap-2 rounded-xl border border-border bg-card p-3.5 text-start transition-colors hover:border-primary/40 hover:bg-muted/40"
+ >
+
+
+
+
+
+
+ {item.label}
+ {item.hint}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/site-settings-panel.tsx b/frontend/src/components/admin/site-settings-panel.tsx
new file mode 100644
index 0000000..8818b04
--- /dev/null
+++ b/frontend/src/components/admin/site-settings-panel.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { HeartHandshake, Loader2, Package, Save, Settings2, Sparkles } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "@/components/providers";
+import type { AdminTabId } from "@/components/admin/nav";
+
+type SettingsMap = Record;
+
+type SponsorDraft = { name: string; url: string; blurb: string };
+
+function SectionHeading({
+ icon: Icon,
+ title,
+ hint,
+}: {
+ icon: typeof Settings2;
+ title: string;
+ hint?: string;
+}) {
+ return (
+
+
+
+ {title}
+
+ {hint &&
{hint}
}
+
+ );
+}
+
+export function SiteSettingsPanel({
+ onNavigate,
+}: {
+ onNavigate?: (tab: AdminTabId) => void;
+}) {
+ const qc = useQueryClient();
+ const [draft, setDraft] = useState({});
+ const [sponsors, setSponsors] = useState([]);
+ const [busy, setBusy] = useState(false);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "settings"],
+ queryFn: () => http.get<{ settings: SettingsMap }>("/api/admin/settings"),
+ });
+
+ useEffect(() => {
+ if (!data?.settings) return;
+ setDraft(data.settings);
+ try {
+ const parsed = JSON.parse(data.settings.sponsors || "[]") as SponsorDraft[];
+ setSponsors(
+ Array.isArray(parsed)
+ ? parsed.map((s) => ({
+ name: s.name ?? "",
+ url: s.url ?? "",
+ blurb: s.blurb ?? "",
+ }))
+ : [],
+ );
+ } catch {
+ setSponsors([]);
+ }
+ }, [data]);
+
+ const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
+
+ const save = async () => {
+ if ((draft.donation_note ?? "").trim().length > 2000) {
+ toast.error("یادداشت دونیت خیلی طولانی است");
+ return;
+ }
+ const price = Number(draft.physical_cert_price_irr);
+ const days = Number(draft.physical_cert_window_days);
+ if (!Number.isFinite(price) || price < 0) {
+ toast.error("قیمت گواهینامه نامعتبر است");
+ return;
+ }
+ if (!Number.isFinite(days) || days < 1 || days > 3650) {
+ toast.error("مهلت گواهینامه باید بین ۱ تا ۳۶۵۰ روز باشد");
+ return;
+ }
+ if (sponsors.length > 20) {
+ toast.error("حداکثر ۲۰ اسپانسر مجاز است");
+ return;
+ }
+ for (const s of sponsors) {
+ if (!s.name.trim()) continue;
+ if ([...s.name.trim()].length > 100) {
+ toast.error("نام اسپانسر خیلی طولانی است");
+ return;
+ }
+ if ([...s.blurb.trim()].length > 300) {
+ toast.error("توضیح اسپانسر خیلی طولانی است");
+ return;
+ }
+ if ([...s.url.trim()].length > 500) {
+ toast.error("لینک اسپانسر خیلی طولانی است");
+ return;
+ }
+ }
+ setBusy(true);
+ try {
+ const settings = {
+ ...draft,
+ sponsors: JSON.stringify(
+ sponsors
+ .filter((s) => s.name.trim())
+ .map((s) => ({
+ name: s.name.trim(),
+ url: s.url.trim(),
+ blurb: s.blurb.trim(),
+ })),
+ ),
+ };
+ await http.put("/api/admin/settings", { settings });
+ toast.success("تنظیمات ذخیره شد");
+ qc.invalidateQueries({ queryKey: ["admin", "settings"] });
+ qc.invalidateQueries({ queryKey: ["community"] });
+ qc.invalidateQueries({ queryKey: ["sponsors"] });
+ qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ گزینههای قابل ذخیره
+ void save()}>
+ {busy ? : }
+ ذخیره
+
+
+
+ {onNavigate && (
+
+ ثبتنام و دسترسی
+
+ روشن/خاموش کردن ثبتنام عمومی، لینک عضویت، فهرست مجاز و فهرست سیاه در بخش جداگانه
+ هستند.
+
+ onNavigate("registration")}
+ >
+ رفتن به ثبتنام و دسترسی
+
+
+ )}
+
+
+
+
+ فعال باشد
+ set("donation_enabled", v ? "true" : "false")}
+ aria-label="حمایت مالی"
+ />
+
+
+
+
+
+
+
+
+ setSponsors((s) => [...s, { name: "", url: "", blurb: "" }])}
+ >
+ افزودن
+
+
+ {sponsors.length === 0 && (
+
+ هنوز اسپانسری ثبت نشده. با «افزودن» شروع کنید.
+
+ )}
+ {sponsors.map((sp, i) => (
+
+
+ setSponsors((list) => list.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
+ }
+ />
+
+ setSponsors((list) => list.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)))
+ }
+ dir="ltr"
+ />
+
+ setSponsors((list) => list.map((x, j) => (j === i ? { ...x, blurb: e.target.value } : x)))
+ }
+ />
+ setSponsors((list) => list.filter((_, j) => j !== i))}
+ >
+ حذف
+
+
+ ))}
+
+
+
+ رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) میمانند — اینجا فقط تنظیمات قابلتغییر
+ روزمره است.
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/stats-panel.tsx b/frontend/src/components/admin/stats-panel.tsx
new file mode 100644
index 0000000..d116a52
--- /dev/null
+++ b/frontend/src/components/admin/stats-panel.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Activity,
+ BookOpen,
+ CalendarPlus,
+ Eraser,
+ Pencil,
+ Trophy,
+ Users,
+} from "lucide-react";
+import { http } from "@/lib/api";
+import type { AdminStats } from "@/lib/types";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+
+const SKETCH_KEY = "pargar.admin.statsSketch";
+const NOTES_KEY = "pargar.admin.statsNotes";
+
+/** Ink is always stored black; CSS invert makes it white in dark mode. */
+const SKETCH_INK = "#000000";
+
+export function StatsPanel() {
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "stats"],
+ queryFn: () => http.get<{ stats: AdminStats }>("/api/admin/stats"),
+ });
+ const [notes, setNotes] = useState("");
+ const canvasRef = useRef(null);
+ const drawing = useRef(false);
+ const [ink, setInk] = useState(true);
+
+ useEffect(() => {
+ setNotes(localStorage.getItem(NOTES_KEY) ?? "");
+ }, []);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.clientWidth;
+ const h = 220;
+ if (w <= 0) return;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.lineWidth = 2.5;
+ ctx.strokeStyle = SKETCH_INK;
+ const saved = localStorage.getItem(SKETCH_KEY);
+ if (saved) {
+ const img = new Image();
+ img.onload = () => ctx.drawImage(img, 0, 0, w, h);
+ img.src = saved;
+ }
+ }, [isLoading]);
+
+ const persistCanvas = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ try {
+ localStorage.setItem(SKETCH_KEY, canvas.toDataURL("image/png"));
+ } catch {
+ /* ignore quota */
+ }
+ };
+
+ const pos = (e: React.PointerEvent) => {
+ const rect = canvasRef.current!.getBoundingClientRect();
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const onDown = (e: React.PointerEvent) => {
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext("2d");
+ if (!canvas || !ctx) return;
+ drawing.current = true;
+ canvas.setPointerCapture(e.pointerId);
+ const p = pos(e);
+ ctx.beginPath();
+ ctx.moveTo(p.x, p.y);
+ if (ink) {
+ ctx.globalCompositeOperation = "source-over";
+ ctx.strokeStyle = SKETCH_INK;
+ ctx.lineWidth = 2.5;
+ } else {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.strokeStyle = "rgba(0,0,0,1)";
+ ctx.lineWidth = 16;
+ }
+ };
+
+ const onMove = (e: React.PointerEvent) => {
+ if (!drawing.current) return;
+ const ctx = canvasRef.current?.getContext("2d");
+ if (!ctx) return;
+ const p = pos(e);
+ ctx.lineTo(p.x, p.y);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(p.x, p.y);
+ };
+
+ const onUp = () => {
+ if (!drawing.current) return;
+ drawing.current = false;
+ persistCanvas();
+ };
+
+ const clearSketch = () => {
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext("2d");
+ if (!canvas || !ctx) return;
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.restore();
+ localStorage.removeItem(SKETCH_KEY);
+ };
+
+ if (isLoading) {
+ return (
+
+ {[0, 1, 2, 3, 4].map((i) => (
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ const s = data?.stats;
+ const cards = [
+ { label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حسابهای فعال در سامانه" },
+ { label: "درسها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درسهای ثبتشده در درخت" },
+ { label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
+ { label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاهها" },
+ { label: "تکمیلها", value: s?.completions ?? 0, icon: Trophy, klass: "text-success bg-success/10", hint: "قبولی آزمون درسها" },
+ ];
+
+ return (
+
+
+ {cards.map((c) => (
+
+
+
+
+
+ {c.value}
+ {c.label}
+ {c.hint}
+
+
+ ))}
+
+
+
+
+
+
+
بوم یادداشت دستی
+
اسکچ و ایدههای جلسه را اینجا بکشید؛ روی همین مرورگر ذخیره میشود.
+
+
+
setInk(true)}
+ >
+ قلم
+
+
setInk(false)}
+ >
+ پاککن
+
+
+ پاک کردن
+
+
+
+
+
+
+
+
+
+
+
+
+ یادداشت متنی
+ چکلیست یا نکات عملیاتی برای تیم منتور.
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/use-confirm.tsx b/frontend/src/components/admin/use-confirm.tsx
new file mode 100644
index 0000000..1ec463d
--- /dev/null
+++ b/frontend/src/components/admin/use-confirm.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { ConfirmDialog } from "@/components/admin/confirm-dialog";
+
+type Pending = {
+ title: string;
+ description: string;
+ confirmLabel?: string;
+ resolve: (ok: boolean) => void;
+};
+
+export function useConfirm() {
+ const [pending, setPending] = useState(null);
+ const settledRef = useRef(false);
+
+ const confirm = useCallback((title: string, description: string, confirmLabel?: string) => {
+ return new Promise((resolve) => {
+ settledRef.current = false;
+ setPending({ title, description, confirmLabel, resolve });
+ });
+ }, []);
+
+ const settle = useCallback((ok: boolean) => {
+ if (settledRef.current || !pending) {
+ return;
+ }
+ settledRef.current = true;
+ const p = pending;
+ setPending(null);
+ p.resolve(ok);
+ }, [pending]);
+
+ const dialog = (
+ {
+ if (!open) {
+ settle(false);
+ }
+ }}
+ onConfirm={() => settle(true)}
+ />
+ );
+
+ return { confirm, dialog };
+}
diff --git a/frontend/src/components/admin/videos-panel.tsx b/frontend/src/components/admin/videos-panel.tsx
new file mode 100644
index 0000000..03ebbdf
--- /dev/null
+++ b/frontend/src/components/admin/videos-panel.tsx
@@ -0,0 +1,698 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ Check,
+ Eye,
+ Film,
+ Link2,
+ Loader2,
+ Play,
+ Search,
+ Trash2,
+ Upload,
+ X,
+} from "lucide-react";
+import { http, signedMediaUrl, toUserError } from "@/lib/api";
+import { uploadVideo, validateVideoFile } from "@/lib/upload";
+import type { Lesson } from "@/lib/types";
+import { useConfirm } from "@/components/admin/use-confirm";
+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 { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+import { toast } from "@/components/providers";
+import { Skeleton } from "@/components/ui/skeleton";
+
+type VideoItem = { key: string; size: number; mime?: string; lastModified?: string };
+
+function formatBytes(n: number) {
+ if (!n) return "—";
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function formatLabel(key: string) {
+ const ext = key.toLowerCase().endsWith(".webm") ? "WebM" : "MP4";
+ return ext;
+}
+
+function VideoThumb({
+ videoKey,
+ className,
+ onOpen,
+}: {
+ videoKey: string;
+ className?: string;
+ onOpen?: () => void;
+}) {
+ const [url, setUrl] = useState(null);
+ const [state, setState] = useState<"loading" | "ready" | "error">("loading");
+
+ useEffect(() => {
+ let cancelled = false;
+ setState("loading");
+ setUrl(null);
+ signedMediaUrl(videoKey)
+ .then((u) => {
+ if (!cancelled) {
+ setUrl(u);
+ setState("ready");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setState("error");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [videoKey]);
+
+ return (
+
+ {state === "loading" && (
+
+
+
+ )}
+ {state === "error" && (
+
+
+ پیشنمایش نیست
+
+ )}
+ {url && state === "ready" && (
+ setState("error")}
+ />
+ )}
+
+
+
+
+
+
+ );
+}
+
+function VideoPreviewDialog({
+ videoKey,
+ onClose,
+}: {
+ videoKey: string | null;
+ onClose: () => void;
+}) {
+ const [url, setUrl] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!videoKey) return;
+ let cancelled = false;
+ setUrl(null);
+ setError(null);
+ signedMediaUrl(videoKey)
+ .then((u) => {
+ if (!cancelled) setUrl(u);
+ })
+ .catch((e) => {
+ if (!cancelled) setError(toUserError(e, "پیشنمایش ویدیو ممکن نشد"));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [videoKey]);
+
+ return (
+ !o && onClose()}>
+
+
+ پیشنمایش ویدیو
+
+ {videoKey}
+
+
+
+ {error && (
+
+ )}
+ {!error && !url && (
+
+
+
+ )}
+ {url && (
+
+ )}
+
+
+
+ بستن
+
+
+
+
+ );
+}
+
+export function VideosPanel() {
+ const qc = useQueryClient();
+ const { confirm, dialog } = useConfirm();
+ const fileRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+ const [q, setQ] = useState("");
+ const [attachKey, setAttachKey] = useState(null);
+ const [previewKey, setPreviewKey] = useState(null);
+
+ const {
+ data,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ isFetching,
+ } = useQuery({
+ queryKey: ["admin", "videos"],
+ queryFn: () => http.get<{ videos: VideoItem[] }>("/api/admin/videos"),
+ retry: 1,
+ });
+ const videos = data?.videos ?? [];
+
+ const {
+ data: lessonsData,
+ isError: lessonsError,
+ error: lessonsErr,
+ refetch: refetchLessons,
+ } = useQuery({
+ queryKey: ["admin", "lessons"],
+ queryFn: () => http.get<{ lessons: Lesson[] }>("/api/admin/lessons"),
+ retry: 1,
+ });
+ const lessons = lessonsData?.lessons ?? [];
+
+ const usedByKey = useMemo(() => {
+ const map = new Map();
+ for (const l of lessons) {
+ if (!l.videoKey) continue;
+ const arr = map.get(l.videoKey) ?? [];
+ arr.push(l);
+ map.set(l.videoKey, arr);
+ }
+ return map;
+ }, [lessons]);
+
+ const filtered = useMemo(() => {
+ const term = q.trim().toLowerCase();
+ if (!term) return videos;
+ return videos.filter((v) => v.key.toLowerCase().includes(term));
+ }, [videos, q]);
+
+ const pick = (file: File | undefined) => {
+ if (!file) return;
+ const invalid = validateVideoFile(file);
+ if (invalid) {
+ toast.error(invalid);
+ if (fileRef.current) fileRef.current.value = "";
+ return;
+ }
+ void upload(file);
+ };
+
+ const upload = async (file: File) => {
+ setUploading(true);
+ try {
+ const res = await uploadVideo(0, file);
+ await qc.invalidateQueries({ queryKey: ["admin", "videos"] });
+ toast.success(file.name.toLowerCase().endsWith(".webm") ? "WebM آپلود شد" : "MP4 آپلود شد");
+ setAttachKey(res.key);
+ } catch (e) {
+ toast.error(toUserError(e, "آپلود ویدیو ممکن نشد"));
+ } finally {
+ setUploading(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ };
+
+ const del = async (key: string) => {
+ if (!(await confirm("حذف ویدیو", `فایل «${key}» از کتابخانه حذف شود؟`))) return;
+ try {
+ await http.del(`/api/admin/videos?key=${encodeURIComponent(key)}`);
+ await qc.invalidateQueries({ queryKey: ["admin", "videos"] });
+ toast.success("از کتابخانه حذف شد");
+ } catch (e) {
+ toast.error(toUserError(e, "حذف ویدیو ممکن نشد"));
+ }
+ };
+
+ return (
+ <>
+ {dialog}
+
+
+
+
+
آپلود ویدیو
+
+ فقط MP4 (پیشنهادی) یا{" "}
+ WebM · حداکثر ۲۰۰ مگابایت
+
+
بعد از آپلود، درس مقصد را انتخاب کنید.
+
+
+ pick(e.target.files?.[0])}
+ />
+ fileRef.current?.click()}
+ >
+ {uploading ? : }
+ {uploading ? "در حال آپلود…" : "انتخاب MP4 / WebM"}
+
+
+
+
+
+ {(isError || lessonsError) && (
+
+
+
+ {toUserError(isError ? error : lessonsErr, "بارگذاری کتابخانه ممکن نشد")}
+
+
{
+ void refetch();
+ void refetchLessons();
+ }}
+ >
+ تلاش دوباره
+
+
+ )}
+
+
+
+
+
+ کتابخانه ویدیو
+ {isFetching && !isLoading && }
+
+
+ {videos.length} فایل · روی تصویر بزنید تا پخش شود
+
+
+
+
+ setQ(e.target.value)}
+ placeholder="جستجوی نام فایل…"
+ className="h-10 ps-9"
+ />
+
+
+
+ {isLoading && (
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+
+ {filtered.map((v) => {
+ const used = usedByKey.get(v.key) ?? [];
+ return (
+
+
setPreviewKey(v.key)} />
+
+
+
+ {formatLabel(v.key)}
+
+ {v.key === "sample.mp4" && (
+
+ پیشفرض پلیر
+
+ )}
+
+ {v.key}
+
+
+
{formatBytes(v.size)}
+ {v.key === "sample.mp4" && (
+
+ اگر درسی ویدیو نداشته باشد، این فایل بهصورت خودکار در پلیر پخش میشود.
+
+ )}
+ {used.length > 0 ? (
+
+ {used.map((l) => (
+
+ L{l.id} · {l.title}
+
+ ))}
+
+ ) : v.key !== "sample.mp4" ? (
+
به هیچ درسی وصل نیست
+ ) : null}
+
+
+ setPreviewKey(v.key)}
+ >
+ پخش
+
+ setAttachKey(v.key)}
+ disabled={lessons.length === 0}
+ >
+ وصل به درس
+
+ void del(v.key)}
+ >
+
+
+
+
+ );
+ })}
+
+ {filtered.length === 0 && !isLoading && !isError && (
+
+ {q ? "نتیجهای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
+
+ )}
+
+
+
+ setAttachKey(null)}
+ onDone={() => {
+ setAttachKey(null);
+ void qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
+ }}
+ />
+ setPreviewKey(null)} />
+ >
+ );
+}
+
+function AttachVideoDialog({
+ videoKey,
+ lessons,
+ onClose,
+ onDone,
+}: {
+ videoKey: string | null;
+ lessons: Lesson[];
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const [selected, setSelected] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ setSelected(null);
+ }, [videoKey]);
+
+ const attach = async () => {
+ if (!videoKey || selected == null) return;
+ const lesson = lessons.find((l) => l.id === selected);
+ if (!lesson) {
+ toast.error("درس انتخابشده پیدا نشد.");
+ return;
+ }
+ setBusy(true);
+ try {
+ await http.put(`/api/admin/lessons/${lesson.id}`, { ...lesson, videoKey });
+ toast.success(`ویدیو به «${lesson.title}» وصل شد`);
+ onDone();
+ } catch (e) {
+ toast.error(toUserError(e, "اتصال ویدیو به درس ممکن نشد"));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ {
+ if (!o) {
+ setSelected(null);
+ onClose();
+ }
+ }}
+ >
+
+
+ وصل کردن ویدیو به درس
+
+ {videoKey}
+
+
+
+ {lessons.length === 0 && (
+
+ هنوز درسی نیست — اول از تب دروس یک درس بسازید.
+
+ )}
+ {lessons
+ .slice()
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map((l) => {
+ const active = selected === l.id;
+ const already = l.videoKey === videoKey;
+ return (
+
setSelected(l.id)}
+ className={cn(
+ "grid w-full grid-cols-[2rem_minmax(0,1fr)_1rem] items-center gap-3 rounded-xl border px-3 py-3 text-right transition-colors",
+ active ? "border-primary bg-primary/10" : "border-border hover:border-primary/40"
+ )}
+ >
+
+ L{l.id}
+
+
+ {l.title}
+
+ {already ? "همین ویدیو وصل است" : l.videoKey ? `فعلی: ${l.videoKey}` : "بدون ویدیو"}
+
+
+ {active ? : }
+
+ );
+ })}
+
+
+
+ انصراف
+
+ void attach()}>
+ {busy ? : "وصل کن"}
+
+
+
+
+ );
+}
+
+/** Compact picker for LessonDialog — browse library instead of typing the key. */
+export function VideoKeyPicker({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (key: string) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [q, setQ] = useState("");
+ const [previewKey, setPreviewKey] = useState(null);
+ const { data, isLoading, isError, error, refetch } = useQuery({
+ queryKey: ["admin", "videos"],
+ queryFn: () => http.get<{ videos: VideoItem[] }>("/api/admin/videos"),
+ enabled: open,
+ retry: 1,
+ });
+ const videos = data?.videos ?? [];
+ const filtered = useMemo(() => {
+ const term = q.trim().toLowerCase();
+ if (!term) return videos;
+ return videos.filter((v) => v.key.toLowerCase().includes(term));
+ }, [videos, q]);
+
+ return (
+
+
ویدیو درس
+
+
+
+
+ {value || "ویدیویی انتخاب نشده"}
+
+ {value && (
+ onChange("")}
+ aria-label="پاک کردن ویدیو"
+ >
+
+
+ )}
+
+
setOpen(true)}>
+ انتخاب
+
+
+ {value && (
+
setPreviewKey(value)}
+ className="inline-flex items-center gap-1.5 text-xs font-bold text-primary hover:underline"
+ >
+ پیشنمایش ویدیو انتخابشده
+
+ )}
+
+
+
+ انتخاب از کتابخانه
+ فقط MP4 و WebMهای آپلودشده
+
+
+
+
+ setQ(e.target.value)} placeholder="جستجو…" className="h-10 ps-9" />
+
+ {isError && (
+
+
+
{toUserError(error)}
+
void refetch()}>
+ دوباره
+
+
+ )}
+
+ {isLoading &&
}
+ {filtered.map((v) => {
+ const active = v.key === value;
+ return (
+
+ setPreviewKey(v.key)}
+ />
+ {
+ onChange(v.key);
+ setOpen(false);
+ }}
+ >
+
+ {v.key}
+
+
+ {formatLabel(v.key)} · {formatBytes(v.size)}
+
+
+ {active ? (
+
+ ) : (
+ {
+ onChange(v.key);
+ setOpen(false);
+ }}
+ >
+ انتخاب
+
+ )}
+
+ );
+ })}
+ {filtered.length === 0 && !isLoading && !isError && (
+
+ ویدیویی نیست — اول از تب ویدیوها یک MP4 آپلود کنید
+
+ )}
+
+
+
+
+
setPreviewKey(null)} />
+
+ );
+}
diff --git a/frontend/src/components/admin/whitelist-panel.tsx b/frontend/src/components/admin/whitelist-panel.tsx
new file mode 100644
index 0000000..836912b
--- /dev/null
+++ b/frontend/src/components/admin/whitelist-panel.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { Loader2, Trash2, Upload } from "lucide-react";
+import { http, toUserError } from "@/lib/api";
+import type { RegistrationPhoneWhitelist, WhitelistStatus } from "@/lib/types";
+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 { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { toast } from "@/components/providers";
+import { formatFaNumber } from "@/lib/utils";
+import {
+ DEFAULT_PAGE_SIZE,
+ ListPagination,
+ buildPageQuery,
+ type Paginated,
+} from "@/components/admin/list-pagination";
+import { ListToolbar } from "@/components/admin/list-toolbar";
+import { useConfirm } from "@/components/admin/use-confirm";
+
+const STATUS_LABEL: Record = {
+ available: "آزاد",
+ consumed: "مصرفشده",
+};
+
+type StatusFilter = "" | WhitelistStatus;
+
+type ImportResult = {
+ inserted: number;
+ skipped: number;
+ invalid: number;
+ rejected?: number;
+ rejectedPhones?: string[];
+ blacklistedConflicts?: string[];
+ removedBlacklist?: number;
+};
+
+export function WhitelistPanel() {
+ const qc = useQueryClient();
+ const { confirm, dialog } = useConfirm();
+ const [text, setText] = useState("");
+ const [filter, setFilter] = useState("");
+ const [q, setQ] = useState("");
+ const [page, setPage] = useState(1);
+ const [busy, setBusy] = useState(false);
+ const [deleteBusy, setDeleteBusy] = useState(null);
+ const [pendingConflicts, setPendingConflicts] = useState([]);
+
+ useEffect(() => {
+ setPage(1);
+ }, [filter, q]);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["admin", "phone-whitelist", filter, q, page],
+ queryFn: () => {
+ const params = new URLSearchParams(buildPageQuery(page));
+ if (filter) params.set("status", filter);
+ if (q.trim()) params.set("q", q.trim());
+ return http.get>(
+ `/api/admin/phone-whitelist?${params.toString()}`,
+ );
+ },
+ });
+
+ const entries = useMemo(() => data?.items ?? [], [data]);
+ const total = data?.total ?? 0;
+ const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
+ const availableCount = useMemo(
+ () => entries.filter((e) => e.status === "available").length,
+ [entries],
+ );
+
+ const summarizeImport = (res: ImportResult) => {
+ const parts = [
+ `${formatFaNumber(res.inserted)} افزوده`,
+ res.skipped ? `${formatFaNumber(res.skipped)} تکراری` : null,
+ res.removedBlacklist
+ ? `${formatFaNumber(res.removedBlacklist)} از فهرست سیاه حذف`
+ : null,
+ res.invalid ? `${formatFaNumber(res.invalid)} نامعتبر` : null,
+ ].filter(Boolean);
+ if (parts.length > 0) {
+ toast.success(parts.join(" · "));
+ }
+ if ((res.rejected ?? 0) > 0) {
+ const phones = res.rejectedPhones ?? [];
+ const detail =
+ phones.length > 0
+ ? ` (${phones.slice(0, 3).join("، ")}${phones.length > 3 ? "…" : ""})`
+ : "";
+ toast.error(
+ `${formatFaNumber(res.rejected ?? 0)} شماره قبلاً ثبتنام شده و قابل افزودن به فهرست مجاز نیست${detail}`,
+ );
+ }
+ };
+
+ const postImport = async (payload: {
+ text?: string;
+ phones?: string[];
+ resolveConflicts?: boolean;
+ }) => {
+ return http.post("/api/admin/phone-whitelist/import", payload);
+ };
+
+ const finishImport = (res: ImportResult) => {
+ summarizeImport(res);
+ setText("");
+ setPendingConflicts([]);
+ setPage(1);
+ qc.invalidateQueries({ queryKey: ["admin", "phone-whitelist"] });
+ if (res.removedBlacklist) {
+ qc.invalidateQueries({ queryKey: ["admin", "phone-blacklist"] });
+ }
+ };
+
+ const importPhones = async () => {
+ if (!text.trim()) {
+ toast.error("حداقل یک شماره وارد کنید");
+ return;
+ }
+ setBusy(true);
+ setPendingConflicts([]);
+ try {
+ const res = await postImport({ text });
+ const conflicts = res.blacklistedConflicts ?? [];
+ if (conflicts.length > 0) {
+ summarizeImport(res);
+ if (conflicts.length === 1) {
+ const phone = conflicts[0];
+ const ok = await confirm(
+ "این شماره در فهرست سیاه است",
+ `شماره ${phone} در فهرست سیاه است. حذف از فهرست سیاه و افزودن به فهرست مجاز؟`,
+ "حذف از فهرست سیاه و افزودن",
+ );
+ if (!ok) {
+ setPendingConflicts(conflicts);
+ return;
+ }
+ const resolved = await postImport({ phones: conflicts, resolveConflicts: true });
+ finishImport(resolved);
+ return;
+ }
+ setPendingConflicts(conflicts);
+ toast.error(
+ `${formatFaNumber(conflicts.length)} شماره در فهرست سیاه است — برای ادامه تعارض را حل کنید`,
+ );
+ return;
+ }
+ finishImport(res);
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const resolveConflicts = async () => {
+ if (pendingConflicts.length === 0) {
+ return;
+ }
+ setBusy(true);
+ try {
+ const res = await postImport({ phones: pendingConflicts, resolveConflicts: true });
+ finishImport(res);
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const remove = async (id: number) => {
+ setDeleteBusy(id);
+ try {
+ await http.del(`/api/admin/phone-whitelist/${id}`);
+ toast.success("شماره از فهرست حذف شد");
+ qc.invalidateQueries({ queryKey: ["admin", "phone-whitelist"] });
+ } catch (e) {
+ toast.error(toUserError(e));
+ } finally {
+ setDeleteBusy(null);
+ }
+ };
+
+ return (
+
+ {dialog}
+
+
+ واردات شماره
+
+ هر شماره پس از یک ثبتنام موفق مصرف میشود. فرمت: هر خط یا جدا با ویرگول.
+
+
+
+
+ شمارهها
+
+ void importPhones()}>
+ {busy ? : }
+ واردات
+
+ {pendingConflicts.length > 0 && (
+
+
این شماره در فهرست سیاه است
+
+ حذف از فهرست سیاه و افزودن به فهرست مجاز؟
+
+
+ {pendingConflicts.map((phone) => (
+ {phone}
+ ))}
+
+
+ void resolveConflicts()}
+ >
+ {busy ? : null}
+ حل تعارض و ادامه
+
+ setPendingConflicts([])}
+ >
+ انصراف
+
+
+
+ )}
+
+
+
+
+
+ فهرست مجاز
+ جستجو و فیلتر شمارههای ثبتشده.
+
+
+
+
+ {(
+ [
+ ["", "همه"],
+ ["available", "آزاد"],
+ ["consumed", "مصرفشده"],
+ ] as const
+ ).map(([value, label]) => (
+ setFilter(value)}
+ >
+ {label}
+
+ ))}
+
+ setQ(e.target.value)}
+ className="max-w-xs text-left tracking-wide"
+ dir="ltr"
+ />
+ {(q || filter) && (
+ {
+ setQ("");
+ setFilter("");
+ }}
+ >
+ پاک کردن
+
+ )}
+
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && entries.length === 0 && (
+
+ هنوز شمارهای در فهرست نیست.
+
+ )}
+ {!isLoading && entries.length > 0 && filter === "" && (
+
+ آزاد در این صفحه: {formatFaNumber(availableCount)} از {formatFaNumber(entries.length)}
+
+ )}
+
+ {entries.map((entry) => (
+
+
+
+
+ {entry.phone}
+
+
+ {STATUS_LABEL[entry.status]}
+
+
+
+ افزوده: {new Date(entry.createdAt).toLocaleString("fa-IR")}
+ {entry.consumedAt
+ ? ` · مصرف: ${new Date(entry.consumedAt).toLocaleString("fa-IR")}`
+ : null}
+
+
+ {entry.status === "available" && (
+
void remove(entry.id)}
+ >
+ {deleteBusy === entry.id ? (
+
+ ) : (
+
+ )}
+ حذف
+
+ )}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/mentor-chat.tsx b/frontend/src/components/mentor-chat.tsx
new file mode 100644
index 0000000..e11601c
--- /dev/null
+++ b/frontend/src/components/mentor-chat.tsx
@@ -0,0 +1,3452 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowDown,
+ ArrowRight,
+ Check,
+ CheckCheck,
+ ChevronDown,
+ ChevronRight,
+ ChevronUp,
+ Clock,
+ CornerDownLeft,
+ Copy,
+ Download,
+ ExternalLink,
+ FileText,
+ Film,
+ Loader2,
+ Maximize,
+ MessageSquare,
+ Mic,
+ Paperclip,
+ Pause,
+ Pencil,
+ Pin,
+ PinOff,
+ Play,
+ Search,
+ Smile,
+ SmilePlus,
+ Sparkles,
+ Square,
+ Trash2,
+ Volume2,
+ VolumeX,
+ X,
+} from "lucide-react";
+import { api, apiForm, http, getWsUrl } from "@/lib/api";
+import type { ChatAttachment, ChatMessage, ChatReaction, Conversation, Mentor } from "@/lib/types";
+import { useAuth } from "@/lib/auth-store";
+import { cn } from "@/lib/utils";
+import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
+import { Button } from "@/components/ui/button";
+import { ThemeToggle } from "@/components/theme-toggle";
+import { toast } from "@/components/providers";
+import { AppleEmoji, AppleEmojiText } from "@/components/apple-emoji";
+import { softPop, softSpring, softTween } from "@/lib/motion";
+
+const MAX_LEN = 4000;
+
+const LAVA_STYLES: React.CSSProperties[] = [
+ {
+ background:
+ "radial-gradient(circle at 40% 35%, color-mix(in srgb, var(--color-primary) 42%, transparent), transparent 70%)",
+ },
+ {
+ background:
+ "radial-gradient(circle at 55% 45%, color-mix(in srgb, var(--color-secondary) 38%, transparent), transparent 72%)",
+ },
+ {
+ background:
+ "radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--color-accent) 30%, transparent), transparent 68%)",
+ },
+];
+
+function LavaLamp() {
+ const orbs = [
+ {
+ cls: "-start-14 -top-14 h-60 w-60",
+ x: [0, 110, 40, 140, 0],
+ y: [0, 70, 150, 45, 0],
+ s: [1, 1.3, 1.05, 1.35, 1],
+ dur: 26,
+ },
+ {
+ cls: "-end-16 top-1/4 h-52 w-52",
+ x: [0, -90, -30, -120, 0],
+ y: [0, -55, -130, -35, 0],
+ s: [1, 1.2, 1.1, 1.28, 1],
+ dur: 22,
+ },
+ {
+ cls: "bottom-2 start-1/4 h-40 w-40",
+ x: [0, 60, -40, 95, 0],
+ y: [0, -70, -40, -110, 0],
+ s: [1, 1.25, 0.95, 1.3, 1],
+ dur: 30,
+ },
+ ];
+ return (
+
+ {orbs.map((o, i) => (
+
+ ))}
+
+ );
+}
+
+export function ChatWidget() {
+ const me = useAuth((s) => s.user);
+ const qc = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [active, setActive] = useState(null);
+ const [composeOpen, setComposeOpen] = useState(false);
+ const [draft, setDraft] = useState("");
+ const [query, setQuery] = useState("");
+ const [sending, setSending] = useState(false);
+ const [pending, setPending] = useState([]);
+ const [partnerTyping, setPartnerTyping] = useState(false);
+ const [attPref, setAttPref] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [notif, setNotif] = useState<{ id: string; partner: number; name: string; body: string } | null>(null);
+ const [preview, setPreview] = useState(null);
+ const [replyTo, setReplyTo] = useState(null);
+ const [emojiOpen, setEmojiOpen] = useState(false);
+ const [dragOver, setDragOver] = useState(false);
+ const [jumpDown, setJumpDown] = useState(false);
+ const [older, setOlder] = useState([]);
+ const [canLoadMore, setCanLoadMore] = useState(false);
+ const [loadingOlder, setLoadingOlder] = useState(false);
+ const [delArm, setDelArm] = useState(null);
+ const [reactId, setReactId] = useState(null);
+ const [heartMsg, setHeartMsg] = useState(null);
+ const [hearts, setHearts] = useState>({});
+ const [msgSearchOpen, setMsgSearchOpen] = useState(false);
+ const [msgQuery, setMsgQuery] = useState("");
+ const [matchIdx, setMatchIdx] = useState(0);
+ const [pinIdx, setPinIdx] = useState(0);
+ const [flashId, setFlashId] = useState(null);
+ const taRef = useRef(null);
+ const flashTimer = useRef | null>(null);
+ const fileRef = useRef(null);
+ const wsRef = useRef(null);
+ const typingSentAt = useRef(0);
+ const typingClearTimer = useRef | null>(null);
+ const notifTimer = useRef | null>(null);
+ const delArmTimer = useRef | null>(null);
+ const heartTimer = useRef | null>(null);
+ const threadRef = useRef(null);
+ const wasNearBottomRef = useRef(true);
+ const suppressAutoScrollRef = useRef(false);
+ const jumpTargetRef = useRef(null);
+ const pendingDraftRef = useRef(null);
+ const activeRef = useRef(active);
+ activeRef.current = active;
+ const convRef = useRef([]);
+
+const { data: convData } = useQuery({
+ queryKey: ["conversations"],
+ queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
+ refetchInterval: 20_000,
+ });
+ const conversations = convData?.conversations ?? [];
+ convRef.current = conversations;
+ const unreadTotal = useMemo(
+ () => conversations.reduce((n, c) => n + (c.unreadCount > 0 ? c.unreadCount : 0), 0),
+ [conversations]
+ );
+
+ const { data: loveList } = useQuery({
+ queryKey: ["mentors"],
+ queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"),
+ enabled: !active && open,
+ });
+
+ const { data: messages } = useQuery({
+ queryKey: ["chat", active],
+ queryFn: () =>
+ http.get<{ messages: ChatMessage[]; hasMore: boolean; pinned?: ChatMessage[] }>(
+ `/api/chats/${active}/messages`,
+ ),
+ enabled: !!active,
+ refetchInterval: active ? 5000 : false,
+ });
+
+ const pinned = messages?.pinned ?? [];
+ const pinnedIds = useMemo(() => new Set(pinned.map((p) => p.id)), [pinned]);
+ const activePin = pinned.length ? pinned[Math.min(pinIdx, pinned.length - 1)] ?? null : null;
+
+ useEffect(() => {
+ setPinIdx(0);
+ }, [active]);
+
+ useEffect(() => {
+ if (pinned.length === 0) {
+ setPinIdx(0);
+ return;
+ }
+ setPinIdx((i) => (i >= pinned.length ? 0 : i));
+ }, [pinned.length]);
+
+ useEffect(() => {
+ if (messages && typeof messages.hasMore === "boolean") setCanLoadMore(messages.hasMore);
+ }, [messages?.hasMore, active]);
+
+ const msgs = useMemo(() => {
+ const all = [...older, ...(messages?.messages ?? []), ...pending];
+ if (!all.length) return all;
+ const byId = new Map();
+ for (const m of all) byId.set(m.id, m);
+ return [...byId.values()].sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ );
+ }, [messages, older, pending]);
+
+ const matchIds = useMemo(() => {
+ const q0 = normText(msgQuery).trim();
+ if (!q0) return [] as number[];
+ return msgs
+ .filter(
+ (m) =>
+ !!m.body && normText(m.body).includes(q0) || (!!m.attachment?.name && normText(m.attachment.name).includes(q0))
+ )
+ .map((m) => m.id);
+ }, [msgs, msgQuery]);
+ const matchCount = matchIds.length;
+ const matchSet = useMemo(() => new Set(matchIds), [matchIds]);
+
+ useEffect(() => {
+ setMatchIdx(0);
+ }, [msgQuery]);
+
+ useEffect(() => {
+ if (!msgSearchOpen || !matchCount) return;
+ const id = matchIds[matchIdx];
+ if (id == null) return;
+ const el = threadRef.current?.querySelector(`[data-mid="${id}"]`);
+ if (el) requestAnimationFrame(() => el.scrollIntoView({ block: "center", behavior: "smooth" }));
+ }, [matchIdx, msgSearchOpen, matchCount, msgQuery]);
+
+ const loadOlder = async () => {
+ const before = older[0]?.id ?? messages?.messages?.[0]?.id;
+ if (!active || !before || loadingOlder) return;
+ setLoadingOlder(true);
+ try {
+ const res = await http.get<{ messages: ChatMessage[]; hasMore: boolean }>(
+ `/api/chats/${active}/messages?before=${before}`,
+ );
+ setOlder((prev) => {
+ const seen = new Set(prev.map((m) => m.id));
+ const add = res.messages.filter((m) => !seen.has(m.id));
+ return [...add, ...prev];
+ });
+ setCanLoadMore(res.hasMore);
+ } catch {
+ /* ignore */
+ } finally {
+ setLoadingOlder(false);
+ }
+ };
+
+ const delMsg = async (id: number) => {
+ setDelArm(null);
+ if (delArmTimer.current) clearTimeout(delArmTimer.current);
+ if (!active) return;
+ try {
+ await http.del<{ ok: boolean }>(`/api/chats/${active}/messages/${id}`);
+ setOlder((prev) => prev.filter((m) => m.id !== id));
+ setPending((prev) => prev.filter((m) => m.id !== id));
+ qc.invalidateQueries({ queryKey: ["chat", active] });
+ qc.invalidateQueries({ queryKey: ["chat"] });
+ qc.invalidateQueries({ queryKey: ["conversations"] });
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const armDelete = (id: number) => {
+ if (delArm === id) {
+ void delMsg(id);
+ return;
+ }
+ setDelArm(id);
+ if (delArmTimer.current) clearTimeout(delArmTimer.current);
+ delArmTimer.current = setTimeout(() => setDelArm(null), 3000);
+ };
+
+ const togglePin = async (m: ChatMessage) => {
+ if (!active || m.id <= 0) return;
+ try {
+ await api<{ ok: boolean; message: ChatMessage }>(`/api/chats/${active}/messages/${m.id}/pin`, {
+ method: "POST",
+ });
+ qc.invalidateQueries({ queryKey: ["chat", active] });
+ } catch (e) {
+ toast.error((e as Error)?.message || "پین پیام ممکن نشد");
+ }
+ };
+
+ const flashMessage = (id: number) => {
+ const thread = threadRef.current;
+ const el = thread?.querySelector(`[data-mid="${id}"]`) as HTMLElement | null;
+ if (!thread || !el) return false;
+ const elRect = el.getBoundingClientRect();
+ const threadRect = thread.getBoundingClientRect();
+ const nextTop =
+ thread.scrollTop + (elRect.top - threadRect.top) - thread.clientHeight / 2 + el.clientHeight / 2;
+ thread.scrollTo({ top: Math.max(0, nextTop), behavior: "smooth" });
+ wasNearBottomRef.current = false;
+ setJumpDown(true);
+ setFlashId(id);
+ if (flashTimer.current) clearTimeout(flashTimer.current);
+ flashTimer.current = setTimeout(() => {
+ setFlashId((cur) => (cur === id ? null : cur));
+ }, 1500);
+ return true;
+ };
+
+ const clearJumpTarget = () => {
+ jumpTargetRef.current = null;
+ window.setTimeout(() => {
+ suppressAutoScrollRef.current = false;
+ }, 500);
+ };
+
+ const jumpToMessage = async (id: number) => {
+ if (!active) return;
+ suppressAutoScrollRef.current = true;
+ jumpTargetRef.current = id;
+
+ if (flashMessage(id)) {
+ clearJumpTarget();
+ return;
+ }
+
+ if (msgs.some((m) => m.id === id)) return;
+
+ setLoadingOlder(true);
+ try {
+ const res = await http.get<{
+ messages: ChatMessage[];
+ hasMore?: boolean;
+ hasMoreBefore?: boolean;
+ pinned?: ChatMessage[];
+ }>(`/api/chats/${active}/messages?around=${id}`);
+ if (!res.messages?.length) {
+ clearJumpTarget();
+ toast.error("پیام پیدا نشد");
+ return;
+ }
+ setOlder((prev) => {
+ const byId = new Map();
+ for (const m of [...res.messages, ...prev]) byId.set(m.id, m);
+ return [...byId.values()].sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ );
+ });
+ if (typeof res.hasMoreBefore === "boolean") setCanLoadMore(res.hasMoreBefore);
+ else if (typeof res.hasMore === "boolean") setCanLoadMore(res.hasMore);
+ qc.setQueryData(["chat", active], (old: typeof messages) => {
+ if (!old) return old;
+ return { ...old, pinned: res.pinned ?? old.pinned };
+ });
+ // Paint + jump handled by effect watching msgs / jumpTargetRef.
+ } catch {
+ clearJumpTarget();
+ toast.error("بارگذاری پیام ممکن نشد");
+ } finally {
+ setLoadingOlder(false);
+ }
+ };
+
+ const toggleReact = async (m: ChatMessage, emoji: string) => {
+ if (!active || m.id <= 0) return;
+ try {
+ await api<{ ok: boolean }>(`/api/chats/${active}/messages/${m.id}/reaction`, { method: "POST", body: { emoji } });
+ qc.invalidateQueries({ queryKey: ["chat"] });
+ } catch {
+ toast.error("ثبت واکنش ممکن نشد");
+ }
+ };
+
+ const like = (m: ChatMessage) => {
+ if (m.id <= 0) return;
+ setHearts((h) => ({ ...h, [m.id]: (h[m.id] ?? 0) + 1 }));
+ setHeartMsg(m.id);
+ if (heartTimer.current) clearTimeout(heartTimer.current);
+ heartTimer.current = setTimeout(() => setHeartMsg(null), 720);
+ void toggleReact(m, "❤️");
+ };
+
+ useEffect(() => {
+ if (!active) return;
+ api<{ ok: boolean }>(`/api/chats/${active}/read`, { method: "POST" })
+ .then(() => qc.invalidateQueries({ queryKey: ["conversations"] }))
+ .catch(() => undefined);
+ }, [active, qc]);
+
+ useEffect(() => {
+ if (active && threadRef.current) {
+ threadRef.current.scrollTop = threadRef.current.scrollHeight;
+ }
+ wasNearBottomRef.current = true;
+ suppressAutoScrollRef.current = false;
+ jumpTargetRef.current = null;
+ }, [active]);
+
+ useEffect(() => {
+ const el = threadRef.current;
+ if (!el) return;
+ if (suppressAutoScrollRef.current) return;
+ if (wasNearBottomRef.current || pending.length) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }, [msgs, pending]);
+
+ useEffect(() => {
+ const id = jumpTargetRef.current;
+ if (id == null) return;
+ if (!msgs.some((m) => m.id === id)) return;
+ let cancelled = false;
+ const tryFlash = (attempt: number) => {
+ if (cancelled) return;
+ if (flashMessage(id)) {
+ clearJumpTarget();
+ return;
+ }
+ if (attempt < 8) {
+ window.setTimeout(() => tryFlash(attempt + 1), 40);
+ } else {
+ clearJumpTarget();
+ toast.error("پیام پیدا نشد");
+ }
+ };
+ requestAnimationFrame(() => tryFlash(0));
+ return () => {
+ cancelled = true;
+ };
+ }, [msgs]);
+
+ useEffect(() => {
+ if (!active || !me) return;
+ if (draft) localStorage.setItem(`mt.draft.${me.id}.${active}`, draft);
+ else localStorage.removeItem(`mt.draft.${me.id}.${active}`);
+ }, [draft, active, me]);
+
+ useEffect(() => {
+ setPartnerTyping(false);
+ return () => {
+ if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
+ };
+ }, [active]);
+
+ useEffect(() => {
+ setReplyTo(null);
+ setEmojiOpen(false);
+ setOlder([]);
+ setCanLoadMore(false);
+ setDelArm(null);
+ if (delArmTimer.current) clearTimeout(delArmTimer.current);
+ setReactId(null);
+ setHeartMsg(null);
+ setHearts({});
+ if (heartTimer.current) clearTimeout(heartTimer.current);
+ setFlashId(null);
+ if (flashTimer.current) clearTimeout(flashTimer.current);
+ setMsgSearchOpen(false);
+ setMsgQuery("");
+ setMatchIdx(0);
+ if (pendingDraftRef.current != null) {
+ const d = pendingDraftRef.current;
+ pendingDraftRef.current = null;
+ setDraft(d);
+ } else {
+ setDraft(active ? localStorage.getItem(`mt.draft.${me?.id}.${active}`) ?? "" : "");
+ }
+ }, [active, me?.id]);
+
+ useEffect(() => {
+ if (open && active) {
+ const t = setTimeout(() => taRef.current?.focus(), 140);
+ return () => clearTimeout(t);
+ }
+ }, [open, active]);
+
+ useEffect(() => {
+ if (!open) setNotif(null);
+ }, [open]);
+
+ useEffect(() => {
+ const onOpen = (ev: Event) => {
+ const detail = (ev as CustomEvent<{ open?: boolean; with?: number; draft?: string }>).detail;
+ if (typeof detail?.draft === "string") {
+ pendingDraftRef.current = detail.draft;
+ }
+ if (detail?.with) {
+ setActive(detail.with);
+ } else if (typeof detail?.draft === "string") {
+ setDraft(detail.draft);
+ pendingDraftRef.current = null;
+ }
+ setOpen(detail?.open ?? true);
+ };
+ window.addEventListener("pargar:chat", onOpen);
+ return () => window.removeEventListener("pargar:chat", onOpen);
+ }, []);
+
+ useEffect(() => {
+ const { accessToken: token, user } = useAuth.getState();
+ let cancelled = false;
+ if (!user) return;
+ // Prefer bearer subprotocol; fall back to httpOnly cookie on same-origin.
+ const ws = token ? new WebSocket(getWsUrl(), ["bearer", token]) : new WebSocket(getWsUrl());
+ wsRef.current = ws;
+ws.onmessage = (ev) => {
+ try {
+ const p = JSON.parse(ev.data) as { type?: string; from?: number; item?: ChatMessage };
+ if (p.type === "chat_message") {
+ qc.invalidateQueries({ queryKey: ["conversations"] });
+ if (p.from && activeRef.current === p.from) {
+ qc.invalidateQueries({ queryKey: ["chat"] });
+ api<{ ok: boolean }>(`/api/chats/${p.from}/read`, { method: "POST" }).catch(() => undefined);
+ }
+ const mine = p.from === useAuth.getState().user?.id;
+ if (!mine && !cancelled) {
+ const activeThread = !!activeRef.current && activeRef.current === p.from;
+ if (!activeThread) {
+ const conv = convRef.current.find((c) => c.partner.id === p.from);
+ if (conv) {
+ setNotif({
+ id: `${Date.now()}-${p.from}`,
+ partner: p.from ?? 0,
+ name: conv.partner.name,
+ body: p.item ? previewOf(p.item) : "پیام جدید",
+ });
+ if (notifTimer.current) clearTimeout(notifTimer.current);
+ notifTimer.current = setTimeout(() => setNotif(null), 5000);
+ }
+ }
+ }
+ } else if (p.type === "typing") {
+ if (p.from && activeRef.current === p.from) {
+ setPartnerTyping(true);
+ if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
+ typingClearTimer.current = setTimeout(() => setPartnerTyping(false), 4500);
+ }
+ } else if (p.type === "chat_reaction") {
+ if (p.from && activeRef.current === p.from) qc.invalidateQueries({ queryKey: ["chat"] });
+ } else if (p.type === "chat_delete") {
+ const deletedId = (p as { id?: number }).id;
+ if (deletedId) {
+ setOlder((prev) => prev.filter((m) => m.id !== deletedId));
+ setPending((prev) => prev.filter((m) => m.id !== deletedId));
+ }
+ qc.invalidateQueries({ queryKey: ["conversations"] });
+ qc.invalidateQueries({ queryKey: ["chat"] });
+ } else if (p.type === "chat_pin") {
+ qc.invalidateQueries({ queryKey: ["chat"] });
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+ return () => {
+ cancelled = true;
+ wsRef.current = null;
+ ws.close();
+ };
+ }, [qc]);
+
+ const signalTyping = () => {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN || !active) return;
+ const now = Date.now();
+ if (now - typingSentAt.current < 2400) return;
+ typingSentAt.current = now;
+ ws.send(JSON.stringify({ type: "typing", to: active }));
+ };
+
+ const partner =
+ conversations.find((c) => c.partner.id === active)?.partner ??
+ (loveList?.mentors ?? []).find((m) => m.id === active);
+
+ const send = async (text?: string, att?: ChatAttachment | null) => {
+ const body = (text ?? draft).trim();
+ if (!active || (!body && !att && !attPref) || body.length > MAX_LEN || sending) return;
+ setSending(true);
+ const attachment = att ?? attPref ?? undefined;
+ const quoteId = replyTo?.id;
+ const uid = useAuth.getState().user;
+ const temp: ChatMessage = {
+ id: -Date.now(),
+ userId: uid?.role === "mentor" ? active : (uid?.id ?? 0),
+ mentorId: uid?.role === "mentor" ? (uid?.id ?? 0) : active,
+ senderRole: (uid?.role ?? "student") as ChatMessage["senderRole"],
+ body,
+ createdAt: new Date().toISOString(),
+ ...(attachment ? { attachment } : {}),
+ ...(quoteId ? { replyTo: quoteId } : {}),
+ };
+ setDraft("");
+ setAttPref(null);
+ setReplyTo(null);
+ setPending((p) => [...p, temp]);
+try {
+ const res = await api<{ message: ChatMessage }>(`/api/chats/${active}/messages`, {
+ method: "POST",
+ body: { body, attachment, replyTo: quoteId },
+ });
+ qc.setQueryData<{ messages: ChatMessage[]; hasMore: boolean }>(["chat", active], (old) => {
+ const list = old?.messages ?? [];
+ if (!list.some((x) => x.id === res.message.id)) {
+ return { ...old, messages: [...list, res.message] } as typeof old;
+ }
+ return old;
+ });
+ setPending([]);
+ qc.invalidateQueries({ queryKey: ["conversations"] });
+ } catch (err) {
+ setPending([]);
+ setDraft(body);
+ setAttPref(attachment ?? null);
+ setReplyTo(replyTo);
+ toast.error((err as Error).message || "ارسال ممکن نشد");
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const pickFile = () => fileRef.current?.click();
+
+ const uploadSelected = async (file: File) => {
+ if (file.size > 25 * 1024 * 1024) {
+ toast.error("فایل باید کمتر از ۲۵ مگابایت باشد");
+ return;
+ }
+ setUploading(true);
+ try {
+ const { attachment } = await apiForm<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(file));
+ setAttPref(attachment);
+ } catch (err) {
+ toast.error((err as Error).message || "آپلود ممکن نشد");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const onFile = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (file) await uploadSelected(file);
+ };
+
+ const voiceRef = useRef<{
+ recorder: MediaRecorder | null;
+ stream: MediaStream | null;
+ chunks: Blob[];
+ timer: ReturnType;
+ start: number;
+ live?: () => void;
+ } | null>(null);
+ const [recSec, setRecSec] = useState(0);
+ const [recLevel, setRecLevel] = useState(0);
+ const [recUrl, setRecUrl] = useState(null);
+ const [recBlob, setRecBlob] = useState(null);
+ const [recMime, setRecMime] = useState(undefined);
+ const [recDur, setRecDur] = useState(0);
+
+ useEffect(() => {
+ return () => {
+ const v = voiceRef.current;
+ if (v?.recorder && v.recorder.state !== "inactive") v.recorder.stop();
+ v?.stream?.getTracks().forEach((t) => t.stop());
+ if (v?.timer) clearInterval(v.timer);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!preview) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setPreview(null);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [preview]);
+
+ const cancelVoicePreview = () => {
+ if (recUrl) URL.revokeObjectURL(recUrl);
+ setRecUrl(null);
+ setRecBlob(null);
+ setRecMime(undefined);
+ setRecDur(0);
+ };
+
+ const sendVoice = async () => {
+ const blob = recBlob;
+ const mime = recMime;
+ if (!blob || !active || sending || uploading) return;
+ cancelVoicePreview();
+ setUploading(true);
+ try {
+ const { attachment } = await apiForm<{ attachment: ChatAttachment }>("/api/chats/upload", toForm(blob, mime));
+ await send("", attachment);
+ } catch (err) {
+ toast.error((err as Error).message || "ارسال صوتی ممکن نشد");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const toggleMic = async () => {
+ const v = voiceRef.current;
+ if (v?.recorder && v.recorder.state === "recording") {
+ v.recorder.stop();
+ return;
+ }
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const mime = pickMime();
+ const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
+ const chunks: Blob[] = [];
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) chunks.push(e.data);
+ };
+ recorder.onstop = async () => {
+ const blob = new Blob(chunks, { type: mime ?? "audio/webm" });
+ const secs = Math.round((Date.now() - (voiceRef.current?.start ?? Date.now())) / 1000);
+ clearInterval(voiceRef.current?.timer);
+ voiceRef.current?.live?.();
+ stream.getTracks().forEach((t) => t.stop());
+ setRecSec(0);
+ setRecLevel(0);
+ voiceRef.current = null;
+ if (secs < 1 || blob.size === 0) {
+ toast.error("پیام صوتی خیلی کوتاه بود");
+ return;
+ }
+ setRecUrl(URL.createObjectURL(blob));
+ setRecBlob(blob);
+ setRecMime(mime);
+ setRecDur(secs);
+ };
+ recorder.start(250);
+ let liveStop: (() => void) | undefined;
+ try {
+ const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (Ctx) {
+ const ctx = new Ctx();
+ const src = ctx.createMediaStreamSource(stream);
+ const analyser = ctx.createAnalyser();
+ analyser.fftSize = 256;
+ analyser.smoothingTimeConstant = 0.6;
+ src.connect(analyser);
+ const data = new Uint8Array(analyser.frequencyBinCount);
+ const liveTick = window.setInterval(() => {
+ analyser.getByteTimeDomainData(data);
+ let max = 0;
+ for (let i = 0; i < data.length; i++) {
+ const v = Math.abs(data[i] - 128) / 128;
+ if (v > max) max = v;
+ }
+ setRecLevel(Math.max(0.05, Math.min(1, max * 1.8)));
+ }, 60);
+ liveStop = () => {
+ clearInterval(liveTick);
+ void ctx.close();
+ };
+ }
+ } catch {
+ /* mic level meter optional */
+ }
+ voiceRef.current = {
+ recorder,
+ stream,
+ chunks,
+ timer: setInterval(() => setRecSec((s) => s + 1), 1000),
+ start: Date.now(),
+ live: liveStop,
+ };
+ } catch {
+ toast.error("دسترسی به میکروفون ممکن نشد");
+ }
+ };
+
+ const cancelVoice = () => {
+ const v = voiceRef.current;
+ if (v?.stream) {
+ v.live?.();
+ v.stream.getTracks().forEach((t) => t.stop());
+ clearInterval(v.timer);
+ setRecSec(0);
+ setRecLevel(0);
+ voiceRef.current = null;
+ }
+ };
+
+ const sortedConvs = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ const list = [...conversations].sort((a, b) => {
+ const ta = a.lastMessage ? new Date(a.lastMessage.createdAt).getTime() : 0;
+ const tb = b.lastMessage ? new Date(b.lastMessage.createdAt).getTime() : 0;
+ return tb - ta;
+ });
+ if (!q) return list;
+ return list.filter(
+ (c) => c.partner.name.toLowerCase().includes(q) || (c.lastMessage?.body ?? "").toLowerCase().includes(q)
+ );
+ }, [conversations, query]);
+
+ const thread = useMemo(() => buildThread(msgs, me?.role), [msgs, me?.role]);
+ const emptyThread = msgs.length === 0;
+ const atLimit = draft.length >= MAX_LEN;
+ const recording = !!voiceRef.current?.recorder && voiceRef.current.recorder.state === "recording";
+
+ const firstUnread = useMemo(() => {
+ if (!me) return null;
+ for (const m of msgs) {
+ if (!isMineMsg(m, me.role) && !m.readAt) return m.id;
+ }
+ return null;
+ }, [msgs, me]);
+
+ return (
+ <>
+
+ {open && (
+ <>
+ {
+ setOpen(false);
+ setActive(null);
+ }}
+ className="fixed inset-0 z-[65] bg-background/35 backdrop-blur-[2px]"
+ />
+
+
+
+
+
+
+ {active && partner ? (
+ <>
+
setActive(null)}>
+
+
+
+
+
+
+
+
{partner.name}
+ {partnerTyping ? (
+
+ در حال تایپ است
+
+ ) : (
+
{roleLabel(partner.role)}
+ )}
+
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
{
+ setOpen(false);
+ setActive(null);
+ }}
+ aria-label="بستن"
+ className="grid h-8 w-8 place-items-center rounded-lg bg-muted/60 text-muted-foreground transition-colors hover:text-foreground"
+ >
+
+
+ {active && (
+
setMsgSearchOpen((o) => !o)}
+ aria-label="جستجو در گفتگو"
+ className={cn(
+ "grid h-8 w-8 place-items-center rounded-lg bg-muted/60 transition-colors",
+ msgSearchOpen ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
+ )}
+ >
+
+
+ )}
+ {!active && (
+
setComposeOpen((value) => !value)}
+ aria-label="شروع گفتگوی جدید"
+ title="گفتگوی جدید"
+ className={cn(
+ "grid h-8 w-8 place-items-center rounded-lg bg-muted/60 transition-colors",
+ composeOpen ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
+ )}
+ >
+
+
+ )}
+
+
+
+
+ {!active ? (
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="جستجو در گفتگوها…"
+ dir="rtl"
+ className="h-9 w-full rounded-xl border-2 border-border bg-background/60 ps-8 pe-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
+
+
+ {composeOpen && (
+
+
شروع گفتگوی جدید
+ {(loveList?.mentors ?? []).map((m) => (
+
{
+ setActive(m.id);
+ setComposeOpen(false);
+ }}
+ className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm transition-colors hover:bg-primary/10"
+ >
+
+ {m.name}
+ {roleLabel(m.role)}
+
+ ))}
+ {(loveList?.mentors ?? []).length === 0 && (
+
مخاطب مجازی برای گفتگو وجود ندارد.
+ )}
+
+ )}
+ {sortedConvs.map((c) => (
+
{
+ setActive(c.partner.id);
+ setComposeOpen(false);
+ }}
+ className="flex w-full items-center gap-3 rounded-2xl border-2 border-border/70 bg-background/50 px-3 py-2.5 text-right text-sm transition-colors hover:border-primary/40 hover:bg-background/80"
+ >
+
+
+
+
+
+
+
{c.partner.name}
+
+ {c.lastMessage ? clock(c.lastMessage.createdAt) : ""}
+
+
+
+
0 ? "font-bold text-foreground" : "text-muted-foreground"
+ )}
+ >
+ {c.lastMessage ? previewOf(c.lastMessage) : "هنوز پیامی نیست"}
+
+ {c.unreadCount > 0 && (
+
+ {fa(c.unreadCount)}
+
+ )}
+
+
+
+ ))}
+
+ {sortedConvs.length === 0 && conversations.length > 0 && (
+
گفتگویی یافت نشد.
+ )}
+ {conversations.length === 0 && !composeOpen && (
+
+
برای شروع، روی مداد بزنید
+ {(loveList?.mentors ?? []).map((m) => (
+
{
+ setActive(m.id);
+ setComposeOpen(false);
+ }}
+ className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm transition-colors hover:bg-primary/10"
+ >
+
+ {m.name}
+
+ ))}
+
+ )}
+
+
+ ) : (
+
+ {msgSearchOpen && (
+
+
+ setMsgQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (!matchCount) return;
+ if (e.key === "Escape") { e.preventDefault(); setMsgSearchOpen(false); setMsgQuery(""); setMatchIdx(0); }
+ else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i + 1) % matchCount); }
+ else if (e.key === "Enter" && e.shiftKey) { e.preventDefault(); setMatchIdx((i) => (i - 1 + matchCount) % matchCount); }
+ }}
+ placeholder="جستجو در این گفتگو…"
+ dir="rtl"
+ autoFocus
+ className="h-8 min-w-0 flex-1 rounded-lg border-2 border-border bg-background/70 px-2.5 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
+ />
+ {msgQuery.trim() !== "" && (
+
+ {matchCount ? `${fa(matchIdx + 1)}/${fa(matchCount)}` : "پیدا نشد"}
+
+ )}
+ setMatchIdx((i) => (i - 1 + matchCount) % matchCount)}
+ disabled={!matchCount}
+ aria-label="نتیجه قبلی"
+ className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
+ >
+
+
+ setMatchIdx((i) => (i + 1) % matchCount)}
+ disabled={!matchCount}
+ aria-label="نتیجه بعدی"
+ className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
+ >
+
+
+ {
+ setMsgSearchOpen(false);
+ setMsgQuery("");
+ setMatchIdx(0);
+ }}
+ aria-label="بستن جستجو"
+ className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ >
+
+
+
+ )}
+ {activePin && (
+
+
+
void jumpToMessage(activePin.id)}
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-start transition hover:bg-gold/15"
+ title="پرش به پیام پینشده"
+ >
+
+
+
+ پین شده
+ {pinned.length > 1 && (
+
+ {fa(pinIdx + 1)}/{fa(pinned.length)}
+
+ )}
+
+
+
+
+ {pinned.length > 1 && (
+
setPinIdx((i) => (i + 1) % pinned.length)}
+ className="shrink-0 rounded-md px-2 py-1 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
+ title="پین بعدی"
+ >
+ بعدی
+
+ )}
+
void togglePin(activePin)}
+ className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
+ aria-label="برداشتن پین"
+ title="برداشتن پین"
+ >
+
+
+
+
+ )}
+
+
+
{
+ const el = threadRef.current;
+ if (!el) return;
+ const near = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
+ wasNearBottomRef.current = near;
+ setJumpDown(!near);
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ setDragOver(true);
+ }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragOver(false);
+ const f = e.dataTransfer.files?.[0];
+ if (f) void uploadSelected(f);
+ }}
+ dir="ltr"
+ className="relative z-10 h-full min-h-0 space-y-1 overflow-y-auto overscroll-contain p-3 chat-scroll"
+ >
+ {dragOver && (
+
+ )}
+ {emptyThread && (
+
+
+
+
+
+ سلام کنید — کمک بگیرید، مشکلات را بگویید یا موفقیت را جشن بگیرید.
+
+
+ پاسخ کمتر از یک روز
+
+
+ )}
+
+ {canLoadMore && (
+
+ void loadOlder()}
+ disabled={loadingOlder}
+ className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-card/80 px-3 py-1 text-[10px] font-extrabold text-muted-foreground shadow-soft transition-colors hover:border-primary/40 hover:text-primary disabled:opacity-60"
+ >
+ {loadingOlder ? (
+ <>
+ در حال بارگذاری…
+ >
+ ) : (
+ <>
+ پیامهای قبلی
+ >
+ )}
+
+
+ )}
+
+ {thread.map((seg, i) =>
+ seg.kind === "date" ? (
+
+
+
+ {seg.label}
+
+
+ ) : (
+ <>
+ {firstUnread && seg.msgs.some((mm) => mm.id === firstUnread) && (
+
+
+
+ پیامهای جدید
+
+
+
+ )}
+
+ {!seg.mine && (
+
+ )}
+
+
+ {seg.msgs.map((m, idx) => {
+ const isLast = idx === seg.msgs.length - 1;
+ const mediaOnly = !!m.attachment && !m.body;
+ const isPending = m.id < 0;
+ const isGif = mediaOnly && isGifName(m.attachment!);
+ const onlyEmoji =
+ !m.replyTo && !m.attachment && !!m.body && isSingleEmoji(m.body);
+ const q0 = msgQuery.trim();
+ const dimmed = q0 !== "" && matchCount > 0 && !matchSet.has(m.id);
+ const isCurrent = matchSet.has(m.id) && m.id === matchIds[matchIdx];
+ return (
+
like(m)}
+ onClick={(e) => {
+ const t = e.target as HTMLElement;
+ if (t.closest("button, a, input, [role='button']")) return;
+ setReactId((cur) => (cur === m.id ? null : m.id));
+ }}
+ className={cn(
+ "relative isolate group/msg max-w-full min-w-0",
+ !isLast && "mb-1",
+ dimmed && "opacity-30 saturate-50",
+ isCurrent && "ring-2 ring-accent rounded-xl",
+ )}
+ >
+
+ {heartMsg === m.id && reactId !== m.id && (
+
+
+
+ )}
+
+
+ {mediaOnly && isGif ? (
+
+ ) : mediaOnly ? (
+ <>
+
+ >
+ ) : onlyEmoji ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+ {m.replyTo && (
+ x.id === m.replyTo) ?? null}
+ mine={seg.mine}
+ partnerName={partner?.name}
+ meRole={me?.role}
+ />
+ )}
+ {m.attachment && (
+
+ )}
+ {m.body && (
+
+
+
+ )}
+ {m.body && (
+
+
+ {clock(m.createdAt)}
+
+ {seg.mine &&
+ (isPending ? (
+
+ ) : (
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {!!m.reactions?.length && (
+
+ void toggleReact(m, em)}
+ />
+
+ )}
+
setReactId(reactId === m.id ? null : m.id)}
+ onReactEmoji={(em) => {
+ setReactId(null);
+ void toggleReact(m, em);
+ }}
+ onReply={() => setReplyTo(m)}
+ onCopy={() => copyText(m)}
+ onPin={() => void togglePin(m)}
+ onDelete={() => armDelete(m.id)}
+ />
+
+ );
+ })}
+
+
+
+ >
+ )
+ )}
+
+
+ {jumpDown && (
+ {
+ const el = threadRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ wasNearBottomRef.current = true;
+ setJumpDown(false);
+ }}
+ aria-label="برو به آخرین پیام"
+ className="absolute bottom-3 end-3 z-30 grid h-9 w-9 place-items-center rounded-full border-2 border-primary/50 bg-card/95 text-primary shadow-soft ring-2 ring-white/40 backdrop-blur-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground active:scale-90 dark:bg-[#1a1e33]/95 dark:ring-black/30"
+ >
+
+
+ )}
+
+
+
+ {partnerTyping && (
+
+
+ {partner?.name} در حال تایپ است…
+
+ )}
+
+ {emptyThread && (
+
+ {(me?.role === "student" ? STUDENT_SUGGESTIONS : MENTOR_SUGGESTIONS).map((q) => (
+ send(q)}
+ disabled={sending}
+ className="rounded-full border-2 border-primary/30 bg-primary/5 px-3 py-1 text-[11px] font-bold text-primary transition-colors hover:bg-primary/15 disabled:opacity-50"
+ >
+ {q}
+
+ ))}
+
+ )}
+
+
+ {recording && (
+
+
+ {Array.from({ length: 9 }).map((_, i) => {
+ const on = recLevel > (i + 1) / 9;
+ return (
+
+ );
+ })}
+
+
{clockDur(recSec)}
+
+
+
+
+
+
+
+ )}
+
+ {(attPref || uploading) && (
+
+ {uploading && (
+
+
+
+ )}
+ {uploading ? (
+ <>
+
+
در حال آپلود…
+ >
+ ) : attPref ? (
+ <>
+ {attPref.type === "image" ? (
+
setPreview(attPref)}
+ aria-label="پیشنمایش تصویر"
+ className="group relative h-28 w-28 shrink-0 cursor-zoom-in overflow-hidden rounded-xl border-2 border-border/70"
+ >
+
+
+
+
+
+ ) : (
+
+ )}
+
+
{attPref.name}
+
{bytes(attPref.size)}
+
+ {attPref.type === "image" && (
+
setPreview(attPref)}
+ aria-label="پیشنمایش"
+ title="پیشنمایش"
+ className="hidden text-muted-foreground transition-colors hover:text-foreground sm:inline-flex"
+ >
+
+
+ )}
+
setAttPref(null)} aria-label="حذف ضمیمه" className="text-muted-foreground hover:text-destructive">
+
+
+ >
+ ) : null}
+
+ )}
+
+
+ {replyTo && (
+
+
+
+
+
+ در پاسخ به {isMineMsg(replyTo, me?.role) ? "خودتان" : partner?.name ?? "پیام"}
+
+
+ {quotePreview(replyTo)}
+
+
+ setReplyTo(null)} aria-label="لغو پاسخ" className="text-muted-foreground transition-colors hover:text-destructive">
+
+
+
+
+ )}
+
+
+ {recUrl ? (
+
void sendVoice()}
+ />
+ ) : (
+ <>
+
+
+ {emojiOpen && (
+
+
+
+ ایموجی
+ setEmojiOpen(false)} aria-label="بستن" className="text-muted-foreground transition-colors hover:text-destructive">
+
+
+
+ {EMOJIS.map((e) => (
+
{
+ if (draft.length + e.length > MAX_LEN) return;
+ setDraft((d) => d + e);
+ signalTyping();
+ }}
+ className="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-primary/10 active:scale-90"
+ aria-label={e}
+ >
+
+
+ ))}
+
+
+ )}
+
+
+ {draft.trim() || attPref ? (
+
send()}
+ disabled={sending || uploading || atLimit}
+ className="h-10 w-10 shrink-0 rounded-xl bg-primary text-primary-foreground transition-all hover:bg-primary/90 active:scale-90"
+ aria-label="ارسال"
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+ {uploading ? : }
+
+
+
{
+ setDraft(v);
+ if (v.trim()) signalTyping();
+ }}
+ onSend={() => send()}
+ placeholder="پیام خود را بنویسید…"
+ disabled={sending || uploading}
+ />
+
+
setEmojiOpen((o) => !o)}
+ disabled={sending || recording}
+ aria-label="ایموجی"
+ >
+
+
+
+
+ >
+ )}
+
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+ {preview && (
+ setPreview(null)}
+ className="fixed inset-0 z-[80] flex flex-col items-center justify-center gap-3 bg-black/85 p-4 backdrop-blur-2xl"
+ >
+
+ {preview.type === "image" ? (
+
+ {preview.name}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
setPreview(null)}
+ aria-label="بستن"
+ className="grid h-9 w-9 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-destructive"
+ >
+
+
+
+
+ {preview.type === "image" ? (
+ e.stopPropagation()}>
+
+
+ ) : (
+ e.stopPropagation()}>
+
+
+ )}
+ {bytes(preview.size)}
+
+ )}
+
+
+
+ {notif && (
+ {
+ setActive(notif.partner);
+ setOpen(true);
+ setNotif(null);
+ }}
+ className="fixed bottom-24 start-5 end-5 z-[64] flex max-w-full items-center gap-2.5 rounded-2xl border-2 border-white/50 bg-card/85 p-2.5 text-right shadow-offset backdrop-blur-2xl sm:end-auto sm:max-w-[300px]"
+ >
+
+
+
+
+ {notif.name}
+
+ {notif.body}
+
+
+
+
+ )}
+
+
+ {me && !open && (
+
+
+ {unreadTotal > 0 && (
+
+ )}
+ setOpen(true)}
+ aria-label="گفتگوها"
+ className="press relative grid h-14 w-14 place-items-center rounded-2xl shadow-offset transition-colors bg-gradient-to-tr from-primary to-accent text-primary-foreground"
+ >
+
+ {unreadTotal > 0 && (
+
+ {fa(unreadTotal)}
+
+ )}
+
+
+
+ )}
+ >
+ );
+}
+
+// ---- attachments -----------------------------------------------------------
+
+function isGifName(att: ChatAttachment) {
+ return /\.gif($|\?)/i.test(att.name || "") || /\.gif($|\?)/i.test(att.url || "");
+}
+
+function GifSticker({
+ att,
+ mine,
+ time,
+ pending,
+ onPreview,
+}: {
+ att: ChatAttachment;
+ mine: boolean;
+ time: string;
+ pending?: boolean;
+ onPreview?: (att: ChatAttachment) => void;
+}) {
+ return (
+
+
onPreview?.(att)}
+ className="group relative block cursor-zoom-in overflow-hidden rounded-xl outline-none"
+ aria-label={`باز کردن ${att.name}`}
+ >
+
+
+
+ {clock(time)}
+ {mine &&
+ (pending ? (
+
+ ) : (
+
+ ))}
+
+
+ );
+}
+
+function MediaOnly({
+ att,
+ mine,
+ time,
+ onPreview,
+}: {
+ att: ChatAttachment;
+ mine: boolean;
+ time: string;
+ onPreview?: (att: ChatAttachment) => void;
+}) {
+ const previewTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (previewTimer.current) clearTimeout(previewTimer.current);
+ },
+ []
+ );
+ const openPreview = (a: ChatAttachment) => {
+ if (previewTimer.current) clearTimeout(previewTimer.current);
+ previewTimer.current = setTimeout(() => onPreview?.(a), 230);
+ };
+ const cancelPreview = () => {
+ if (previewTimer.current) clearTimeout(previewTimer.current);
+ previewTimer.current = null;
+ };
+ const corner = mine ? "end-1.5 bottom-1.5" : "start-1.5 bottom-1.5";
+ const chip = (
+
+ {clock(time)}
+
+ );
+ if (att.type === "image") {
+ return (
+ openPreview(att)}
+ onDoubleClick={cancelPreview}
+ className="relative block w-full cursor-zoom-in"
+ aria-label={`باز کردن ${att.name}`}
+ >
+
+ {chip}
+
+ );
+ }
+ if (att.type === "video") {
+ return (
+
+ onPreview?.(att)} />
+
+ {clock(time)}
+
+
+ );
+ }
+ if (att.type === "audio") {
+ return (
+
+
+
+ {clock(time)}
+ {mine && }
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+function AttachmentView({
+ att,
+ mine,
+ time,
+ onPreview,
+}: {
+ att: ChatAttachment;
+ mine: boolean;
+ time: string;
+ onPreview?: (att: ChatAttachment) => void;
+}) {
+ const previewTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (previewTimer.current) clearTimeout(previewTimer.current);
+ },
+ []
+ );
+ const openPreview = (a: ChatAttachment) => {
+ if (previewTimer.current) clearTimeout(previewTimer.current);
+ previewTimer.current = setTimeout(() => onPreview?.(a), 230);
+ };
+ const cancelPreview = () => {
+ if (previewTimer.current) clearTimeout(previewTimer.current);
+ previewTimer.current = null;
+ };
+ if (att.type === "image") {
+ return (
+
+
openPreview(att)}
+ onDoubleClick={cancelPreview}
+ className="block w-full cursor-zoom-in"
+ aria-label={`باز کردن ${att.name}`}
+ >
+
+
+
+ {clock(time)}
+
+
+ );
+ }
+ if (att.type === "video") {
+ return (
+
+ onPreview?.(att)} />
+
+ {clock(time)}
+
+
+ );
+ }
+ if (att.type === "audio") {
+ return (
+
+
+
+ {clock(time)}
+ {mine && }
+
+
+ );
+ }
+ return (
+ e.preventDefault()}
+ className={cn(
+ "mb-0.5 flex items-center gap-2 rounded-xl bg-background/50 px-3 py-2",
+ !mine && "border border-border/50"
+ )}
+ >
+
+
+
+
+
+ {att.name}
+
+ {bytes(att.size)}
+
+
+
+ );
+}
+
+let activeVoice: HTMLAudioElement | null = null;
+
+function VoiceRecPreview({
+ url,
+ dur,
+ sending,
+ onCancel,
+ onSend,
+}: {
+ url: string;
+ dur: number;
+ sending: boolean;
+ onCancel: () => void;
+ onSend: () => void;
+}) {
+ const audioRef = useRef(null);
+ const canvasRef = useRef(null);
+ const peaksRef = useRef([]);
+ const [playing, setPlaying] = useState(false);
+ const [cur, setCur] = useState(0);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ let ctx: AudioContext | null = null;
+ let cleanup = false;
+ const a = new Audio(url);
+ audioRef.current = a;
+ const onTime = () => setCur(a.currentTime);
+ const onEnd = () => {
+ setPlaying(false);
+ setCur(0);
+ };
+ a.addEventListener("timeupdate", onTime);
+ a.addEventListener("ended", onEnd);
+ fetch(url)
+ .then((r) => r.arrayBuffer())
+ .then((buf) => {
+ if (cleanup) return;
+ const AC =
+ window.AudioContext ??
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
+ ctx = new AC();
+ ctx.decodeAudioData(
+ buf,
+ (audio) => {
+ if (cleanup) return;
+ peaksRef.current = computePeaks(audio, 80);
+ setReady(true);
+ },
+ () => {
+ if (!cleanup) setReady(true);
+ }
+ );
+ })
+ .catch(() => {
+ if (!cleanup) setReady(true);
+ });
+ return () => {
+ cleanup = true;
+ a.pause();
+ a.removeEventListener("timeupdate", onTime);
+ a.removeEventListener("ended", onEnd);
+ void ctx?.close().catch(() => undefined);
+ };
+ }, [url]);
+
+ useEffect(() => {
+ drawWave(canvasRef.current, peaksRef.current, cur, dur);
+ }, [cur, dur, ready]);
+
+ const toggle = () => {
+ const a = audioRef.current;
+ if (!a) return;
+ if (a.paused) {
+ void a.play().catch(() => undefined);
+ setPlaying(true);
+ } else {
+ a.pause();
+ setPlaying(false);
+ }
+ };
+
+ const seekFromEvent = (e: React.PointerEvent) => {
+ const a = audioRef.current;
+ const cv = canvasRef.current;
+ if (!a || !cv || dur <= 0) return;
+ const rect = cv.getBoundingClientRect();
+ if (rect.width === 0) return;
+ const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
+ a.currentTime = ratio * dur;
+ setCur(a.currentTime);
+ };
+
+ return (
+
+
+
+
+
+
+ {sending ? : }
+
+
+
+
+ {playing ? "در حال پخش…" : ""}
+
+ {clockDur(cur)} / {clockDur(dur)}
+
+
+
{
+ if (e.buttons & 1) seekFromEvent(e);
+ }}
+ className="mt-1 h-[28px] w-full cursor-pointer touch-none"
+ aria-label="نوار پخش پیشنمایش صدا"
+ />
+
+
+ {playing ? : }
+
+
+ );
+}
+
+function isSingleEmoji(text: string): boolean {
+ const s = text.trim();
+ if (!s) return false;
+ // ZWJ sequences (e.g. family) are one grapheme — do not use string.length.
+ if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
+ const parts = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)];
+ if (parts.length !== 1) return false;
+ }
+ const RE =
+ /^(?:(?:\p{Regional_Indicator}){2}|\p{Extended_Pictographic}(?:\p{Emoji_Modifier}\uFE0F?|\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\p{Emoji_Modifier}\uFE0F?|\uFE0F|\uFE0E)?)*)$/u;
+ return RE.test(s);
+}
+
+function computePeaks(audio: AudioBuffer, buckets: number): number[] {
+ const ch = audio.length > 0 ? audio.getChannelData(0) : new Float32Array(0);
+ const step = Math.max(1, Math.floor(ch.length / buckets));
+ const out: number[] = [];
+ for (let i = 0; i < buckets; i++) {
+ let max = 0;
+ for (let j = i * step; j < Math.min((i + 1) * step, ch.length); j++) {
+ const v = Math.abs(ch[j]);
+ if (v > max) max = v;
+ }
+ out.push(Math.min(1, max * 3.6));
+ }
+ return out;
+}
+
+function drawWave(
+ cv: HTMLCanvasElement | null,
+ peaks: number[],
+ cur: number,
+ dur: number
+) {
+ if (!cv) return;
+ const px = window.devicePixelRatio || 1;
+ const rect = cv.getBoundingClientRect();
+ if (rect.width < 1) return;
+ const w = rect.width;
+ const h = rect.height;
+ if (cv.width !== Math.round(w * px) || cv.height !== Math.round(h * px)) {
+ cv.width = Math.round(w * px);
+ cv.height = Math.round(h * px);
+ }
+ const g = cv.getContext("2d");
+ if (!g) return;
+ g.clearRect(0, 0, cv.width, cv.height);
+ g.scale(px, px);
+ const css = getComputedStyle(document.documentElement);
+ const playedC = css.getPropertyValue("--color-primary").trim() || "#6d63f0";
+ const n = Math.max(32, peaks.length || 32);
+ const playedRatio = dur > 0 ? Math.min(1, Math.max(0, cur / dur)) : 0;
+ const slot = w / n;
+ const bw = Math.max(2, slot * 0.62);
+ for (let i = 0; i < n; i++) {
+ const pk = peaks[i] ?? 0.22 + 0.42 * Math.abs(Math.sin(i * 1.3 + 0.5));
+ const bh = Math.max(3, pk * (h - 4));
+ const x = i * slot + (slot - bw) / 2;
+ const y = (h - bh) / 2;
+ g.fillStyle = i / n <= playedRatio ? playedC : "rgba(120,122,150,0.35)";
+ g.beginPath();
+ g.roundRect(x, y, bw, bh, 2);
+ g.fill();
+ }
+ g.setTransform(1, 0, 0, 1, 0, 0);
+}
+
+function stopActiveVoice() {
+ if (activeVoice) {
+ const prev = activeVoice;
+ activeVoice = null;
+ prev.pause();
+ }
+}
+
+function ChatVideoPlayer({
+ att,
+ onPreview,
+}: {
+ att: ChatAttachment;
+ onPreview?: () => void;
+}) {
+ const wrapRef = useRef(null);
+ const videoRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [cur, setCur] = useState(0);
+ const [dur, setDur] = useState(0);
+ const [muted, setMuted] = useState(false);
+ const [buffering, setBuffering] = useState(false);
+ const [showUI, setShowUI] = useState(false);
+
+ const toggle = () => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (v.paused) {
+ void v.play().catch(() => undefined);
+ } else {
+ v.pause();
+ }
+ };
+
+ const seek = (t: number) => {
+ const v = videoRef.current;
+ if (!v || !Number.isFinite(t)) return;
+ v.currentTime = t;
+ setCur(t);
+ };
+
+ const fullscreen = () => {
+ const el = wrapRef.current;
+ if (!el) return;
+ if (document.fullscreenElement) {
+ void document.exitFullscreen().catch(() => undefined);
+ } else {
+ void el.requestFullscreen().catch(() => undefined);
+ }
+ };
+
+ const prog = dur > 0 ? Math.min(cur / dur, 1) : 0;
+
+ return (
+ setShowUI(true)}
+ onMouseLeave={() => setShowUI(false)}
+ >
+
setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ onWaiting={() => setBuffering(true)}
+ onPlaying={() => setBuffering(false)}
+ onTimeUpdate={(e) => setCur(e.currentTarget.currentTime)}
+ onLoadedMetadata={(e) => setDur(e.currentTarget.duration || 0)}
+ onEnded={() => {
+ setPlaying(false);
+ setCur(0);
+ }}
+ className="block max-h-64 w-full bg-black object-contain"
+ />
+
+
+
+ {buffering ? (
+
+ ) : playing ? (
+
+ ) : (
+
+ )}
+
+
+
+ {onPreview && (
+
+
+
+ )}
+
+
+
+
+
seek(+e.target.value)}
+ aria-label="موقعیت ویدیو"
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
+ />
+
+
+
+ {playing ? : }
+
+
+ {fmtDur(cur)} / {fmtDur(dur)}
+
+
+
{
+ const v = videoRef.current;
+ if (!v) return;
+ v.muted = !muted;
+ setMuted(!muted);
+ }}
+ aria-label={muted ? "باز کردن صدا" : "بیصدا"}
+ className="grid h-6 w-6 place-items-center rounded-full transition-colors hover:bg-white/20"
+ >
+ {muted ? : }
+
+
+
+
+
+
+
+ );
+}
+
+function VoicePlayer({ att, mine }: { att: ChatAttachment; mine: boolean }) {
+ const audioRef = useRef(null);
+ const liveRef = useRef<{ ctx: AudioContext; analyser: AnalyserNode } | null>(null);
+ const rafRef = useRef(0);
+ const [playing, setPlaying] = useState(false);
+ const [current, setCurrent] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [bars, setBars] = useState(() => voiceBars(att.url, 44));
+ const [live, setLive] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ analyzeAudio(att.url)
+ .then((b) => {
+ if (!cancelled) setBars(b);
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, [att.url]);
+
+ useEffect(() => {
+ const a = new Audio();
+ a.crossOrigin = "anonymous";
+ a.src = att.url;
+ a.preload = "metadata";
+ audioRef.current = a;
+ const onMeta = () => setDuration(Number.isFinite(a.duration) ? a.duration : 0);
+ const onTime = () => setCurrent(a.currentTime);
+ const onEnd = () => {
+ setPlaying(false);
+ setCurrent(0);
+ setLive(null);
+ stopPulse();
+ if (activeVoice === a) activeVoice = null;
+ };
+ const onErr = () => {
+ setDuration(0);
+ };
+ a.addEventListener("loadedmetadata", onMeta);
+ a.addEventListener("timeupdate", onTime);
+ a.addEventListener("ended", onEnd);
+ a.addEventListener("error", onErr);
+ return () => {
+ stopPulse();
+ a.pause();
+ if (activeVoice === a) activeVoice = null;
+ a.removeEventListener("loadedmetadata", onMeta);
+ a.removeEventListener("timeupdate", onTime);
+ a.removeEventListener("ended", onEnd);
+ a.removeEventListener("error", onErr);
+ audioRef.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [att.url]);
+
+ useEffect(() => {
+ return () => stopActiveVoice();
+ }, []);
+
+ const startPulse = () => {
+ const lr = liveRef.current;
+ if (!lr) return;
+ const analyser = lr.analyser;
+ const freq = new Uint8Array(analyser.frequencyBinCount);
+ const loop = () => {
+ analyser.getByteFrequencyData(freq);
+ const vals: number[] = [];
+ for (let i = 0; i < 44; i++) {
+ const f = freq[i] / 255;
+ vals.push(Math.max(0.12, Math.min(1, f * 1.5)));
+ }
+ setLive(vals);
+ rafRef.current = requestAnimationFrame(loop);
+ };
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = requestAnimationFrame(loop);
+ };
+
+ const stopPulse = () => {
+ cancelAnimationFrame(rafRef.current);
+ setLive(null);
+ };
+
+ const toggle = () => {
+ const a = audioRef.current;
+ if (!a) return;
+ if (playing) {
+ stopPulse();
+ a.pause();
+ setPlaying(false);
+ return;
+ }
+ stopActiveVoice();
+ activeVoice = a;
+ if (!liveRef.current) {
+ try {
+ const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (Ctx) {
+ const ctx = new Ctx();
+ const src = ctx.createMediaElementSource(a);
+ const analyser = ctx.createAnalyser();
+ analyser.fftSize = 128;
+ analyser.smoothingTimeConstant = 0.7;
+ src.connect(analyser);
+ analyser.connect(ctx.destination);
+ liveRef.current = { ctx, analyser };
+ }
+ } catch {
+ /* waveform peaks optional */
+ }
+ }
+ void a.play().then(
+ () => {
+ setPlaying(true);
+ if (liveRef.current) startPulse();
+ },
+ () => setPlaying(false)
+ );
+ };
+
+ const seek = (v: number) => {
+ const a = audioRef.current;
+ if (!a) return;
+ a.currentTime = v;
+ setCurrent(v);
+ };
+
+ const scrubRef = useRef(null);
+ const scrubbingRef = useRef(false);
+ const seekTo = (clientX: number) => {
+ if (dur <= 0) return;
+ const el = scrubRef.current;
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
+ let a: HTMLAudioElement | null = null;
+ try {
+ a = audioRef.current;
+ } catch {
+ /* noop */
+ }
+ const v = ratio * dur;
+ if (a) {
+ try {
+ a.currentTime = v;
+ } catch {
+ /* noop */
+ }
+ }
+ setCurrent(v);
+ };
+ const onScrubDown = (e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ scrubbingRef.current = true;
+ seekTo(e.clientX);
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ };
+ const onScrubMove = (e: React.PointerEvent) => {
+ if (scrubbingRef.current) seekTo(e.clientX);
+ };
+ const endScrub = () => {
+ scrubbingRef.current = false;
+ };
+
+ const dur = Number.isFinite(duration) && duration > 0 ? duration : 0;
+ const progress = dur > 0 ? Math.min(current / dur, 1) : 0;
+ const shown = live ?? bars;
+
+ return (
+
+
+ {playing ? : }
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onDoubleClick={(e) => e.stopPropagation()}
+ className="relative flex h-8 cursor-pointer touch-none select-none items-end gap-[3px]"
+ >
+ {shown.map((h, i) => {
+ const isLit = playing || i / shown.length <= progress;
+ return (
+
+ );
+ })}
+
+ {dur > 0 && (
+
+ )}
+
+
+ {fmtDur(current)}
+
+ {fmtDur(dur)}
+
+
+
+
+ );
+}
+
+function analyzeAudio(url: string): Promise {
+ const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!Ctx) return Promise.resolve(voiceBars(url, 44));
+ const ctx = new Ctx();
+ const fallback = (): number[] => {
+ void ctx.close();
+ return voiceBars(url, 44);
+ };
+ return fetch(url)
+ .then((r) => {
+ if (!r.ok) throw new Error("fetch");
+ return r.arrayBuffer();
+ })
+ .then((buf) => ctx.decodeAudioData(buf))
+ .then((audio) => {
+ const data = audio.getChannelData(0);
+ const n = 44;
+ const seg = Math.max(1, Math.floor(data.length / n));
+ const out: number[] = [];
+ for (let i = 0; i < n; i++) {
+ let max = 0;
+ const start = i * seg;
+ const end = Math.min(data.length, start + seg);
+ for (let j = start; j < end; j++) {
+ const v = Math.abs(data[j]);
+ if (v > max) max = v;
+ }
+ out.push(max);
+ }
+ const peak = Math.max(...out, 1e-6);
+ const rough = out.map((v) => Math.max(0.08, Math.min(1, (v / peak) * 1.1)));
+ const smooth = rough.map((v, i) => {
+ const lo = rough[Math.max(0, i - 1)] ?? v;
+ const hi = rough[Math.min(n - 1, i + 1)] ?? v;
+ return (lo + v * 2 + hi) / 4;
+ });
+ void ctx.close();
+ return smooth;
+ })
+ .catch(() => fallback());
+}
+
+function voiceBars(seed: string, n: number) {
+ let h = 2166136261;
+ for (let i = 0; i < seed.length; i++) {
+ h ^= seed.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ const bars: number[] = [];
+ for (let i = 0; i < n; i++) {
+ h = Math.imul(h ^ (h >>> 15), 2246822507);
+ h = Math.imul(h ^ (h >>> 13), 3266489909);
+ h ^= h >>> 16;
+ const r = (h >>> 0) / 4294967296;
+ const env = Math.exp(-Math.pow((i / (n - 1) - 0.5) * 2.4, 2));
+ bars.push(Math.max(0.12, Math.min(1, (0.35 + r * 0.65) * (0.35 + env * 0.65))));
+ }
+ return bars;
+}
+
+function fmtDur(sec: number) {
+ if (!Number.isFinite(sec) || sec < 0) sec = 0;
+ const m = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ return `${fa(m)}:${fa(s).padStart(2, "۰")}`;
+}
+
+function ZoomableImg({ src, alt }: { src: string; alt?: string }) {
+ const [scale, setScale] = useState(1);
+ const [off, setOff] = useState({ x: 0, y: 0 });
+ const [grabbing, setGrabbing] = useState(false);
+ const imgRef = useRef(null);
+ const pointers = useRef(new Map());
+ const pinchRef = useRef<{ dist: number; scale: number } | null>(null);
+ const scaleRef = useRef(1);
+
+ const clampScale = (s: number) => Math.min(6, Math.max(1, s));
+ const setSc = (s: number) => {
+ const v = clampScale(s);
+ scaleRef.current = v;
+ setScale(v);
+ };
+
+ useEffect(() => {
+ setSc(1);
+ setOff({ x: 0, y: 0 });
+ pointers.current.clear();
+ pinchRef.current = null;
+ setGrabbing(false);
+ }, [src]);
+
+ const clampOff = (x: number, y: number) => {
+ const el = imgRef.current;
+ const w = (el?.offsetWidth ?? 0) / 2;
+ const h = (el?.offsetHeight ?? 0) / 2;
+ const maxX = Math.max(0, w * (scaleRef.current - 1));
+ const maxY = Math.max(0, h * (scaleRef.current - 1));
+ return { x: Math.min(maxX, Math.max(-maxX, x)), y: Math.min(maxY, Math.max(-maxY, y)) };
+ };
+
+ useEffect(() => {
+ const el = imgRef.current;
+ if (!el) return;
+ const onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ const s = clampScale(scaleRef.current * (e.deltaY < 0 ? 1.18 : 1 / 1.18));
+ setSc(s);
+ if (s === 1) setOff({ x: 0, y: 0 });
+ };
+ el.addEventListener("wheel", onWheel, { passive: false });
+ return () => el.removeEventListener("wheel", onWheel);
+ }, []);
+
+ const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y);
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ e.stopPropagation();
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
+ const m = pointers.current;
+ m.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (m.size === 2) {
+ const [a, b] = [...m.values()];
+ pinchRef.current = { dist: dist(a, b), scale: scaleRef.current };
+ } else if (m.size === 1 && scaleRef.current > 1) {
+ setGrabbing(true);
+ }
+ };
+
+ const onPointerMove = (e: React.PointerEvent) => {
+ const m = pointers.current;
+ if (!m.has(e.pointerId)) return;
+ const prev = m.get(e.pointerId)!;
+ if (m.size === 2) {
+ const [a, b] = [...m.values()];
+ const start = pinchRef.current;
+ if (start) setSc(start.scale * (dist(a, b) / start.dist));
+ } else if (m.size === 1 && scaleRef.current > 1) {
+ setOff((o) => clampOff(o.x + (e.clientX - prev.x), o.y + (e.clientY - prev.y)));
+ }
+ m.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ };
+
+ const endPointer = (e: React.PointerEvent) => {
+ pointers.current.delete(e.pointerId);
+ pinchRef.current = null;
+ if (pointers.current.size === 0) setGrabbing(false);
+ };
+
+ const toggleZoom = () => {
+ if (scaleRef.current > 1) {
+ setSc(1);
+ setOff({ x: 0, y: 0 });
+ } else {
+ setSc(2.2);
+ }
+ };
+
+ return (
+ {
+ e.stopPropagation();
+ toggleZoom();
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={endPointer}
+ onPointerCancel={endPointer}
+ style={{ transform: `translate(${off.x}px, ${off.y}px) scale(${scale})` }}
+ className={cn(
+ "max-h-[72vh] max-w-[92vw] select-none touch-none rounded-2xl object-contain shadow-offset",
+ scale > 1 ? (grabbing ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
+ )}
+ />
+ );
+}
+
+function AttThumb({ att }: { att: ChatAttachment }) {
+ if (att.type === "image")
+ return ;
+ if (att.type === "video")
+ return (
+
+
+
+ );
+ if (att.type === "audio")
+ return (
+
+
+
+ );
+ return (
+
+
+
+ );
+}
+
+// ---- helpers ----------------------------------------------------------------
+
+const STUDENT_SUGGESTIONS = [
+ "سلام! به کمک نیاز دارم.",
+ "قفل شدهام — لطفاً حسابم را فعال کنید.",
+ "در آزمون گیر کردهام و راهنمایی میخواهم.",
+];
+
+const MENTOR_SUGGESTIONS = [
+ "سلام! چطور میتوانم کمکتان کنم؟",
+ "برای بازگشایی حساب، آزمون را دوباره امتحان کنید.",
+ "به پیشرفتتان افتخار میکنم — ادامه دهید.",
+];
+
+function isMentorOf(role: string) {
+ return role === "mentor" || role === "admin";
+}
+
+function roleLabel(role: string) {
+ if (role === "mentor") return "منتور";
+ if (role === "admin") return "ادمین";
+ return "هنرجو";
+}
+
+function fa(n: number) {
+ return n.toLocaleString("fa-IR");
+}
+
+function bytes(n: number) {
+ if (n < 1024) return `${fa(n)} بایت`;
+ if (n < 1024 * 1024) return `${fa(Math.round(n / 1024))} کیلوبایت`;
+ return `${fa(Number((n / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")))} مگابایت`;
+}
+
+function clock(iso: string) {
+ return new Date(iso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
+}
+
+function clockDur(secs: number) {
+ const m = Math.floor(secs / 60);
+ const s = secs % 60;
+ return `${fa(m)}:${fa(s).padStart(2, "۰")}`;
+}
+
+function dateKey(iso: string) {
+ const d = new Date(iso);
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
+}
+
+function dateLabel(iso: string) {
+ const d = new Date(iso);
+ const now = new Date();
+ const y = new Date(now);
+ y.setDate(now.getDate() - 1);
+ if (dateKey(iso) === dateKey(now.toISOString())) return "امروز";
+ if (dateKey(iso) === dateKey(y.toISOString())) return "دیروز";
+ return d.toLocaleDateString("fa-IR", { day: "numeric", month: "long" });
+}
+
+function previewOf(m: ChatMessage) {
+ if (m.attachment) {
+ const names: Record = { image: "عکس", video: "ویدیو", audio: "پیام صوتی", file: m.attachment.name };
+ const label = names[m.attachment.type] ?? m.attachment.name;
+ return m.body ? `${m.body}` : label;
+ }
+ return m.body;
+}
+
+function PinnedPreview({ m }: { m: ChatMessage }) {
+ const body = (m.body ?? "").trim();
+ const att = m.attachment;
+ const onlyEmoji = !att && !!body && isSingleEmoji(body);
+ const gif = att && isGifName(att);
+
+ if (onlyEmoji) {
+ return (
+
+
+
+ );
+ }
+
+ if (att?.type === "image") {
+ return (
+
+
+ {body ? (
+ {body}
+ ) : (
+ {gif ? "GIF" : "عکس"}
+ )}
+
+ );
+ }
+
+ if (att?.type === "video") {
+ return (
+
+
+
+
+ {body || "ویدیو"}
+
+ );
+ }
+
+ if (att?.type === "audio") {
+ return (
+
+
+ {body || "پیام صوتی"}
+
+ );
+ }
+
+ if (att?.type === "file") {
+ return (
+
+
+ {body || att.name || "فایل"}
+
+ );
+ }
+
+ if (body) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ return پیام پینشده ;
+}
+
+function toForm(file: File | Blob, mime?: string) {
+ const fd = new FormData();
+ const isFile = file instanceof File;
+ // Prefer .weba / .m4a so audio/webm is never classified as video/webm on the server.
+ // Important: check webm BEFORE "opus" — MediaRecorder often reports audio/webm;codecs=opus.
+ let name: string;
+ let type: string;
+ if (isFile) {
+ name = file.name;
+ type = file.type || mimeForExt(file.name);
+ } else {
+ const m = (mime ?? "audio/webm").toLowerCase();
+ if (m.includes("webm")) {
+ name = `voice-${Date.now()}.weba`;
+ type = "audio/webm";
+ } else if (m.includes("mp4") || m.includes("m4a") || m.includes("aac")) {
+ name = `voice-${Date.now()}.m4a`;
+ type = "audio/mp4";
+ } else if (m.startsWith("audio/ogg") || m === "audio/opus" || m.startsWith("audio/opus")) {
+ name = `voice-${Date.now()}.ogg`;
+ type = "audio/ogg";
+ } else {
+ name = `voice-${Date.now()}.weba`;
+ type = "audio/webm";
+ }
+ }
+ fd.append("file", new File([file], name, { type }));
+ return fd;
+}
+
+function mimeForExt(name: string) {
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
+ const map: Record = {
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", avif: "image/avif",
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4",
+ mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac", ogg: "audio/ogg", opus: "audio/ogg",
+ weba: "audio/webm",
+ pdf: "application/pdf", zip: "application/zip", txt: "text/plain", md: "text/markdown", csv: "text/csv", json: "application/json",
+ doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ };
+ return map[ext] ?? "application/octet-stream";
+}
+
+function pickMime() {
+ const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
+ if (typeof MediaRecorder === "undefined") return "";
+ for (const c of candidates) {
+ if (MediaRecorder.isTypeSupported(c)) return c;
+ }
+ return "";
+}
+
+function SeenTicks({ read }: { read?: boolean }) {
+ if (read) return ;
+ return ;
+}
+
+function TypingDots() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ );
+}
+
+function AutoTextarea({
+ value,
+ onChange,
+ onSend,
+ placeholder,
+ disabled,
+ inputRef,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ onSend: () => void;
+ placeholder?: string;
+ disabled?: boolean;
+ inputRef?: React.Ref;
+}) {
+ const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, 140)}px`;
+ }, [value]);
+ return (
+