Compare commits
12
Commits
6a6143ceb2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f855699e72 | ||
|
|
52cd48b12e | ||
|
|
0a23ab59f7 | ||
|
|
3af4e1bcd3 | ||
|
|
fed12b0d46 | ||
|
|
d64485daa4 | ||
|
|
0968b6046d | ||
|
|
8dd72de359 | ||
|
|
8537eb6ce2 | ||
|
|
09dc734613 | ||
|
|
5832125ab2 | ||
|
|
0e2e0d7300 |
@@ -173,13 +173,14 @@ export default async function TranscriptionPage({
|
|||||||
{/* Play Audio button */}
|
{/* Play Audio button */}
|
||||||
{t.audioUrl && (
|
{t.audioUrl && (
|
||||||
<div className="w-full md:w-auto flex flex-wrap items-center justify-end gap-2">
|
<div className="w-full md:w-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
<QuizButton path={`/transcriptions/${t.slug}`} className="py-2" />
|
<QuizButton path={`/transcriptions/${t.slug}`} size="lg" />
|
||||||
<PlayAudioButton
|
<PlayAudioButton
|
||||||
audioUrl={t.audioUrl}
|
audioUrl={t.audioUrl}
|
||||||
title={t.name}
|
title={t.name}
|
||||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
sourceUrl={`/transcriptions/${t.slug}`}
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
type={t.type}
|
type={t.type}
|
||||||
|
fallbackYear={t.year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
type AudioTrack,
|
type AudioTrack,
|
||||||
AudioWaveAnimation,
|
AudioWaveAnimation,
|
||||||
} from "@/components/audio-player";
|
} from "@/components/audio-player";
|
||||||
|
import {
|
||||||
|
rememberedQueue,
|
||||||
|
useTranscriptQueue,
|
||||||
|
yearQueue,
|
||||||
|
} from "@/components/audio-player/transcript-queue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
|
|
||||||
@@ -17,6 +22,11 @@ type PlayAudioButtonProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Compact icon-only variant for list items */
|
/** Compact icon-only variant for list items */
|
||||||
variant?: "default" | "compact";
|
variant?: "default" | "compact";
|
||||||
|
/**
|
||||||
|
* On a lecture's own page: its year, queued as a fallback when the lecture
|
||||||
|
* wasn't opened from a list (search, a shared link…).
|
||||||
|
*/
|
||||||
|
fallbackYear?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PlayAudioButton({
|
export function PlayAudioButton({
|
||||||
@@ -27,8 +37,18 @@ export function PlayAudioButton({
|
|||||||
type,
|
type,
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
fallbackYear,
|
||||||
}: PlayAudioButtonProps) {
|
}: PlayAudioButtonProps) {
|
||||||
const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer();
|
const {
|
||||||
|
currentTrack,
|
||||||
|
isPlaying,
|
||||||
|
play,
|
||||||
|
playPlaylist,
|
||||||
|
attachQueue,
|
||||||
|
togglePlayPause,
|
||||||
|
} = useAudioPlayer();
|
||||||
|
// The list this button sits in (a transcription list row), if any.
|
||||||
|
const listQueue = useTranscriptQueue();
|
||||||
|
|
||||||
const isThisTrack = currentTrack?.url === audioUrl;
|
const isThisTrack = currentTrack?.url === audioUrl;
|
||||||
const isThisPlaying = isThisTrack && isPlaying;
|
const isThisPlaying = isThisTrack && isPlaying;
|
||||||
@@ -44,7 +64,25 @@ export function PlayAudioButton({
|
|||||||
sourceUrl,
|
sourceUrl,
|
||||||
type: type ?? "lecture",
|
type: type ?? "lecture",
|
||||||
};
|
};
|
||||||
|
// Queue the list this recording was found in, so Next / Previous
|
||||||
|
// follow it: the list the button sits in, or — on a lecture's own
|
||||||
|
// page — the list it was opened from.
|
||||||
|
const queue =
|
||||||
|
listQueue ??
|
||||||
|
rememberedQueue(sourceUrl.replace(/^\/transcriptions\//, ""));
|
||||||
|
const at = queue?.findIndex((q) => q.url === audioUrl) ?? -1;
|
||||||
|
if (queue && at >= 0 && queue.length > 1) {
|
||||||
|
playPlaylist(queue, at);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No list to follow: start this lecture now — audio must begin inside
|
||||||
|
// the tap, or iOS blocks it — then queue its year once that loads.
|
||||||
play(track);
|
play(track);
|
||||||
|
if (fallbackYear) {
|
||||||
|
void yearQueue(fallbackYear).then((tracks) => {
|
||||||
|
if (tracks && tracks.length > 1) attachQueue(tracks);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronLeft, ChevronRight, Menu, Calendar, Clock, MapPin } from "lucide-react";
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Menu,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
|
||||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
||||||
import {
|
import {
|
||||||
@@ -417,6 +425,13 @@ export function TranscriptionBookBrowser({
|
|||||||
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Next / Previous run through every chapter's lectures in display order,
|
||||||
|
// so playback carries on into the next chapter.
|
||||||
|
const queueItems = useMemo(
|
||||||
|
() => flat.flatMap((f) => f.chapter.transcriptions),
|
||||||
|
[flat],
|
||||||
|
);
|
||||||
|
|
||||||
const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
|
const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
|
||||||
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
||||||
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
||||||
@@ -463,6 +478,7 @@ export function TranscriptionBookBrowser({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
|
<TranscriptQueue items={queueItems}>
|
||||||
{/* Top navigation */}
|
{/* Top navigation */}
|
||||||
<ChapterNav
|
<ChapterNav
|
||||||
prev={prev}
|
prev={prev}
|
||||||
@@ -499,6 +515,7 @@ export function TranscriptionBookBrowser({
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
drawerTitle={title}
|
drawerTitle={title}
|
||||||
/>
|
/>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader } from "@/components/layout/page-header";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Calendar, Clock } from "lucide-react";
|
import { Calendar, Clock } from "lucide-react";
|
||||||
|
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
|
||||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -55,6 +56,8 @@ export default async function TranscriptionsByPlacePage({
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
{/* Next / Previous in the player follow this list. */}
|
||||||
|
<TranscriptQueue items={transcriptions}>
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
{transcriptions.map((t) => (
|
{transcriptions.map((t) => (
|
||||||
@@ -107,6 +110,7 @@ export default async function TranscriptionsByPlacePage({
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
|
||||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -481,6 +482,16 @@ export function TranscriptionTypeBrowser({
|
|||||||
[activeGroup],
|
[activeGroup],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Next / Previous run through the whole list in display order — year by
|
||||||
|
// year, month by month — so playback carries on past the year on screen.
|
||||||
|
const queueItems = useMemo(
|
||||||
|
() =>
|
||||||
|
yearGroups.flatMap((yg) =>
|
||||||
|
buildMonthGroups(yg.transcriptions).flatMap((mg) => mg.transcriptions),
|
||||||
|
),
|
||||||
|
[yearGroups],
|
||||||
|
);
|
||||||
|
|
||||||
const activeYearLabel = activeGroup?.label ?? "Unknown";
|
const activeYearLabel = activeGroup?.label ?? "Unknown";
|
||||||
const drawerTitle = typeLabel;
|
const drawerTitle = typeLabel;
|
||||||
|
|
||||||
@@ -520,6 +531,7 @@ export function TranscriptionTypeBrowser({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
|
<TranscriptQueue items={queueItems}>
|
||||||
{/* Top navigation */}
|
{/* Top navigation */}
|
||||||
<YearNav
|
<YearNav
|
||||||
prev={prev}
|
prev={prev}
|
||||||
@@ -560,6 +572,7 @@ export function TranscriptionTypeBrowser({
|
|||||||
onSelect={handleYearSelect}
|
onSelect={handleYearSelect}
|
||||||
drawerTitle={drawerTitle}
|
drawerTitle={drawerTitle}
|
||||||
/>
|
/>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
|
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
|
||||||
|
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
|
||||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
type TranscriptionItem = {
|
type TranscriptionItem = {
|
||||||
@@ -169,6 +170,12 @@ export function TranscriptionYearBrowser({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [transcriptions]);
|
}, [transcriptions]);
|
||||||
|
|
||||||
|
// Display order — month by month — is the order Next / Previous play in.
|
||||||
|
const queueItems = useMemo(
|
||||||
|
() => monthGroups.flatMap((g) => g.items),
|
||||||
|
[monthGroups],
|
||||||
|
);
|
||||||
|
|
||||||
const lectureCount = transcriptions.filter(
|
const lectureCount = transcriptions.filter(
|
||||||
(t) => t.type === "lecture",
|
(t) => t.type === "lecture",
|
||||||
).length;
|
).length;
|
||||||
@@ -202,6 +209,7 @@ export function TranscriptionYearBrowser({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<TranscriptQueue items={queueItems}>
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
{monthGroups.map((mg, i) => (
|
{monthGroups.map((mg, i) => (
|
||||||
@@ -215,6 +223,7 @@ export function TranscriptionYearBrowser({
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TranscriptQueue>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+43
-14
@@ -9,23 +9,31 @@
|
|||||||
* Authed visitors don't see the contact-email field — the BFF reads
|
* Authed visitors don't see the contact-email field — the BFF reads
|
||||||
* their account email from the session cookie. Anonymous visitors get
|
* their account email from the session cookie. Anonymous visitors get
|
||||||
* the field and must fill it in so we have a reply path.
|
* the field and must fill it in so we have a reply path.
|
||||||
|
*
|
||||||
|
* Categories are managed in the admin panel and loaded from the BFF. If
|
||||||
|
* that request fails the form falls back to the original four, so it can
|
||||||
|
* always be submitted.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
|
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { feedback as feedbackAPI, SocialError } from "@/lib/social";
|
import {
|
||||||
|
feedback as feedbackAPI,
|
||||||
|
SocialError,
|
||||||
|
type FeedbackCategory,
|
||||||
|
} from "@/lib/social";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
type Category = "bug" | "feature" | "general" | "other";
|
// Used only if the categories can't be loaded — the original four, which
|
||||||
|
// the BFF seeds and keeps accepting unless an admin archives them.
|
||||||
const CATEGORIES: { value: Category; label: string; description: string }[] = [
|
const FALLBACK_CATEGORIES: FeedbackCategory[] = [
|
||||||
{ value: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
|
{ key: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
|
||||||
{ value: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
|
{ key: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
|
||||||
{ value: "feature", label: "Feature request", description: "An idea for something new on the site." },
|
{ key: "feature", label: "Feature request", description: "An idea for something new on the site." },
|
||||||
{ value: "other", label: "Other", description: "Doesn't fit the above? Use this." },
|
{ key: "other", label: "Other", description: "Doesn't fit the above? Use this." },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function FeedbackPage() {
|
export default function FeedbackPage() {
|
||||||
@@ -33,7 +41,28 @@ export default function FeedbackPage() {
|
|||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const authed = !loading && !!user;
|
const authed = !loading && !!user;
|
||||||
|
|
||||||
const [category, setCategory] = useState<Category>("general");
|
const [categories, setCategories] =
|
||||||
|
useState<FeedbackCategory[]>(FALLBACK_CATEGORIES);
|
||||||
|
// Empty until chosen: the first category on the form.
|
||||||
|
const [picked, setPicked] = useState("");
|
||||||
|
const category = categories.some((c) => c.key === picked)
|
||||||
|
? picked
|
||||||
|
: (categories[0]?.key ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
feedbackAPI
|
||||||
|
.categories()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled && list?.length) setCategories(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* keep the fallback list */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [contactEmail, setContactEmail] = useState("");
|
const [contactEmail, setContactEmail] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -72,7 +101,7 @@ export default function FeedbackPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = CATEGORIES.find((c) => c.value === category);
|
const selected = categories.find((c) => c.key === category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
|
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
|
||||||
@@ -111,12 +140,12 @@ export default function FeedbackPage() {
|
|||||||
<select
|
<select
|
||||||
id="category"
|
id="category"
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value as Category)}
|
onChange={(e) => setPicked(e.target.value)}
|
||||||
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((c) => (
|
{categories.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.key} value={c.key}>{c.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
+24
-300
@@ -1,306 +1,30 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import { bff } from "@/lib/bff";
|
||||||
|
import { ogForQuiz } from "@/lib/og";
|
||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
import { QuizPlayer } from "./quiz-player";
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Taking a quiz: an introduction, then one question at a time, then your
|
* Link previews name the quiz. The quiz's card is the BFF's one public quiz
|
||||||
* result.
|
* endpoint — title, description and what it's attached to, never questions —
|
||||||
*
|
* so a shared link unfurls even though taking the quiz needs an account.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
import { useCallback, useEffect, useState } from "react";
|
const { id } = await params;
|
||||||
import Link from "next/link";
|
const card = await bff<QuizCard>(`/quizzes/${encodeURIComponent(id)}`, {
|
||||||
import { useParams } from "next/navigation";
|
revalidate: 300,
|
||||||
import {
|
}).catch(() => null);
|
||||||
ArrowLeft,
|
if (!card) return { title: "Quiz" };
|
||||||
ArrowRight,
|
return {
|
||||||
Check,
|
title: card.title,
|
||||||
ClipboardCheck,
|
description: `A quiz on Prabhupāda Says — ${card.question_count} question${card.question_count === 1 ? "" : "s"}.`,
|
||||||
Loader2,
|
...ogForQuiz(card),
|
||||||
} 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<Stage>({ kind: "loading" });
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
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) => {
|
export default async function QuizPage({ params }: Props) {
|
||||||
if (submitting) return;
|
const { id } = await params;
|
||||||
setSelected((cur) =>
|
return <QuizPlayer id={id} />;
|
||||||
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 (
|
|
||||||
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
|
||||||
<Link
|
|
||||||
href="/quizzes"
|
|
||||||
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" /> All quizzes
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{stage.kind === "loading" && (
|
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.kind === "missing" && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This quiz isn't available.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(stage.kind === "intro" ||
|
|
||||||
stage.kind === "playing" ||
|
|
||||||
stage.kind === "result") && (
|
|
||||||
<header className="mb-6">
|
|
||||||
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
|
|
||||||
{stage.card.title}
|
|
||||||
</h1>
|
|
||||||
{stage.card.target_path && (
|
|
||||||
<Link
|
|
||||||
href={stage.card.target_path}
|
|
||||||
className="mt-1 inline-block text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{stage.card.target_title || stage.card.target_path}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.kind === "intro" && (
|
|
||||||
<section className="space-y-5 rounded-2xl border border-border bg-card p-5">
|
|
||||||
{stage.card.description && (
|
|
||||||
<div className="text-sm leading-relaxed text-foreground">
|
|
||||||
<RichText text={stage.card.description} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{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.
|
|
||||||
</p>
|
|
||||||
{stage.card.my.state === "in_progress" && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
You've answered {stage.card.my.answered} of{" "}
|
|
||||||
{stage.card.my.total}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void begin(stage.card)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 dark:text-white"
|
|
||||||
>
|
|
||||||
<ClipboardCheck className="size-4" />
|
|
||||||
{user
|
|
||||||
? quizActionLabel(stage.card.my.state)
|
|
||||||
: "Sign in to take the quiz"}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.kind === "playing" && stage.play.question && (
|
|
||||||
<section className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
Question {stage.play.answered + 1} of {stage.play.total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${(stage.play.answered / stage.play.total) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-lg leading-relaxed text-foreground">
|
|
||||||
<RichText text={stage.play.question.prompt} />
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{stage.play.question.multi
|
|
||||||
? "Select all that apply"
|
|
||||||
: "Select one answer"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul
|
|
||||||
className="space-y-2"
|
|
||||||
role={stage.play.question.multi ? "group" : "radiogroup"}
|
|
||||||
>
|
|
||||||
{stage.play.question.options.map((o) => {
|
|
||||||
const on = selected.includes(o.id);
|
|
||||||
const multi = stage.play.question?.multi;
|
|
||||||
return (
|
|
||||||
<li key={o.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role={multi ? "checkbox" : "radio"}
|
|
||||||
aria-checked={on}
|
|
||||||
disabled={submitting}
|
|
||||||
onClick={() => toggle(stage.play, o.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
|
|
||||||
on
|
|
||||||
? "border-primary bg-primary/10"
|
|
||||||
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
|
|
||||||
"disabled:cursor-default",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Radio dot for one answer, checkbox for several. */}
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"flex size-4 shrink-0 items-center justify-center border transition-colors",
|
|
||||||
multi ? "rounded" : "rounded-full",
|
|
||||||
on
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-muted-foreground/40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{on &&
|
|
||||||
(multi ? (
|
|
||||||
<Check className="size-3" strokeWidth={3} />
|
|
||||||
) : (
|
|
||||||
<span className="size-1.5 rounded-full bg-primary-foreground" />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<RichText text={o.body} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={selected.length === 0 || submitting}
|
|
||||||
onClick={() => void submit(stage.card, stage.play)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 dark:text-white"
|
|
||||||
>
|
|
||||||
{submitting && <Loader2 className="size-4 animate-spin" />}
|
|
||||||
{stage.play.answered + 1 === stage.play.total
|
|
||||||
? "Submit and finish"
|
|
||||||
: "Next question"}
|
|
||||||
{!submitting && stage.play.answered + 1 < stage.play.total && (
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.kind === "result" && <QuizResultView result={stage.result} />}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"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.
|
||||||
|
*
|
||||||
|
* Signed in only: visitors get the sign-in modal over the page, and home if
|
||||||
|
* they dismiss it. Nothing about the quiz loads until they're signed in.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
ClipboardCheck,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
|
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
|
||||||
|
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 { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
|
||||||
|
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 function QuizPlayer({ id }: { id: string }) {
|
||||||
|
const { user } = useSignedInPage();
|
||||||
|
const userId = user?.id;
|
||||||
|
const { requireAuth } = useAuthGate();
|
||||||
|
const [stage, setStage] = useState<Stage>({ kind: "loading" });
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
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(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
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, userId, 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 (
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/quizzes"
|
||||||
|
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" /> All quizzes
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{stage.kind === "loading" && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "missing" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This quiz isn't available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(stage.kind === "intro" ||
|
||||||
|
stage.kind === "playing" ||
|
||||||
|
stage.kind === "result") && (
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
|
||||||
|
{stage.card.title}
|
||||||
|
</h1>
|
||||||
|
{stage.card.target_path && (
|
||||||
|
<Link
|
||||||
|
href={stage.card.target_path}
|
||||||
|
className="mt-1 inline-block text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{stage.card.target_title || stage.card.target_path}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{/* Shares the quiz itself; the link unfurls with its own OG card. */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<CopyPageLinkButton label="Copy quiz link" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "intro" && (
|
||||||
|
<section className="space-y-5 rounded-2xl border border-border bg-card p-5">
|
||||||
|
{stage.card.description && (
|
||||||
|
<div className="text-sm leading-relaxed text-foreground">
|
||||||
|
<RichText text={stage.card.description} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
{stage.card.my.state === "in_progress" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You've answered {stage.card.my.answered} of{" "}
|
||||||
|
{stage.card.my.total}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void begin(stage.card)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 dark:text-white"
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-4" />
|
||||||
|
{user
|
||||||
|
? quizActionLabel(stage.card.my.state)
|
||||||
|
: "Sign in to take the quiz"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "playing" && stage.play.question && (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Question {stage.play.answered + 1} of {stage.play.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${(stage.play.answered / stage.play.total) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-lg leading-relaxed text-foreground">
|
||||||
|
<RichText text={stage.play.question.prompt} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{stage.play.question.multi
|
||||||
|
? "Select all that apply"
|
||||||
|
: "Select one answer"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
className="space-y-2"
|
||||||
|
role={stage.play.question.multi ? "group" : "radiogroup"}
|
||||||
|
>
|
||||||
|
{stage.play.question.options.map((o) => {
|
||||||
|
const on = selected.includes(o.id);
|
||||||
|
const multi = stage.play.question?.multi;
|
||||||
|
return (
|
||||||
|
<li key={o.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role={multi ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => toggle(stage.play, o.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
|
||||||
|
on
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
|
||||||
|
"disabled:cursor-default",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Radio dot for one answer, checkbox for several. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center border transition-colors",
|
||||||
|
multi ? "rounded" : "rounded-full",
|
||||||
|
on
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{on &&
|
||||||
|
(multi ? (
|
||||||
|
<Check className="size-3" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<span className="size-1.5 rounded-full bg-primary-foreground" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<RichText text={o.body} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selected.length === 0 || submitting}
|
||||||
|
onClick={() => void submit(stage.card, stage.play)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 dark:text-white"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{stage.play.answered + 1 === stage.play.total
|
||||||
|
? "Submit and finish"
|
||||||
|
: "Next question"}
|
||||||
|
{!submitting && stage.play.answered + 1 < stage.play.total && (
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "result" && <QuizResultView result={stage.result} />}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-140
@@ -1,147 +1,21 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { ogForQuizzes } from "@/lib/og";
|
||||||
|
import { QuizzesList } from "./quizzes-list";
|
||||||
|
|
||||||
/**
|
export const metadata: Metadata = {
|
||||||
* /quizzes — every published quiz, with where you are in each.
|
title: "Quizzes",
|
||||||
*
|
description:
|
||||||
* Reached from the Quizzes card on the search page. Quizzes are deliberately
|
"Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.",
|
||||||
* not in search results: they're something to do, not something to look up.
|
...ogForQuizzes(),
|
||||||
*/
|
|
||||||
|
|
||||||
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<QuizCard["target_kind"], string> = {
|
|
||||||
chapter: "Book chapter",
|
|
||||||
lecture: "Lecture",
|
|
||||||
conversation: "Conversation",
|
|
||||||
standalone: "Quiz",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Filter = "all" | "todo" | "done";
|
|
||||||
|
|
||||||
export default function QuizzesPage() {
|
export default function QuizzesPage() {
|
||||||
const { user } = useAuth();
|
// The list reads its tab and category from the URL (useSearchParams),
|
||||||
const [list, setList] = useState<QuizCard[] | null>(null);
|
// which needs a Suspense boundary to render.
|
||||||
const [filter, setFilter] = useState<Filter>("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 (
|
return (
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
<Suspense fallback={null}>
|
||||||
<header className="mb-6">
|
<QuizzesList />
|
||||||
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground sm:text-3xl">
|
</Suspense>
|
||||||
Quizzes
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Test what you've read and heard. Each quiz can be taken once, and
|
|
||||||
only you can see your results.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{user && (
|
|
||||||
<div className="mb-4 flex gap-1">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["all", "All"],
|
|
||||||
["todo", "To do"],
|
|
||||||
["done", "Finished"],
|
|
||||||
] as const
|
|
||||||
).map(([k, label]) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilter(k)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-3 py-1 text-sm transition-colors",
|
|
||||||
filter === k
|
|
||||||
? "bg-primary text-primary-foreground dark:text-white"
|
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{list === null ? (
|
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
||||||
) : shown.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{list.length === 0
|
|
||||||
? "No quizzes yet — check back soon."
|
|
||||||
: "Nothing here."}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{shown.map((q) => {
|
|
||||||
const done = q.my.state === "completed";
|
|
||||||
return (
|
|
||||||
<li key={q.id}>
|
|
||||||
<Link
|
|
||||||
href={`/quizzes/${q.id}`}
|
|
||||||
className="flex h-full flex-col rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium text-primary">
|
|
||||||
{KIND[q.target_kind]}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-semibold leading-snug text-foreground">
|
|
||||||
{q.title}
|
|
||||||
</p>
|
|
||||||
{q.target_title && (
|
|
||||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
|
||||||
{q.target_title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{q.question_count}{" "}
|
|
||||||
{q.question_count === 1 ? "question" : "questions"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 font-medium",
|
|
||||||
done ? "text-muted-foreground" : "text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ClipboardCheck className="size-3.5" />
|
|
||||||
{quizActionLabel(q.my.state)}
|
|
||||||
{done &&
|
|
||||||
q.my.score !== undefined &&
|
|
||||||
` · ${q.my.score}/${q.my.total}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"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.
|
||||||
|
* Signed in only: visitors get the sign-in modal, and home if they dismiss it.
|
||||||
|
*
|
||||||
|
* The tab (?tab=todo) and, under All, the category you've drilled into
|
||||||
|
* (?in=books&book=bg) both live in the URL, so a refresh keeps your place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
|
||||||
|
import { quiz, type QuizCard } from "@/lib/quiz";
|
||||||
|
import { QuizGrid } from "@/components/quiz/quiz-grid";
|
||||||
|
import { QuizBrowser } from "@/components/quiz/quiz-browser";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Filter = "all" | "todo" | "done";
|
||||||
|
const FILTERS: readonly Filter[] = ["all", "todo", "done"];
|
||||||
|
|
||||||
|
export function QuizzesList() {
|
||||||
|
const { user } = useSignedInPage();
|
||||||
|
const userId = user?.id;
|
||||||
|
const [list, setList] = useState<QuizCard[] | null>(null);
|
||||||
|
const params = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const tabParam = params.get("tab") as Filter | null;
|
||||||
|
const filter: Filter =
|
||||||
|
tabParam && FILTERS.includes(tabParam) ? tabParam : "all";
|
||||||
|
// Switching tab starts that tab at its top; All is the bare URL.
|
||||||
|
const setFilter = (f: Filter) =>
|
||||||
|
router.replace(f === "all" ? pathname : `${pathname}?tab=${f}`, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
quiz
|
||||||
|
.list()
|
||||||
|
.then((l) => !cancelled && setList(l))
|
||||||
|
.catch(() => !cancelled && setList([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() =>
|
||||||
|
(list ?? []).filter((q) =>
|
||||||
|
filter === "all"
|
||||||
|
? true
|
||||||
|
: filter === "done"
|
||||||
|
? q.my.state === "completed"
|
||||||
|
: // To do: started but not finished. Untouched quizzes live
|
||||||
|
// under All.
|
||||||
|
q.my.state === "in_progress",
|
||||||
|
),
|
||||||
|
[list, filter],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground sm:text-3xl">
|
||||||
|
Quizzes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Test what you've read and heard. Each quiz can be taken once, and
|
||||||
|
only you can see your results.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
// Phones: three equal, thumb-sized tabs across the width. From sm up
|
||||||
|
// they shrink back to compact pills.
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:flex sm:gap-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["all", "All"],
|
||||||
|
["todo", "To do"],
|
||||||
|
["done", "Finished"],
|
||||||
|
] as const
|
||||||
|
).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(k)}
|
||||||
|
className={cn(
|
||||||
|
"min-h-10 rounded-full px-4 py-2 text-[15px] font-medium transition-colors sm:min-h-0 sm:px-3 sm:py-1 sm:text-sm sm:font-normal",
|
||||||
|
filter === k
|
||||||
|
? "bg-primary text-primary-foreground dark:text-white"
|
||||||
|
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground sm:bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!user ? null : list === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{list.length === 0
|
||||||
|
? "No quizzes yet — check back soon."
|
||||||
|
: "Nothing here."}
|
||||||
|
</p>
|
||||||
|
) : filter !== "all" ? (
|
||||||
|
<QuizGrid quizzes={shown} />
|
||||||
|
) : (
|
||||||
|
<QuizBrowser
|
||||||
|
quizzes={shown}
|
||||||
|
path={{
|
||||||
|
in: params.get("in"),
|
||||||
|
book: params.get("book"),
|
||||||
|
year: params.get("year"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -152,7 +152,9 @@ export default function FeedbacksPage() {
|
|||||||
function FeedbackCard({ feedback: f }: { feedback: Feedback }) {
|
function FeedbackCard({ feedback: f }: { feedback: Feedback }) {
|
||||||
const status = statusOf(f);
|
const status = statusOf(f);
|
||||||
const meta = STATUS_META[status];
|
const meta = STATUS_META[status];
|
||||||
const category = CATEGORY_LABEL[f.category] ?? f.category;
|
// The server's label tracks renames in the admin; the map covers an older
|
||||||
|
// BFF that doesn't send one yet.
|
||||||
|
const category = f.category_label || CATEGORY_LABEL[f.category] || f.category;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-xl border border-border bg-card p-4 sm:p-5">
|
<li className="rounded-xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ type AudioPlayerState = {
|
|||||||
type AudioPlayerActions = {
|
type AudioPlayerActions = {
|
||||||
play: (track: AudioTrack) => void;
|
play: (track: AudioTrack) => void;
|
||||||
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
|
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
|
||||||
|
/**
|
||||||
|
* Gives the track that's already playing a queue to sit in, without
|
||||||
|
* restarting it — for a queue that arrives after playback began. Ignored
|
||||||
|
* if the current track isn't in it (the user moved on meanwhile).
|
||||||
|
*/
|
||||||
|
attachQueue: (tracks: AudioTrack[]) => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
resume: () => void;
|
resume: () => void;
|
||||||
togglePlayPause: () => void;
|
togglePlayPause: () => void;
|
||||||
@@ -469,6 +475,14 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
[saveCurrent],
|
[saveCurrent],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const attachQueue = useCallback((tracks: AudioTrack[]) => {
|
||||||
|
const current = currentTrackRef.current;
|
||||||
|
const idx = current ? tracks.findIndex((t) => t.url === current.url) : -1;
|
||||||
|
if (idx < 0) return;
|
||||||
|
setPlaylist(tracks);
|
||||||
|
setCurrentIndex(idx);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
saveCurrent();
|
saveCurrent();
|
||||||
@@ -630,6 +644,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
currentIndex,
|
currentIndex,
|
||||||
play,
|
play,
|
||||||
playPlaylist,
|
playPlaylist,
|
||||||
|
attachQueue,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayPause,
|
togglePlayPause,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TranscriptQueue — makes the audio player's Next / Previous follow the list
|
||||||
|
* a recording was found in.
|
||||||
|
*
|
||||||
|
* The transcription lists (by place, year, book, type) each wrap their rows
|
||||||
|
* in this. It does two things with the list, in the order it's displayed:
|
||||||
|
*
|
||||||
|
* 1. Play buttons inside it queue the whole list, starting at the row
|
||||||
|
* pressed — so Next plays the next lecture *in that list*.
|
||||||
|
* 2. Opening a lecture from it remembers the list (sessionStorage) for that
|
||||||
|
* lecture's page, so pressing Play there queues the same list.
|
||||||
|
*
|
||||||
|
* A lecture reached any other way (search, a shared link) falls back to its
|
||||||
|
* year in date order — the same order as the Previous / Next links on its
|
||||||
|
* page and the Year view. Only recordings with audio are ever queued.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
import type { AudioTrack } from "./audio-context";
|
||||||
|
|
||||||
|
/** The fields every transcription list row already has. */
|
||||||
|
export type QueueSource = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
date: string | null;
|
||||||
|
place: string | null;
|
||||||
|
audioUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "transcript-queue";
|
||||||
|
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
/** The track a row plays — identical whether built here or by the button. */
|
||||||
|
export function transcriptTrack(
|
||||||
|
t: QueueSource & { audioUrl: string },
|
||||||
|
): AudioTrack {
|
||||||
|
return {
|
||||||
|
url: t.audioUrl,
|
||||||
|
title: t.name,
|
||||||
|
subtitle: [t.date, t.place].filter(Boolean).join(" · "),
|
||||||
|
sourceUrl: `/transcriptions/${t.slug}`,
|
||||||
|
type: t.type ?? "lecture",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueueContext = createContext<AudioTrack[] | null>(null);
|
||||||
|
|
||||||
|
/** The list a play button sits in, or null outside any list. */
|
||||||
|
export function useTranscriptQueue() {
|
||||||
|
return useContext(QueueContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list remembered for this lecture's page, if it was opened from one.
|
||||||
|
* Only the lecture actually clicked qualifies, so an old list never leaks
|
||||||
|
* onto a page reached some other way.
|
||||||
|
*/
|
||||||
|
export function rememberedQueue(slug: string): AudioTrack[] | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const saved = JSON.parse(raw) as { slug: string; tracks: AudioTrack[] };
|
||||||
|
return saved.slug === slug ? saved.tracks : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fallback queue: every recording from that year, in the Year view's
|
||||||
|
* order (by month and day, undated months last). Null if it can't be loaded.
|
||||||
|
*/
|
||||||
|
export async function yearQueue(year: number): Promise<AudioTrack[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BFF_BASE}/api/v1/transcriptions/year/${year}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
data?: (Omit<QueueSource, "audioUrl"> & {
|
||||||
|
month?: number | null;
|
||||||
|
audio_url?: string | null;
|
||||||
|
})[];
|
||||||
|
};
|
||||||
|
const rows = (json.data ?? []).map((r, i) => ({
|
||||||
|
...r,
|
||||||
|
audioUrl: r.audio_url ?? null,
|
||||||
|
// Known months first, keeping the BFF's date order within them.
|
||||||
|
sortKey: (r.month && r.month >= 1 && r.month <= 12 ? 0 : 1) * 1e6 + i,
|
||||||
|
}));
|
||||||
|
rows.sort((a, b) => a.sortKey - b.sortKey);
|
||||||
|
return rows
|
||||||
|
.filter((t): t is typeof t & { audioUrl: string } => !!t.audioUrl)
|
||||||
|
.map(transcriptTrack);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptQueue({
|
||||||
|
items,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
/** Rows in display order. */
|
||||||
|
items: QueueSource[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const tracks = useMemo(
|
||||||
|
() =>
|
||||||
|
items
|
||||||
|
.filter((t): t is QueueSource & { audioUrl: string } => !!t.audioUrl)
|
||||||
|
.map(transcriptTrack),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Event delegation: the rows are plain <Link>s (some rendered on the
|
||||||
|
// server), so catch the click on the way down rather than threading a
|
||||||
|
// handler into every row.
|
||||||
|
const onClickCapture = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const a = (e.target as HTMLElement).closest("a");
|
||||||
|
const href = a?.getAttribute("href");
|
||||||
|
if (!href?.startsWith("/transcriptions/")) return;
|
||||||
|
const slug = href.slice("/transcriptions/".length).split(/[?#]/)[0];
|
||||||
|
if (!tracks.some((t) => t.sourceUrl === `/transcriptions/${slug}`))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ slug, tracks }));
|
||||||
|
} catch {
|
||||||
|
/* storage full or blocked — the page just plays the one lecture */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tracks],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueueContext.Provider value={tracks}>
|
||||||
|
<div className="contents" onClickCapture={onClickCapture}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</QueueContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSignedInPage — for a page that only makes sense signed in.
|
||||||
|
*
|
||||||
|
* Signed-out visitors get the sign-in modal over the page; dismissing it
|
||||||
|
* takes them home. This is the same gate /notifications, /chats and /u/me
|
||||||
|
* each wire up by hand. Returns the user (null until signed in) so the page
|
||||||
|
* can hold its content back until then.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
|
|
||||||
|
export function useSignedInPage() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const { requireAuth } = useAuthGate();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || user) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const ok = await requireAuth();
|
||||||
|
if (!ok && !cancelled) router.replace("/");
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [loading, user, requireAuth, router]);
|
||||||
|
|
||||||
|
return { user, loading };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Copy link to this page" — copies the full, unshortened absolute URL of
|
||||||
|
* the active page. Deliberately bypasses useCopyFeedback (which rewrites
|
||||||
|
* `?hl=` highlight URLs into short /share links); here we want the literal
|
||||||
|
* page URL. The write is synchronous within the click so a single tap works.
|
||||||
|
*
|
||||||
|
* Shared by the PageHeader controls row and pages with their own header
|
||||||
|
* (e.g. a quiz).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, Link2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function CopyPageLinkButton({
|
||||||
|
label = "Copy link to this page",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyPageLink() {
|
||||||
|
const url = window.location.href;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch {
|
||||||
|
// Fallback for browsers without the async clipboard API.
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = url;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch {
|
||||||
|
// silent fail
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyPageLink}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
|
||||||
|
copied
|
||||||
|
? "text-foreground border-primary/40"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-3.5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Link2 className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{copied ? "Link copied" : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { Check, Link2 } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
|
||||||
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
||||||
import { QuizButton } from "@/components/quiz/quiz-button";
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import type { OfflineDescriptor } from "@/lib/offline";
|
import type { OfflineDescriptor } from "@/lib/offline";
|
||||||
@@ -153,43 +153,6 @@ export function PageHeader({
|
|||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, [sticky]);
|
}, [sticky]);
|
||||||
|
|
||||||
// "Copy link to this page" — copies the full, unshortened absolute URL of
|
|
||||||
// the active page. Deliberately bypasses useCopyFeedback (which rewrites
|
|
||||||
// `?hl=` highlight URLs into short /share links); here we want the literal
|
|
||||||
// page URL. The write is synchronous within the click so a single tap works.
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function copyPageLink() {
|
|
||||||
const url = window.location.href;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
} catch {
|
|
||||||
// Fallback for browsers without the async clipboard API.
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = url;
|
|
||||||
ta.style.position = "fixed";
|
|
||||||
ta.style.opacity = "0";
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
try {
|
|
||||||
document.execCommand("copy");
|
|
||||||
} catch {
|
|
||||||
// silent fail
|
|
||||||
}
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
}
|
|
||||||
setCopied(true);
|
|
||||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
|
||||||
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -228,25 +191,7 @@ export function PageHeader({
|
|||||||
{/* First, so it reads as belonging to the title above it. Renders
|
{/* First, so it reads as belonging to the title above it. Renders
|
||||||
nothing on pages without a published quiz. */}
|
nothing on pages without a published quiz. */}
|
||||||
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
||||||
<button
|
<CopyPageLinkButton />
|
||||||
type="button"
|
|
||||||
onClick={copyPageLink}
|
|
||||||
aria-label="Copy link to this page"
|
|
||||||
title="Copy link to this page"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
|
|
||||||
copied
|
|
||||||
? "text-foreground border-primary/40"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="size-3.5 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Link2 className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{copied ? "Link copied" : "Copy link to this page"}
|
|
||||||
</button>
|
|
||||||
{resolvedOffline ? (
|
{resolvedOffline ? (
|
||||||
<SaveOfflineButton descriptor={resolvedOffline} />
|
<SaveOfflineButton descriptor={resolvedOffline} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "All" tab of /quizzes, as a drill-down:
|
||||||
|
*
|
||||||
|
* All → Books → a book → its quizzes
|
||||||
|
* → Lectures & Conversations → a year → its quizzes
|
||||||
|
* → Other quizzes (stand-alone)
|
||||||
|
*
|
||||||
|
* Where you are lives in the URL (?in=books&book=bg, ?in=lectures&year=1972)
|
||||||
|
* so a refresh, Back, or a shared link lands in the same place. Every level
|
||||||
|
* only offers categories that actually hold quizzes; a stale or hand-typed
|
||||||
|
* URL pointing at an empty one falls back to its parent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
CalendarDays,
|
||||||
|
ChevronRight,
|
||||||
|
ClipboardCheck,
|
||||||
|
Mic,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
import { groupQuizzes, type QuizGroup } from "@/lib/quiz-groups";
|
||||||
|
import { QuizCategoryCard } from "./quiz-category-card";
|
||||||
|
import { QuizGrid } from "./quiz-grid";
|
||||||
|
|
||||||
|
export type QuizBrowsePath = {
|
||||||
|
in?: string | null;
|
||||||
|
book?: string | null;
|
||||||
|
year?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOOKS = "books";
|
||||||
|
const LECTURES = "lectures";
|
||||||
|
const OTHER = "other";
|
||||||
|
|
||||||
|
/** /quizzes URL for a browse position (the All tab is the default). */
|
||||||
|
export function quizzesHref(p: QuizBrowsePath): string {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (p.in) qs.set("in", p.in);
|
||||||
|
if (p.book) qs.set("book", p.book);
|
||||||
|
if (p.year) qs.set("year", p.year);
|
||||||
|
const s = qs.toString();
|
||||||
|
return s ? `/quizzes?${s}` : "/quizzes";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizBrowser({
|
||||||
|
quizzes,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
quizzes: QuizCard[];
|
||||||
|
path: QuizBrowsePath;
|
||||||
|
}) {
|
||||||
|
const { books, lectures, other } = groupQuizzes(quizzes);
|
||||||
|
const all = (gs: QuizGroup[]) => gs.flatMap((g) => g.quizzes);
|
||||||
|
|
||||||
|
const book =
|
||||||
|
path.in === BOOKS ? books.find((g) => g.key === path.book) : undefined;
|
||||||
|
const year =
|
||||||
|
path.in === LECTURES
|
||||||
|
? lectures.find((g) => g.key === path.year)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Books → a book: its quizzes.
|
||||||
|
if (book) {
|
||||||
|
return (
|
||||||
|
<Level crumbs={[["Books", quizzesHref({ in: BOOKS })], [book.label]]}>
|
||||||
|
<QuizGrid quizzes={book.quizzes} />
|
||||||
|
</Level>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Lectures → a year: its quizzes.
|
||||||
|
if (year) {
|
||||||
|
return (
|
||||||
|
<Level
|
||||||
|
crumbs={[
|
||||||
|
["Lectures & Conversations", quizzesHref({ in: LECTURES })],
|
||||||
|
[year.label],
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<QuizGrid quizzes={year.quizzes} />
|
||||||
|
</Level>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Books: a card per book.
|
||||||
|
if (path.in === BOOKS && books.length > 0) {
|
||||||
|
return (
|
||||||
|
<Level crumbs={[["Books"]]}>
|
||||||
|
<CardGrid>
|
||||||
|
{books.map((g) => (
|
||||||
|
<QuizCategoryCard
|
||||||
|
key={g.key}
|
||||||
|
href={quizzesHref({ in: BOOKS, book: g.key })}
|
||||||
|
label={g.label}
|
||||||
|
quizzes={g.quizzes}
|
||||||
|
icon={<BookOpen className="size-5" />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardGrid>
|
||||||
|
</Level>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Lectures: a card per year.
|
||||||
|
if (path.in === LECTURES && lectures.length > 0) {
|
||||||
|
return (
|
||||||
|
<Level crumbs={[["Lectures & Conversations"]]}>
|
||||||
|
<CardGrid>
|
||||||
|
{lectures.map((g) => (
|
||||||
|
<QuizCategoryCard
|
||||||
|
key={g.key}
|
||||||
|
href={quizzesHref({ in: LECTURES, year: g.key })}
|
||||||
|
label={g.label}
|
||||||
|
quizzes={g.quizzes}
|
||||||
|
icon={<CalendarDays className="size-5" />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardGrid>
|
||||||
|
</Level>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stand-alone quizzes.
|
||||||
|
if (path.in === OTHER && other.length > 0) {
|
||||||
|
return (
|
||||||
|
<Level crumbs={[["Other quizzes"]]}>
|
||||||
|
<QuizGrid quizzes={other} />
|
||||||
|
</Level>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top level: one card per category that has quizzes.
|
||||||
|
return (
|
||||||
|
<CardGrid>
|
||||||
|
{books.length > 0 && (
|
||||||
|
<QuizCategoryCard
|
||||||
|
href={quizzesHref({ in: BOOKS })}
|
||||||
|
label="Books"
|
||||||
|
quizzes={all(books)}
|
||||||
|
icon={<BookOpen className="size-5" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{lectures.length > 0 && (
|
||||||
|
<QuizCategoryCard
|
||||||
|
href={quizzesHref({ in: LECTURES })}
|
||||||
|
label="Lectures & Conversations"
|
||||||
|
quizzes={all(lectures)}
|
||||||
|
icon={<Mic className="size-5" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{other.length > 0 && (
|
||||||
|
<QuizCategoryCard
|
||||||
|
href={quizzesHref({ in: OTHER })}
|
||||||
|
label="Other quizzes"
|
||||||
|
quizzes={other}
|
||||||
|
icon={<ClipboardCheck className="size-5" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardGrid({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 [&>*]:min-w-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A level below the top: a breadcrumb back up, then its content. */
|
||||||
|
function Level({
|
||||||
|
crumbs,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
/** [label, href?] — the last (current) one has no link. */
|
||||||
|
crumbs: [string, string?][];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<nav
|
||||||
|
aria-label="Quiz categories"
|
||||||
|
className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={quizzesHref({})}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
All quizzes
|
||||||
|
</Link>
|
||||||
|
{crumbs.map(([label, href]) => (
|
||||||
|
<span key={label} className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
{href ? (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="truncate text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Asks the BFF whether this page has a published quiz and renders nothing if
|
* Asks the BFF whether this page has a published quiz and renders nothing if
|
||||||
* not, so it can sit on every content page without leaving an empty slot.
|
* not, so it can sit on every content page without leaving an empty slot.
|
||||||
|
* Quizzes are for signed-in readers only, so signed out it never asks.
|
||||||
* Its label tracks progress: Take quiz → Continue quiz → View results.
|
* Its label tracks progress: Take quiz → Continue quiz → View results.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,17 +18,26 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
export function QuizButton({
|
export function QuizButton({
|
||||||
path,
|
path,
|
||||||
|
size = "sm",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
/** The content page's own path, e.g. /books/bg/2. */
|
/** The content page's own path, e.g. /books/bg/2. */
|
||||||
path: string;
|
path: string;
|
||||||
|
/**
|
||||||
|
* "sm" for the page header's controls row; "lg" to sit beside the lecture
|
||||||
|
* page's Play Audio button as an equal — same height, padding, type and
|
||||||
|
* colours.
|
||||||
|
*/
|
||||||
|
size?: "sm" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
const [card, setCard] = useState<QuizCard | null>(null);
|
const [card, setCard] = useState<QuizCard | null>(null);
|
||||||
|
|
||||||
// Re-asked when the user changes, so progress appears right after sign-in.
|
// Re-asked when the user changes, so progress appears right after sign-in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
quiz
|
quiz
|
||||||
.forPath(path)
|
.forPath(path)
|
||||||
@@ -40,24 +50,37 @@ export function QuizButton({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [path, user?.id]);
|
}, [path, userId]);
|
||||||
|
|
||||||
if (!card) return null;
|
// A card left over from before sign-out is ignored rather than cleared.
|
||||||
|
if (!card || !user) return null;
|
||||||
|
|
||||||
const done = card.my.state === "completed";
|
const done = card.my.state === "completed";
|
||||||
|
const lg = size === "lg";
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/quizzes/${card.id}`}
|
href={`/quizzes/${card.id}`}
|
||||||
title={card.title}
|
title={card.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
"inline-flex items-center border transition-colors",
|
||||||
|
lg
|
||||||
|
? // Mirrors PlayAudioButton's default variant. That has no border,
|
||||||
|
// so 1px less vertical padding keeps the two exactly as tall.
|
||||||
|
"gap-2 rounded-lg px-5 py-[9px] text-sm font-semibold transition-all"
|
||||||
|
: "gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium",
|
||||||
done
|
done
|
||||||
? "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
? "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15",
|
: // Take / Continue: solid, so a pending quiz stands out.
|
||||||
|
cn(
|
||||||
|
"border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||||
|
// Header pill keeps white text in dark mode; beside Play Audio
|
||||||
|
// it takes Play Audio's own foreground so the pair match.
|
||||||
|
lg ? "hover:shadow-md" : "dark:text-white",
|
||||||
|
),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ClipboardCheck className="size-3.5" />
|
<ClipboardCheck className={lg ? "size-4" : "size-3.5"} />
|
||||||
{quizActionLabel(card.my.state)}
|
{quizActionLabel(card.my.state)}
|
||||||
{card.my.state === "in_progress" && (
|
{card.my.state === "in_progress" && (
|
||||||
<span className="tabular-nums opacity-70">
|
<span className="tabular-nums opacity-70">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* A clickable category in the quiz browser — Books, a book, Lectures, a
|
||||||
|
* year. Shows how many quizzes it holds and how many are still to take.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
|
||||||
|
export function QuizCategoryCard({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
quizzes,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
quizzes: QuizCard[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const todo = quizzes.filter((q) => q.my.state !== "completed").length;
|
||||||
|
const n = quizzes.length;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex h-full min-w-0 items-center gap-3.5 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<span className="inline-flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
title={label}
|
||||||
|
className="line-clamp-2 font-semibold leading-snug text-foreground [overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
{n} {n === 1 ? "quiz" : "quizzes"}
|
||||||
|
{todo > 0 && todo < n ? ` · ${todo} to do` : ""}
|
||||||
|
{todo === 0 ? " · all finished" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
import { QuizListCard } from "./quiz-list-card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quiz cards, two across from sm up. minmax(0,1fr) + min-w-0: grid items
|
||||||
|
* otherwise refuse to shrink below their content, so one long word pushes a
|
||||||
|
* card off-screen.
|
||||||
|
*/
|
||||||
|
export function QuizGrid({ quizzes }: { quizzes: QuizCard[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{quizzes.map((q) => (
|
||||||
|
<li key={q.id} className="min-w-0">
|
||||||
|
<QuizListCard q={q} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** One quiz in the /quizzes list: what it's on, its title, and your progress. */
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
import { quizActionLabel, type QuizCard } from "@/lib/quiz";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const KIND: Record<QuizCard["target_kind"], string> = {
|
||||||
|
chapter: "Book chapter",
|
||||||
|
lecture: "Lecture",
|
||||||
|
conversation: "Conversation",
|
||||||
|
standalone: "Quiz",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuizListCard({ q }: { q: QuizCard }) {
|
||||||
|
const done = q.my.state === "completed";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/quizzes/${q.id}`}
|
||||||
|
className="flex h-full min-w-0 flex-col rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium text-primary">{KIND[q.target_kind]}</p>
|
||||||
|
<p className="mt-1 break-words font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
|
||||||
|
{q.title}
|
||||||
|
</p>
|
||||||
|
{q.target_title && (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||||
|
{q.target_title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-4 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{q.question_count} {q.question_count === 1 ? "question" : "questions"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 whitespace-nowrap font-medium",
|
||||||
|
done ? "text-muted-foreground" : "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
{quizActionLabel(q.my.state)}
|
||||||
|
{done && q.my.score !== undefined && ` · ${q.my.score}/${q.my.total}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,10 +14,24 @@ import { RichText } from "@/lib/rich-text";
|
|||||||
import type { QuizResult } from "@/lib/quiz";
|
import type { QuizResult } from "@/lib/quiz";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "8.4s" / "1m 12s" / "3h 20m" / "2d 4h". Hours past 23h 59m roll up into
|
||||||
|
* days. Mirrors fmtMs in the admin's quizzes/labels.ts.
|
||||||
|
*/
|
||||||
export function fmtMs(ms?: number | null): string {
|
export function fmtMs(ms?: number | null): string {
|
||||||
if (ms == null) return "—";
|
if (ms == null) return "—";
|
||||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
// Below 59.95s: one decimal place (anything higher would print "60.0s").
|
||||||
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
if (ms < 59_950) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
// Whole seconds from here, so rounding can't print "1m 60s".
|
||||||
|
const total = Math.round(ms / 1000);
|
||||||
|
const d = Math.floor(total / 86_400);
|
||||||
|
const h = Math.floor((total % 86_400) / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
// The two largest units: 2d 4h · 3h 20m · 12m 5s.
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuizResultView({ result }: { result: QuizResult }) {
|
export function QuizResultView({ result }: { result: QuizResult }) {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* - "tile": a card that leads the search page's "Start here" row.
|
* - "tile": a card that leads the search page's "Start here" row.
|
||||||
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
|
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
|
||||||
*
|
*
|
||||||
* Renders nothing until there is at least one published quiz; with
|
* Signed-in readers only, like quizzes themselves. Renders nothing until
|
||||||
|
* there is at least one published quiz; with
|
||||||
* `pendingOnly` it also hides once every quiz has been finished, so the
|
* `pendingOnly` it also hides once every quiz has been finished, so the
|
||||||
* homepage only nudges when there's something left to do.
|
* homepage only nudges when there's something left to do.
|
||||||
*/
|
*/
|
||||||
@@ -28,9 +29,11 @@ export function QuizzesCTA({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
const [cards, setCards] = useState<QuizCard[] | null>(null);
|
const [cards, setCards] = useState<QuizCard[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
quiz
|
quiz
|
||||||
.list()
|
.list()
|
||||||
@@ -44,9 +47,10 @@ export function QuizzesCTA({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
// Refetch on sign-in/out: progress is per user.
|
// Refetch on sign-in/out: progress is per user.
|
||||||
}, [user?.id]);
|
}, [userId]);
|
||||||
|
|
||||||
if (!cards?.length) return null;
|
// Cards left over from before sign-out are ignored rather than cleared.
|
||||||
|
if (!user || !cards?.length) return null;
|
||||||
|
|
||||||
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
|
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -318,6 +318,68 @@ export function ogForLettersByLocation(location: string, count: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quizzes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const QUIZ_DESCRIPTION =
|
||||||
|
"Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.";
|
||||||
|
|
||||||
|
const QUIZ_KIND_LABEL: Record<string, string> = {
|
||||||
|
chapter: "Book chapter",
|
||||||
|
lecture: "Lecture",
|
||||||
|
conversation: "Conversation",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin text carries light formatting markers (*bold*, _italic_, links…).
|
||||||
|
* A preview card wants the words only.
|
||||||
|
*/
|
||||||
|
function stripRichMarkers(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1")
|
||||||
|
.replace(/__([^_\n]+?)__/g, "$1")
|
||||||
|
.replace(/\*([^*\n]+?)\*/g, "$1")
|
||||||
|
.replace(/~([^~\n]+?)~/g, "$1")
|
||||||
|
.replace(/(^|\W)_([^_\n]+?)_(?=\W|$)/g, "$1$2")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OG metadata for a single quiz. */
|
||||||
|
export function ogForQuiz(quiz: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
target_kind: string;
|
||||||
|
target_title?: string;
|
||||||
|
question_count: number;
|
||||||
|
}) {
|
||||||
|
const kind = QUIZ_KIND_LABEL[quiz.target_kind];
|
||||||
|
const count = `${quiz.question_count} question${quiz.question_count === 1 ? "" : "s"}`;
|
||||||
|
const detail = [
|
||||||
|
"Quiz",
|
||||||
|
kind && quiz.target_title ? `${kind}: ${quiz.target_title}` : null,
|
||||||
|
count,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
const about = stripRichMarkers(quiz.description ?? "");
|
||||||
|
return buildOgMeta({
|
||||||
|
title: quiz.title,
|
||||||
|
detail,
|
||||||
|
quote: about || QUIZ_DESCRIPTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OG metadata for the /quizzes index. */
|
||||||
|
export function ogForQuizzes() {
|
||||||
|
return buildOgMeta({
|
||||||
|
title: "Quizzes",
|
||||||
|
detail: "Prabhupāda Says",
|
||||||
|
quote: QUIZ_DESCRIPTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// hl= query param → OG quote extraction
|
// hl= query param → OG quote extraction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Groups the quiz list for the "All" tab:
|
||||||
|
*
|
||||||
|
* Books → one group per book, in the library's order
|
||||||
|
* Lectures & Conversations → one group per year, oldest first, undated last
|
||||||
|
* Other quizzes → stand-alone quizzes
|
||||||
|
*
|
||||||
|
* Only groups with quizzes in them are returned, so an empty category or
|
||||||
|
* sub-category never renders.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BOOK_NAMES } from "@/lib/constants";
|
||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
|
||||||
|
export type QuizGroup = { key: string; label: string; quizzes: QuizCard[] };
|
||||||
|
|
||||||
|
export type QuizSections = {
|
||||||
|
books: QuizGroup[];
|
||||||
|
lectures: QuizGroup[];
|
||||||
|
other: QuizCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOOK_ORDER = Object.keys(BOOK_NAMES);
|
||||||
|
|
||||||
|
/** "bg" from /books/bg/2 — the book a chapter quiz belongs to. */
|
||||||
|
function bookCode(path?: string | null): string | null {
|
||||||
|
const m = path?.match(/^\/books\/([^/]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recording's year. Saved on the quiz since target_year existed; for
|
||||||
|
* older quizzes, read from the slug's yymmdd prefix (720720sb-london → 1972)
|
||||||
|
* when it falls in Śrīla Prabhupāda's recorded years.
|
||||||
|
*/
|
||||||
|
function lectureYear(q: QuizCard): number | null {
|
||||||
|
if (q.target_year) return q.target_year;
|
||||||
|
const m = q.target_path?.match(/^\/transcriptions\/(\d{2})\d{4}/);
|
||||||
|
if (!m) return null;
|
||||||
|
const y = 1900 + Number(m[1]);
|
||||||
|
return y >= 1966 && y <= 1977 ? y : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupQuizzes(cards: QuizCard[]): QuizSections {
|
||||||
|
const books = new Map<string, QuizCard[]>();
|
||||||
|
const years = new Map<number | null, QuizCard[]>();
|
||||||
|
const other: QuizCard[] = [];
|
||||||
|
|
||||||
|
for (const q of cards) {
|
||||||
|
if (q.target_kind === "chapter") {
|
||||||
|
const code = bookCode(q.target_path) ?? "other";
|
||||||
|
books.set(code, [...(books.get(code) ?? []), q]);
|
||||||
|
} else if (
|
||||||
|
q.target_kind === "lecture" ||
|
||||||
|
q.target_kind === "conversation"
|
||||||
|
) {
|
||||||
|
const y = lectureYear(q);
|
||||||
|
years.set(y, [...(years.get(y) ?? []), q]);
|
||||||
|
} else {
|
||||||
|
other.push(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (code: string) => {
|
||||||
|
const i = BOOK_ORDER.indexOf(code);
|
||||||
|
return i < 0 ? BOOK_ORDER.length : i;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
books: [...books.entries()]
|
||||||
|
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||||
|
.map(([code, quizzes]) => ({
|
||||||
|
key: code,
|
||||||
|
label: BOOK_NAMES[code] ?? code.toUpperCase(),
|
||||||
|
// Chapter order (/books/bg/2 before /books/bg/10), not newest first.
|
||||||
|
quizzes: [...quizzes].sort((a, b) =>
|
||||||
|
(a.target_path ?? "").localeCompare(b.target_path ?? "", undefined, {
|
||||||
|
numeric: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
lectures: [...years.entries()]
|
||||||
|
// Oldest first; undated (null) last.
|
||||||
|
.sort(([a], [b]) => (a ?? 9999) - (b ?? 9999))
|
||||||
|
.map(([year, quizzes]) => ({
|
||||||
|
key: String(year ?? "undated"),
|
||||||
|
label: year ? String(year) : "Undated",
|
||||||
|
quizzes,
|
||||||
|
})),
|
||||||
|
other,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export type QuizCard = {
|
|||||||
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
|
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
|
||||||
target_path?: string | null;
|
target_path?: string | null;
|
||||||
target_title: string;
|
target_title: string;
|
||||||
|
/** Year of the lecture / conversation, when known. */
|
||||||
|
target_year?: number | null;
|
||||||
question_count: number;
|
question_count: number;
|
||||||
my: { state: QuizState; answered: number; total: number; score?: number };
|
my: { state: QuizState; answered: number; total: number; score?: number };
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-1
@@ -212,6 +212,8 @@ export type Feedback = {
|
|||||||
id: string;
|
id: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
/** The category's current display name (renames apply to past feedback). */
|
||||||
|
category_label?: string;
|
||||||
message: string;
|
message: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -560,13 +562,25 @@ export const collections = {
|
|||||||
|
|
||||||
// ─── Feedback ────────────────────────────────────────────────────────────
|
// ─── Feedback ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One option on the /feedback form, managed from the admin panel. */
|
||||||
|
export type FeedbackCategory = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const feedback = {
|
export const feedback = {
|
||||||
|
/** The form's categories, in order. Public. */
|
||||||
|
categories() {
|
||||||
|
return call<FeedbackCategory[]>("/feedback/categories");
|
||||||
|
},
|
||||||
/** Submit a /feedback page entry. When the viewer is signed in the
|
/** Submit a /feedback page entry. When the viewer is signed in the
|
||||||
* BFF captures their user_id automatically via the auth cookie —
|
* BFF captures their user_id automatically via the auth cookie —
|
||||||
* the form doesn't ask for it. `contact_email` is required only
|
* the form doesn't ask for it. `contact_email` is required only
|
||||||
* for anonymous submissions; the BFF re-enforces that on its end. */
|
* for anonymous submissions; the BFF re-enforces that on its end. */
|
||||||
submit(input: {
|
submit(input: {
|
||||||
category: "bug" | "feature" | "general" | "other";
|
/** A FeedbackCategory key. */
|
||||||
|
category: string;
|
||||||
message: string;
|
message: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user