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:
Garret Patti
2026-04-13 20:37:20 -04:00
parent d754f85717
commit 96cfb8aae7
7 changed files with 552 additions and 220 deletions

View File

@@ -1,4 +1,5 @@
import fs from 'fs' import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
@@ -31,6 +32,21 @@ export async function GET(request: NextRequest) {
const listing = recursive const listing = recursive
? scanDirectoryRecursive(root, libraryId, subpath) ? scanDirectoryRecursive(root, libraryId, subpath)
: scanDirectory(root, libraryId, subpath) : scanDirectory(root, libraryId, subpath)
// Annotate image entries with whether they have extracted text
const db = getDb()
const rows = db
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
.all(libraryId) as { item_key: string }[]
const withText = new Set(rows.map((r) => r.item_key))
listing.entries = listing.entries.map((e) => {
if (e.type !== 'file' || e.mediaType !== 'image') return e
const relPath = subpath ? path.join(subpath, e.name) : e.name
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
return { ...e, hasExtractedText: withText.has(itemKey) }
})
return NextResponse.json(listing) return NextResponse.json(listing)
} }

View File

@@ -48,8 +48,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
const [extracting, setExtracting] = useState(false) const [extracting, setExtracting] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null) const [extractError, setExtractError] = useState<string | null>(null)
const [extractPending, setExtractPending] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cooldownRef = useRef(false) const cooldownRef = useRef(false)
const touchStartY = useRef<number | null>(null) const touchStartY = useRef<number | null>(null)
@@ -126,14 +128,19 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
return () => clearTimeout(id) return () => clearTimeout(id)
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext]) }, [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(() => { useEffect(() => {
if (extractPollRef.current) {
clearInterval(extractPollRef.current)
extractPollRef.current = null
}
setExtractedText(null) setExtractedText(null)
setTranslatedText(null) setTranslatedText(null)
setShowTextOverlay(false) setShowTextOverlay(false)
setShowOriginal(false) setShowOriginal(false)
setExtracting(false) setExtracting(false)
setExtractError(null) setExtractError(null)
setExtractPending(false)
if (!current?.itemKey) return if (!current?.itemKey) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`) fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -144,6 +151,13 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
.catch(() => {}) .catch(() => {})
}, [current?.itemKey]) }, [current?.itemKey])
// Clean up poll on unmount
useEffect(() => {
return () => {
if (extractPollRef.current) clearInterval(extractPollRef.current)
}
}, [])
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return } if (e.key === 'Escape') { onClose(); return }
@@ -184,23 +198,44 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const handleExtractText = async () => { const handleExtractText = async () => {
if (!current?.itemKey) return if (!current?.itemKey) return
const itemKey = current.itemKey
setExtracting(true) setExtracting(true)
setExtractError(null) setExtractError(null)
setExtractPending(false)
try { try {
const res = await fetch('/api/ai-tagging/extract-text', { const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Extraction failed') 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() const result = await res.json()
setExtractedText(result.extractedText || null) setExtractedText(result.extractedText || null)
setTranslatedText(result.translatedText || null) setTranslatedText(result.translatedText || null)
@@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
) : current?.itemKey && current?.mediaType === 'image' ? ( ) : current?.itemKey && current?.mediaType === 'image' ? (
<button <button
onClick={handleExtractText} 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" className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
style={{ 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', 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> <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"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
interface Props { interface Props {
@@ -25,16 +25,27 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const [extractedText, setExtractedText] = useState<string | null>(null) const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null) const [translatedText, setTranslatedText] = useState<string | null>(null)
const [extracting, setExtracting] = useState(false) const [extracting, setExtracting] = useState(false)
const [extractPending, setExtractPending] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null) const [extractError, setExtractError] = useState<string | null>(null)
const [retranslating, setRetranslating] = useState(false) const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [editedExtractedText, setEditedExtractedText] = useState<string>('') const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false) const [savingText, setSavingText] = useState(false)
const [sourceLanguage, setSourceLanguage] = useState('') const [sourceLanguage, setSourceLanguage] = useState('')
// Description state (moved from TagSelector)
const [aiDescription, setAiDescription] = useState<string | null>(null)
const [generatingDesc, setGeneratingDesc] = useState(false)
const [descPending, setDescPending] = useState(false)
const [descError, setDescError] = useState<string | null>(null)
// Text overlay state // Text overlay state
const [showTextOverlay, setShowTextOverlay] = useState(false) const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false) const [showOriginal, setShowOriginal] = useState(false)
// Polling ref
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Determine if this is an image file (for text extraction controls) // Determine if this is an image file (for text extraction controls)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name) const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
@@ -42,18 +53,61 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
// Fetch existing AI fields on mount / item change // Fetch existing AI fields on mount / item change
useEffect(() => { const fetchAiFields = useCallback(() => {
if (!itemKey) return if (!itemKey) return Promise.resolve()
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => { .then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
setExtractedText(data.extractedText) setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '') setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated) setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
}) })
.catch(() => {}) .catch(() => {})
}, [itemKey]) }, [itemKey])
useEffect(() => {
fetchAiFields()
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [fetchAiFields])
// Start polling fields every 2s until data changes or 5-min timeout
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
if (!itemKey) return
if (pollRef.current) clearInterval(pollRef.current)
const deadline = Date.now() + 5 * 60 * 1000
pollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
const textChanged = data.extractedText !== snapshotText
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
const descChanged = data.aiDescription !== snapshotDesc
if (textChanged || translationChanged || descChanged) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
}
} catch { /* ignore */ }
}, 2000)
}, [itemKey])
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
@@ -72,6 +126,38 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const handleGenerateDescription = async () => {
if (!itemKey) return
setGeneratingDesc(true)
setDescError(null)
setDescPending(false)
try {
const res = await fetch('/api/ai-tagging/describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (res.status === 202) {
setDescPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
}
const { description } = await res.json()
setAiDescription(description)
} catch (err) {
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
setTimeout(() => setDescError(null), 4000)
} finally {
setGeneratingDesc(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
@@ -79,13 +165,13 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Toolbar */} {/* Toolbar — collapses to just filename + text overlay when tag panel is open */}
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}> <div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name} {name}
</span> </span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{/* Text overlay button — only shown when extracted text exists */} {/* Text overlay button — always shown when text exists */}
{extractedText && ( {extractedText && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
@@ -110,22 +196,17 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</svg> </svg>
</button> </button>
)} )}
{/* These buttons only show in the toolbar when the tag panel is closed */}
{!showTags && (
<>
{itemKey && ( {itemKey && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors" className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)', onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
color: showTags ? '#fff' : 'var(--text-primary)', onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
fontSize: '1.5rem', aria-label="Show tags"
}}
onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags" title="Tags"
> >
🏷 🏷
@@ -173,12 +254,14 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
onClick={onClose} onClick={onClose}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors" className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close" aria-label="Close"
> >
</button> </button>
</>
)}
</div> </div>
</div> </div>
@@ -237,21 +320,122 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</div> </div>
)} )}
</div> </div>
{/* Tag panel */} {/* Tag panel */}
<div <div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4" className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> {/* Panel header — hide panel + AI tagger + close lightbox */}
Tags <div className="flex items-center justify-between flex-shrink-0">
<button
onClick={() => setShowTags(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<div className="flex items-center gap-1.5">
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag this image"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
</div>
{/* Description section */}
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Description
</p> </p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} /> {aiDescription && (
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
{aiDescription}
</p>
)}
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc || descPending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: descPending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!descPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc ? '⟳ Generating…' : descPending ? '⟳ Queued…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)}
</div>
</div>
{/* Text extraction section — only for images */} {/* Text extraction section — only for images */}
{isImage && ( {isImage && (
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction Text Extraction
</p> </p>
@@ -259,21 +443,22 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
onClick={async () => { onClick={async () => {
setExtracting(true) setExtracting(true)
setExtractError(null) setExtractError(null)
setExtractPending(false)
try { try {
const res = await fetch('/api/ai-tagging/extract-text', { const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }), body: JSON.stringify({ itemKey }),
}) })
if (res.status === 202) {
setExtractPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text') throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
} }
if (res.status === 202) {
setExtractError('Queued — check AI Integrations for progress')
setTimeout(() => setExtractError(null), 4000)
return
}
const result = await res.json() const result = await res.json()
setExtractedText(result.extractedText || null) setExtractedText(result.extractedText || null)
setEditedExtractedText(result.extractedText || '') setEditedExtractedText(result.extractedText || '')
@@ -285,25 +470,30 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
setExtracting(false) setExtracting(false)
} }
}} }}
disabled={extracting} disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2" className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
color: extractPending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!extracting) { if (!extracting && !extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)' ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}} }}
> >
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'} {extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
</button> </button>
{extractError && ( {extractError && (
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p> <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
)} )}
{extractedText && ( {extractedText && (
@@ -380,47 +570,64 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
<button <button
onClick={async () => { onClick={async () => {
setRetranslating(true) setRetranslating(true)
setTranslatePending(false)
try { try {
const res = await fetch('/api/ai-tagging/translate', { const res = await fetch('/api/ai-tagging/translate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }), body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
}) })
if (res.status === 202) {
setTranslatePending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate') throw new Error((data as { error?: string }).error ?? 'Failed to translate')
} }
if (res.status !== 202) {
const result = await res.json() const result = await res.json()
setTranslatedText(result.translatedText || null) setTranslatedText(result.translatedText || null)
}
} catch { } catch {
// ignore // ignore
} finally { } finally {
setRetranslating(false) setRetranslating(false)
} }
}} }}
disabled={retranslating} disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50" className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!retranslating) { if (!retranslating && !translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)' ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}} }}
> >
{retranslating ? '⟳ Translating…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'} {retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
{/* Tags section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
</div>
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -844,7 +844,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text for Folder 🔍 Extract Text for Folder
</button> </button>
)} )}
{onTranslate && entry.mediaType === 'image' && ( {onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()

View File

@@ -45,6 +45,8 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
<div <div
ref={overlayRef} ref={overlayRef}
@@ -52,27 +54,21 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }} style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
{/* Toolbar */} {/* Toolbar — collapses to just filename when tag panel is open */}
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}> <div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name} {name}
</span> </span>
{!showTags && (
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{itemKey && ( {itemKey && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)', onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
color: showTags ? '#fff' : 'var(--text-primary)', onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
}} aria-label="Show tags"
onMouseEnter={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTags ? 'Hide tags' : 'Show tags'}
title="Tags" title="Tags"
> >
🏷 🏷
@@ -119,13 +115,14 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
onClick={onClose} onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors" className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }} style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')} onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')} onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close" aria-label="Close"
> >
</button> </button>
</div> </div>
)}
</div> </div>
{showTags ? ( {showTags ? (
@@ -164,18 +161,86 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
</button> </button>
)} )}
</div> </div>
{/* Tag panel */} {/* Tag panel */}
<div <div
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4" className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Panel header — hide panel + AI tagger + close */}
<div className="flex items-center justify-between flex-shrink-0">
<button
onClick={() => setShowTags(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<div className="flex items-center gap-1.5">
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag this video"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
</div>
{/* Tags section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags Tags
</p> </p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} /> <TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
</div> </div>
</div> </div>
</div>
) : ( ) : (
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative"> <div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
<video <video

View File

@@ -8,6 +8,7 @@ interface Props {
itemKey: string itemKey: string
onTagsChanged?: () => void onTagsChanged?: () => void
refreshKey?: number refreshKey?: number
hideDescription?: boolean
} }
interface AllTags { interface AllTags {
@@ -15,7 +16,7 @@ interface AllTags {
tags: Tag[] tags: Tag[]
} }
export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) { export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [], tags: [],
categories: [], categories: [],
@@ -210,6 +211,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* AI description */} {/* AI description */}
{!hideDescription && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{aiDescription && ( {aiDescription && (
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
@@ -241,6 +243,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
)} )}
</div> </div>
</div> </div>
)}
{/* Assigned tags grouped by category */} {/* Assigned tags grouped by category */}
{assigned.tags.length > 0 && ( {assigned.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">

View File

@@ -44,6 +44,7 @@ export interface FileEntry {
mediaType: MediaType | null mediaType: MediaType | null
url: string | null url: string | null
thumbnailUrl: string | null thumbnailUrl: string | null
hasExtractedText?: boolean
} }
export interface Movie { export interface Movie {