feat: implement 8 features across full stack

- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
    deterministic tie-breaking (xp DESC, user_id ASC)
  - Mentor chat: md: breakpoints, safe-area-inset-bottom padding
  - Learning progress: server-side progressPct, curriculum expansion safety
  - Physical orders: hard-disabled with 410 Gone
  - Certificate approval: pending/issued flow, admin issue/revoke endpoints,
    certificate_requires_approval setting (migration 0031)
  - Draft/publish: is_published on lessons and chapters, admin publish UI
    (migration 0030)
  - Video panel: static thumbnails (no preload), client-side pagination
  - Rich editor: TipTap + tiptap-markdown for lesson descriptions,
    marked + DOMPurify for SafeMarkdown rendering
This commit is contained in:
ahp
2026-10-18 10:14:22 +03:30
parent 75e2892e36
commit 03610bc3d3
23 changed files with 1641 additions and 474 deletions
+42 -51
View File
@@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
Eye,
Film,
Link2,
@@ -32,7 +34,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { cn, formatFaNumber } from "@/lib/utils";
import { toast } from "@/components/providers";
import { Skeleton } from "@/components/ui/skeleton";
@@ -59,59 +61,19 @@ function VideoThumb({
className?: string;
onOpen?: () => void;
}) {
const [url, setUrl] = useState<string | null>(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 (
<button
type="button"
onClick={onOpen}
className={cn(
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
className
className,
)}
title="پیش‌نمایش"
>
{state === "loading" && (
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
)}
{state === "error" && (
<span className="absolute inset-0 grid place-items-center gap-1 text-muted-foreground">
<Film className="h-5 w-5" />
<span className="text-[10px] font-bold">پیش‌نمایش نیست</span>
</span>
)}
{url && state === "ready" && (
<video
src={url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onError={() => setState("error")}
/>
)}
<span className="absolute inset-0 grid place-items-center text-muted-foreground/30">
<Film className="h-10 w-10" />
</span>
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
<Play className="h-4 w-4 fill-current" />
@@ -169,7 +131,7 @@ function VideoPreviewDialog({
</div>
)}
{url && (
<video src={url} controls playsInline className="max-h-[70vh] w-full bg-black" autoPlay />
<video src={url} controls playsInline preload="none" className="max-h-[70vh] w-full bg-black" />
)}
</div>
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
@@ -182,12 +144,15 @@ function VideoPreviewDialog({
);
}
const PAGE_SIZE = 24;
export function VideosPanel() {
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [attachKey, setAttachKey] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState<string | null>(null);
@@ -230,10 +195,13 @@ export function VideosPanel() {
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return videos;
return videos.filter((v) => v.key.toLowerCase().includes(term));
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);
@@ -333,14 +301,14 @@ export function VideosPanel() {
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{videos.length} فایل · روی تصویر بزنید تا پخش شود
{videos.length} فایل · برای پخش روی دکمه کلیک کنید
</p>
</div>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => { setQ(e.target.value); setPage(1); }}
placeholder="جستجوی نام فایل…"
className="h-10 ps-9"
/>
@@ -355,7 +323,7 @@ export function VideosPanel() {
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((v) => {
{paginated.map((v) => {
const used = usedByKey.get(v.key) ?? [];
return (
<div
@@ -431,6 +399,29 @@ export function VideosPanel() {
{q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
</p>
)}
{totalPages > 1 && (
<div className="col-span-full flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">
صفحه {formatFaNumber(safePage)} از {formatFaNumber(totalPages)}
</span>
<Button
size="sm"
variant="outline"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>