media viewer consistency
This commit is contained in:
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)
|
? scanDirectoryRecursive(root, libraryId, subpath)
|
||||||
: scanDirectory(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 db = getDb()
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
|
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
|
||||||
.all(libraryId) as { item_key: string }[]
|
.all(libraryId) as { item_key: string }[]
|
||||||
const withText = new Set(rows.map((r) => r.item_key))
|
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) => {
|
listing.entries = listing.entries.map((e) => {
|
||||||
if (e.type !== 'file' || e.mediaType !== 'image') return e
|
if (e.type === 'file') {
|
||||||
|
if (e.mediaType !== 'image') return e
|
||||||
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
||||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
||||||
return { ...e, hasExtractedText: withText.has(itemKey) }
|
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)
|
return NextResponse.json(listing)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const [savingText, setSavingText] = useState(false)
|
const [savingText, setSavingText] = useState(false)
|
||||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
// Description state (moved from TagSelector)
|
// Description state
|
||||||
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||||
const [generatingDesc, setGeneratingDesc] = useState(false)
|
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||||
const [descPending, setDescPending] = useState(false)
|
const [descPending, setDescPending] = useState(false)
|
||||||
@@ -173,121 +173,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar — collapses to just filename + text overlay when tag panel is open */}
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
|
||||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{/* Text overlay button — always shown when text exists */}
|
|
||||||
{extractedText && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
|
||||||
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
|
||||||
}}
|
|
||||||
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
|
||||||
title="Display text"
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
||||||
<line x1="3" y1="12" x2="15" y2="12"/>
|
|
||||||
<line x1="3" y1="18" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* These buttons only show in the toolbar when the tag panel is closed */}
|
|
||||||
{!showTags && (
|
|
||||||
<>
|
|
||||||
{itemKey && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
|
||||||
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="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-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>
|
|
||||||
|
|
||||||
{showTags ? (
|
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
<div className="relative flex-1 min-h-0 min-w-0">
|
||||||
{/* Image */}
|
|
||||||
<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}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="max-w-full max-h-full w-auto h-auto object-contain rounded-lg"
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Prev / Next */}
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
@@ -308,10 +211,11 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Text overlay */}
|
{/* Text overlay */}
|
||||||
{showTextOverlay && displayText && (
|
{showTextOverlay && displayText && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
|
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -331,16 +235,86 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Floating controls ── */}
|
||||||
|
|
||||||
|
{/* Filename pill — bottom-left */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||||
|
>
|
||||||
|
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag panel */}
|
{/* Tags + Close — top-right */}
|
||||||
<div
|
<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="absolute top-4 right-4 flex items-center gap-1.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{itemKey && !showTags && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
|
||||||
|
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={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="Close"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text display button — bottom-right, hidden when panel open */}
|
||||||
|
{!showTags && extractedText && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||||
|
className={`absolute bottom-4 right-4 ${smallBtn}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
|
||||||
|
title="Display text"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
|
{showTags && (
|
||||||
|
<div
|
||||||
|
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)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Panel header — hide panel + AI tagger + close lightbox */}
|
{/* Panel header — ‹ hide | ✨ AI tag ✕ close */}
|
||||||
<div className="flex items-center justify-between flex-shrink-0">
|
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTags(false)}
|
onClick={() => setShowTags(false)}
|
||||||
className={smallBtn}
|
className={smallBtn}
|
||||||
@@ -403,6 +377,9 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable panel content */}
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
|
||||||
|
|
||||||
{/* Description section */}
|
{/* Description section */}
|
||||||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
@@ -663,60 +640,8 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={name}
|
|
||||||
className="max-w-full max-h-full object-contain rounded-lg"
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{/* Text overlay */}
|
|
||||||
{showTextOverlay && displayText && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4"
|
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{extractedText && translatedText && (
|
|
||||||
<div className="flex justify-end mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowOriginal((v) => !v)}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
|
|
||||||
>
|
|
||||||
{showOriginal ? 'Show Translation' : 'Show Original'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
|
|
||||||
{displayText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,18 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTranslate={async (e) => {
|
onTranslate={async (e) => {
|
||||||
|
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 itemKey = itemKeyFor(e)
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -479,6 +491,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={(e) => {
|
onDelete={(e) => {
|
||||||
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
@@ -746,7 +759,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kebab menu — bottom-right, shown on hover */}
|
{/* 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}>
|
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(null) }}
|
||||||
@@ -917,7 +930,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
🔍 Extract Text for Folder
|
🔍 Extract Text for Folder
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && (
|
{onTranslate && (entry.mediaType === 'image' || entry.type === 'directory') && entry.hasExtractedText && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -934,7 +947,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
|||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
>
|
>
|
||||||
🌐 Translate
|
{entry.type === 'directory' ? '🌐 Translate Folder' : '🌐 Translate'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onRename && (
|
{onRename && (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
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 loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
||||||
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
|
||||||
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTags, setShowTags] = useState(false)
|
const [showTags, setShowTags] = useState(false)
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
@@ -50,21 +51,26 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex flex-col items-center p-4 gap-3 overflow-hidden max-h-screen"
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
{/* Toolbar — collapses to just filename when tag panel is open */}
|
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
|
||||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
|
{/* ── 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}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
{!showTags && (
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{itemKey && !showTags && (
|
||||||
{itemKey && (
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
onClick={() => setShowTags(true)}
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
className={smallBtn}
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
@@ -74,61 +80,22 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
🏷
|
🏷
|
||||||
</button>
|
</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={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
|
className={smallBtn}
|
||||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
title="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTags ? (
|
{/* Video area — single element, never remounts on panel toggle */}
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden">
|
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Video */}
|
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full relative">
|
|
||||||
<video
|
<video
|
||||||
key={url}
|
key={url}
|
||||||
src={url}
|
src={url}
|
||||||
@@ -136,14 +103,18 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
loop={loop}
|
loop={loop}
|
||||||
className="w-full h-full object-contain rounded-lg"
|
playsInline
|
||||||
|
className="w-full h-full object-contain"
|
||||||
style={{ backgroundColor: '#000' }}
|
style={{ backgroundColor: '#000' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
|
||||||
|
so they align with ImageLightbox's buttons which span the full viewport */}
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
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"
|
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' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
aria-label="Previous"
|
aria-label="Previous"
|
||||||
>
|
>
|
||||||
@@ -153,7 +124,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
{onNext && (
|
{onNext && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
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"
|
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' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
>
|
>
|
||||||
@@ -162,14 +133,15 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag panel */}
|
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
|
||||||
|
{showTags && (
|
||||||
<div
|
<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)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Panel header — hide panel + AI tagger + close */}
|
{/* Panel header — ‹ hide | ✨ AI tag ✕ close */}
|
||||||
<div className="flex items-center justify-between flex-shrink-0">
|
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTags(false)}
|
onClick={() => setShowTags(false)}
|
||||||
className={smallBtn}
|
className={smallBtn}
|
||||||
@@ -232,50 +204,16 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags section */}
|
{/* Tags */}
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Tags
|
Tags
|
||||||
</p>
|
</p>
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user