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/app/api/ai-tagging/translate-bulk/route.ts b/src/app/api/ai-tagging/translate-bulk/route.ts new file mode 100644 index 0000000..7b10fa1 --- /dev/null +++ b/src/app/api/ai-tagging/translate-bulk/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { enqueueJob } from '@/lib/ai-jobs' +import { getDb } from '@/lib/db' + +export async function POST(request: NextRequest) { + let body: { libraryId?: string; path?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { libraryId, path: dirPath } = body + if (!libraryId || typeof libraryId !== 'string') { + return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) + } + + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const db = getDb() + const prefix = dirPath + ? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}` + : `${libraryId}:mixed_file:` + + // Only enqueue translate jobs for items that already have extracted text + const items = db + .prepare( + 'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL' + ) + .all(`${prefix}%`, 'mixed_file') as { item_key: string }[] + + const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId)) + return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 }) +} diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 2bd89e5..6182661 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -33,18 +33,37 @@ export async function GET(request: NextRequest) { ? scanDirectoryRecursive(root, libraryId, subpath) : scanDirectory(root, libraryId, subpath) - // Annotate image entries with whether they have extracted text + // Annotate image files with hasExtractedText, and directories if any descendant has extracted text const db = getDb() const rows = db .prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL') .all(libraryId) as { item_key: string }[] const withText = new Set(rows.map((r) => r.item_key)) + // Build a set of all ancestor directory relative paths that contain at least one item with text + // e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1" + const dirsWithText = new Set() + const keyPrefix = `${libraryId}:mixed_file:` + for (const key of withText) { + const decoded = decodeURIComponent(key.slice(keyPrefix.length)) + const parts = decoded.split('/') + for (let i = 1; i < parts.length; i++) { + dirsWithText.add(parts.slice(0, i).join('/')) + } + } + listing.entries = listing.entries.map((e) => { - if (e.type !== 'file' || e.mediaType !== 'image') return e - const relPath = subpath ? path.join(subpath, e.name) : e.name - const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}` - return { ...e, hasExtractedText: withText.has(itemKey) } + if (e.type === 'file') { + if (e.mediaType !== 'image') return e + const relPath = subpath ? path.join(subpath, e.name) : e.name + const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}` + return { ...e, hasExtractedText: withText.has(itemKey) } + } + if (e.type === 'directory') { + const dirRel = subpath ? `${subpath}/${e.name}` : e.name + if (dirsWithText.has(dirRel)) return { ...e, hasExtractedText: true } + } + return e }) return NextResponse.json(listing) diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index d059616..3d045fa 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -12,11 +12,15 @@ interface Props { itemKey?: string onTagsChanged?: () => void onAiTag?: () => Promise + showTags?: boolean + onShowTagsChange?: (v: boolean) => void } -export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) { +export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange }: Props) { const overlayRef = useRef(null) - const [showTags, setShowTags] = useState(false) + 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) @@ -33,8 +37,10 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item const [savingText, setSavingText] = useState(false) const [sourceLanguage, setSourceLanguage] = useState('') - // Description state (moved from TagSelector) + // 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) @@ -67,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]) @@ -112,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) @@ -168,510 +176,80 @@ 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 (
- {/* Toolbar — collapses to just filename + text overlay when tag panel is open */} -
- - {name} - -
- {/* Text overlay button — always shown when text exists */} - {extractedText && ( - - )} - {/* These buttons only show in the toolbar when the tag panel is closed */} - {!showTags && ( - <> - {itemKey && ( - - )} - {onAiTag && ( - - )} - - - )} -
-
+ {/* Outer flex — row on md+, col on mobile when panel open */} +
- {showTags ? ( -
- {/* Image */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {name} e.stopPropagation()} - /> - {onPrev && ( - - )} - {onNext && ( - - )} - {/* Text overlay */} - {showTextOverlay && displayText && ( -
e.stopPropagation()} - > - {extractedText && translatedText && ( -
- -
- )} -

- {displayText} -

-
- )} -
- - {/* Tag panel */} -
e.stopPropagation()} - > - {/* Panel header — hide panel + AI tagger + close lightbox */} -
- -
- {onAiTag && ( - - )} - -
-
- - {/* Description section */} -
-

- Description -

- {aiDescription && ( -

- {aiDescription} -

- )} -
- - {descError && ( - {descError} - )} -
-
- - {/* Text extraction section — only for images */} - {isImage && ( -
-

- Text Extraction -

- -
- - {ocrMode && ocrMode !== 'llm' && ( - 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." - /> - )} -
- - {extractError && ( -

{extractError}

- )} - - {extractedText && ( -
-
-

- Extracted Text -

-