responsiveness #26
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,4 +9,5 @@ medialore.db-shm
|
|||||||
medialore.db-wal
|
medialore.db-wal
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.session_secret
|
.session_secret
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.traineddata
|
||||||
11
src/app/api/ai-settings/ocr/route.ts
Normal file
11
src/app/api/ai-settings/ocr/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getAiConfig } from '@/lib/app-settings'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { ocrMode, ocrLanguages } = getAiConfig()
|
||||||
|
return NextResponse.json({ ocrMode, ocrLanguages })
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ import { requireLibraryAccess } from '@/lib/auth'
|
|||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string; ocrLanguages?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey } = body
|
const { itemKey, ocrLanguages } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'extract', libraryId)
|
const jobId = enqueueJob(
|
||||||
|
itemKey,
|
||||||
|
'extract',
|
||||||
|
libraryId,
|
||||||
|
undefined,
|
||||||
|
ocrLanguages ? { ocrLanguages } : undefined,
|
||||||
|
)
|
||||||
return NextResponse.json({ jobId }, { status: 202 })
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
||||||
@@ -31,6 +32,21 @@ export async function GET(request: NextRequest) {
|
|||||||
const listing = recursive
|
const listing = recursive
|
||||||
? scanDirectoryRecursive(root, libraryId, subpath)
|
? scanDirectoryRecursive(root, libraryId, subpath)
|
||||||
: scanDirectory(root, libraryId, subpath)
|
: scanDirectory(root, libraryId, subpath)
|
||||||
|
|
||||||
|
// Annotate image entries with whether they have 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))
|
||||||
|
|
||||||
|
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) }
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json(listing)
|
return NextResponse.json(listing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const cooldownRef = useRef(false)
|
const cooldownRef = useRef(false)
|
||||||
const touchStartY = useRef<number | null>(null)
|
const touchStartY = useRef<number | null>(null)
|
||||||
|
|
||||||
@@ -126,14 +128,19 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
||||||
|
|
||||||
// Fetch extracted text for current item
|
// Fetch extracted text for current item; clear any in-flight poll on item change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (extractPollRef.current) {
|
||||||
|
clearInterval(extractPollRef.current)
|
||||||
|
extractPollRef.current = null
|
||||||
|
}
|
||||||
setExtractedText(null)
|
setExtractedText(null)
|
||||||
setTranslatedText(null)
|
setTranslatedText(null)
|
||||||
setShowTextOverlay(false)
|
setShowTextOverlay(false)
|
||||||
setShowOriginal(false)
|
setShowOriginal(false)
|
||||||
setExtracting(false)
|
setExtracting(false)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -144,6 +151,13 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [current?.itemKey])
|
}, [current?.itemKey])
|
||||||
|
|
||||||
|
// Clean up poll on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { onClose(); return }
|
if (e.key === 'Escape') { onClose(); return }
|
||||||
@@ -184,23 +198,44 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
|
|
||||||
const handleExtractText = async () => {
|
const handleExtractText = async () => {
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
|
const itemKey = current.itemKey
|
||||||
setExtracting(true)
|
setExtracting(true)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
|
setExtractPending(false)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey: current.itemKey }),
|
body: JSON.stringify({ itemKey }),
|
||||||
})
|
})
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
}
|
}
|
||||||
if (res.status === 202) {
|
|
||||||
setExtractError('Queued — check AI Integrations for progress')
|
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
setExtractedText(result.extractedText || null)
|
setExtractedText(result.extractedText || null)
|
||||||
setTranslatedText(result.translatedText || null)
|
setTranslatedText(result.translatedText || null)
|
||||||
@@ -301,7 +336,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
{/* Text overlay */}
|
{/* Text overlay */}
|
||||||
{showTextOverlay && displayText && (
|
{showTextOverlay && displayText && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-16 left-4 right-4 z-20 rounded-xl p-4"
|
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleExtractText}
|
onClick={handleExtractText}
|
||||||
disabled={extracting}
|
disabled={extracting || extractPending}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)',
|
backgroundColor: extractPending
|
||||||
|
? 'var(--accent)'
|
||||||
|
: extractError
|
||||||
|
? 'rgba(127,29,29,0.8)'
|
||||||
|
: 'rgba(0,0,0,0.5)',
|
||||||
color: extractError ? '#fca5a5' : '#fff',
|
color: extractError ? '#fca5a5' : '#fff',
|
||||||
}}
|
}}
|
||||||
aria-label="Extract text"
|
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||||||
|
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||||||
>
|
>
|
||||||
{extracting ? (
|
{extracting || extractPending ? (
|
||||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||||||
) : (
|
) : (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,16 +25,32 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
const [retranslating, setRetranslating] = useState(false)
|
const [retranslating, setRetranslating] = useState(false)
|
||||||
|
const [translatePending, setTranslatePending] = useState(false)
|
||||||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||||
const [savingText, setSavingText] = useState(false)
|
const [savingText, setSavingText] = useState(false)
|
||||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
|
// Description state (moved from TagSelector)
|
||||||
|
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||||
|
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||||
|
const [descPending, setDescPending] = useState(false)
|
||||||
|
const [descError, setDescError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// OCR settings
|
||||||
|
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
|
||||||
// Text overlay state
|
// Text overlay state
|
||||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
|
|
||||||
|
// Polling ref
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
// Determine if this is an image file (for text extraction controls)
|
// Determine if this is an image file (for text extraction controls)
|
||||||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||||||
|
|
||||||
@@ -42,18 +58,68 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||||
|
|
||||||
// Fetch existing AI fields on mount / item change
|
// Fetch existing AI fields on mount / item change
|
||||||
useEffect(() => {
|
const fetchAiFields = useCallback(() => {
|
||||||
if (!itemKey) return
|
if (!itemKey) return Promise.resolve()
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
|
||||||
setExtractedText(data.extractedText)
|
setExtractedText(data.extractedText)
|
||||||
setEditedExtractedText(data.extractedText ?? '')
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
setAiDescription(data.aiDescription)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [itemKey])
|
}, [itemKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAiFields()
|
||||||
|
fetch('/api/ai-settings/ocr')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
||||||
|
setOcrMode(d.ocrMode)
|
||||||
|
setDefaultOcrLanguages(d.ocrLanguages)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
}, [fetchAiFields])
|
||||||
|
|
||||||
|
// Start polling fields every 2s until data changes or 5-min timeout
|
||||||
|
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
|
||||||
|
if (!itemKey) return
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
const deadline = Date.now() + 5 * 60 * 1000
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
clearInterval(pollRef.current!)
|
||||||
|
pollRef.current = null
|
||||||
|
setExtractPending(false)
|
||||||
|
setTranslatePending(false)
|
||||||
|
setDescPending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
|
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
|
||||||
|
const textChanged = data.extractedText !== snapshotText
|
||||||
|
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
|
||||||
|
const descChanged = data.aiDescription !== snapshotDesc
|
||||||
|
if (textChanged || translationChanged || descChanged) {
|
||||||
|
clearInterval(pollRef.current!)
|
||||||
|
pollRef.current = null
|
||||||
|
setExtractedText(data.extractedText)
|
||||||
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
setAiDescription(data.aiDescription)
|
||||||
|
setExtractPending(false)
|
||||||
|
setTranslatePending(false)
|
||||||
|
setDescPending(false)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000)
|
||||||
|
}, [itemKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -72,6 +138,38 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGenerateDescription = async () => {
|
||||||
|
if (!itemKey) return
|
||||||
|
setGeneratingDesc(true)
|
||||||
|
setDescError(null)
|
||||||
|
setDescPending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/describe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey }),
|
||||||
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setDescPending(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 generate description')
|
||||||
|
}
|
||||||
|
const { description } = await res.json()
|
||||||
|
setAiDescription(description)
|
||||||
|
} catch (err) {
|
||||||
|
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
|
||||||
|
setTimeout(() => setDescError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setGeneratingDesc(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
@@ -79,13 +177,13 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar — collapses to just filename + text overlay when tag panel is open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
||||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{/* Text overlay button — only shown when extracted text exists */}
|
{/* Text overlay button — always shown when text exists */}
|
||||||
{extractedText && (
|
{extractedText && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||||
@@ -110,82 +208,79 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{itemKey && (
|
{/* These buttons only show in the toolbar when the tag panel is closed */}
|
||||||
<button
|
{!showTags && (
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
<>
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
{itemKey && (
|
||||||
style={{
|
<button
|
||||||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
||||||
color: showTags ? '#fff' : 'var(--text-primary)',
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
fontSize: '1.5rem',
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
||||||
}}
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
onMouseEnter={(e) => {
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
aria-label="Show tags"
|
||||||
}}
|
title="Tags"
|
||||||
onMouseLeave={(e) => {
|
>
|
||||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
🏷
|
||||||
}}
|
</button>
|
||||||
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
)}
|
||||||
title="Tags"
|
{onAiTag && (
|
||||||
>
|
<button
|
||||||
🏷
|
onClick={async (e) => {
|
||||||
</button>
|
e.stopPropagation()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this image"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? (
|
||||||
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
||||||
|
) : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{onAiTag && (
|
|
||||||
<button
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={aiTagging}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
|
||||||
}}
|
|
||||||
aria-label="AI Tag this image"
|
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
|
||||||
>
|
|
||||||
{aiTagging ? (
|
|
||||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
|
||||||
) : '✨'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTags ? (
|
{showTags ? (
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
@@ -237,73 +332,201 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag panel */}
|
{/* Tag panel */}
|
||||||
<div
|
<div
|
||||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
{/* Panel header — hide panel + AI tagger + close lightbox */}
|
||||||
Tags
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
</p>
|
<button
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
onClick={() => setShowTags(false)}
|
||||||
|
className={smallBtn}
|
||||||
{/* Text extraction section — only for images */}
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||||
{isImage && (
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
aria-label="Hide panel"
|
||||||
Text Extraction
|
title="Hide panel"
|
||||||
</p>
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this image"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={onClose}
|
||||||
setExtracting(true)
|
className={smallBtn}
|
||||||
setExtractError(null)
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||||
try {
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
method: 'POST',
|
aria-label="Close"
|
||||||
headers: { 'Content-Type': 'application/json' },
|
title="Close"
|
||||||
body: JSON.stringify({ itemKey }),
|
>
|
||||||
})
|
✕
|
||||||
if (!res.ok) {
|
</button>
|
||||||
const data = await res.json().catch(() => ({}))
|
</div>
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
</div>
|
||||||
}
|
|
||||||
if (res.status === 202) {
|
{/* Description section */}
|
||||||
setExtractError('Queued — check AI Integrations for progress')
|
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
return
|
Description
|
||||||
}
|
</p>
|
||||||
const result = await res.json()
|
{aiDescription && (
|
||||||
setExtractedText(result.extractedText || null)
|
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
setEditedExtractedText(result.extractedText || '')
|
{aiDescription}
|
||||||
setTranslatedText(result.translatedText || null)
|
</p>
|
||||||
} catch (err) {
|
)}
|
||||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
<div className="flex items-center gap-1.5">
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
<button
|
||||||
} finally {
|
onClick={handleGenerateDescription}
|
||||||
setExtracting(false)
|
disabled={generatingDesc || descPending}
|
||||||
}
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
disabled={extracting}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!extracting) {
|
if (!generatingDesc && !descPending) {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
if (!descPending) {
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||||
>
|
>
|
||||||
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
{generatingDesc ? '⟳ Generating…' : descPending ? '⟳ Queued…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||||
</button>
|
</button>
|
||||||
|
{descError && (
|
||||||
|
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text extraction section — only for images */}
|
||||||
|
{isImage && (
|
||||||
|
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Text Extraction
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
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,
|
||||||
|
...(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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!extracting && !extractPending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!extractPending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||||||
|
</button>
|
||||||
|
{ocrMode && ocrMode !== 'llm' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ocrLanguageInput}
|
||||||
|
onChange={(e) => 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."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{extractError && (
|
{extractError && (
|
||||||
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
|
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{extractedText && (
|
{extractedText && (
|
||||||
@@ -380,47 +603,64 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRetranslating(true)
|
setRetranslating(true)
|
||||||
|
setTranslatePending(false)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setTranslatePending(true)
|
||||||
|
startPolling(extractedText, translatedText, aiDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
}
|
}
|
||||||
if (res.status !== 202) {
|
const result = await res.json()
|
||||||
const result = await res.json()
|
setTranslatedText(result.translatedText || null)
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRetranslating(false)
|
setRetranslating(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={retranslating}
|
disabled={retranslating || translatePending}
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
style={{
|
||||||
|
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!retranslating) {
|
if (!retranslating && !translatePending) {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
if (!translatePending) {
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{retranslating ? '⟳ Translating…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tags section */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
setDoomScrollLoading(false)
|
setDoomScrollLoading(false)
|
||||||
}, [currentPath])
|
}, [currentPath])
|
||||||
|
|
||||||
|
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
const fetchAssignments = useCallback(() => {
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -92,6 +95,16 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/ai-settings/ocr')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
||||||
|
setOcrMode(d.ocrMode)
|
||||||
|
setDefaultOcrLanguages(d.ocrLanguages)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
const fetchRecursive = useCallback(() => {
|
const fetchRecursive = useCallback(() => {
|
||||||
@@ -387,6 +400,8 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
onOpen={handleEntry}
|
onOpen={handleEntry}
|
||||||
onTag={handleTagEntry}
|
onTag={handleTagEntry}
|
||||||
|
ocrMode={ocrMode}
|
||||||
|
defaultOcrLanguages={defaultOcrLanguages}
|
||||||
onAiTag={async (e) => {
|
onAiTag={async (e) => {
|
||||||
const itemKey = itemKeyFor(e)
|
const itemKey = itemKeyFor(e)
|
||||||
const res = await fetch('/api/ai-tagging', {
|
const res = await fetch('/api/ai-tagging', {
|
||||||
@@ -401,7 +416,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
fetchAssignments()
|
fetchAssignments()
|
||||||
setFilterRefreshKey((k) => k + 1)
|
setFilterRefreshKey((k) => k + 1)
|
||||||
}}
|
}}
|
||||||
onExtractText={async (e) => {
|
onExtractText={async (e, ocrLanguages) => {
|
||||||
if (e.type === 'directory') {
|
if (e.type === 'directory') {
|
||||||
// Bulk extract for directory
|
// Bulk extract for directory
|
||||||
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
@@ -420,7 +435,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey }),
|
body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
@@ -594,7 +609,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void> }) {
|
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate, ocrMode, defaultOcrLanguages }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry, ocrLanguages?: string) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void>; ocrMode?: string | null; defaultOcrLanguages?: string }) {
|
||||||
type ImgState = 'loading' | 'loaded' | 'error'
|
type ImgState = 'loading' | 'loaded' | 'error'
|
||||||
const [imgState, setImgState] = useState<ImgState>(
|
const [imgState, setImgState] = useState<ImgState>(
|
||||||
entry.thumbnailUrl ? 'loading' : 'error'
|
entry.thumbnailUrl ? 'loading' : 'error'
|
||||||
@@ -615,6 +630,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
const [describeError, setDescribeError] = useState<string | null>(null)
|
const [describeError, setDescribeError] = useState<string | null>(null)
|
||||||
const [translating, setTranslating] = useState(false)
|
const [translating, setTranslating] = useState(false)
|
||||||
const [translateError, setTranslateError] = useState<string | null>(null)
|
const [translateError, setTranslateError] = useState<string | null>(null)
|
||||||
|
const [showOcrPrompt, setShowOcrPrompt] = useState(false)
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@@ -804,16 +821,21 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
📝 Describe Folder
|
📝 Describe Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onExtractText && entry.mediaType === 'image' && (
|
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setMenuOpen(false)
|
if (ocrMode && ocrMode !== 'llm') {
|
||||||
setTextExtracting(true)
|
setOcrLanguageInput('')
|
||||||
setTextExtractError(null)
|
setShowOcrPrompt(true)
|
||||||
onExtractText(entry)
|
} else {
|
||||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
setMenuOpen(false)
|
||||||
.finally(() => setTextExtracting(false))
|
setTextExtracting(true)
|
||||||
|
setTextExtractError(null)
|
||||||
|
onExtractText(entry)
|
||||||
|
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||||
|
.finally(() => setTextExtracting(false))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={textExtracting}
|
disabled={textExtracting}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||||
@@ -824,6 +846,57 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
🔍 Extract Text
|
🔍 Extract Text
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExtractText && entry.mediaType === 'image' && showOcrPrompt && (
|
||||||
|
<div className="px-4 py-2 flex flex-col gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>OCR language</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={ocrLanguageInput}
|
||||||
|
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') { setShowOcrPrompt(false) }
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setShowOcrPrompt(false)
|
||||||
|
setMenuOpen(false)
|
||||||
|
setTextExtracting(true)
|
||||||
|
setTextExtractError(null)
|
||||||
|
onExtractText(entry, ocrLanguageInput.trim() || undefined)
|
||||||
|
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||||
|
.finally(() => setTextExtracting(false))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={defaultOcrLanguages ?? 'eng'}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg outline-none w-full"
|
||||||
|
style={{ backgroundColor: 'var(--background)', border: '1px solid var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowOcrPrompt(false)
|
||||||
|
setMenuOpen(false)
|
||||||
|
setTextExtracting(true)
|
||||||
|
setTextExtractError(null)
|
||||||
|
onExtractText(entry, ocrLanguageInput.trim() || undefined)
|
||||||
|
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||||
|
.finally(() => setTextExtracting(false))
|
||||||
|
}}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Extract
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOcrPrompt(false)}
|
||||||
|
className="text-xs px-2 py-1"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{onExtractText && entry.type === 'directory' && (
|
{onExtractText && entry.type === 'directory' && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -844,7 +917,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
🔍 Extract Text for Folder
|
🔍 Extract Text for Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onTranslate && entry.mediaType === 'image' && (
|
{onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
@@ -52,80 +54,75 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar — collapses to just filename when tag panel is open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
||||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{!showTags && (
|
||||||
{itemKey && (
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{itemKey && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
|
aria-label="Show tags"
|
||||||
|
title="Tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this video"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? (
|
||||||
|
<span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span>
|
||||||
|
) : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
onClick={onClose}
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
|
||||||
style={{
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
color: showTags ? '#fff' : 'var(--text-primary)',
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
}}
|
aria-label="Close"
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
|
||||||
}}
|
|
||||||
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
|
||||||
title="Tags"
|
|
||||||
>
|
>
|
||||||
🏷
|
✕
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
{onAiTag && (
|
)}
|
||||||
<button
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={aiTagging}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
|
||||||
}}
|
|
||||||
aria-label="AI Tag this video"
|
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
|
||||||
>
|
|
||||||
{aiTagging ? (
|
|
||||||
<span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span>
|
|
||||||
) : '✨'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
|
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTags ? (
|
{showTags ? (
|
||||||
@@ -164,16 +161,84 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag panel */}
|
{/* Tag panel */}
|
||||||
<div
|
<div
|
||||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
{/* Panel header — hide panel + AI tagger + close */}
|
||||||
Tags
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
</p>
|
<button
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
onClick={() => setShowTags(false)}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
|
aria-label="Hide panel"
|
||||||
|
title="Hide panel"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this video"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
|
aria-label="Close"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags section */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface Props {
|
|||||||
itemKey: string
|
itemKey: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
refreshKey?: number
|
refreshKey?: number
|
||||||
|
hideDescription?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllTags {
|
interface AllTags {
|
||||||
@@ -15,7 +16,7 @@ interface AllTags {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) {
|
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) {
|
||||||
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
||||||
tags: [],
|
tags: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -210,37 +211,39 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* AI description */}
|
{/* AI description */}
|
||||||
<div className="flex flex-col gap-1">
|
{!hideDescription && (
|
||||||
{aiDescription && (
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
{aiDescription && (
|
||||||
{aiDescription}
|
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
||||||
</p>
|
{aiDescription}
|
||||||
)}
|
</p>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateDescription}
|
|
||||||
disabled={generatingDesc}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!generatingDesc) {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
|
||||||
>
|
|
||||||
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
|
||||||
</button>
|
|
||||||
{descError && (
|
|
||||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateDescription}
|
||||||
|
disabled={generatingDesc}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!generatingDesc) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||||
|
>
|
||||||
|
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||||
|
</button>
|
||||||
|
{descError && (
|
||||||
|
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{/* Assigned tags grouped by category */}
|
{/* Assigned tags grouped by category */}
|
||||||
{assigned.tags.length > 0 && (
|
{assigned.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface AiJobRow {
|
|||||||
started_at: number | null
|
started_at: number | null
|
||||||
completed_at: number | null
|
completed_at: number | null
|
||||||
item_title: string | null
|
item_title: string | null
|
||||||
|
payload: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToJob(row: AiJobRow): AiJob {
|
function rowToJob(row: AiJobRow): AiJob {
|
||||||
@@ -75,6 +76,7 @@ export function enqueueJob(
|
|||||||
jobType: AiJobType,
|
jobType: AiJobType,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
sourceLanguage?: string,
|
sourceLanguage?: string,
|
||||||
|
payload?: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
@@ -96,9 +98,9 @@ export function enqueueJob(
|
|||||||
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
|
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload)
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
|
||||||
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
|
||||||
|
|
||||||
// Wake the processor
|
// Wake the processor
|
||||||
wakeProcessor()
|
wakeProcessor()
|
||||||
@@ -251,6 +253,8 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
|
|
||||||
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
||||||
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
||||||
|
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
|
||||||
|
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
||||||
@@ -265,7 +269,7 @@ async function processNextJob(): Promise<boolean> {
|
|||||||
await generateItemDescription(row.item_key)
|
await generateItemDescription(row.item_key)
|
||||||
break
|
break
|
||||||
case 'extract':
|
case 'extract':
|
||||||
await extractItemText(row.item_key)
|
await extractItemText(row.item_key, jobPayload?.ocrLanguages)
|
||||||
break
|
break
|
||||||
case 'translate':
|
case 'translate':
|
||||||
await translateItemText(row.item_key, sourceLanguage || undefined)
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ async function extractWithTesseract(
|
|||||||
* Translation is not performed automatically — call translateItemText() separately.
|
* Translation is not performed automatically — call translateItemText() separately.
|
||||||
* Returns { extractedText, translatedText } where translatedText is always null.
|
* Returns { extractedText, translatedText } where translatedText is always null.
|
||||||
*/
|
*/
|
||||||
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const config = getEffectiveAiConfig(libraryId)
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
|
|
||||||
@@ -567,7 +567,8 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
|
|||||||
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ocrMode, ocrLanguages, ocrConfidenceThreshold } = config
|
const { ocrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
||||||
|
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
|
||||||
|
|
||||||
// ── Tesseract path ────────────────────────────────────────────────────────
|
// ── Tesseract path ────────────────────────────────────────────────────────
|
||||||
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {
|
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {
|
||||||
|
|||||||
@@ -338,4 +338,12 @@ function migrateAiJobs(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
||||||
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Add payload column if not present
|
||||||
|
const aiJobsRow = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
|
||||||
|
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface FileEntry {
|
|||||||
mediaType: MediaType | null
|
mediaType: MediaType | null
|
||||||
url: string | null
|
url: string | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
|
hasExtractedText?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Movie {
|
export interface Movie {
|
||||||
|
|||||||
Reference in New Issue
Block a user