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

+