Compare commits
4 Commits
0b03b937e0
...
c2135747b5
| Author | SHA1 | Date | |
|---|---|---|---|
| c2135747b5 | |||
|
|
afcf740f63 | ||
|
|
dae33a36bc | ||
|
|
a379e94bce |
@@ -3,14 +3,14 @@ import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { enqueueJob } from '@/lib/ai-jobs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { itemKey?: string; ocrLanguages?: string }
|
||||
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { itemKey, ocrLanguages } = body
|
||||
const { itemKey, ocrLanguages, ocrMode } = body
|
||||
if (!itemKey || typeof itemKey !== 'string') {
|
||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||
}
|
||||
@@ -19,12 +19,15 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const payload: Record<string, string> = {}
|
||||
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
|
||||
if (ocrMode) payload.ocrMode = ocrMode
|
||||
const jobId = enqueueJob(
|
||||
itemKey,
|
||||
'extract',
|
||||
libraryId,
|
||||
undefined,
|
||||
ocrLanguages ? { ocrLanguages } : undefined,
|
||||
Object.keys(payload).length ? payload : undefined,
|
||||
)
|
||||
return NextResponse.json({ jobId }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { getAiFields, updateExtractedText } from '@/lib/ai-tagger'
|
||||
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
@@ -19,25 +19,37 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
let body: { itemKey?: string; extractedText?: string }
|
||||
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { itemKey, extractedText } = body
|
||||
const { itemKey, extractedText, aiDescription } = body
|
||||
if (!itemKey || typeof itemKey !== 'string') {
|
||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||
}
|
||||
if (typeof extractedText !== 'string') {
|
||||
return NextResponse.json({ error: 'extractedText is required' }, { status: 400 })
|
||||
if (extractedText === undefined && aiDescription === undefined) {
|
||||
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const libraryId = itemKey.split(':')[0]
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
updateExtractedText(itemKey, extractedText)
|
||||
if (extractedText !== undefined) {
|
||||
if (typeof extractedText !== 'string') {
|
||||
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
|
||||
}
|
||||
updateExtractedText(itemKey, extractedText)
|
||||
}
|
||||
if (aiDescription !== undefined) {
|
||||
if (typeof aiDescription !== 'string') {
|
||||
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
|
||||
}
|
||||
updateAiDescription(itemKey, aiDescription)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { enqueueJob } from '@/lib/ai-jobs'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { libraryId?: string; path?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { libraryId, path: dirPath } = body
|
||||
if (!libraryId || typeof libraryId !== 'string') {
|
||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const db = getDb()
|
||||
const prefix = dirPath
|
||||
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
|
||||
: `${libraryId}:mixed_file:`
|
||||
|
||||
// Only enqueue translate jobs for items that already have extracted text
|
||||
const items = db
|
||||
.prepare(
|
||||
'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL'
|
||||
)
|
||||
.all(`${prefix}%`, 'mixed_file') as { item_key: string }[]
|
||||
|
||||
const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId))
|
||||
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||
}
|
||||
@@ -33,18 +33,37 @@ export async function GET(request: NextRequest) {
|
||||
? scanDirectoryRecursive(root, libraryId, subpath)
|
||||
: scanDirectory(root, libraryId, subpath)
|
||||
|
||||
// Annotate image entries with whether they have extracted text
|
||||
// Annotate image files with hasExtractedText, and directories if any descendant has extracted text
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
|
||||
.all(libraryId) as { item_key: string }[]
|
||||
const withText = new Set(rows.map((r) => r.item_key))
|
||||
|
||||
// Build a set of all ancestor directory relative paths that contain at least one item with text
|
||||
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
|
||||
const dirsWithText = new Set<string>()
|
||||
const keyPrefix = `${libraryId}:mixed_file:`
|
||||
for (const key of withText) {
|
||||
const decoded = decodeURIComponent(key.slice(keyPrefix.length))
|
||||
const parts = decoded.split('/')
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirsWithText.add(parts.slice(0, i).join('/'))
|
||||
}
|
||||
}
|
||||
|
||||
listing.entries = listing.entries.map((e) => {
|
||||
if (e.type !== 'file' || e.mediaType !== 'image') return e
|
||||
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
||||
return { ...e, hasExtractedText: withText.has(itemKey) }
|
||||
if (e.type === 'file') {
|
||||
if (e.mediaType !== 'image') return e
|
||||
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
||||
return { ...e, hasExtractedText: withText.has(itemKey) }
|
||||
}
|
||||
if (e.type === 'directory') {
|
||||
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
|
||||
if (dirsWithText.has(dirRel)) return { ...e, hasExtractedText: true }
|
||||
}
|
||||
return e
|
||||
})
|
||||
|
||||
return NextResponse.json(listing)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modal, setModal] = useState<ModalState>(null)
|
||||
const [modalShowTags, setModalShowTags] = useState(false)
|
||||
const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||
@@ -469,15 +470,28 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
}
|
||||
}}
|
||||
onTranslate={async (e) => {
|
||||
const itemKey = itemKeyFor(e)
|
||||
const res = await fetch('/api/ai-tagging/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||
if (e.type === 'directory') {
|
||||
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||
const res = await fetch('/api/ai-tagging/translate-bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, path: dirRel }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||
}
|
||||
} else {
|
||||
const itemKey = itemKeyFor(e)
|
||||
const res = await fetch('/api/ai-tagging/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDelete={(e) => {
|
||||
@@ -520,9 +534,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
name={modal.name}
|
||||
itemKey={modal.itemKey}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onClose={() => setModal(null)}
|
||||
onClose={() => { setModal(null); setModalShowTags(false) }}
|
||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||
showTags={modalShowTags}
|
||||
onShowTagsChange={setModalShowTags}
|
||||
onAiTag={modal.itemKey ? async () => {
|
||||
const res = await fetch('/api/ai-tagging', {
|
||||
method: 'POST',
|
||||
@@ -544,9 +560,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
name={modal.name}
|
||||
itemKey={modal.itemKey}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onClose={() => setModal(null)}
|
||||
onClose={() => { setModal(null); setModalShowTags(false) }}
|
||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||
showTags={modalShowTags}
|
||||
onShowTagsChange={setModalShowTags}
|
||||
onAiTag={async () => {
|
||||
const res = await fetch('/api/ai-tagging', {
|
||||
method: 'POST',
|
||||
@@ -746,7 +764,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
</button>
|
||||
|
||||
{/* Kebab menu — bottom-right, shown on hover */}
|
||||
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
|
||||
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory')) || (onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText)) && (
|
||||
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
|
||||
@@ -917,7 +935,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
🔍 Extract Text for Folder
|
||||
</button>
|
||||
)}
|
||||
{onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && (
|
||||
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -934,7 +952,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
🌐 Translate
|
||||
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
|
||||
</button>
|
||||
)}
|
||||
{onRename && (
|
||||
|
||||
@@ -14,15 +14,20 @@ interface Props {
|
||||
onTagsChanged?: () => void
|
||||
onAiTag?: () => Promise<void>
|
||||
context?: 'mixed' | 'movies' | 'tv'
|
||||
showTags?: boolean
|
||||
onShowTagsChange?: (v: boolean) => void
|
||||
}
|
||||
|
||||
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed' }: Props) {
|
||||
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange }: Props) {
|
||||
const settings = useUserSettings()
|
||||
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
||||
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
||||
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||
const showTags = showTagsProp ?? showTagsLocal
|
||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||
const [aiTagging, setAiTagging] = useState(false)
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
@@ -45,90 +50,72 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
const handleAiTag = async () => {
|
||||
if (!onAiTag) return
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||
className="fixed inset-0 z-50 overflow-hidden"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
{/* 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'}`}>
|
||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||
{name}
|
||||
</span>
|
||||
{!showTags && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{itemKey && (
|
||||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
|
||||
|
||||
{/* ── Video column ── */}
|
||||
<div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
|
||||
|
||||
{/* Toolbar — scoped to this column's width */}
|
||||
<div className="flex items-center justify-between px-3 py-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-sm truncate mr-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{itemKey && !showTags && (
|
||||
<button
|
||||
onClick={() => setShowTags(true)}
|
||||
className={smallBtn}
|
||||
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>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
onClick={onClose}
|
||||
className={smallBtn}
|
||||
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"
|
||||
aria-label="Close"
|
||||
title="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-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 ? (
|
||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
|
||||
{/* Video */}
|
||||
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
|
||||
{/* Video area — single element, never remounts on panel toggle */}
|
||||
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
|
||||
<video
|
||||
key={url}
|
||||
src={url}
|
||||
@@ -136,40 +123,45 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
className="w-full h-full object-contain rounded-lg"
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
style={{ backgroundColor: '#000' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag panel */}
|
||||
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
|
||||
so they align with ImageLightbox's buttons which span the full viewport */}
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
|
||||
{showTags && (
|
||||
<div
|
||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
|
||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Panel header — hide panel + AI tagger + close */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
{/* Panel header — ‹ hide | ✕ close */}
|
||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowTags(false)}
|
||||
className={smallBtn}
|
||||
@@ -182,23 +174,29 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
›
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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 */}
|
||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between mt-4 mb-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
{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)
|
||||
}
|
||||
}}
|
||||
onClick={handleAiTag}
|
||||
disabled={aiTagging}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
@@ -218,64 +216,13 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
{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>
|
||||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
||||
<video
|
||||
key={url}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
className="w-full h-full max-w-4xl object-contain rounded-lg"
|
||||
style={{ backgroundColor: '#000' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{onPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ async function processNextJob(): Promise<boolean> {
|
||||
await generateItemDescription(row.item_key)
|
||||
break
|
||||
case 'extract':
|
||||
await extractItemText(row.item_key, jobPayload?.ocrLanguages)
|
||||
await extractItemText(row.item_key, jobPayload?.ocrLanguages, jobPayload?.ocrMode)
|
||||
break
|
||||
case 'translate':
|
||||
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||
|
||||
@@ -538,7 +538,7 @@ async function extractWithTesseract(
|
||||
* Translation is not performed automatically — call translateItemText() separately.
|
||||
* Returns { extractedText, translatedText } where translatedText is always null.
|
||||
*/
|
||||
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string, ocrModeOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||
const libraryId = itemKey.split(':')[0]
|
||||
const config = getEffectiveAiConfig(libraryId)
|
||||
|
||||
@@ -567,7 +567,8 @@ export async function extractItemText(itemKey: string, ocrLanguagesOverride?: st
|
||||
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
|
||||
}
|
||||
|
||||
const { ocrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
||||
const { ocrMode: configOcrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
|
||||
const ocrMode = ocrModeOverride ?? configOcrMode
|
||||
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
|
||||
|
||||
// ── Tesseract path ────────────────────────────────────────────────────────
|
||||
@@ -655,6 +656,14 @@ export function updateExtractedText(itemKey: string, text: string): void {
|
||||
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the ai_description of an item.
|
||||
*/
|
||||
export function updateAiDescription(itemKey: string, description: string): void {
|
||||
const db = getDb()
|
||||
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate text to a target language using the chat API.
|
||||
* Returns null if the text is already in the target language.
|
||||
|
||||
Reference in New Issue
Block a user