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:
@@ -64,6 +64,7 @@ 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 { RichEditor } from "@/components/ui/rich-editor";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -751,11 +752,31 @@ function CurriculumPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const publishLesson = async (l: Lesson, publish: boolean) => {
|
||||
try {
|
||||
await http.post(`/api/admin/lessons/${l.id}/publish`, { publish });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||
toast.success(publish ? "درس منتشر شد" : "درس به پیشنویس برگشت");
|
||||
} catch (e) {
|
||||
toast.error(toUserError(e));
|
||||
}
|
||||
};
|
||||
|
||||
const publishChapter = async (ch: Chapter, publish: boolean) => {
|
||||
try {
|
||||
await http.post(`/api/admin/chapters/${ch.id}/publish`, { publish });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||
toast.success(publish ? "فصل منتشر شد" : "فصل به پیشنویس برگشت");
|
||||
} 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,
|
||||
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
|
||||
});
|
||||
|
||||
const duplicateLesson = async (l: Lesson) => {
|
||||
@@ -810,7 +831,7 @@ function CurriculumPanel() {
|
||||
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
|
||||
</p>
|
||||
</div>
|
||||
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
|
||||
<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>
|
||||
@@ -830,8 +851,8 @@ function CurriculumPanel() {
|
||||
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
|
||||
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
|
||||
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو
|
||||
<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}
|
||||
@@ -840,6 +861,15 @@ function CurriculumPanel() {
|
||||
<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>
|
||||
@@ -895,8 +925,8 @@ function CurriculumPanel() {
|
||||
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]">
|
||||
{l.isActive ? "فعال" : "پیشنویس"}
|
||||
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
|
||||
{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 />
|
||||
@@ -906,6 +936,15 @@ function CurriculumPanel() {
|
||||
<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>
|
||||
@@ -1056,7 +1095,7 @@ function LessonDialog({
|
||||
onClose: () => void;
|
||||
onSave: (l: Lesson) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true });
|
||||
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false });
|
||||
useEffect(() => {
|
||||
if (lesson) setDraft(lesson);
|
||||
}, [lesson]);
|
||||
@@ -1125,12 +1164,10 @@ function LessonDialog({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>توضیحات</Label>
|
||||
<textarea
|
||||
<RichEditor
|
||||
value={draft.description}
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
rows={3}
|
||||
onChange={(v) => set({ description: v })}
|
||||
placeholder="چرا این درس مهم است و چه چیزی میآموزد؟"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
Reference in New Issue
Block a user