11"use client" ;
22
3- import { useState , useEffect , useRef , useCallback } from "react" ;
3+ import { useState , useEffect , useRef , useCallback , useMemo } from "react" ;
44import {
55 Eye , EyeSlash , Info , CheckCircle , XCircle ,
66 ArrowsClockwise ,
@@ -16,6 +16,7 @@ import {
1616 DialogHeader ,
1717 DialogTitle ,
1818} from "@/components/ui/dialog" ;
19+ import { cn } from "@/lib/utils" ;
1920import { localeName } from "@/lib/asc/locale-names" ;
2021import { screenshotImageUrl } from "@/lib/asc/display-types" ;
2122import type { AscScreenshot } from "@/lib/asc/display-types" ;
@@ -70,6 +71,7 @@ export function TranslateScreenshotsModal({
7071 const stopRef = useRef ( false ) ;
7172 const abortRef = useRef < AbortController | null > ( null ) ;
7273 const setIdCacheRef = useRef ( new Map < string , string > ( ) ) ;
74+ const scrollRef = useRef < HTMLDivElement > ( null ) ;
7375
7476 // Gemini key state
7577 const [ geminiKey , setGeminiKey ] = useState ( "" ) ;
@@ -300,6 +302,27 @@ export function TranslateScreenshotsModal({
300302 processItems ( false , geminiKey . trim ( ) ) ;
301303 }
302304
305+ // Auto-scroll to the latest in-progress or completed item
306+ const lastActiveIndex = useMemo ( ( ) => {
307+ let idx = - 1 ;
308+ for ( let i = 0 ; i < items . length ; i ++ ) {
309+ const s = itemStates . get ( items [ i ] . screenshot . id ) ?. status ;
310+ if ( s === "translating" || s === "uploading" || s === "done" || s === "failed" ) {
311+ idx = i ;
312+ }
313+ }
314+ return idx ;
315+ } , [ items , itemStates ] ) ;
316+
317+ useEffect ( ( ) => {
318+ if ( lastActiveIndex < 0 || ! scrollRef . current ) return ;
319+ const container = scrollRef . current ;
320+ const child = container . children [ lastActiveIndex ] as HTMLElement | undefined ;
321+ if ( child ) {
322+ child . scrollIntoView ( { behavior : "smooth" , block : "nearest" , inline : "center" } ) ;
323+ }
324+ } , [ lastActiveIndex ] ) ;
325+
303326 // Compute progress stats
304327 const total = items . length ;
305328 const doneCount = Array . from ( itemStates . values ( ) ) . filter ( ( s ) => s . status === "done" ) . length ;
@@ -310,117 +333,75 @@ export function TranslateScreenshotsModal({
310333
311334 return (
312335 < Dialog open = { open } onOpenChange = { ( o ) => { if ( ! o ) handleClose ( ) ; } } >
313- < DialogContent className = "sm:max-w-4xl max-h-[90vh] flex flex-col" >
336+ < DialogContent className = "sm:max-w-4xl w-full max-h-[90vh] flex flex-col" >
314337 < DialogHeader >
315338 < DialogTitle >
316339 { isSingle ? "Translate screenshot" : `Translate ${ total } screenshots` }
317340 { " " } to { localeName ( toLocale ) }
318341 </ DialogTitle >
319342 </ DialogHeader >
320343
321- { /* Progress grid */ }
322- { started && (
323- < div className = "flex-1 overflow-y-auto" >
324- { /* Progress bar */ }
325- < div className = "mb-3 space-y-1.5" >
326- < div className = "flex items-center justify-between text-xs text-muted-foreground" >
327- < span >
328- { doneCount } of { total } completed
329- { failedCount > 0 && ` (${ failedCount } failed)` }
330- </ span >
331- </ div >
332- < div className = "h-1.5 w-full overflow-hidden rounded-full bg-muted" >
333- < div
334- className = "h-full rounded-full bg-primary transition-all duration-300"
335- style = { { width : `${ total > 0 ? ( ( doneCount + failedCount ) / total ) * 100 : 0 } %` } }
336- />
337- </ div >
338- </ div >
339-
340- { /* Thumbnail grid */ }
341- < div className = "grid grid-cols-2 gap-3 sm:grid-cols-3" >
342- { items . map ( ( item ) => {
343- const state = itemStates . get ( item . screenshot . id ) ;
344- const status = state ?. status ?? "queued" ;
345- const token = item . screenshot . attributes . assetToken ;
346-
347- return (
348- < div
349- key = { item . screenshot . id }
350- className = "relative flex flex-col items-center rounded-lg border bg-muted/20 p-2"
351- >
352- { /* Show translated thumbnail if done, otherwise original */ }
353- { status === "done" && state ?. thumbnail ? (
354- < img
355- src = { `data:${ state . thumbnailMimeType } ;base64,${ state . thumbnail } ` }
356- alt = "Translated"
357- className = "h-[300px] w-auto rounded object-contain"
358- />
359- ) : token ? (
360- < img
361- src = { screenshotImageUrl ( token , 400 ) }
362- alt = { item . screenshot . attributes . fileName }
363- className = "h-[300px] w-auto rounded object-contain opacity-40"
364- />
365- ) : (
366- < div className = "flex h-[300px] w-[168px] items-center justify-center rounded bg-muted" >
367- < Spinner className = "size-6 text-muted-foreground/40" />
368- </ div >
344+ { /* Horizontal screenshot row */ }
345+ < div ref = { scrollRef } className = "flex gap-3 overflow-x-auto pb-2" >
346+ { items . map ( ( item ) => {
347+ const state = itemStates . get ( item . screenshot . id ) ;
348+ const status = started ? ( state ?. status ?? "queued" ) : "idle" ;
349+ const token = item . screenshot . attributes . assetToken ;
350+
351+ return (
352+ < div
353+ key = { item . screenshot . id }
354+ className = "relative shrink-0 flex flex-col items-center rounded-lg border bg-muted/20 p-2"
355+ >
356+ { status === "done" && state ?. thumbnail ? (
357+ < img
358+ src = { `data:${ state . thumbnailMimeType } ;base64,${ state . thumbnail } ` }
359+ alt = "Translated"
360+ className = "h-[300px] w-auto rounded object-contain"
361+ />
362+ ) : token ? (
363+ < img
364+ src = { screenshotImageUrl ( token , 400 ) }
365+ alt = { item . screenshot . attributes . fileName }
366+ className = { cn (
367+ "h-[300px] w-auto rounded object-contain" ,
368+ started && status !== "done" && "opacity-40" ,
369369 ) }
370+ />
371+ ) : (
372+ < div className = "flex h-[300px] w-[168px] items-center justify-center rounded bg-muted" >
373+ < Spinner className = "size-6 text-muted-foreground/40" />
374+ </ div >
375+ ) }
370376
371- { /* Status overlay */ }
372- < div className = "absolute inset-0 flex items-center justify-center" >
373- { ( status === "translating" || status === "uploading" ) && (
377+ { /* Status overlay */ }
378+ { started && (
379+ < >
380+ { ( status === "translating" || status === "uploading" ) && (
381+ < div className = "absolute inset-0 flex items-center justify-center" >
374382 < div className = "rounded-full bg-background/80 p-2" >
375383 < Spinner className = "size-7 text-primary" />
376384 </ div >
377- ) }
378- { status === "done" && (
379- < div className = "absolute top-1 right-1 rounded-full bg-background/80 p-0.5" >
380- < CheckCircle size = { 20 } weight = "fill" className = "text-green-500" />
381- </ div >
382- ) }
383- { status === "failed" && (
385+ </ div >
386+ ) }
387+ { status === "done" && (
388+ < div className = "absolute top-1 right-1 rounded-full bg-background/80 p-0.5" >
389+ < CheckCircle size = { 20 } weight = "fill" className = "text-green-500" />
390+ </ div >
391+ ) }
392+ { status === "failed" && (
393+ < div className = "absolute inset-0 flex items-center justify-center" >
384394 < div className = "rounded-full bg-background/80 p-2" title = { state ?. error } >
385395 < XCircle size = { 24 } weight = "fill" className = "text-destructive" />
386396 </ div >
387- ) }
388- </ div >
389- </ div >
390- ) ;
391- } ) }
392- </ div >
393- </ div >
394- ) }
395-
396- { /* Idle state – preview of what will be translated */ }
397- { ! started && (
398- < div className = "flex-1 overflow-y-auto" >
399- < div className = "grid grid-cols-2 gap-3 sm:grid-cols-3" >
400- { items . map ( ( item ) => {
401- const token = item . screenshot . attributes . assetToken ;
402- return (
403- < div
404- key = { item . screenshot . id }
405- className = "flex flex-col items-center rounded-lg border bg-muted/20 p-2"
406- >
407- { token ? (
408- < img
409- src = { screenshotImageUrl ( token , 400 ) }
410- alt = { item . screenshot . attributes . fileName }
411- className = "h-[300px] w-auto rounded object-contain"
412- />
413- ) : (
414- < div className = "flex h-[300px] w-[168px] items-center justify-center rounded bg-muted" >
415- < Spinner className = "size-6 text-muted-foreground/40" />
416397 </ div >
417398 ) }
418- </ div >
419- ) ;
420- } ) }
421- </ div >
422- </ div >
423- ) }
399+ </ >
400+ ) }
401+ </ div >
402+ ) ;
403+ } ) }
404+ </ div >
424405
425406 { /* Gemini key input – shown when no key available */ }
426407 { hasKey === false && ! started && (
@@ -494,17 +475,24 @@ export function TranslateScreenshotsModal({
494475
495476 { started && (
496477 < >
497- < div className = "mr-auto text-xs text-muted-foreground" >
498- { inProgress
499- ? copyMode
500- ? "Uploading..."
501- : "Translating... This can take 1\u20132 minutes per image."
502- : allDone && ! hasFailed
503- ? "All done!"
504- : allDone && hasFailed
505- ? `${ doneCount } completed, ${ failedCount } failed`
506- : "Stopped"
507- }
478+ < div className = "mr-auto flex items-center gap-3 text-xs text-muted-foreground" >
479+ < span className = "shrink-0" >
480+ { inProgress
481+ ? copyMode ? "Uploading..." : "Translating (up to 1\u20132 min per image)"
482+ : allDone && ! hasFailed
483+ ? "All done!"
484+ : allDone && hasFailed
485+ ? `${ failedCount } failed`
486+ : "Stopped"
487+ }
488+ </ span >
489+ < div className = "h-1.5 w-24 overflow-hidden rounded-full bg-muted" >
490+ < div
491+ className = "h-full rounded-full bg-primary transition-all duration-300"
492+ style = { { width : `${ total > 0 ? ( ( doneCount + failedCount ) / total ) * 100 : 0 } %` } }
493+ />
494+ </ div >
495+ < span className = "shrink-0" > { doneCount } /{ total } </ span >
508496 </ div >
509497 { hasFailed && ! inProgress && (
510498 < Button variant = "outline" size = "sm" className = "gap-1" onClick = { handleRetryFailed } >
0 commit comments