feat(frontend): draft/publish, certificate approval UI, contributors, leaderboard tiers

- admin-studio: chapter/lesson publish toggle with Draft/Published badges; per-chapter accordion
- cap/page: remove auto-issue effect; student requests, admin approves
- learning-panel: admin issue-certificate action per student
- site-settings: contributors editor with avatar upload (ContributorAvatarPicker, TagInput)
- community-panel: render contributors section below social links
- leaderboard-panel: TierBadge, totalParticipants, tier on entries and self
- page.tsx: fix "شروع مسیر" routing to /register instead of /login
- navbar: correct icons (Settings, Palette) in user dropdown
- types: Contributor, LeaderboardTier, Certificate status field, updated LeaderboardData/Entry
- layout: PWA icon metadata (192, 512, apple-touch-icon)
This commit is contained in:
ahp
2026-10-20 17:51:23 +03:30
parent 161610049c
commit 7716c1677c
18 changed files with 791 additions and 128 deletions
+282 -105
View File
@@ -12,6 +12,7 @@ import {
Copy,
Cpu,
Eye,
EyeOff,
Flag,
GitBranch,
Globe,
@@ -21,6 +22,7 @@ import {
Loader2,
Lock,
MapPin,
MoreHorizontal,
Pencil,
Plus,
Rocket,
@@ -59,6 +61,15 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@@ -685,7 +696,11 @@ function CurriculumPanel() {
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
const [chapterModal, setChapterModal] = useState<Chapter | null>(null);
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number; over?: number } | null>(null);
const [activeChapter, setActiveChapter] = useState<number | null>(null);
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number } | null>(null);
const toggleChapter = (id: number) =>
setActiveChapter((prev) => (prev === id ? null : id));
const persistChapterOrder = async (list: Chapter[]) => {
for (let i = 0; i < list.length; i++) {
@@ -772,12 +787,14 @@ function CurriculumPanel() {
}
};
const newLesson = (chapterId: number) =>
const newLesson = (chapterId: number) => {
setActiveChapter(chapterId);
setLessonModal({
id: 0, chapterId, title: "", description: "", videoKey: "",
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
});
};
const duplicateLesson = async (l: Lesson) => {
try {
@@ -812,6 +829,7 @@ function CurriculumPanel() {
}
try {
await http.del(`/api/admin/chapters/${ch.id}`);
if (activeChapter === ch.id) setActiveChapter(null);
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
toast.success("فصل حذف شد");
@@ -820,70 +838,159 @@ function CurriculumPanel() {
}
};
const publishedChapters = chapters.filter((c) => c.isPublished).length;
const publishedLessons = lessons.filter((l) => l.isPublished).length;
return (
<>
{dialog}
<Card>
<CardHeader className="flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">فصل‌ها و درخت مهارت</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
</p>
{/* Course overview header */}
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-bold">دوره آموزشی</p>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>{chapters.length} فصل</span>
<span>{lessons.length} درس</span>
<span className="text-success">{publishedChapters} فصل منتشر</span>
<span className="text-success">{publishedLessons} درس منتشر</span>
</div>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && <Loader />}
{chapters.map((ch, ci) => {
const ls = lessons.filter((l) => l.chapterId === ch.id);
const ChIcon = chapterIconOf(ch.icon);
return (
<div key={ch.id} className={cn("rounded-xl border border-border bg-muted/20", dragIdx !== null && dragIdx?.chapter === ch.id && "border-primary/50")}>
<div className="flex items-center gap-2 p-3 pb-2">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">
</div>
<Button
size="sm"
className="shrink-0"
onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}
>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</div>
{/* Chapter list */}
{isLoading && <Loader />}
<div className="space-y-2">
{chapters.map((ch, ci) => {
const ls = lessons.filter((l) => l.chapterId === ch.id);
const ChIcon = chapterIconOf(ch.icon);
const isOpen = activeChapter === ch.id;
return (
<div
key={ch.id}
className={cn(
"overflow-hidden rounded-xl border border-border transition-colors",
isOpen && "border-primary/40 shadow-sm",
)}
>
{/* Chapter header row */}
<div className="flex items-center gap-2 px-3 py-2.5">
{/* Expand toggle + identity — takes full remaining width */}
<button
type="button"
aria-expanded={isOpen}
aria-controls={`chapter-${ch.id}-lessons`}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-md py-0.5 text-start outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => toggleChapter(ch.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ChIcon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate font-bold">{ch.title}</h3>
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
<p className="truncate text-sm font-bold">{ch.title}</p>
{ch.description && (
<p className="truncate text-[11px] text-muted-foreground">{ch.description}</p>
)}
</div>
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
<Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{ch.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
<GroupNav
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
onDown={ci < chapters.length - 1 ? () => moveChapter(ci, 1) : undefined}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180",
)}
aria-hidden
/>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
<Pencil className="h-3 w-3" /> فصل
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
onClick={() => publishChapter(ch, !ch.isPublished)}
title={ch.isPublished ? "برگشت به پیش‌نویس" : "انتشار فصل"}
>
{ch.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
<Plus className="h-3.5 w-3.5" /> درس
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive" onClick={() => delChapter(ch)} title="حذف فصل">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{ls.length === 0 ? (
<p className="px-3 pb-3 text-xs text-muted-foreground">هنوز درسی نیست. «+ درس» را بزنید.</p>
) : (
<div className="px-3 pb-3">
<div className="grid gap-1.5 sm:grid-cols-2">
</button>
{/* Status badges */}
<Badge variant="outline" className="shrink-0 px-2 text-[10px]">
{ls.length} درس
</Badge>
<Badge
variant={ch.isPublished ? "success" : "secondary"}
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
>
{ch.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
{/* Chapter actions menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 px-0"
aria-label={`اقدامات فصل ${ch.title}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>فصل {ci + 1}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setChapterModal(ch)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش فصل
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setActiveChapter(ch.id);
newLesson(ch.id);
}}
>
<Plus className="h-3.5 w-3.5" /> درس جدید
</DropdownMenuItem>
<DropdownMenuSeparator />
{ci > 0 && (
<DropdownMenuItem onClick={() => moveChapter(ci, -1)}>
<ChevronUp className="h-3.5 w-3.5" /> جابجا به بالا
</DropdownMenuItem>
)}
{ci < chapters.length - 1 && (
<DropdownMenuItem onClick={() => moveChapter(ci, 1)}>
<ChevronDown className="h-3.5 w-3.5" /> جابجا به پایین
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => publishChapter(ch, !ch.isPublished)}>
{ch.isPublished ? (
<><EyeOff className="h-3.5 w-3.5" /> برگشت به پیش‌نویس</>
) : (
<><Globe className="h-3.5 w-3.5" /> انتشار فصل</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => delChapter(ch)}
>
<Trash2 className="h-3.5 w-3.5" /> حذف فصل
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Lessons panel (progressive disclosure) */}
{isOpen && (
<div
id={`chapter-${ch.id}-lessons`}
className="border-t border-border/70"
>
{ls.length === 0 ? (
<div className="px-4 py-5 text-center text-sm text-muted-foreground">
هنوز درسی نیست — از دکمه زیر درس اضافه کنید.
</div>
) : (
<div className="divide-y divide-border/60">
{ls.map((l, li) => {
const cols = ls.filter((o) => o.x === l.x && o.y === l.y && o.id !== l.id);
const collision = ls.some(
(o) => o.x === l.x && o.y === l.y && o.id !== l.id,
);
return (
<div
key={l.id}
@@ -892,80 +999,150 @@ function CurriculumPanel() {
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);
const next = reorderArray(ls, dragIdx.from, 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",
"flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-muted/40",
dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40",
!l.videoKey && "border-dashed"
)}
>
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/50" />
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">L{l.id}</span>
{/* Grip handle */}
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground/40 active:cursor-grabbing" aria-hidden />
{/* Lesson identity */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{l.title}</p>
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<Timer className="h-3 w-3" /> {formatDur(l.durationSeconds)}
<Sparkles className="h-3 w-3 text-gold" /> +{formatFaNumber(l.xpReward)}
<MapPin className="h-3 w-3" /> {l.x},{l.y}
<p className="truncate text-sm font-medium leading-tight">
{l.title}
</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-0.5">
<Timer className="h-3 w-3" />
{formatDur(l.durationSeconds)}
</span>
<span className="inline-flex items-center gap-0.5 text-gold">
<Sparkles className="h-3 w-3" />
+{formatFaNumber(l.xpReward)}
</span>
{l.videoKey ? (
<span className="inline-flex items-center gap-0.5 text-success"><Video className="h-3 w-3" /> ویدیو</span>
<span className="inline-flex items-center gap-0.5 text-success">
<Video className="h-3 w-3" /> ویدیو
</span>
) : (
<span className="text-warning">بدون ویدیو</span>
)}
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
{collision && (
<Badge variant="destructive" className="px-1 text-[9px]">
تداخل موقعیت
</Badge>
)}
</div>
</div>
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{/* Publish badge */}
<Badge
variant={l.isPublished ? "success" : "secondary"}
className="hidden shrink-0 px-1.5 text-[10px] sm:inline-flex"
>
{l.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
<div className="flex shrink-0 gap-0.5">
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
<Button size="sm" variant="ghost" className="h-7 px-1.5" title="تکثیر" onClick={() => duplicateLesson(l)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-1.5 text-[10px]"
title={l.isPublished ? "برگشت به پیش‌نویس" : "انتشار درس"}
onClick={() => publishLesson(l, !l.isPublished)}
>
{l.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Up/down reorder (accessible) */}
<GroupNav
onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined}
onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined}
compact
/>
{/* Edit (primary action) */}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 px-0"
title="ویرایش درس"
aria-label={`ویرایش ${l.title}`}
onClick={() => setLessonModal(l)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{/* Secondary actions menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 px-0"
aria-label={`اقدامات ${l.title}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="truncate">L{l.id}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => duplicateLesson(l)}>
<Copy className="h-3.5 w-3.5" /> تکثیر
</DropdownMenuItem>
<DropdownMenuItem onClick={() => publishLesson(l, !l.isPublished)}>
{l.isPublished ? (
<><EyeOff className="h-3.5 w-3.5" /> برگشت به پیش‌نویس</>
) : (
<><Globe className="h-3.5 w-3.5" /> انتشار درس</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => delLesson(l)}
>
<Trash2 className="h-3.5 w-3.5" /> حذف درس
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
{/* Add lesson footer */}
<div className="border-t border-border/60 px-3 py-2.5">
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => newLesson(ch.id)}
>
<Plus className="h-3.5 w-3.5" /> درس جدید در این فصل
</Button>
</div>
)}
</div>
);
})}
{chapters.length === 0 && !isLoading && (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<p className="font-bold">هنوز فصلی نیست</p>
<p className="mt-1 text-sm text-muted-foreground">با «فصل جدید» شروع کنید؛ درس‌های هر فصل روی درخت مهارت هنرجو نمایش داده می‌شود.</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{chapters.length === 0 && !isLoading && (
<div className="rounded-xl border border-dashed border-border p-10 text-center">
<p className="font-bold">هنوز فصلی نیست</p>
<p className="mt-1 text-sm text-muted-foreground">
با «فصل جدید» شروع کنید؛ درس‌های هر فصل روی درخت مهارت هنرجو نمایش داده می‌شود.
</p>
<Button
className="mt-4"
onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: 1, isPublished: false })}
>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</div>
)}
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
<LessonDialog
@@ -1437,11 +1614,11 @@ function EventDialog({ event, onClose, onSave }: { event: AdminEvent | null; onC
</div>
<div className="space-y-1.5">
<Label>توضیحات</Label>
<textarea
<Textarea
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
rows={3}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
className="resize-y"
placeholder="شرح کوتاه رویداد (اختیاری)…"
/>
</div>