update ai buttons
This commit is contained in:
@@ -39,6 +39,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
// 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)
|
||||
@@ -71,6 +73,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
setEditedExtractedText(data.extractedText ?? '')
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setAiDescription(data.aiDescription)
|
||||
setEditedDescription(data.aiDescription ?? '')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [itemKey])
|
||||
@@ -116,6 +119,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
setEditedExtractedText(data.extractedText ?? '')
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setAiDescription(data.aiDescription)
|
||||
setEditedDescription(data.aiDescription ?? '')
|
||||
setExtractPending(false)
|
||||
setTranslatePending(false)
|
||||
setDescPending(false)
|
||||
@@ -172,6 +176,57 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -331,42 +386,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
›
|
||||
</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}
|
||||
@@ -386,92 +405,112 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
{/* 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>
|
||||
{aiDescription && (
|
||||
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{aiDescription}
|
||||
{/* 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>
|
||||
)}
|
||||
<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"
|
||||
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)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!descPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
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 ? '⟳ Generating…' : descPending ? '⟳ Queued…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
{descError && (
|
||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||
)}
|
||||
</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' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Text Extraction
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 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={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,
|
||||
...(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)
|
||||
}
|
||||
}}
|
||||
onClick={() => callExtract('llm')}
|
||||
disabled={extracting || extractPending}
|
||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||
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) {
|
||||
@@ -480,30 +519,26 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||
</button>
|
||||
{ocrMode && ocrMode !== 'llm' && (
|
||||
<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."
|
||||
/>
|
||||
)}
|
||||
<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 && (
|
||||
@@ -637,9 +672,34 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user