"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Check, ChevronLeft, ChevronRight, 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, formatFaNumber } 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; }) { return ( ); } 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}
)} {!error && !url && (
)} {url && (
); } const PAGE_SIZE = 24; export function VideosPanel() { const qc = useQueryClient(); const { confirm, dialog } = useConfirm(); const fileRef = useRef(null); const [uploading, setUploading] = useState(false); const [q, setQ] = useState(""); const [page, setPage] = useState(1); 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(); return term ? videos.filter((v) => v.key.toLowerCase().includes(term)) : videos; }, [videos, q]); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); 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])} />
{(isError || lessonsError) && (

{toUserError(isError ? error : lessonsErr, "بارگذاری کتابخانه ممکن نشد")}

)}
کتابخانه ویدیو {isFetching && !isLoading && }

{videos.length} فایل · برای پخش روی دکمه کلیک کنید

{ setQ(e.target.value); setPage(1); }} placeholder="جستجوی نام فایل…" className="h-10 ps-9" />
{isLoading && (
{[0, 1, 2, 3].map((i) => ( ))}
)}
{paginated.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}
); })}
{filtered.length === 0 && !isLoading && !isError && (

{q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}

)} {totalPages > 1 && (
صفحه {formatFaNumber(safePage)} از {formatFaNumber(totalPages)}
)}
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 ( ); })}
); } /** 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 && ( )}
{value && ( )} انتخاب از کتابخانه فقط MP4 و WebMهای آپلودشده
setQ(e.target.value)} placeholder="جستجو…" className="h-10 ps-9" />
{isError && (
{toUserError(error)}
)}
{isLoading && } {filtered.map((v) => { const active = v.key === value; return (
setPreviewKey(v.key)} /> {active ? ( ) : ( )}
); })} {filtered.length === 0 && !isLoading && !isError && (

ویدیویی نیست — اول از تب ویدیوها یک MP4 آپلود کنید

)}
setPreviewKey(null)} />
); }