11import { 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"
33import { useQueryClient } from "@tanstack/react-query"
44import { api } from "@/api/client"
55import type { QuizGenerationOutput , VersionEntry } from "@/api/client"
66import { useQuizzes } from "@/hooks/use-quizzes"
7+ import { usePageImage } from "@/hooks/use-pages"
8+ import { Dialog , DialogContent , DialogDescription , DialogTitle } from "@/components/ui/dialog"
79import { useStepHeader } from "../StepViewRouter"
810import { useStepRun } from "@/hooks/use-step-run"
911import { useApiKey } from "@/hooks/use-api-key"
1012import { StepRunCard } from "../StepRunCard"
1113import { STEP_DESCRIPTIONS } from "../StepSidebar"
14+ import { getRequestedPageId , getQuizImageRenderState } from "./quizzes-image-state"
1215
1316const 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+
134273export 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}
0 commit comments