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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user