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..f3ec6f4 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -33,7 +33,7 @@ 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 [generatingDesc, setGeneratingDesc] = useState(false) const [descPending, setDescPending] = useState(false) @@ -173,21 +173,117 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item return (
- {/* Toolbar — collapses to just filename + text overlay when tag panel is open */} -
- - {name} - -
- {/* Text overlay button — always shown when text exists */} - {extractedText && ( + {/* Outer flex — row on md+, col on mobile when panel open */} +
+ + {/* ── Media pane — always full when no panel, flex-1 when panel open ── */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {name} e.stopPropagation()} + /> + + {/* Prev / Next */} + {onPrev && ( + + )} + {onNext && ( + + )} + + {/* Text overlay */} + {showTextOverlay && displayText && ( +
e.stopPropagation()} + > + {extractedText && translatedText && ( +
+ +
+ )} +

+ {displayText} +

+
+ )} + + {/* ── Floating controls ── */} + + {/* Filename pill — bottom-left */} +
+ + {name} + +
+ + {/* Tags + Close — top-right */} +
e.stopPropagation()} + > + {itemKey && !showTags && ( + + )} + +
+ + {/* Text display button — bottom-right, hidden when panel open */} + {!showTags && extractedText && ( )} - {/* These buttons only show in the toolbar when the tag panel is closed */} - {!showTags && ( - <> - {itemKey && ( - - )} - {onAiTag && ( - - )} - - - )}
-
- {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 */} + {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} + {showTags && (
e.stopPropagation()} > - {/* Panel header — hide panel + AI tagger + close lightbox */} -
+ {/* Panel header — ‹ hide | ✨ AI tag ✕ close */} +
- {/* Description section */} -
-

- Description -

- {aiDescription && ( -

- {aiDescription} + {/* Scrollable panel content */} +

+ + {/* Description section */} +
+

+ Description

- )} -
- - {descError && ( - {descError} + {aiDescription && ( +

+ {aiDescription} +

)} -
-
- - {/* 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." - /> + {descError && ( + {descError} )}
+
- {extractError && ( -

{extractError}

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

+ Text Extraction +

- {extractedText && ( -
-
-

- Extracted Text -

-