From 22e516c7f7f0c446a13fa8bb06b081ca2e6fc757 Mon Sep 17 00:00:00 2001 From: kinga Date: Fri, 24 Apr 2026 23:49:33 +0200 Subject: [PATCH 1/7] style(flashcards): implement theme synchronization and enhance flashcard rendering with Markdown support --- .../dashboard/flashcards/study/page.tsx | 112 ++++-------- .../app/api/flashcards/[id]/review/route.ts | 50 +++--- .../app/api/flashcards/study/route.ts | 15 +- LearningPlatform/app/layout.tsx | 28 +-- .../components/admin/add-flashcard-dialog.tsx | 6 +- LearningPlatform/components/admin/topbar.tsx | 9 +- .../components/student/flashcard-markdown.tsx | 164 ++++++++++++++++++ .../student/markdown-theory-body.tsx | 5 +- LearningPlatform/components/theme-sync.tsx | 48 +++++ LearningPlatform/components/theme-toggle.tsx | 5 + LearningPlatform/lib/flashcard-media-urls.ts | 88 ++++++++++ LearningPlatform/lib/theme-cookie.ts | 18 ++ .../lib/valid-payload-media-id.ts | 23 +++ .../src/payload/collections/Tasks.ts | 19 ++ 14 files changed, 454 insertions(+), 136 deletions(-) create mode 100644 LearningPlatform/components/student/flashcard-markdown.tsx create mode 100644 LearningPlatform/components/theme-sync.tsx create mode 100644 LearningPlatform/lib/flashcard-media-urls.ts create mode 100644 LearningPlatform/lib/theme-cookie.ts create mode 100644 LearningPlatform/lib/valid-payload-media-id.ts diff --git a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx index 9daad07..0479a2b 100644 --- a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx @@ -11,7 +11,7 @@ * * The study loop: * 1. Fetch due cards from /api/flashcards/study - * 2. Show ONE card at a time (question side up) + * 2. Show ONE card at a time (question side up); question/answer use GFM Markdown + KaTeX ($ / $$) * 3. Click / tap → flip to reveal answer * 4. Tap Again / Hard / Good / Easy → POST to /api/flashcards/[id]/review * 5. SRS algorithm runs server-side and returns updated card state @@ -35,6 +35,7 @@ import { Zap, } from 'lucide-react' import { cn } from '@/lib/utils' +import { FlashcardRichText } from '@/components/student/flashcard-markdown' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -45,20 +46,23 @@ interface Tag { } interface StudyCard { - id: string - question: string - answer: string - questionImageId: string | null - answerImageId: string | null - state: string - interval: number - easeFactor: number - repetition: number - stepIndex: number - nextReviewAt: string | null - lastReviewedAt: string | null - tags: Tag[] - deck?: { id: string; name: string; slug: string } | null + id: string + question: string + answer: string + questionImageId: string | null + answerImageId: string | null + /** Resolved on the server so students do not need admin-only `/api/media/list`. */ + questionImageUrl?: string | null + answerImageUrl?: string | null + state: string + interval: number + easeFactor: number + repetition: number + stepIndex: number + nextReviewAt: string | null + lastReviewedAt: string | null + tags: Tag[] + deck?: { id: string; name: string; slug: string } | null } type AnswerButton = 'AGAIN' | 'HARD' | 'GOOD' | 'EASY' @@ -106,26 +110,6 @@ function stateLabel(state: string): { label: string; color: string } { } } -/** Render LaTeX if katex is loaded, otherwise return the raw string. */ -async function renderLatex(raw: string): Promise { - try { - const katex = (await import('katex')).default - // Block LaTeX: $$...$$ - let html = raw.replace(/\$\$([\s\S]*?)\$\$/g, (_m, tex) => { - try { return katex.renderToString(tex, { displayMode: true, throwOnError: false }) } - catch { return _m } - }) - // Inline LaTeX: $...$ - html = html.replace(/\$([\s\S]*?)\$/g, (_m, tex) => { - try { return katex.renderToString(tex, { displayMode: false, throwOnError: false }) } - catch { return _m } - }) - return html - } catch { - return raw - } -} - // ─── Answer button configs ──────────────────────────────────────────────────── const ANSWER_BUTTONS: { @@ -196,11 +180,7 @@ function StudyPage() { const [reviewedCount,setReviewedCount]= useState(0) const totalRef = useRef(0) - // LaTeX-rendered HTML for the current card - const [questionHtml, setQuestionHtml] = useState('') - const [answerHtml, setAnswerHtml] = useState('') - - // Image URLs resolved from Payload CMS media IDs + // Image URLs from GET /api/flashcards/study (and PATCH via review); set in effect when the card changes const [questionImgUrl, setQuestionImgUrl] = useState(null) const [answerImgUrl, setAnswerImgUrl] = useState(null) @@ -233,39 +213,19 @@ function StudyPage() { return () => window.clearTimeout(t) }, [loadSession]) - // ── Render LaTeX whenever current card changes ───────────────────────────── + // ── Image URLs when the current card changes (markdown renders in JSX) ──────────────────────────── useEffect(() => { const card = queue[currentIdx] if (!card) return queueMicrotask(() => { - setQuestionHtml('') - setAnswerHtml('') setQuestionImgUrl(null) setAnswerImgUrl(null) }) - renderLatex(card.question).then(setQuestionHtml) - renderLatex(card.answer).then(setAnswerHtml) - - // Resolve image IDs → URLs - if (card.questionImageId || card.answerImageId) { - fetch('/api/media/list') - .then((r) => r.json()) - .then((d) => { - const docs: { id: string | number; url: string }[] = d?.media ?? [] - if (card.questionImageId) { - const m = docs.find((x) => String(x.id) === card.questionImageId) - if (m) setQuestionImgUrl(m.url) - } - if (card.answerImageId) { - const m = docs.find((x) => String(x.id) === card.answerImageId) - if (m) setAnswerImgUrl(m.url) - } - }) - .catch(() => { /* ignore — images optional */ }) - } + setQuestionImgUrl(card.questionImageUrl ?? null) + setAnswerImgUrl(card.answerImageUrl ?? null) }, [queue, currentIdx]) // ── Keyboard shortcuts ───────────────────────────────────────────────────── @@ -539,16 +499,9 @@ function StudyPage() {

Question

- {questionHtml ? ( -
- ) : ( -

- {card.question} -

- )} +
+ +
{questionImgUrl && ( /* eslint-disable-next-line @next/next/no-img-element */ Answer

- {answerHtml ? ( -
- ) : ( -

- {card.answer} -

- )} +
+ +
{answerImgUrl && ( /* eslint-disable-next-line @next/next/no-img-element */ ) { - // The nonce is generated per-request in middleware.ts and forwarded via the - // x-nonce request header. Applying it to the inline theme-init script allows - // the Content-Security-Policy to block all OTHER inline scripts while still - // permitting this one - eliminating the need for 'unsafe-inline' in script-src. - // Use `undefined` when the header is absent so React omits the attribute - // (rendering `nonce=""` causes hydration mismatches if the client - // later applies a real nonce). `headers().get` may return null during - // certain server-rendering scenarios, so prefer `undefined` over an - // empty string to avoid emitting an empty attribute. - const rawNonce = (await headers()).get('x-nonce') - // Never pass '' — React/Next can serialize that as nonce="" and mismatch a later render that has a real nonce. - const nonce = typeof rawNonce === 'string' && rawNonce.length > 0 ? rawNonce : undefined + const themeCookie = (await cookies()).get(THEME_COOKIE_NAME)?.value + const themePref = readThemeFromCookie(themeCookie) + const isDark = themePref === 'dark' - const themeInitScript = `(function(){try{var k='theme';var v=localStorage.getItem(k);var r=document.documentElement;if(v==='dark'){r.classList.add('dark');}else if(v==='light'){r.classList.remove('dark');}else{r.classList.add('dark');}}catch(e){try{document.documentElement.classList.add('dark');}catch(_){}} })()` return ( - + {/* Explicit favicon link to ensure the app/favicon.ico is used */} -