diff --git a/app/(app)/(reader)/transcriptions/[slug]/page.tsx b/app/(app)/(reader)/transcriptions/[slug]/page.tsx index c7641def..a1c8051d 100644 --- a/app/(app)/(reader)/transcriptions/[slug]/page.tsx +++ b/app/(app)/(reader)/transcriptions/[slug]/page.tsx @@ -9,6 +9,7 @@ import { } from "@/lib/transcriptions"; import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og"; import { PageHeader } from "@/components/layout/page-header"; +import { QuizButton } from "@/components/quiz/quiz-button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -95,6 +96,9 @@ export default async function TranscriptionPage({ /> +
+ -
- - +
+
+ {/* A compact sādhana tile beside a wider Vāṇī of the Day: the + streak is a daily habit you already know how to reach, so it + needs only a recognisable mark; the vāṇī changes daily and + earns the room to say what it is. */} + + +
+ {/* Only while something is left to take. */} +
@@ -88,9 +97,17 @@ export default function HomePage() {

{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */} -
- - +
+
+ {/* A compact sādhana tile beside a wider Vāṇī of the Day: the + streak is a daily habit you already know how to reach, so it + needs only a recognisable mark; the vāṇī changes daily and + earns the room to say what it is. */} + + +
+ {/* Only while something is left to take. */} +
diff --git a/app/(app)/quizzes/[id]/page.tsx b/app/(app)/quizzes/[id]/page.tsx new file mode 100644 index 00000000..11e19a89 --- /dev/null +++ b/app/(app)/quizzes/[id]/page.tsx @@ -0,0 +1,306 @@ +"use client"; + +/** + * Taking a quiz: an introduction, then one question at a time, then your + * result. + * + * Each question is fetched from the BFF as you reach it, and the BFF starts + * its clock at that moment — so response times are measured server-side and + * reloading the page doesn't restart them. Correct answers are only revealed + * on the results screen, never while you're still answering. + * + * Tapping an option only selects it; the answer is sent, and becomes final, + * when you press Submit. Multi questions ("select all that apply") take + * several selections. + */ + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { + ArrowLeft, + ArrowRight, + Check, + ClipboardCheck, + Loader2, +} from "lucide-react"; +import { toast } from "sonner"; +import { useAuth } from "@/components/auth/auth-provider"; +import { useAuthGate } from "@/components/auth/auth-gate"; +import { RichText } from "@/lib/rich-text"; +import { + quiz as quizApi, + quizActionLabel, + QuizError, + type QuizCard, + type QuizPlay, + type QuizResult, +} from "@/lib/quiz"; +import { QuizResultView } from "@/components/quiz/quiz-result-view"; +import { cn } from "@/lib/utils"; + +type Stage = + | { kind: "loading" } + | { kind: "intro"; card: QuizCard } + | { kind: "playing"; card: QuizCard; play: QuizPlay } + | { kind: "result"; card: QuizCard; result: QuizResult } + | { kind: "missing" }; + +export default function QuizPage() { + const { id } = useParams<{ id: string }>(); + const { user } = useAuth(); + const { requireAuth } = useAuthGate(); + const [stage, setStage] = useState({ kind: "loading" }); + const [selected, setSelected] = useState([]); + const [submitting, setSubmitting] = useState(false); + + // Every new question starts with nothing selected. + const goTo = (card: QuizCard, play: QuizPlay) => { + setSelected([]); + setStage({ kind: "playing", card, play }); + }; + + const toggle = (play: QuizPlay, optionId: string) => { + if (submitting) return; + setSelected((cur) => + play.question?.multi + ? cur.includes(optionId) + ? cur.filter((x) => x !== optionId) + : [...cur, optionId] + : [optionId], + ); + }; + + const showResult = useCallback( + async (card: QuizCard) => { + const result = await quizApi.result(id); + setStage({ kind: "result", card, result }); + }, + [id], + ); + + useEffect(() => { + let cancelled = false; + quizApi + .get(id) + .then(async (card) => { + if (cancelled) return; + if (card.my.state === "completed") await showResult(card); + else setStage({ kind: "intro", card }); + }) + .catch(() => !cancelled && setStage({ kind: "missing" })); + return () => { + cancelled = true; + }; + // user?.id: progress appears once you sign in. + }, [id, user?.id, showResult]); + + const begin = async (card: QuizCard) => { + if (!(await requireAuth())) return; + try { + const play = await quizApi.start(id); + if (play.done) await showResult(card); + else goTo(card, play); + } catch (err) { + toast.error( + err instanceof QuizError ? err.message : "Could not start the quiz", + ); + } + }; + + const submit = async (card: QuizCard, play: QuizPlay) => { + if (!play.question || submitting || selected.length === 0) return; + setSubmitting(true); + try { + const next = await quizApi.answer(id, play.question.id, selected); + if (next.done) await showResult(card); + else goTo(card, next); + } catch (err) { + // Most likely the question was already answered in another tab: pick up + // wherever the attempt actually is rather than leaving this one stuck. + toast.error( + err instanceof QuizError ? err.message : "Could not save that answer", + ); + try { + const resumed = await quizApi.start(id); + if (resumed.done) await showResult(card); + else goTo(card, resumed); + } catch { + /* the toast already said something went wrong */ + } + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + All quizzes + + + {stage.kind === "loading" && ( +

Loading…

+ )} + + {stage.kind === "missing" && ( +

+ This quiz isn't available. +

+ )} + + {(stage.kind === "intro" || + stage.kind === "playing" || + stage.kind === "result") && ( +
+

+ {stage.card.title} +

+ {stage.card.target_path && ( + + {stage.card.target_title || stage.card.target_path} + + )} +
+ )} + + {stage.kind === "intro" && ( +
+ {stage.card.description && ( +
+ +
+ )} +

+ {stage.card.question_count}{" "} + {stage.card.question_count === 1 ? "question" : "questions"}, one + attempt. Each answer is final once submitted, and your time on each + question is recorded. +

+ {stage.card.my.state === "in_progress" && ( +

+ You've answered {stage.card.my.answered} of{" "} + {stage.card.my.total}. +

+ )} + +
+ )} + + {stage.kind === "playing" && stage.play.question && ( +
+
+
+ + Question {stage.play.answered + 1} of {stage.play.total} + +
+
+
+
+
+ +
+

+ +

+

+ {stage.play.question.multi + ? "Select all that apply" + : "Select one answer"} +

+
+ +
    + {stage.play.question.options.map((o) => { + const on = selected.includes(o.id); + const multi = stage.play.question?.multi; + return ( +
  • + +
  • + ); + })} +
+ +
+ +
+
+ )} + + {stage.kind === "result" && } +
+ ); +} diff --git a/app/(app)/quizzes/page.tsx b/app/(app)/quizzes/page.tsx new file mode 100644 index 00000000..89919a4c --- /dev/null +++ b/app/(app)/quizzes/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +/** + * /quizzes — every published quiz, with where you are in each. + * + * Reached from the Quizzes card on the search page. Quizzes are deliberately + * not in search results: they're something to do, not something to look up. + */ + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { ClipboardCheck } from "lucide-react"; +import { useAuth } from "@/components/auth/auth-provider"; +import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz"; +import { cn } from "@/lib/utils"; + +const KIND: Record = { + chapter: "Book chapter", + lecture: "Lecture", + conversation: "Conversation", + standalone: "Quiz", +}; + +type Filter = "all" | "todo" | "done"; + +export default function QuizzesPage() { + const { user } = useAuth(); + const [list, setList] = useState(null); + const [filter, setFilter] = useState("all"); + + useEffect(() => { + let cancelled = false; + quiz + .list() + .then((l) => !cancelled && setList(l)) + .catch(() => !cancelled && setList([])); + return () => { + cancelled = true; + }; + }, [user?.id]); + + const shown = useMemo( + () => + (list ?? []).filter((q) => + filter === "all" + ? true + : filter === "done" + ? q.my.state === "completed" + : q.my.state !== "completed", + ), + [list, filter], + ); + + return ( +
+
+

+ Quizzes +

+

+ Test what you've read and heard. Each quiz can be taken once, and + only you can see your results. +

+
+ + {user && ( +
+ {( + [ + ["all", "All"], + ["todo", "To do"], + ["done", "Finished"], + ] as const + ).map(([k, label]) => ( + + ))} +
+ )} + + {list === null ? ( +

Loading…

+ ) : shown.length === 0 ? ( +

+ {list.length === 0 + ? "No quizzes yet — check back soon." + : "Nothing here."} +

+ ) : ( +
    + {shown.map((q) => { + const done = q.my.state === "completed"; + return ( +
  • + +

    + {KIND[q.target_kind]} +

    +

    + {q.title} +

    + {q.target_title && ( +

    + {q.target_title} +

    + )} +
    + + {q.question_count}{" "} + {q.question_count === 1 ? "question" : "questions"} + + + + {quizActionLabel(q.my.state)} + {done && + q.my.score !== undefined && + ` · ${q.my.score}/${q.my.total}`} + +
    + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/components/knowledgebase-index.tsx b/components/knowledgebase-index.tsx index efbb6296..121f2746 100644 --- a/components/knowledgebase-index.tsx +++ b/components/knowledgebase-index.tsx @@ -5,7 +5,8 @@ * library. * * Two sections, top to bottom: - * 1. Start here — three primary entry points (Books, Letters, Lectures) + * 1. Start here — a Quizzes tile, then three primary entry points + * (Books, Letters, Lectures), all one width * rendered as full-width feature tiles with longer copy. * 2. Explore — a six-card asymmetric grid for the remaining sections. * @@ -26,6 +27,7 @@ import { PenLine, Users, } from "lucide-react"; +import { QuizzesCTA } from "@/components/quiz/quizzes-cta"; type Feature = { href: string; @@ -112,7 +114,11 @@ export function KnowledgebaseIndex() {
-
+ {/* One grid so all four share a width. Quizzes lead: something to + do, ahead of the three ways in to read. Absent until one is + published, leaving the original three across. */} +
+ {FEATURED.map((f) => ( ))} @@ -145,7 +151,7 @@ function FeaturedTile({ feature }: { feature: Feature }) {
-

+

{feature.label}

diff --git a/components/layout/page-header.tsx b/components/layout/page-header.tsx index f82ac480..feb75d12 100644 --- a/components/layout/page-header.tsx +++ b/components/layout/page-header.tsx @@ -28,6 +28,7 @@ import { useRouter, usePathname } from "next/navigation"; import { Check, Link2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { SaveOfflineButton } from "@/components/layout/save-offline-button"; +import { QuizButton } from "@/components/quiz/quiz-button"; import type { OfflineDescriptor } from "@/lib/offline"; const SEARCH_RETURN_KEY = "spsays_from_search"; @@ -71,6 +72,7 @@ export function PageHeader({ bare = false, sticky = false, offline, + quiz = true, }: { children: React.ReactNode; /** Drop the bordered chrome — useful for pages that want flush title text. */ @@ -89,6 +91,13 @@ export function PageHeader({ * visible gap. */ sticky?: boolean; + /** + * Show this page's quiz button (if it has a published quiz) under the + * title. On by default for content pages; a page that places the button + * itself — the transcript, beside its audio player — turns this off so it + * never appears twice. + */ + quiz?: boolean; className?: string; }) { const router = useRouter(); @@ -216,6 +225,9 @@ export function PageHeader({ {/* Page-level controls, anchored at the header's bottom. */}
+ {/* First, so it reads as belonging to the title above it. Renders + nothing on pages without a published quiz. */} + {quiz && isContentPath(pathname) ? : null}