-
- Question
-
- {questionHtml ? (
+
+
+ {/* Front (question) face - absolute so its height does not force the back face box */}
- ) : (
-
- {card.question}
-
- )}
- {questionImgUrl && (
- /* eslint-disable-next-line @next/next/no-img-element */
-

- )}
-
+ ref={frontFaceRef}
+ className={cn(
+ 'absolute left-0 top-0 flex w-full min-h-[280px] min-w-0 flex-col gap-4 overflow-x-auto rounded-2xl border border-gray-200 bg-white p-5 sm:p-8',
+ 'text-left shadow-sm [backface-visibility:hidden] [transform:rotateY(0deg)] dark:border-gray-700 dark:bg-gray-900',
+ phase === 'question' &&
+ 'hover:border-blue-300 dark:hover:border-blue-700',
+ )}
+ >
+ {/* Which side: left = question, right = answer (matches creative-space flashcard blocks). */}
+
+
+
+
+
+
+
+
+ {questionImgUrl && (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ )}
+
- {/* Answer side (revealed after flip) */}
- {phase !== 'question' && (
-
-
- Answer
-
- {answerHtml ? (
-
- ) : (
-
- {card.answer}
-
- )}
- {answerImgUrl && (
- /* eslint-disable-next-line @next/next/no-img-element */
-

- )}
+ {/* Back (answer) face */}
+
+
+
+
+
+
+
+
+
+ {answerImgUrl && (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ )}
+
- )}
+
- {/* "Click to reveal" hint */}
+ {/* Flip hint */}
{phase === 'question' && (
-
- Space / click to reveal ↓
+
+ Click or Space to flip to answer
+
+ )}
+ {phase === 'answer' && !flippedToBack && (
+
+ Click or Space to see answer again
)}
- {/* 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) => (
@@ -143,7 +148,7 @@ export function AllCoursesPromo() {
isDark
? 'bg-fuchsia-400/20'
: useLiquid
- ? 'bg-violet-400/11'
+ ? 'bg-sky-300/14'
: 'bg-fuchsia-500/22',
)}
aria-hidden
diff --git a/LearningPlatform/components/dashboard/flashcard-section.tsx b/LearningPlatform/components/dashboard/flashcard-section.tsx
index 115c4f4..36ecf5f 100644
--- a/LearningPlatform/components/dashboard/flashcard-section.tsx
+++ b/LearningPlatform/components/dashboard/flashcard-section.tsx
@@ -13,7 +13,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { DashboardHorizontalScroll } from '@/components/dashboard/dashboard-horizontal-scroll'
import { Brain, Loader2, BookOpen } from 'lucide-react'
import type { ReactNode } from 'react'
-import { studentGlassCard } from '@/lib/student-glass-styles'
+import { studentGlassCard, studentGlassFooterNavButton } from '@/lib/student-glass-styles'
import { cn } from '@/lib/utils'
import type { FlashcardDashboardSummary } from '@/lib/flashcards-dashboard-summary'
@@ -206,9 +206,23 @@ export function FlashcardDashboardSection({ children }: { children?: React.React
{!loading && !error && summary && summary.decks.length > 0 &&
}
+ {/* Footer nav: glass-tuned (`studentGlassFooterNavButton`); `hero` stays for primary CTAs (Open Deck Tree). */}
{!loading && !error && summary && (
-
-
diff --git a/LearningPlatform/components/theme-preference-provider.tsx b/LearningPlatform/components/theme-preference-provider.tsx
new file mode 100644
index 0000000..a5cdad1
--- /dev/null
+++ b/LearningPlatform/components/theme-preference-provider.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react'
+import type { ThemePreference } from '@/lib/theme-cookie'
+import { writeThemeCookie } from '@/lib/theme-cookie'
+import { resolveThemePreference } from '@/lib/resolve-theme-preference'
+
+const ThemeIsDarkContext = createContext
(undefined)
+
+function applyDomTheme(pref: ThemePreference) {
+ document.documentElement.classList.toggle('dark', pref === 'dark')
+ writeThemeCookie(pref)
+}
+
+export function ThemePreferenceProvider({
+ initialPreference,
+ children,
+}: {
+ initialPreference: ThemePreference
+ children: ReactNode
+}) {
+ const [preference, setPreference] = useState(initialPreference)
+ const isDark = preference === 'dark'
+
+ /** Apply cookie + class immediately; defer setState so we do not sync-setState inside an effect (react-hooks/set-state-in-effect). */
+ useLayoutEffect(() => {
+ try {
+ const ls = localStorage.getItem('theme')
+ const resolved = resolveThemePreference(ls, document.cookie)
+ applyDomTheme(resolved)
+ if (resolved !== initialPreference) {
+ queueMicrotask(() => setPreference(resolved))
+ }
+ } catch {
+ try {
+ document.documentElement.classList.add('dark')
+ writeThemeCookie('dark')
+ if (initialPreference !== 'dark') {
+ queueMicrotask(() => setPreference('dark'))
+ }
+ } catch {
+ /* noop */
+ }
+ }
+ }, [initialPreference])
+
+ const syncFromBrowser = useCallback(() => {
+ try {
+ const ls = localStorage.getItem('theme')
+ const resolved = resolveThemePreference(ls, document.cookie)
+ applyDomTheme(resolved)
+ setPreference(resolved)
+ } catch {
+ try {
+ document.documentElement.classList.add('dark')
+ writeThemeCookie('dark')
+ setPreference('dark')
+ } catch {
+ /* noop */
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === 'theme') syncFromBrowser()
+ }
+ const onThemeChange = () => syncFromBrowser()
+ window.addEventListener('storage', onStorage)
+ window.addEventListener('theme-change', onThemeChange as EventListener)
+ return () => {
+ window.removeEventListener('storage', onStorage)
+ window.removeEventListener('theme-change', onThemeChange as EventListener)
+ }
+ }, [syncFromBrowser])
+
+ return {children}
+}
+
+export function useThemeIsDark(): boolean {
+ const v = useContext(ThemeIsDarkContext)
+ if (v === undefined) {
+ throw new Error('useThemeIsDark must be used within ThemePreferenceProvider')
+ }
+ return v
+}
diff --git a/LearningPlatform/components/theme-toggle.tsx b/LearningPlatform/components/theme-toggle.tsx
index d7927c2..e0dfe1f 100644
--- a/LearningPlatform/components/theme-toggle.tsx
+++ b/LearningPlatform/components/theme-toggle.tsx
@@ -1,38 +1,11 @@
"use client"
-import { useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { Button } from '@/components/ui/button'
+import { writeThemeCookie } from '@/lib/theme-cookie'
+import useIsDark from '@/components/useIsDark'
export default function ThemeToggle() {
- // null = not yet hydrated; avoids SSR/client mismatch
- const [isDark, setIsDark] = useState(null)
-
- useEffect(() => {
- // Read real theme state only after mount (client-only)
- try {
- const ls = localStorage.getItem('theme')
- if (ls === 'dark') { queueMicrotask(() => setIsDark(true)); return }
- if (ls === 'light') { queueMicrotask(() => setIsDark(false)); return }
- queueMicrotask(() => setIsDark(true))
- } catch {
- queueMicrotask(() => setIsDark(true))
- }
- }, [])
-
- useEffect(() => {
- if (isDark === null) return // not yet mounted — don't touch the DOM
- try {
- if (isDark) {
- document.documentElement.classList.add('dark')
- localStorage.setItem('theme', 'dark')
- } else {
- document.documentElement.classList.remove('dark')
- localStorage.setItem('theme', 'light')
- }
- } catch {
- // noop
- }
- }, [isDark])
+ const isDark = useIsDark()
const toggleAndReload = () => {
const next = !isDark
@@ -40,37 +13,22 @@ export default function ThemeToggle() {
if (next) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
+ writeThemeCookie('dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
+ writeThemeCookie('light')
}
} catch {
// noop
}
- // update local state only; avoid reloading so the color transition can animate
- setIsDark(next)
try {
- // notify other components in this window so they can re-read theme and re-render
window.dispatchEvent(new CustomEvent('theme-change', { detail: { isDark: next } }))
} catch {
// noop
}
}
- // Before hydration: render an invisible placeholder with the same size so
- // layout doesn't shift. Both server and client agree on this output.
- if (isDark === null) {
- return (
-
- )
- }
-
return (
{
- const getTheme = () => {
- try {
- const ls = localStorage.getItem('theme')
- if (ls === 'light') return false
- if (ls === 'dark') return true
- return true
- } catch {
- // Match root layout default (dark) if storage is unavailable
- return true
- }
- }
-
- queueMicrotask(() => setIsDark(getTheme()))
-
- // Update when localStorage changes in other tabs
- const onStorage = (e: StorageEvent) => {
- if (e.key === 'theme') setIsDark(getTheme())
- }
- window.addEventListener('storage', onStorage)
+import { useThemeIsDark } from '@/components/theme-preference-provider'
- // Update when we dispatch a local theme-change event (same-tab)
- const onThemeChange = () => setIsDark(getTheme())
- window.addEventListener('theme-change', onThemeChange as EventListener)
-
- return () => {
- window.removeEventListener('storage', onStorage)
- window.removeEventListener('theme-change', onThemeChange as EventListener)
- }
- }, [])
-
- return isDark
+/** Mirrors `` and shared cookie/LS resolution — must run under `ThemePreferenceProvider`. */
+export default function useIsDark() {
+ return useThemeIsDark()
}
diff --git a/LearningPlatform/documentation/ADAPTIVE_LEARNING.md b/LearningPlatform/documentation/ADAPTIVE_LEARNING.md
index 9ffa1a8..9ffe165 100644
--- a/LearningPlatform/documentation/ADAPTIVE_LEARNING.md
+++ b/LearningPlatform/documentation/ADAPTIVE_LEARNING.md
@@ -52,7 +52,7 @@ Tasks are the atomic unit of knowledge assessment. Each task can be assigned one
### 2.2 Flashcards
-Flashcards are a parallel study mechanism: they are **not** lesson blocks. Each card lives in the `public` schema on a **`FlashcardDeck`** (`Flashcard.deckId`). Like tasks, each flashcard can carry multiple **`Tag`** records.
+Flashcards are a parallel study mechanism: they are **not** lesson blocks. Each card lives in the `public` schema on a **`FlashcardDeck`** (`Flashcard.deckId`). Like tasks, each flashcard can carry multiple **`Tag`** records. **Question and answer** fields are **GFM Markdown** with optional **KaTeX** (`$` / `$$`) on the study UI; optional **question/answer images** reference Payload media (see `PLATFORM_FEATURES.md` § *Flashcards and deck hierarchy → Card content*).
**Deck shapes:**
diff --git a/LearningPlatform/documentation/PLATFORM_FEATURES.md b/LearningPlatform/documentation/PLATFORM_FEATURES.md
index a17d21d..91692b9 100644
--- a/LearningPlatform/documentation/PLATFORM_FEATURES.md
+++ b/LearningPlatform/documentation/PLATFORM_FEATURES.md
@@ -141,9 +141,9 @@ Every task can include:
When an S3-compatible bucket is configured (AWS S3, Cloudflare R2, Railway Object Storage, or any compatible API), uploads are pushed to object storage after being written locally for metadata extraction. **If no bucket is configured**, files remain on the server under **`public/media`** (typical for local development).
- Files are uploaded via `POST /api/media/upload`, then registered in the Payload **Media** collection.
-- Files are delivered via **`/api/media/serve/:filename`**: the handler **serves from disk when the file exists**, otherwise (if S3 is configured) **redirects to a signed URL** for the object in the bucket.
+- Files are delivered via **`/api/media/serve/:filename`**: the handler **serves from disk when the file exists**, otherwise (if S3 is configured) **streams the object from the bucket** through the app (same-origin, CSP-friendly) instead of exposing raw signed URLs to the browser.
- Usage is tracked per file so admins can see which lessons and tasks reference a given asset before deletion.
-- Used by image-oriented lesson/task fields (lesson image blocks, task question media, task solution media, and lesson attachments).
+- Used by image-oriented lesson/task fields (lesson image blocks, task question media, task solution media, lesson attachments, and **flashcard** question/answer images).
## Pro Features
@@ -167,6 +167,13 @@ Flashcards are not lesson blocks; they live in the **`public`** schema on **`Fla
- Decks with **no** `courseId` are **standalone** collections (e.g. “JavaScript flashcards”, “Interview prep”).
- The standalone **main** deck may hold **direct** cards (cards whose `deckId` is that main). You can add **subdecks** under the main for finer organisation; cards on subdecks behave like course subdecks.
+### Card content (Markdown, math, images)
+
+Each card stores **question** and **answer** as plain-text **Markdown** (GitHub-flavored), rendered on the student study page (`/dashboard/flashcards/study`) with the same general conventions as lesson theory: headings, lists, tables, fenced code, links, etc.
+
+- **Math:** inline and display **KaTeX** using `$...$` and `$$...$$` delimiters.
+- **Images:** optional **question** and **answer** images are separate from the Markdown body. Admins pick assets from the **Media** library; the API stores `questionImageId` / `answerImageId` (Payload media). Study responses attach resolved **`/api/media/serve/...`** URLs so students do not need admin-only media listing.
+
### APIs and study
- Deck CRUD and listing: **`/api/flashcard-decks`** (admin); deck update by id: **`PATCH /api/flashcard-decks/[id]`** (admin).
diff --git a/LearningPlatform/lib/flashcard-media-urls.ts b/LearningPlatform/lib/flashcard-media-urls.ts
new file mode 100644
index 0000000..7e41a55
--- /dev/null
+++ b/LearningPlatform/lib/flashcard-media-urls.ts
@@ -0,0 +1,130 @@
+import path from 'path'
+import type { Payload } from 'payload'
+import { isResolvablePayloadMediaId } from '@/lib/valid-payload-media-id'
+
+export { isResolvablePayloadMediaId } from '@/lib/valid-payload-media-id'
+
+type MediaDoc = {
+ id?: string | number
+ url?: string | null
+ filename?: string | null
+}
+
+function docToPublicUrl(doc: MediaDoc): string | null {
+ // Prefer our serve URL built from filename so we never return stale hosts, broken
+ // Payload `url` values, or signed URLs that fail under the app CSP for `
`.
+ if (typeof doc.filename === 'string' && doc.filename.trim().length > 0) {
+ const base = path.posix.basename(doc.filename.replace(/\\/g, '/'))
+ if (base && base !== '.' && base !== '..') {
+ return `/api/media/serve/${encodeURIComponent(base)}`
+ }
+ }
+
+ if (typeof doc.url === 'string' && doc.url.trim().length > 0) {
+ const u = doc.url.trim()
+ if (u.startsWith('/api/media/serve/')) return u
+ if (u.startsWith('/media/')) {
+ const base = path.posix.basename(u.replace(/^\/media\//, ''))
+ if (base) return `/api/media/serve/${encodeURIComponent(base)}`
+ }
+ if (u.startsWith('/')) return u
+ try {
+ const parsed = new URL(u, 'http://local.invalid')
+ if (parsed.pathname.startsWith('/media/')) {
+ const base = path.posix.basename(parsed.pathname)
+ if (base) return `/api/media/serve/${encodeURIComponent(base)}`
+ }
+ } catch {
+ /* ignore */
+ }
+ return u
+ }
+
+ return null
+}
+
+async function getPayloadClient(): Promise {
+ const { getPayload } = await import('payload')
+ const config = (await import('@payload-config')).default
+ return getPayload({ config })
+}
+
+/** Build id → public URL for Payload `media` docs (student-safe paths like `/api/media/serve/...`). */
+export async function resolvePayloadMediaUrlMap(ids: (string | null | undefined)[]): Promise