update ai buttons
This commit is contained in:
@@ -3,14 +3,14 @@ import { requireLibraryAccess } from '@/lib/auth'
|
|||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string; ocrLanguages?: string }
|
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey, ocrLanguages } = body
|
const { itemKey, ocrLanguages, ocrMode } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
|
||||||
|
if (ocrMode) payload.ocrMode = ocrMode
|
||||||
const jobId = enqueueJob(
|
const jobId = enqueueJob(
|
||||||
itemKey,
|
itemKey,
|
||||||
'extract',
|
'extract',
|
||||||
libraryId,
|
libraryId,
|
||||||
undefined,
|
undefined,
|
||||||
ocrLanguages ? { ocrLanguages } : undefined,
|
Object.keys(payload).length ? payload : undefined,
|
||||||
)
|
)
|
||||||
return NextResponse.json({ jobId }, { status: 202 })
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { getAiFields, updateExtractedText } from '@/lib/ai-tagger'
|
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -19,25 +19,37 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
let body: { itemKey?: string; extractedText?: string }
|
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey, extractedText } = body
|
const { itemKey, extractedText, aiDescription } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
if (typeof extractedText !== 'string') {
|
if (extractedText === undefined && aiDescription === undefined) {
|
||||||
return NextResponse.json({ error: 'extractedText is required' }, { status: 400 })
|
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
updateExtractedText(itemKey, extractedText)
|
if (extractedText !== undefined) {
|
||||||
|
if (typeof extractedText !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
|
||||||
|
}
|
||||||
|
updateExtractedText(itemKey, extractedText)
|
||||||
|
}
|
||||||
|
if (aiDescription !== undefined) {
|
||||||
|
if (typeof aiDescription !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
|
||||||
|
}
|
||||||
|
updateAiDescription(itemKey, aiDescription)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
|
|
||||||
// Description state
|
// Description state
|
||||||
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||||
|
const [editedDescription, setEditedDescription] = useState<string>('')
|
||||||
|
const [savingDesc, setSavingDesc] = useState(false)
|
||||||
const [generatingDesc, setGeneratingDesc] = useState(false)
|
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||||
const [descPending, setDescPending] = useState(false)
|
const [descPending, setDescPending] = useState(false)
|
||||||
const [descError, setDescError] = useState<string | null>(null)
|
const [descError, setDescError] = useState<string | null>(null)
|
||||||
@@ -71,6 +73,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
setEditedExtractedText(data.extractedText ?? '')
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
setAiDescription(data.aiDescription)
|
setAiDescription(data.aiDescription)
|
||||||
|
setEditedDescription(data.aiDescription ?? '')
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [itemKey])
|
}, [itemKey])
|
||||||
@@ -116,6 +119,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
setEditedExtractedText(data.extractedText ?? '')
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
setAiDescription(data.aiDescription)
|
setAiDescription(data.aiDescription)
|
||||||
|
setEditedDescription(data.aiDescription ?? '')
|
||||||
setExtractPending(false)
|
setExtractPending(false)
|
||||||
setTranslatePending(false)
|
setTranslatePending(false)
|
||||||
setDescPending(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'
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -331,42 +386,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={smallBtn}
|
className={smallBtn}
|
||||||
@@ -386,92 +405,112 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
|
|
||||||
{/* Description section */}
|
{/* Description section */}
|
||||||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<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)' }}>
|
{/* Heading row */}
|
||||||
Description
|
<div className="flex items-center justify-between mb-2">
|
||||||
</p>
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{aiDescription && (
|
Description
|
||||||
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{aiDescription}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateDescription}
|
onClick={handleGenerateDescription}
|
||||||
disabled={generatingDesc || descPending}
|
disabled={generatingDesc || descPending}
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!generatingDesc && !descPending) {
|
if (!generatingDesc && !descPending) (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)'
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!descPending) {
|
if (!descPending) (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)'
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
||||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI 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>
|
</button>
|
||||||
{descError && (
|
|
||||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Text extraction section — only for images */}
|
{/* Text extraction section — only for images */}
|
||||||
{isImage && (
|
{isImage && (
|
||||||
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<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)' }}>
|
{/* Heading row */}
|
||||||
Text Extraction
|
<div className="flex items-center justify-between">
|
||||||
</p>
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Text Extraction
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
</p>
|
||||||
|
{/* AI button — forces LLM, no OCR */}
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={() => callExtract('llm')}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={extracting || extractPending}
|
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={{
|
style={{
|
||||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
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) => {
|
onMouseEnter={(e) => {
|
||||||
if (!extracting && !extractPending) {
|
if (!extracting && !extractPending) {
|
||||||
@@ -480,30 +519,26 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
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…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||||
</button>
|
</button>
|
||||||
{ocrMode && ocrMode !== 'llm' && (
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={ocrLanguageInput}
|
||||||
value={ocrLanguageInput}
|
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
placeholder={defaultOcrLanguages}
|
||||||
placeholder={defaultOcrLanguages}
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
style={{
|
||||||
style={{
|
backgroundColor: 'var(--background)',
|
||||||
backgroundColor: 'var(--background)',
|
border: '1px solid var(--border)',
|
||||||
border: '1px solid var(--border)',
|
color: 'var(--text-primary)',
|
||||||
color: 'var(--text-primary)',
|
width: 120,
|
||||||
width: 120,
|
}}
|
||||||
}}
|
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{extractError && (
|
{extractError && (
|
||||||
@@ -637,9 +672,34 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
|
|
||||||
{/* Tags section */}
|
{/* Tags section */}
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<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)' }}>
|
<div className="flex items-center justify-between mb-3">
|
||||||
Tags
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
</p>
|
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 />
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,7 +160,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
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 | ✨ AI tag ✕ close */}
|
{/* Panel header — ‹ hide | ✕ close */}
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTags(false)}
|
onClick={() => setShowTags(false)}
|
||||||
@@ -158,23 +174,29 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1.5">
|
<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>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex items-center justify-between mt-4 mb-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
{onAiTag && (
|
{onAiTag && (
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={handleAiTag}
|
||||||
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}
|
disabled={aiTagging}
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
style={{
|
style={{
|
||||||
@@ -194,25 +216,8 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
await generateItemDescription(row.item_key)
|
await generateItemDescription(row.item_key)
|
||||||
break
|
break
|
||||||
case 'extract':
|
case 'extract':
|
||||||
await extractItemText(row.item_key, jobPayload?.ocrLanguages)
|
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
|
||||||
break
|
break
|
||||||
case 'translate':
|
case 'translate':
|
||||||
await translateItemText(row.item_key, sourceLanguage || undefined)
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ async function extractWithTesseract(
|
|||||||
* Translation is not performed automatically — call translateItemText() separately.
|
* Translation is not performed automatically — call translateItemText() separately.
|
||||||
* Returns { extractedText, translatedText } where translatedText is always null.
|
* Returns { extractedText, translatedText } where translatedText is always null.
|
||||||
*/
|
*/
|
||||||
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const config = getEffectiveAiConfig(libraryId)
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
|
||||||
@@ -567,7 +567,8 @@ export async function extractItemText(itemKey: string, ocrLanguagesOverride?: st
|
|||||||
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ocrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
const { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
||||||
|
const ocrMode = ocrModeOverride ?? configOcrMode
|
||||||
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
|
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
|
||||||
|
|
||||||
// ── Tesseract path ────────────────────────────────────────────────────────
|
// ── Tesseract path ────────────────────────────────────────────────────────
|
||||||
@@ -655,6 +656,14 @@ export function updateExtractedText(itemKey: string, text: string): void {
|
|||||||
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the ai_description of an item.
|
||||||
|
*/
|
||||||
|
export function updateAiDescription(itemKey: string, description: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate text to a target language using the chat API.
|
* Translate text to a target language using the chat API.
|
||||||
* Returns null if the text is already in the target language.
|
* Returns null if the text is already in the target language.
|
||||||
|
|||||||
Reference in New Issue
Block a user