diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 6182661..63cf227 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -52,12 +52,20 @@ export async function GET(request: NextRequest) { } } + const ratingRows = db + .prepare('SELECT item_key, user_rating FROM media_items WHERE library_id = ? AND user_rating IS NOT NULL') + .all(libraryId) as { item_key: string; user_rating: number }[] + const ratingMap = new Map(ratingRows.map((r) => [r.item_key, r.user_rating])) + listing.entries = listing.entries.map((e) => { 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) } + return { + ...e, + ...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}), + userRating: ratingMap.get(itemKey) ?? null, + } } if (e.type === 'directory') { const dirRel = subpath ? `${subpath}/${e.name}` : e.name diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts new file mode 100644 index 0000000..7d421e9 --- /dev/null +++ b/src/app/api/ratings/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth' +import { getDb } from '@/lib/db' + +function extractLibraryId(itemKey: string): string | null { + const colonIdx = itemKey.indexOf(':') + if (colonIdx === -1) return null + return itemKey.slice(0, colonIdx) +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const itemKey = searchParams.get('itemKey') + if (!itemKey) { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + const libraryId = extractLibraryId(itemKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) + } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const db = getDb() + const row = db + .prepare('SELECT user_rating FROM media_items WHERE item_key = ?') + .get(itemKey) as { user_rating: number | null } | undefined + + if (!row) { + return NextResponse.json({ error: 'Item not found' }, { status: 404 }) + } + + return NextResponse.json({ userRating: row.user_rating ?? null }) +} + +export async function PATCH(request: NextRequest) { + const body = await request.json() + const { itemKey, userRating } = body as { itemKey: string; userRating: number | null } + + if (!itemKey) { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + if (userRating !== null && (typeof userRating !== 'number' || !Number.isInteger(userRating) || userRating < 1 || userRating > 5)) { + return NextResponse.json({ error: 'userRating must be null or an integer 1–5' }, { status: 400 }) + } + + const libraryId = extractLibraryId(itemKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) + } + const auth = await requireLibraryWriteAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const db = getDb() + const result = db + .prepare('UPDATE media_items SET user_rating = ? WHERE item_key = ?') + .run(userRating, itemKey) + + if (result.changes === 0) { + return NextResponse.json({ error: 'Item not found' }, { status: 404 }) + } + + return NextResponse.json({ success: true }) +} diff --git a/src/components/DoomScrollView.tsx b/src/components/DoomScrollView.tsx index d750160..32db788 100644 --- a/src/components/DoomScrollView.tsx +++ b/src/components/DoomScrollView.tsx @@ -41,14 +41,29 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, const [autoPlayEnabled, setAutoPlayEnabled] = useState(false) const [autoPlaySeconds, setAutoPlaySeconds] = useState(5) + // Tools overlay visibility + const [showToolsOverlay, setShowToolsOverlay] = useState(false) + + // Rating state + const [userRating, setUserRatingState] = useState(null) + const [ratingHover, setRatingHover] = useState(null) + const [savingRating, setSavingRating] = useState(false) + // Text overlay state const [extractedText, setExtractedText] = useState(null) + const [editedExtractedText, setEditedExtractedText] = useState('') + const [savingText, setSavingText] = useState(false) const [translatedText, setTranslatedText] = useState(null) const [showTextOverlay, setShowTextOverlay] = useState(false) const [showOriginal, setShowOriginal] = useState(false) const [extracting, setExtracting] = useState(false) const [extractError, setExtractError] = useState(null) const [extractPending, setExtractPending] = useState(false) + const [retranslating, setRetranslating] = useState(false) + const [translatePending, setTranslatePending] = useState(false) + const [ocrLanguageInput, setOcrLanguageInput] = useState('') + const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng') + const [sourceLanguage, setSourceLanguage] = useState('') const videoRef = useRef(null) const extractPollRef = useRef | null>(null) @@ -128,27 +143,50 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, return () => clearTimeout(id) }, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext]) - // Fetch extracted text for current item; clear any in-flight poll on item change + // Fetch OCR settings once on mount + useEffect(() => { + fetch('/api/ai-settings/ocr') + .then((r) => r.json()) + .then((d: { ocrMode: string; ocrLanguages: string }) => { + setDefaultOcrLanguages(d.ocrLanguages) + }) + .catch(() => {}) + }, []) + + // Fetch extracted text + rating for current item; clear any in-flight poll on item change useEffect(() => { if (extractPollRef.current) { clearInterval(extractPollRef.current) extractPollRef.current = null } setExtractedText(null) + setEditedExtractedText('') setTranslatedText(null) setShowTextOverlay(false) setShowOriginal(false) setExtracting(false) setExtractError(null) setExtractPending(false) + setRetranslating(false) + setTranslatePending(false) + setUserRatingState(null) + setRatingHover(null) if (!current?.itemKey) return - fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`) + const key = current.itemKey + fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`) .then((r) => r.json()) .then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => { setExtractedText(data.extractedText) + setEditedExtractedText(data.extractedText ?? '') setTranslatedText(data.extractedTextTranslated) }) .catch(() => {}) + fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`) + .then((r) => r.json()) + .then((data: { userRating: number | null }) => { + setUserRatingState(data.userRating) + }) + .catch(() => {}) }, [current?.itemKey]) // Clean up poll on unmount @@ -196,7 +234,58 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, } }, [navigate, onClose, extractedText]) - const handleExtractText = async () => { + // ── Polling helper ────────────────────────────────────────────────────────── + const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => { + if (!current?.itemKey) return + const itemKey = current.itemKey + if (extractPollRef.current) clearInterval(extractPollRef.current) + const deadline = Date.now() + 5 * 60 * 1000 + extractPollRef.current = setInterval(async () => { + if (Date.now() > deadline) { + clearInterval(extractPollRef.current!) + extractPollRef.current = null + setExtractPending(false) + setTranslatePending(false) + return + } + try { + const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) + const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json() + const textChanged = data.extractedText !== snapshotText + const translationChanged = data.extractedTextTranslated !== snapshotTranslated + if (textChanged || translationChanged) { + clearInterval(extractPollRef.current!) + extractPollRef.current = null + setExtractedText(data.extractedText) + setEditedExtractedText(data.extractedText ?? '') + setTranslatedText(data.extractedTextTranslated) + setExtractPending(false) + setTranslatePending(false) + if (data.extractedText) setShowTextOverlay(true) + } + } catch { /* ignore */ } + }, 2000) + }, [current?.itemKey]) + + // ── Rating actions ─────────────────────────────────────────────────────────── + const handleSetRating = useCallback(async (star: number) => { + if (!current?.itemKey) return + const next = userRating === star ? null : star + setSavingRating(true) + try { + const res = await fetch('/api/ratings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemKey: current.itemKey, userRating: next }), + }) + if (res.ok) setUserRatingState(next) + } finally { + setSavingRating(false) + } + }, [current?.itemKey, userRating]) + + // ── Text extraction ────────────────────────────────────────────────────────── + const callExtract = useCallback(async (modeOverride: string) => { if (!current?.itemKey) return const itemKey = current.itemKey setExtracting(true) @@ -206,30 +295,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, const res = await fetch('/api/ai-tagging/extract-text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ itemKey }), + body: JSON.stringify({ + itemKey, + ocrMode: modeOverride, + ...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }), + }), }) if (res.status === 202) { - // Job queued — poll until it completes (up to 5 min) setExtractPending(true) - const deadline = Date.now() + 5 * 60 * 1000 - extractPollRef.current = setInterval(async () => { - if (Date.now() > deadline) { - if (extractPollRef.current) clearInterval(extractPollRef.current) - setExtractPending(false) - return - } - try { - const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) - const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json() - if (data.extractedText) { - if (extractPollRef.current) clearInterval(extractPollRef.current) - setExtractPending(false) - setExtractedText(data.extractedText) - setTranslatedText(data.extractedTextTranslated) - setShowTextOverlay(true) - } - } catch { /* ignore */ } - }, 2000) + startPolling(extractedText, translatedText) return } if (!res.ok) { @@ -237,16 +311,67 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, throw new Error((data as { error?: string }).error ?? 'Extraction failed') } const result = await res.json() - setExtractedText(result.extractedText || null) - setTranslatedText(result.translatedText || null) - if (result.extractedText) setShowTextOverlay(true) + const newText: string | null = result.extractedText || null + const newTranslated: string | null = result.translatedText || null + setExtractedText(newText) + setEditedExtractedText(newText ?? '') + setTranslatedText(newTranslated) + if (newText) setShowTextOverlay(true) } catch (err) { setExtractError(err instanceof Error ? err.message : 'Extraction failed') setTimeout(() => setExtractError(null), 4000) } finally { setExtracting(false) } - } + }, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling]) + + // ── Save edited extracted text ─────────────────────────────────────────────── + const handleSaveExtractedText = useCallback(async () => { + if (!current?.itemKey) return + setSavingText(true) + try { + await fetch('/api/ai-tagging/fields', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }), + }) + setExtractedText(editedExtractedText) + } finally { + setSavingText(false) + } + }, [current?.itemKey, editedExtractedText]) + + // ── Translation ────────────────────────────────────────────────────────────── + const handleTranslate = useCallback(async () => { + if (!current?.itemKey) return + setRetranslating(true) + setTranslatePending(false) + try { + const res = await fetch('/api/ai-tagging/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + itemKey: current.itemKey, + ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }), + }), + }) + if (res.status === 202) { + setTranslatePending(true) + startPolling(extractedText, translatedText) + return + } + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error((data as { error?: string }).error ?? 'Translation failed') + } + const result = await res.json() + setTranslatedText(result.translatedText || null) + } catch { + // ignore + } finally { + setRetranslating(false) + } + }, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling]) return (
@@ -333,6 +458,193 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, ) : null}
+ {/* Tools overlay — anchored lower-left, above the bottom bar */} + {showToolsOverlay && current?.itemKey && ( +
e.stopPropagation()} + > + {/* ── Rating ──────────────────────────────────────────── */} +
+

+ Rating +

+
setRatingHover(null)}> + {[1, 2, 3, 4, 5].map((star) => { + const filled = (ratingHover ?? userRating ?? 0) >= star + return ( + + ) + })} +
+
+ + {/* ── Text Extraction (images only) ───────────────────── */} + {current.mediaType === 'image' && ( +
+
+

+ Text Extraction +

+ +
+ +
+ + setOcrLanguageInput(e.target.value)} + placeholder={defaultOcrLanguages} + className="text-xs px-2 py-0.5 rounded-full outline-none" + style={{ + backgroundColor: 'rgba(255,255,255,0.07)', + border: '1px solid rgba(255,255,255,0.15)', + color: 'rgba(255,255,255,0.85)', + width: 120, + }} + title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default." + /> +
+ + {extractError && ( +

{extractError}

+ )} + + {/* Extracted text editor */} + {extractedText !== null && ( +
+

Extracted Text

+