diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index f39d863..2bd89e5 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' @@ -31,6 +32,21 @@ export async function GET(request: NextRequest) { const listing = recursive ? scanDirectoryRecursive(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) } diff --git a/src/components/DoomScrollView.tsx b/src/components/DoomScrollView.tsx index e1dd38d..daa5f96 100644 --- a/src/components/DoomScrollView.tsx +++ b/src/components/DoomScrollView.tsx @@ -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(null) + const [extractPending, setExtractPending] = useState(false) const videoRef = useRef(null) + const extractPollRef = useRef | null>(null) const cooldownRef = useRef(false) const touchStartY = useRef(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' ? ( )} - {itemKey && ( - + {/* These buttons only show in the toolbar when the tag panel is closed */} + {!showTags && ( + <> + {itemKey && ( + + )} + {onAiTag && ( + + )} + + )} - {onAiTag && ( - - )} - {showTags ? (
{/* Image */} -
+
{/* eslint-disable-next-line @next/next/no-img-element */} )}
+ {/* Tag panel */}
e.stopPropagation()} > -

- Tags -

- + {/* Panel header — hide panel + AI tagger + close lightbox */} +
+ +
+ {onAiTag && ( + + )} + +
+
+ + {/* Description section */} +
+

+ Description +

+ {aiDescription && ( +

+ {aiDescription} +

+ )} +
+ + {descError && ( + {descError} + )} +
+
{/* Text extraction section — only for images */} {isImage && ( -
-

+

+

Text Extraction

@@ -259,21 +443,22 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item onClick={async () => { 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 }), }) + if (res.status === 202) { + setExtractPending(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 extract text') } - 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) setEditedExtractedText(result.extractedText || '') @@ -285,25 +470,30 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item setExtracting(false) } }} - disabled={extracting} - className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2" - style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} + disabled={extracting || extractPending} + className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start" + style={{ + backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)', + color: extractPending ? '#fff' : 'var(--text-secondary)', + }} onMouseEnter={(e) => { - if (!extracting) { + if (!extracting && !extractPending) { ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.color = 'var(--background)' } }} onMouseLeave={(e) => { - ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' - ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' + if (!extractPending) { + ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' + ;(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'} {extractError && ( -

{extractError}

+

{extractError}

)} {extractedText && ( @@ -380,47 +570,64 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
)}
)} + + {/* Tags section */} +
+

+ Tags +

+ +
) : ( diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 07e93db..e51735d 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -844,7 +844,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac 🔍 Extract Text for Folder )} - {onTranslate && entry.mediaType === 'image' && ( + {onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && ( + )} + {onAiTag && ( + + )} - )} - {onAiTag && ( - - )} - - + + )} {showTags ? ( @@ -164,16 +161,84 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i )} + {/* Tag panel */}
e.stopPropagation()} > -

- Tags -

- + {/* Panel header — hide panel + AI tagger + close */} +
+ +
+ {onAiTag && ( + + )} + +
+
+ + {/* Tags section */} +
+

+ Tags +

+ +
) : ( diff --git a/src/components/tags/TagSelector.tsx b/src/components/tags/TagSelector.tsx index 742cb16..7d3a756 100644 --- a/src/components/tags/TagSelector.tsx +++ b/src/components/tags/TagSelector.tsx @@ -8,6 +8,7 @@ interface Props { itemKey: string onTagsChanged?: () => void refreshKey?: number + hideDescription?: boolean } interface AllTags { @@ -15,7 +16,7 @@ interface AllTags { 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[] }>({ tags: [], categories: [], @@ -210,37 +211,39 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop return (
{/* AI description */} -
- {aiDescription && ( -

- {aiDescription} -

- )} -
- - {descError && ( - {descError} + {!hideDescription && ( +
+ {aiDescription && ( +

+ {aiDescription} +

)} +
+ + {descError && ( + {descError} + )} +
-
+ )} {/* Assigned tags grouped by category */} {assigned.tags.length > 0 && (
diff --git a/src/types/index.ts b/src/types/index.ts index 19d014c..fcf8b88 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,6 +44,7 @@ export interface FileEntry { mediaType: MediaType | null url: string | null thumbnailUrl: string | null + hasExtractedText?: boolean } export interface Movie {