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
+40 -50
View File
@@ -1,9 +1,8 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
export function SafeMarkdown({
text,
className,
@@ -11,56 +10,47 @@ export function SafeMarkdown({
}: {
text: string;
className?: string;
/** When set, only the first block is shown, clamped to this many lines. */
lines?: 2 | 3;
}) {
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
const shown = lines ? blocks.slice(0, 1) : blocks;
const [html, setHtml] = useState("");
useEffect(() => {
if (!text) { setHtml(""); return; }
void (async () => {
const { marked } = await import("marked");
const DOMPurify = (await import("dompurify")).default;
const raw = await marked(text);
setHtml(DOMPurify.sanitize(raw));
})();
}, [text]);
if (!html) {
return (
<div
dir="auto"
className={cn(
"break-words text-sm [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
className,
)}
>
{text}
</div>
);
}
return (
<div className={className}>
{shown.map((block, i) => (
<p
key={i}
dir="auto"
className={cn(
"break-words [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
)}
>
{inline(block)}
</p>
))}
</div>
<div
dir="auto"
className={cn(
"prose prose-sm max-w-none break-words [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
function inline(src: string): ReactNode[] {
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
const nodes: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = re.exec(src))) {
if (m.index > last) nodes.push(src.slice(last, m.index));
if (m[1] && m[2]) {
nodes.push(
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
{m[1]}
</a>,
);
} else if (m[3]) {
nodes.push(<strong key={k++}>{m[3]}</strong>);
} else if (m[4]) {
nodes.push(
<code key={k++} className="rounded bg-muted px-1">
{m[4]}
</code>,
);
}
last = m.index + m[0].length;
}
if (last < src.length) nodes.push(src.slice(last));
return nodes;
}