diff --git a/LearningPlatform/app/(student)/(shell)/courses/page.tsx b/LearningPlatform/app/(student)/(shell)/courses/page.tsx index 4ba9255..f89e8f0 100644 --- a/LearningPlatform/app/(student)/(shell)/courses/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/courses/page.tsx @@ -15,7 +15,7 @@ import { serializeCatalogQuery, } from '@/lib/courses-catalog' import { CourseCatalogCard } from '@/components/courses/course-catalog-card' -import { studentGlassCard } from '@/lib/student-glass-styles' +import { studentGlassCard, studentGlassFooterNavButton } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' export const dynamic = 'force-dynamic' @@ -65,7 +65,12 @@ export default async function CoursesPage({ searchParams }: PageProps) {
-
+
+

+ {pageTitle} +

+ {pageDescription != null ? ( +

{pageDescription}

+ ) : ( +

+ These are the flashcard decks you have added to your library. +
+ You can{' '} + + discover more here + + . +

+ )} +
{loading && ( @@ -146,13 +258,17 @@ export default function StudentFlashcardDeckTreePage() { {!loading && !error && - rows.map((row) => { + displayedRows.map((row) => { const mainDeckSlugQ = encodeURIComponent(row.deck.slug) const courseTitle = row.course ? String(row.course.title ?? '') : '' const deckName = String(row.deck.name ?? '') const deckTitleSameAsCourse = row.source === 'course' && titlesMatch(deckName, courseTitle) + const subdecksExpanded = subdeckListOpen[row.deck.id] ?? false return ( - +
{row.source === 'standalone' ? ( @@ -228,38 +344,139 @@ export default function StudentFlashcardDeckTreePage() { : 'No subdecks linked yet.'}

) : ( - row.subdecks.map((subdeck) => { - const subdeckSlugQ = encodeURIComponent(subdeck.deck.slug) - const subStats: Stats = subdeck.stats - const sectionLabel = - row.source === 'standalone' - ? subdeck.deck.name - : `${subdeck.deck.moduleTitle ?? 'Module'}: ${subdeck.deck.name}` - return ( -
-

- {sectionLabel} + <> + + + {subdecksExpanded && ( +

+

+ {row.source === 'course' + ? 'Each box is a module section. You can study it separately or use the whole-course buttons above.' + : 'Each box is a section of this deck. You can study it on its own or use the full-deck buttons above.'}

-
- - - -
-
- -
+ {row.subdecks.map((subdeck) => { + const subdeckSlugQ = encodeURIComponent(subdeck.deck.slug) + const subStats: Stats = subdeck.stats + const sectionLabel = + row.source === 'standalone' + ? subdeck.deck.name + : courseSubdeckLabel(subdeck.deck.moduleTitle, subdeck.deck.name) + return ( +
+

+ {sectionLabel} +

+
+ + + +
+
+ +
+
+ ) + })}
- ) - }) + )} + )} ) })} + + {!loading && !error && rows.length > 0 && ( +
+

+ Showing{' '} + + {(currentPage - 1) * FLASHCARD_DECK_TREE_PAGE_SIZE + 1} + + {'–'} + + {Math.min(currentPage * FLASHCARD_DECK_TREE_PAGE_SIZE, rows.length)} + {' '} + of {rows.length} deck + {rows.length === 1 ? '' : 's'} +

+
+ {currentPage > 1 ? ( + + ) : ( + + )} + + {currentPage} / {lastPage} + + {currentPage < lastPage ? ( + + ) : ( + + )} +
+
+ )}
) diff --git a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx index 9daad07..7e14c99 100644 --- a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx +++ b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/study/page.tsx @@ -1,27 +1,27 @@ 'use client' /** - * ─── Flashcard Study Page ─────────────────────────────────────────────────── + * Flashcard Study Page * * Supports two modes (selected via ?mode= query param): - * srs — Only shows cards that are due today (respects daily limits). - * free — Shows ALL cards in the set regardless of due date. + * srs - Only shows cards that are due today (respects daily limits). + * free - Shows ALL cards in the set regardless of due date. * * Optional query params: ?tagSlug=, ?subject=, ?deckSlug=, ?subdeckSlug=, ?mainDeckSlug= limit which cards load. * * The study loop: * 1. Fetch due cards from /api/flashcards/study - * 2. Show ONE card at a time (question side up) - * 3. Click / tap → flip to reveal answer - * 4. Tap Again / Hard / Good / Easy → POST to /api/flashcards/[id]/review + * 2. Show ONE card at a time (question side up); question/answer use GFM Markdown + KaTeX ($ / $$) + * 3. Click / tap -> 3D flip to reveal answer; tap again to flip between sides + * 4. Tap Again / Hard / Good / Easy -> POST to /api/flashcards/[id]/review * 5. SRS algorithm runs server-side and returns updated card state * 6. If the card is still in a short-step phase (LEARNING/RELEARNING with * nextReviewAt within REQUEUE_WINDOW_MS), push it back to the END of the * in-memory queue so it reappears in this session. - * 7. When queue is empty → show completion screen + * 7. When queue is empty -> show completion screen */ -import { useEffect, useState, useCallback, useRef, Suspense } from 'react' +import { useEffect, useLayoutEffect, useState, useCallback, useRef, Suspense } from 'react' import { useSearchParams } from 'next/navigation' import Link from 'next/link' import { Button } from '@/components/ui/button' @@ -35,8 +35,9 @@ import { Zap, } from 'lucide-react' import { cn } from '@/lib/utils' +import { FlashcardRichText } from '@/components/student/flashcard-markdown' -// ─── Types ──────────────────────────────────────────────────────────────────── +// --- Types --- interface Tag { id: string @@ -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' @@ -93,7 +97,7 @@ function studyQuery( return p.toString() } -// ─── Helpers ────────────────────────────────────────────────────────────────── +// --- Helpers --- function stateLabel(state: string): { label: string; color: string } { switch (state) { @@ -106,27 +110,7 @@ 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 ──────────────────────────────────────────────────── +// --- Answer button configs --- const ANSWER_BUTTONS: { label: string @@ -160,7 +144,7 @@ const ANSWER_BUTTONS: { }, ] -// ─── Page ───────────────────────────────────────────────────────────────────── +// --- Page --- /** * Wrapper required by Next.js App Router so that useSearchParams() is @@ -187,7 +171,7 @@ function StudyPage() { const deckSlug = searchParams.get('deckSlug') ?? '' const subdeckSlug = searchParams.get('subdeckSlug') ?? '' const mainDeckSlug = searchParams.get('mainDeckSlug') ?? '' - // ── State ────────────────────────────────────────────────────────────────── + // --- State --- const [queue, setQueue] = useState([]) const [currentIdx, setCurrentIdx] = useState(0) @@ -196,15 +180,59 @@ 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) - // ── Fetch study session ──────────────────────────────────────────────────── + /** After reveal, which physical face is toward the user (answer = back of the 3D card). While phase is question, rotation ignores this. */ + const [flippedToBack, setFlippedToBack] = useState(false) + + const card = queue[currentIdx] + + const frontFaceRef = useRef(null) + const backFaceRef = useRef(null) + /** Natural heights of each face (absolute siblings so they do not stretch to max of the other). */ + const [faceHeights, setFaceHeights] = useState({ front: 280, back: 280 }) + + const visibleIsAnswerFace = + phase === 'submitting' || (phase === 'answer' && flippedToBack) + + const activeFlipHeight = Math.max(280, visibleIsAnswerFace ? faceHeights.back : faceHeights.front) + + const measureFaces = useCallback(() => { + const fr = frontFaceRef.current?.getBoundingClientRect().height ?? 0 + const br = backFaceRef.current?.getBoundingClientRect().height ?? 0 + setFaceHeights({ + front: Math.max(280, Math.ceil(fr)), + back: Math.max(280, Math.ceil(br)), + }) + }, []) + + useLayoutEffect(() => { + queueMicrotask(() => { + setFaceHeights({ front: 280, back: 280 }) + }) + }, [currentIdx, card?.id, card?.question, card?.answer, questionImgUrl, answerImgUrl]) + + useLayoutEffect(() => { + queueMicrotask(() => { + measureFaces() + }) + }, [measureFaces, visibleIsAnswerFace, phase]) + + useEffect(() => { + const f = frontFaceRef.current + const b = backFaceRef.current + if (!f && !b) return + const ro = new ResizeObserver(() => { + measureFaces() + }) + if (f) ro.observe(f) + if (b) ro.observe(b) + return () => ro.disconnect() + }, [measureFaces, card?.id]) + + // --- Fetch study session --- const loadSession = useCallback(async () => { setPhase('loading') @@ -233,50 +261,38 @@ 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) + if (!card) { + setQuestionImgUrl(null) + setAnswerImgUrl(null) + return + } + setQuestionImgUrl(card.questionImageUrl ?? null) + setAnswerImgUrl(card.answerImageUrl ?? 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 */ }) - } }, [queue, currentIdx]) - // ── Keyboard shortcuts ───────────────────────────────────────────────────── + // --- Keyboard shortcuts --- useEffect(() => { function onKey(e: KeyboardEvent) { - if (phase === 'question' && (e.key === ' ' || e.key === 'Enter')) { - e.preventDefault() - setPhase('answer') + if (e.key === ' ' || e.key === 'Enter') { + if (phase === 'question') { + e.preventDefault() + setPhase('answer') + setFlippedToBack(true) + return + } + if (phase === 'answer') { + e.preventDefault() + setFlippedToBack((v) => !v) + } } - if (phase === 'answer') { + if (phase === 'answer' && flippedToBack) { if (e.key === '1') handleAnswer('AGAIN') if (e.key === '2') handleAnswer('HARD') if (e.key === '3') handleAnswer('GOOD') @@ -286,9 +302,9 @@ function StudyPage() { window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase, currentIdx, queue]) + }, [phase, currentIdx, queue, flippedToBack]) - // ── Answer handler ───────────────────────────────────────────────────────── + // --- Answer handler --- async function handleAnswer(answer: AnswerButton) { const card = queue[currentIdx] @@ -332,7 +348,7 @@ function StudyPage() { ] nextIdx = currentIdx >= nextQueue.length - 1 ? 0 : currentIdx // If after requeue the remaining non-requeued items are exhausted, - // we're done with the "fresh" cards — treat as done when queue is 1 item long + // we're done with the "fresh" cards - treat as done when queue is 1 item long if (nextQueue.length === 1) { // Only the re-queued card remains; reset index to 0 and continue nextIdx = 0 @@ -357,15 +373,12 @@ function StudyPage() { } } - // ───────────────────────────────────────────────────────────────────────────── - - const card = queue[currentIdx] const progressTotal = totalRef.current const progressDone = reviewedCount const pct = progressTotal > 0 ? Math.round((progressDone / progressTotal) * 100) : 0 const backHref = '/dashboard/flashcards' - // ─── Render ─────────────────────────────────────────────────────────────── + // --- Render --- return (
@@ -408,7 +421,7 @@ function StudyPage() {
- {/* ── Progress bar ── */} + {/* Progress bar */} {progressTotal > 0 && (
)} - {/* ── Main content ── */} + {/* Main content */}
{/* Loading */} @@ -494,7 +507,7 @@ function StudyPage() { {/* Study card (question or answer phase) */} {card && (phase === 'question' || phase === 'answer' || phase === 'submitting') && ( -
+
{/* Progress counter */}
@@ -518,84 +531,185 @@ function StudyPage() {
- {/* Flip card */} + {/* 3D flip card (same front/back indicator pattern as Creative Space whiteboard flashcards) */} - {/* Answer buttons */} - {(phase === 'answer' || phase === 'submitting') && ( + {/* Answer buttons: only while the answer face is toward the user (hidden again if you flip to question) */} + {((phase === 'answer' && flippedToBack) || phase === 'submitting') && (
{ANSWER_BUTTONS.map((btn) => (