'use client' import { useEffect, useRef, useState, useCallback } from 'react' import TagSelector from '@/components/tags/TagSelector' interface Props { url: string name: string onClose: () => void onPrev?: () => void onNext?: () => void itemKey?: string onTagsChanged?: () => void onAiTag?: () => Promise showTags?: boolean onShowTagsChange?: (v: boolean) => void } export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange }: Props) { const overlayRef = useRef(null) const [showTagsLocal, setShowTagsLocal] = useState(false) const showTags = showTagsProp ?? showTagsLocal const setShowTags = onShowTagsChange ?? setShowTagsLocal const [aiTagging, setAiTagging] = useState(false) const [aiTagError, setAiTagError] = useState(null) const [tagRefreshKey, setTagRefreshKey] = useState(0) // Text extraction state const [extractedText, setExtractedText] = useState(null) const [translatedText, setTranslatedText] = useState(null) const [extracting, setExtracting] = useState(false) const [extractPending, setExtractPending] = useState(false) const [extractError, setExtractError] = useState(null) const [retranslating, setRetranslating] = useState(false) const [translatePending, setTranslatePending] = useState(false) const [editedExtractedText, setEditedExtractedText] = useState('') const [savingText, setSavingText] = useState(false) const [sourceLanguage, setSourceLanguage] = useState('') // Description state const [aiDescription, setAiDescription] = useState(null) const [editedDescription, setEditedDescription] = useState('') const [savingDesc, setSavingDesc] = useState(false) const [generatingDesc, setGeneratingDesc] = useState(false) const [descPending, setDescPending] = useState(false) const [descError, setDescError] = useState(null) // OCR settings const [ocrMode, setOcrMode] = useState(null) const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng') const [ocrLanguageInput, setOcrLanguageInput] = useState('') // Text overlay state const [showTextOverlay, setShowTextOverlay] = useState(false) const [showOriginal, setShowOriginal] = useState(false) // Polling ref const pollRef = useRef | null>(null) // Determine if this is an image file (for text extraction controls) const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name) // Derived: what text to display in the overlay const displayText = (translatedText && !showOriginal) ? translatedText : extractedText // Fetch existing AI fields on mount / item change const fetchAiFields = useCallback(() => { if (!itemKey) return Promise.resolve() return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) .then((r) => r.json()) .then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => { setExtractedText(data.extractedText) setEditedExtractedText(data.extractedText ?? '') setTranslatedText(data.extractedTextTranslated) setAiDescription(data.aiDescription) setEditedDescription(data.aiDescription ?? '') }) .catch(() => {}) }, [itemKey]) useEffect(() => { fetchAiFields() fetch('/api/ai-settings/ocr') .then((r) => r.json()) .then((d: { ocrMode: string; ocrLanguages: string }) => { setOcrMode(d.ocrMode) setDefaultOcrLanguages(d.ocrLanguages) }) .catch(() => {}) 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) setEditedDescription(data.aiDescription ?? '') setExtractPending(false) setTranslatePending(false) setDescPending(false) } } catch { /* ignore */ } }, 2000) }, [itemKey]) useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() if (e.key === 'ArrowLeft') onPrev?.() if (e.key === 'ArrowRight') onNext?.() } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } }, [onClose, onPrev, onNext]) const handleOverlayClick = (e: React.MouseEvent) => { 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 callExtract = async (modeOverride: string) => { 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, ocrMode: modeOverride, ...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }), }), }) 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') } const result = await res.json() setExtractedText(result.extractedText || null) setEditedExtractedText(result.extractedText || '') setTranslatedText(result.translatedText || null) } catch (err) { setExtractError(err instanceof Error ? err.message : 'Failed to extract text') setTimeout(() => setExtractError(null), 4000) } finally { setExtracting(false) } } const handleAiTag = async () => { if (!onAiTag) return 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) } } const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' return (
{/* Outer flex — row on md+, col on mobile when panel open */}
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
{/* eslint-disable-next-line @next/next/no-img-element */} {name} e.stopPropagation()} /> {/* Prev / Next */} {onPrev && ( )} {onNext && ( )} {/* Text overlay */} {showTextOverlay && displayText && (
e.stopPropagation()} > {extractedText && translatedText && (
)}

{displayText}

)} {/* ── Floating controls ── */} {/* Filename pill — bottom-left */}
{name}
{/* Tags + Close — top-right */}
e.stopPropagation()} > {itemKey && !showTags && ( )} {!showTags && ( )}
{/* Text display button — bottom-right, hidden when panel open */} {!showTags && extractedText && ( )}
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {showTags && (
e.stopPropagation()} > {/* Panel header — ‹ hide | ✨ AI tag ✕ close */}
{/* Scrollable panel content */}
{/* Description section */}
{/* Heading row */}

Description

{/* Editable textarea */}