714 lines
32 KiB
TypeScript
714 lines
32 KiB
TypeScript
'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<void>
|
||
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<HTMLDivElement>(null)
|
||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||
const showTags = showTagsProp ?? showTagsLocal
|
||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||
const [aiTagging, setAiTagging] = useState(false)
|
||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||
|
||
// Text extraction state
|
||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||
const [extracting, setExtracting] = useState(false)
|
||
const [extractPending, setExtractPending] = useState(false)
|
||
const [extractError, setExtractError] = useState<string | null>(null)
|
||
const [retranslating, setRetranslating] = useState(false)
|
||
const [translatePending, setTranslatePending] = useState(false)
|
||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||
const [savingText, setSavingText] = useState(false)
|
||
const [sourceLanguage, setSourceLanguage] = useState('')
|
||
|
||
// Description state
|
||
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||
const [editedDescription, setEditedDescription] = useState<string>('')
|
||
const [savingDesc, setSavingDesc] = useState(false)
|
||
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||
const [descPending, setDescPending] = useState(false)
|
||
const [descError, setDescError] = useState<string | null>(null)
|
||
|
||
// OCR settings
|
||
const [ocrMode, setOcrMode] = useState<string | null>(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<ReturnType<typeof setInterval> | 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 (
|
||
<div
|
||
ref={overlayRef}
|
||
className="fixed inset-0 z-50 overflow-hidden"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||
onClick={handleOverlayClick}
|
||
>
|
||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
|
||
|
||
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
|
||
<div className="relative flex-1 min-h-0 min-w-0">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={url}
|
||
alt={name}
|
||
className="absolute inset-0 w-full h-full object-contain"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
|
||
{/* Prev / Next */}
|
||
{onPrev && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||
aria-label="Previous"
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
{onNext && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||
aria-label="Next"
|
||
>
|
||
›
|
||
</button>
|
||
)}
|
||
|
||
{/* Text overlay */}
|
||
{showTextOverlay && displayText && (
|
||
<div
|
||
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{extractedText && translatedText && (
|
||
<div className="flex justify-end mb-2">
|
||
<button
|
||
onClick={() => setShowOriginal((v) => !v)}
|
||
className="text-xs px-2 py-0.5 rounded-full"
|
||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
||
>
|
||
{showOriginal ? 'Show Translation' : 'Show Original'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
||
{displayText}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Floating controls ── */}
|
||
|
||
{/* Filename pill — bottom-left */}
|
||
<div
|
||
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||
>
|
||
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||
{name}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Tags + Close — top-right */}
|
||
<div
|
||
className="absolute top-4 right-4 flex items-center gap-1.5"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{itemKey && !showTags && (
|
||
<button
|
||
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
|
||
className={smallBtn}
|
||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||
aria-label="Show tags"
|
||
title="Tags"
|
||
>
|
||
🏷
|
||
</button>
|
||
)}
|
||
{!showTags && (
|
||
<button
|
||
onClick={onClose}
|
||
className={smallBtn}
|
||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||
aria-label="Close"
|
||
title="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Text display button — bottom-right, hidden when panel open */}
|
||
{!showTags && extractedText && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||
className={`absolute bottom-4 right-4 ${smallBtn}`}
|
||
style={{
|
||
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
||
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||
}}
|
||
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||
title="Display text"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||
<line x1="3" y1="12" x2="15" y2="12"/>
|
||
<line x1="3" y1="18" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||
{showTags && (
|
||
<div
|
||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Panel header — ‹ hide | ✨ AI tag ✕ close */}
|
||
<div className="flex items-center justify-between p-4 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">
|
||
<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>
|
||
|
||
{/* Scrollable panel content */}
|
||
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
|
||
|
||
{/* Description section */}
|
||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||
{/* Heading row */}
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||
Description
|
||
</p>
|
||
<button
|
||
onClick={handleGenerateDescription}
|
||
disabled={generatingDesc || descPending}
|
||
className={`${smallBtn} disabled:opacity-50`}
|
||
style={{
|
||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||
fontSize: '1rem',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||
}}
|
||
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||
>
|
||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||
</button>
|
||
</div>
|
||
{/* Editable textarea */}
|
||
<textarea
|
||
value={editedDescription}
|
||
onChange={(e) => setEditedDescription(e.target.value)}
|
||
placeholder="No description yet…"
|
||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||
style={{
|
||
backgroundColor: 'var(--background)',
|
||
border: '1px solid var(--border)',
|
||
color: 'var(--text-primary)',
|
||
minHeight: '3.5rem',
|
||
maxHeight: '8rem',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
{editedDescription !== (aiDescription ?? '') && (
|
||
<button
|
||
onClick={async () => {
|
||
setSavingDesc(true)
|
||
try {
|
||
await fetch('/api/ai-tagging/fields', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
||
})
|
||
setAiDescription(editedDescription)
|
||
} finally {
|
||
setSavingDesc(false)
|
||
}
|
||
}}
|
||
disabled={savingDesc}
|
||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||
>
|
||
{savingDesc ? '⟳ Saving…' : 'Save'}
|
||
</button>
|
||
)}
|
||
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
||
</div>
|
||
|
||
{/* Text extraction section — only for images */}
|
||
{isImage && (
|
||
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||
{/* Heading row */}
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||
Text Extraction
|
||
</p>
|
||
{/* AI button — forces LLM, no OCR */}
|
||
<button
|
||
onClick={() => callExtract('llm')}
|
||
disabled={extracting || extractPending}
|
||
className={`${smallBtn} disabled:opacity-50`}
|
||
style={{
|
||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||
fontSize: '1rem',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||
}}
|
||
aria-label="Extract text with AI"
|
||
title="Extract with AI (skips OCR)"
|
||
>
|
||
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* OCR button row */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button
|
||
onClick={() => callExtract('tesseract')}
|
||
disabled={extracting || extractPending}
|
||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||
style={{
|
||
backgroundColor: 'var(--border)',
|
||
color: 'var(--text-secondary)',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
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)'
|
||
}}
|
||
>
|
||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||
</button>
|
||
<input
|
||
type="text"
|
||
value={ocrLanguageInput}
|
||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||
placeholder={defaultOcrLanguages}
|
||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||
style={{
|
||
backgroundColor: 'var(--background)',
|
||
border: '1px solid var(--border)',
|
||
color: 'var(--text-primary)',
|
||
width: 120,
|
||
}}
|
||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||
/>
|
||
</div>
|
||
|
||
{extractError && (
|
||
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
|
||
)}
|
||
|
||
{extractedText && (
|
||
<div className="flex flex-col gap-2">
|
||
<div>
|
||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Extracted Text
|
||
</p>
|
||
<textarea
|
||
value={editedExtractedText}
|
||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||
style={{
|
||
backgroundColor: 'var(--background)',
|
||
color: 'var(--text-primary)',
|
||
border: '1px solid var(--border)',
|
||
minHeight: '4rem',
|
||
maxHeight: '10rem',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
{editedExtractedText !== extractedText && (
|
||
<button
|
||
onClick={async () => {
|
||
setSavingText(true)
|
||
try {
|
||
await fetch('/api/ai-tagging/fields', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||
})
|
||
setExtractedText(editedExtractedText)
|
||
} finally {
|
||
setSavingText(false)
|
||
}
|
||
}}
|
||
disabled={savingText}
|
||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||
>
|
||
{savingText ? '⟳ Saving…' : 'Save'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{translatedText && (
|
||
<div>
|
||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Translation
|
||
</p>
|
||
<pre
|
||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||
>
|
||
{translatedText}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<input
|
||
type="text"
|
||
value={sourceLanguage}
|
||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||
placeholder="Source lang…"
|
||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||
style={{
|
||
backgroundColor: 'var(--background)',
|
||
border: '1px solid var(--border)',
|
||
color: 'var(--text-primary)',
|
||
width: 100,
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={async () => {
|
||
setRetranslating(true)
|
||
setTranslatePending(false)
|
||
try {
|
||
const res = await fetch('/api/ai-tagging/translate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||
})
|
||
if (res.status === 202) {
|
||
setTranslatePending(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 translate')
|
||
}
|
||
const result = await res.json()
|
||
setTranslatedText(result.translatedText || null)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setRetranslating(false)
|
||
}
|
||
}}
|
||
disabled={retranslating || translatePending}
|
||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||
style={{
|
||
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!retranslating && !translatePending) {
|
||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!translatePending) {
|
||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||
}
|
||
}}
|
||
>
|
||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tags section */}
|
||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||
Tags
|
||
</p>
|
||
{onAiTag && (
|
||
<button
|
||
onClick={handleAiTag}
|
||
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>
|
||
)}
|
||
</div>
|
||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|