diff --git a/src/app/api/ai-tagging/extract-text/route.ts b/src/app/api/ai-tagging/extract-text/route.ts index b213555..857f710 100644 --- a/src/app/api/ai-tagging/extract-text/route.ts +++ b/src/app/api/ai-tagging/extract-text/route.ts @@ -3,14 +3,14 @@ import { requireLibraryAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { - let body: { itemKey?: string; ocrLanguages?: string } + let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { itemKey, ocrLanguages } = body + const { itemKey, ocrLanguages, ocrMode } = body if (!itemKey || typeof itemKey !== 'string') { 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) if (auth instanceof NextResponse) return auth + const payload: Record = {} + if (ocrLanguages) payload.ocrLanguages = ocrLanguages + if (ocrMode) payload.ocrMode = ocrMode const jobId = enqueueJob( itemKey, 'extract', libraryId, undefined, - ocrLanguages ? { ocrLanguages } : undefined, + Object.keys(payload).length ? payload : undefined, ) return NextResponse.json({ jobId }, { status: 202 }) } diff --git a/src/app/api/ai-tagging/fields/route.ts b/src/app/api/ai-tagging/fields/route.ts index c4155b1..db65d63 100644 --- a/src/app/api/ai-tagging/fields/route.ts +++ b/src/app/api/ai-tagging/fields/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' 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) { const { searchParams } = request.nextUrl @@ -19,25 +19,37 @@ export async function GET(request: NextRequest) { } export async function PATCH(request: NextRequest) { - let body: { itemKey?: string; extractedText?: string } + let body: { itemKey?: string; extractedText?: string; aiDescription?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { itemKey, extractedText } = body + const { itemKey, extractedText, aiDescription } = body if (!itemKey || typeof itemKey !== 'string') { return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) } - if (typeof extractedText !== 'string') { - return NextResponse.json({ error: 'extractedText is required' }, { status: 400 }) + if (extractedText === undefined && aiDescription === undefined) { + return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 }) } const libraryId = itemKey.split(':')[0] const auth = await requireLibraryAccess(request, libraryId) 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 }) } diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index 22ad6b3..3d045fa 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -39,6 +39,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item // 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) @@ -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 ›
- {onAiTag && ( - - )} - {descError && ( - {descError} - )}
+ {/* Editable textarea */} +