UI polish: live job polling, panel layout, pending button states
- Poll /api/ai-tagging/fields every 2s after any 202 (queued) response in ImageLightbox and DoomScrollView so extraction, translation, and description results appear automatically without a page refresh - DoomScrollView extract button now turns accent-coloured while a job is queued instead of flashing red; red is reserved for genuine errors - Kebab menu "Translate" option is now gated on entry.hasExtractedText (populated via a batch DB query in the browse API) so it only appears when there is text to translate - Tag panel redesigned: toolbar collapses to just the filename when open; panel header holds hide (›), AI Tagger (✨), and Close (✕) buttons; sections ordered Description → Text Extraction → Tags; description state and generate handler moved from TagSelector into ImageLightbox - VideoPlayerModal receives the same toolbar/panel restructure - TagSelector gains hideDescription prop so the parent can own description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,8 +48,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractError, setExtractError] = useState<string | null>(null)
|
||||
const [extractPending, setExtractPending] = useState(false)
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const cooldownRef = useRef(false)
|
||||
const touchStartY = useRef<number | null>(null)
|
||||
|
||||
@@ -126,14 +128,19 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
return () => clearTimeout(id)
|
||||
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
||||
|
||||
// Fetch extracted text for current item
|
||||
// Fetch extracted text for current item; clear any in-flight poll on item change
|
||||
useEffect(() => {
|
||||
if (extractPollRef.current) {
|
||||
clearInterval(extractPollRef.current)
|
||||
extractPollRef.current = null
|
||||
}
|
||||
setExtractedText(null)
|
||||
setTranslatedText(null)
|
||||
setShowTextOverlay(false)
|
||||
setShowOriginal(false)
|
||||
setExtracting(false)
|
||||
setExtractError(null)
|
||||
setExtractPending(false)
|
||||
if (!current?.itemKey) return
|
||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
||||
.then((r) => r.json())
|
||||
@@ -144,6 +151,13 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
.catch(() => {})
|
||||
}, [current?.itemKey])
|
||||
|
||||
// Clean up poll on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return }
|
||||
@@ -184,23 +198,44 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
|
||||
const handleExtractText = async () => {
|
||||
if (!current?.itemKey) return
|
||||
const itemKey = current.itemKey
|
||||
setExtracting(true)
|
||||
setExtractError(null)
|
||||
setExtractPending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey: current.itemKey }),
|
||||
body: JSON.stringify({ itemKey }),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
// Job queued — poll until it completes (up to 5 min)
|
||||
setExtractPending(true)
|
||||
const deadline = Date.now() + 5 * 60 * 1000
|
||||
extractPollRef.current = setInterval(async () => {
|
||||
if (Date.now() > deadline) {
|
||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||
setExtractPending(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
|
||||
if (data.extractedText) {
|
||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||
setExtractPending(false)
|
||||
setExtractedText(data.extractedText)
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setShowTextOverlay(true)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||
}
|
||||
if (res.status === 202) {
|
||||
setExtractError('Queued — check AI Integrations for progress')
|
||||
setTimeout(() => setExtractError(null), 4000)
|
||||
return
|
||||
}
|
||||
const result = await res.json()
|
||||
setExtractedText(result.extractedText || null)
|
||||
setTranslatedText(result.translatedText || null)
|
||||
@@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||
<button
|
||||
onClick={handleExtractText}
|
||||
disabled={extracting}
|
||||
disabled={extracting || extractPending}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||
style={{
|
||||
backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)',
|
||||
backgroundColor: extractPending
|
||||
? 'var(--accent)'
|
||||
: extractError
|
||||
? 'rgba(127,29,29,0.8)'
|
||||
: 'rgba(0,0,0,0.5)',
|
||||
color: extractError ? '#fca5a5' : '#fff',
|
||||
}}
|
||||
aria-label="Extract text"
|
||||
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||||
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||||
>
|
||||
{extracting ? (
|
||||
{extracting || extractPending ? (
|
||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
Reference in New Issue
Block a user