media viewer consistency

This commit is contained in:
Garret Patti
2026-04-14 18:45:06 -04:00
parent 0b03b937e0
commit a379e94bce
5 changed files with 501 additions and 570 deletions

View 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 })
}

View File

@@ -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') {
const relPath = subpath ? path.join(subpath, e.name) : e.name if (e.mediaType !== 'image') return e
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}` const relPath = subpath ? path.join(subpath, e.name) : e.name
return { ...e, hasExtractedText: withText.has(itemKey) } 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) return NextResponse.json(listing)

View File

@@ -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,21 +173,117 @@ 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} {/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
</span> <div className="relative flex-1 min-h-0 min-w-0">
<div className="flex items-center gap-2 flex-shrink-0"> {/* eslint-disable-next-line @next/next/no-img-element */}
{/* Text overlay button — always shown when text exists */} <img
{extractedText && ( src={url}
alt={name}
className="absolute inset-0 w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* Prev / Next */}
{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-16 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>
)}
{/* ── 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>
{/* Tags + Close — top-right */}
<div
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 <button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
className="w-12 h-12 rounded-full flex items-center justify-center transition-colors" className={`absolute bottom-4 right-4 ${smallBtn}`}
style={{ style={{
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)', backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)', color: showTextOverlay ? '#fff' : 'var(--text-primary)',
@@ -201,146 +297,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
aria-label={showTextOverlay ? 'Hide text' : 'Show text'} aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text" title="Display text"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="15" y2="12"/> <line x1="3" y1="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/> <line x1="3" y1="18" x2="18" y2="18"/>
</svg> </svg>
</button> </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>
</div>
{showTags ? ( {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit"> {showTags && (
{/* 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 */}
<img
src={url}
alt={name}
className="max-w-full max-h-full w-auto h-auto 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>
{/* Tag panel */}
<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 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,320 +377,271 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</div> </div>
</div> </div>
{/* Description section */} {/* Scrollable panel content */}
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}> <div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Description {/* Description section */}
</p> <div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
{aiDescription && ( <p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}> Description
{aiDescription}
</p> </p>
)} {aiDescription && (
<div className="flex items-center gap-1.5"> <p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
<button {aiDescription}
onClick={handleGenerateDescription} </p>
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)',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!descPending) {
;(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…' : descPending ? '⟳ Queued…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)} )}
</div> <div className="flex items-center gap-1.5">
</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 <button
onClick={async () => { onClick={handleGenerateDescription}
setExtracting(true) disabled={generatingDesc || descPending}
setExtractError(null) className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
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={{ style={{
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)', backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: extractPending ? '#fff' : 'var(--text-secondary)', color: descPending ? '#fff' : 'var(--text-secondary)',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!extracting && !extractPending) { 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) => {
if (!extractPending) { if (!descPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
} }
}} }}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
> >
{extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'} {generatingDesc ? '⟳ Generating…' : descPending ? '⟳ Queued…' : aiDescription ? ' Regenerate Description' : '✦ Generate Description'}
</button> </button>
{ocrMode && ocrMode !== 'llm' && ( {descError && (
<input <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
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> </div>
</div>
{extractError && ( {/* Text extraction section — only for images */}
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p> {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>
{extractedText && ( <div className="flex items-center gap-2 flex-wrap">
<div className="flex flex-col gap-2"> <button
<div> onClick={async () => {
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}> setExtracting(true)
Extracted Text setExtractError(null)
</p> setExtractPending(false)
<textarea try {
value={editedExtractedText} const res = await fetch('/api/ai-tagging/extract-text', {
onChange={(e) => setEditedExtractedText(e.target.value)} method: 'POST',
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none" headers: { 'Content-Type': 'application/json' },
style={{ body: JSON.stringify({
backgroundColor: 'var(--background)', itemKey,
color: 'var(--text-primary)', ...(ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
border: '1px solid var(--border)', }),
minHeight: '4rem', })
maxHeight: '10rem', if (res.status === 202) {
fontFamily: 'inherit', setExtractPending(true)
}} startPolling(extractedText, translatedText, aiDescription)
/> return
{editedExtractedText !== extractedText && ( }
<button if (!res.ok) {
onClick={async () => { const data = await res.json().catch(() => ({}))
setSavingText(true) throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
try { }
await fetch('/api/ai-tagging/fields', { const result = await res.json()
method: 'PATCH', setExtractedText(result.extractedText || null)
headers: { 'Content-Type': 'application/json' }, setEditedExtractedText(result.extractedText || '')
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }), setTranslatedText(result.translatedText || null)
}) } catch (err) {
setExtractedText(editedExtractedText) setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
} finally { setTimeout(() => setExtractError(null), 4000)
setSavingText(false) } finally {
} setExtracting(false)
}} }
disabled={savingText} }}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50" disabled={extracting || extractPending}
style={{ backgroundColor: 'var(--accent)', color: '#fff' }} className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
> style={{
{savingText ? '⟳ Saving…' : 'Save'} backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
</button> color: extractPending ? '#fff' : 'var(--text-secondary)',
)} }}
</div> onMouseEnter={(e) => {
if (!extracting && !extractPending) {
{translatedText && ( ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
<div> ;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}> }
Translation }}
</p> onMouseLeave={(e) => {
<pre if (!extractPending) {
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto" ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
> }
{translatedText} }}
</pre> >
</div> {extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
)} </button>
{ocrMode && ocrMode !== 'llm' && (
<div className="flex items-center gap-1.5 flex-wrap">
<input <input
type="text" type="text"
value={sourceLanguage} value={ocrLanguageInput}
onChange={(e) => setSourceLanguage(e.target.value)} onChange={(e) => setOcrLanguageInput(e.target.value)}
placeholder="Source lang…" placeholder={defaultOcrLanguages}
className="text-xs px-2 py-0.5 rounded-full outline-none" className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{ style={{
backgroundColor: 'var(--background)', backgroundColor: 'var(--background)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
width: 100, width: 120,
}} }}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/> />
<button )}
onClick={async () => {
setRetranslating(true)
setTranslatePending(false)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
})
if (res.status === 202) {
setTranslatePending(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 translate')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}}
disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!retranslating && !translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div> </div>
)}
</div>
)}
{/* Tags section */} {extractError && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}> <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> )}
Tags
</p> {extractedText && (
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription /> <div className="flex flex-col gap-2">
</div> <div>
</div> <p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
</div> Extracted Text
) : ( </p>
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full relative"> <textarea
{/* eslint-disable-next-line @next/next/no-img-element */} value={editedExtractedText}
<img onChange={(e) => setEditedExtractedText(e.target.value)}
src={url} className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
alt={name} style={{
className="max-w-full max-h-full object-contain rounded-lg" backgroundColor: 'var(--background)',
onClick={(e) => e.stopPropagation()} color: 'var(--text-primary)',
/> border: '1px solid var(--border)',
{onPrev && ( minHeight: '4rem',
<button maxHeight: '10rem',
onClick={(e) => { e.stopPropagation(); onPrev() }} fontFamily: 'inherit',
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" {editedExtractedText !== extractedText && (
> <button
onClick={async () => {
</button> setSavingText(true)
)} try {
{onNext && ( await fetch('/api/ai-tagging/fields', {
<button method: 'PATCH',
onClick={(e) => { e.stopPropagation(); onNext() }} headers: { 'Content-Type': 'application/json' },
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" body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }} })
aria-label="Next" setExtractedText(editedExtractedText)
> } finally {
setSavingText(false)
</button> }
)} }}
{/* Text overlay */} disabled={savingText}
{showTextOverlay && displayText && ( className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
<div style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
className="absolute bottom-4 left-4 right-4 z-10 rounded-xl p-4" >
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }} {savingText ? '⟳ Saving…' : 'Save'}
onClick={(e) => e.stopPropagation()} </button>
> )}
{extractedText && translatedText && ( </div>
<div className="flex justify-end mb-2">
<button {translatedText && (
onClick={() => setShowOriginal((v) => !v)} <div>
className="text-xs px-2 py-0.5 rounded-full" <p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }} Translation
> </p>
{showOriginal ? 'Show Translation' : 'Show Original'} <pre
</button> className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
{translatedText}
</pre>
</div>
)}
<div className="flex items-center gap-1.5 flex-wrap">
<input
type="text"
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="Source lang…"
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: 100,
}}
/>
<button
onClick={async () => {
setRetranslating(true)
setTranslatePending(false)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
})
if (res.status === 202) {
setTranslatePending(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 translate')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}}
disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!retranslating && !translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div>
)}
</div> </div>
)} )}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText} {/* Tags section */}
</p> <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> )}
)} </div>
</div> </div>
) )
} }

View File

@@ -469,15 +469,28 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} }
}} }}
onTranslate={async (e) => { onTranslate={async (e) => {
const itemKey = itemKeyFor(e) if (e.type === 'directory') {
const res = await fetch('/api/ai-tagging/translate', { const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
method: 'POST', const res = await fetch('/api/ai-tagging/translate-bulk', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ itemKey }), headers: { 'Content-Type': 'application/json' },
}) body: JSON.stringify({ libraryId, path: dirRel }),
if (!res.ok) { })
const data = await res.json().catch(() => ({})) if (!res.ok) {
throw new Error((data as { error?: string }).error ?? 'Translation failed') 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) => { onDelete={(e) => {
@@ -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 && (

View File

@@ -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,85 +51,51 @@ 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)' }}>
{name} {/* ── Video column ── */}
</span> <div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
{!showTags && (
<div className="flex items-center gap-2 flex-shrink-0"> {/* Toolbar — scoped to this column's width */}
{itemKey && ( <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 <button
onClick={(e) => { e.stopPropagation(); setShowTags(true) }} onClick={onClose}
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)'}
aria-label="Show tags" aria-label="Close"
title="Tags" title="Close"
> >
🏷
</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 ? ( {/* 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,40 +103,45 @@ 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()}
/> />
{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>
{/* 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 <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>
<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>
) )
} }