Skip to content

Commit e07ef6b

Browse files
authored
Show page thumbnails, lightbox, and reasoning on quiz cards (#60)
* Add page thumbnails, lightbox, and reasoning to quiz cards * Improve quiz page preview UX and resilience
1 parent edbfacb commit e07ef6b

3 files changed

Lines changed: 245 additions & 4 deletions

File tree

apps/studio/src/components/v2/steps/QuizzesView.tsx

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { useState, useEffect, useRef, useCallback } from "react"
2-
import { Check, CheckCircle2, XCircle, ChevronDown, Loader2 } from "lucide-react"
2+
import { Check, CheckCircle2, XCircle, ChevronDown, Loader2, ImageOff } from "lucide-react"
33
import { useQueryClient } from "@tanstack/react-query"
44
import { api } from "@/api/client"
55
import type { QuizGenerationOutput, VersionEntry } from "@/api/client"
66
import { useQuizzes } from "@/hooks/use-quizzes"
7+
import { usePageImage } from "@/hooks/use-pages"
8+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
79
import { useStepHeader } from "../StepViewRouter"
810
import { useStepRun } from "@/hooks/use-step-run"
911
import { useApiKey } from "@/hooks/use-api-key"
1012
import { StepRunCard } from "../StepRunCard"
1113
import { STEP_DESCRIPTIONS } from "../StepSidebar"
14+
import { getRequestedPageId, getQuizImageRenderState } from "./quizzes-image-state"
1215

1316
const QUIZZES_SUB_STEPS = [
1417
{ key: "quiz-generation", label: "Generate Quizzes" },
@@ -131,6 +134,142 @@ function VersionPicker({
131134
)
132135
}
133136

137+
function PageThumb({
138+
bookLabel,
139+
pageId,
140+
onClick,
141+
}: {
142+
bookLabel: string
143+
pageId: string
144+
onClick: () => void
145+
}) {
146+
const [requestImage, setRequestImage] = useState(false)
147+
const ref = useRef<HTMLButtonElement>(null)
148+
149+
useEffect(() => {
150+
if (requestImage) return
151+
if (typeof IntersectionObserver === "undefined") {
152+
setRequestImage(true)
153+
return
154+
}
155+
const element = ref.current
156+
if (!element) return
157+
const observer = new IntersectionObserver(
158+
(entries) => {
159+
if (entries.some((entry) => entry.isIntersecting)) {
160+
setRequestImage(true)
161+
observer.disconnect()
162+
}
163+
},
164+
{ rootMargin: "200px" }
165+
)
166+
observer.observe(element)
167+
return () => observer.disconnect()
168+
}, [requestImage])
169+
170+
const { data: imageData, isLoading, isError } = usePageImage(
171+
bookLabel,
172+
getRequestedPageId(pageId, requestImage)
173+
)
174+
const imageState = getQuizImageRenderState({
175+
isRequested: requestImage,
176+
isLoading,
177+
isError,
178+
hasImage: !!imageData,
179+
})
180+
181+
return (
182+
<button
183+
ref={ref}
184+
type="button"
185+
onClick={onClick}
186+
onMouseEnter={() => setRequestImage(true)}
187+
onFocus={() => setRequestImage(true)}
188+
aria-label={`Open page preview for ${pageId}`}
189+
className="shrink-0 rounded border border-border bg-muted/40 overflow-hidden hover:ring-2 hover:ring-ring transition-shadow cursor-pointer"
190+
>
191+
{imageState === "ready" ? (
192+
<img
193+
src={`data:image/png;base64,${imageData!.imageBase64}`}
194+
alt={`Page ${pageId}`}
195+
loading="lazy"
196+
className="h-44 w-auto block"
197+
/>
198+
) : imageState === "error" ? (
199+
<div className="h-44 w-32 flex flex-col items-center justify-center gap-1 text-[10px] text-muted-foreground">
200+
<ImageOff className="h-4 w-4" />
201+
<span>No image</span>
202+
</div>
203+
) : (
204+
<div className="h-44 w-32 flex items-center justify-center px-2 text-[10px] text-muted-foreground">
205+
Page {pageId}
206+
</div>
207+
)}
208+
</button>
209+
)
210+
}
211+
212+
function PageLightbox({
213+
bookLabel,
214+
pageId,
215+
open,
216+
onOpenChange,
217+
}: {
218+
bookLabel: string
219+
pageId: string | null
220+
open: boolean
221+
onOpenChange: (open: boolean) => void
222+
}) {
223+
const isRequested = open && !!pageId
224+
const queryPageId = getRequestedPageId(pageId ?? "", isRequested)
225+
const { data: imageData, isLoading, isError, refetch } = usePageImage(bookLabel, queryPageId)
226+
const imageState = getQuizImageRenderState({
227+
isRequested,
228+
isLoading,
229+
isError,
230+
hasImage: !!imageData,
231+
})
232+
233+
return (
234+
<Dialog open={open} onOpenChange={onOpenChange}>
235+
{pageId && (
236+
<DialogContent className="w-auto max-w-[95vw] overflow-hidden gap-2 p-2 sm:max-w-[90vw] bg-white">
237+
<DialogTitle className="sr-only">Page preview {pageId}</DialogTitle>
238+
<DialogDescription className="sr-only">
239+
Full-size source page preview for the selected quiz.
240+
</DialogDescription>
241+
<div className="flex max-h-[90vh] max-w-[90vw] items-center justify-center overflow-hidden rounded-md bg-muted/20">
242+
{imageState === "ready" ? (
243+
<img
244+
src={`data:image/png;base64,${imageData!.imageBase64}`}
245+
alt={`Page ${pageId}`}
246+
className="max-h-[90vh] max-w-[90vw] object-contain"
247+
/>
248+
) : imageState === "error" ? (
249+
<div className="flex h-64 w-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
250+
<ImageOff className="h-5 w-5" />
251+
<span>Image unavailable</span>
252+
<button
253+
type="button"
254+
onClick={() => void refetch()}
255+
className="rounded border px-2 py-0.5 text-xs hover:bg-muted transition-colors cursor-pointer"
256+
>
257+
Retry
258+
</button>
259+
</div>
260+
) : (
261+
<div className="flex h-64 w-52 items-center justify-center gap-2 text-sm text-muted-foreground">
262+
<Loader2 className="h-4 w-4 animate-spin" />
263+
<span>Loading image...</span>
264+
</div>
265+
)}
266+
</div>
267+
</DialogContent>
268+
)}
269+
</Dialog>
270+
)
271+
}
272+
134273
export function QuizzesView({ bookLabel }: { bookLabel: string }) {
135274
const queryClient = useQueryClient()
136275
const { data, isLoading } = useQuizzes(bookLabel)
@@ -150,6 +289,7 @@ export function QuizzesView({ bookLabel }: { bookLabel: string }) {
150289

151290
const [pending, setPending] = useState<QuizData | null>(null)
152291
const [saving, setSaving] = useState(false)
292+
const [lightboxPageId, setLightboxPageId] = useState<string | null>(null)
153293

154294
// Reset pending when data changes
155295
useEffect(() => {
@@ -258,14 +398,23 @@ export function QuizzesView({ bookLabel }: { bookLabel: string }) {
258398
<div className="space-y-2">
259399
{quizzes.map((quiz, idx) => (
260400
<div key={idx} className="rounded-md border bg-card overflow-hidden">
261-
<div className="px-4 py-3 flex items-start justify-between gap-3">
401+
<div className="flex flex-wrap items-center gap-1.5 px-4 py-2 bg-muted/20 border-b">
402+
{quiz.pageIds.length > 0 ? (
403+
quiz.pageIds.map((pageId) => (
404+
<PageThumb key={pageId} bookLabel={bookLabel} pageId={pageId} onClick={() => setLightboxPageId(pageId)} />
405+
))
406+
) : (
407+
<span className="text-xs text-muted-foreground">After {quiz.afterPageId}</span>
408+
)}
409+
</div>
410+
<div className="px-4 py-3">
262411
<textarea
263412
value={quiz.question}
264413
onChange={(e) => updateQuestion(idx, e.target.value)}
265-
className="flex-1 text-sm font-medium resize-none rounded border border-transparent bg-transparent p-1 -m-1 hover:border-border hover:bg-muted/30 focus:border-ring focus:bg-white focus:outline-none focus:ring-1 focus:ring-ring transition-colors"
414+
className="w-full text-sm font-medium resize-none rounded border border-transparent bg-transparent p-1 -m-1 hover:border-border hover:bg-muted/30 focus:border-ring focus:bg-white focus:outline-none focus:ring-1 focus:ring-ring transition-colors"
266415
rows={1}
267416
/>
268-
<span className="text-[10px] text-muted-foreground shrink-0 mt-1.5">
417+
<span className="text-[10px] text-muted-foreground mt-1 inline-block">
269418
After {quiz.afterPageId}
270419
</span>
271420
</div>
@@ -300,9 +449,20 @@ export function QuizzesView({ bookLabel }: { bookLabel: string }) {
300449
</div>
301450
</div>
302451
))}
452+
{quiz.reasoning && (
453+
<p className="text-xs italic text-muted-foreground px-1 pt-1">{quiz.reasoning}</p>
454+
)}
303455
</div>
304456
</div>
305457
))}
458+
<PageLightbox
459+
bookLabel={bookLabel}
460+
pageId={lightboxPageId}
461+
open={lightboxPageId != null}
462+
onOpenChange={(open) => {
463+
if (!open) setLightboxPageId(null)
464+
}}
465+
/>
306466
</div>
307467
)
308468
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from "vitest"
2+
import { getQuizImageRenderState, getRequestedPageId } from "./quizzes-image-state"
3+
4+
describe("getRequestedPageId", () => {
5+
it("returns empty page id when image is not requested", () => {
6+
expect(getRequestedPageId("pg001", false)).toBe("")
7+
})
8+
9+
it("returns source page id when image is requested", () => {
10+
expect(getRequestedPageId("pg001", true)).toBe("pg001")
11+
})
12+
})
13+
14+
describe("getQuizImageRenderState", () => {
15+
it("returns idle before a request starts", () => {
16+
expect(
17+
getQuizImageRenderState({
18+
isRequested: false,
19+
isLoading: false,
20+
isError: false,
21+
hasImage: false,
22+
})
23+
).toBe("idle")
24+
})
25+
26+
it("returns loading while query is in progress", () => {
27+
expect(
28+
getQuizImageRenderState({
29+
isRequested: true,
30+
isLoading: true,
31+
isError: false,
32+
hasImage: false,
33+
})
34+
).toBe("loading")
35+
})
36+
37+
it("returns error when query fails", () => {
38+
expect(
39+
getQuizImageRenderState({
40+
isRequested: true,
41+
isLoading: false,
42+
isError: true,
43+
hasImage: false,
44+
})
45+
).toBe("error")
46+
})
47+
48+
it("returns ready when image payload exists", () => {
49+
expect(
50+
getQuizImageRenderState({
51+
isRequested: true,
52+
isLoading: false,
53+
isError: false,
54+
hasImage: true,
55+
})
56+
).toBe("ready")
57+
})
58+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export type QuizImageRenderState = "idle" | "loading" | "error" | "ready"
2+
3+
export function getRequestedPageId(pageId: string, isRequested: boolean): string {
4+
return isRequested ? pageId : ""
5+
}
6+
7+
export function getQuizImageRenderState({
8+
isRequested,
9+
isLoading,
10+
isError,
11+
hasImage,
12+
}: {
13+
isRequested: boolean
14+
isLoading: boolean
15+
isError: boolean
16+
hasImage: boolean
17+
}): QuizImageRenderState {
18+
if (!isRequested) return "idle"
19+
if (hasImage) return "ready"
20+
if (isError) return "error"
21+
if (isLoading) return "loading"
22+
return "loading"
23+
}

0 commit comments

Comments
 (0)