- move play/pause to clicking the video directly; remove dedicated button - replace emoji mute icons with flat minimal SVGs - add view-in-library button in doom scroll that navigates to the file's directory and opens it in the regular viewer - add display text overlay button in doom scroll and image lightbox; shows extracted text (translated by default when available) in a semi-transparent box at the bottom; toggle between translated/original - hide tag panel by default in image lightbox and video player modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
427 lines
19 KiB
TypeScript
427 lines
19 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import TagSelector from '@/components/tags/TagSelector'
|
||
|
||
interface Props {
|
||
url: string
|
||
name: string
|
||
onClose: () => void
|
||
onPrev?: () => void
|
||
onNext?: () => void
|
||
itemKey?: string
|
||
onTagsChanged?: () => void
|
||
onAiTag?: () => Promise<void>
|
||
}
|
||
|
||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) {
|
||
const overlayRef = useRef<HTMLDivElement>(null)
|
||
const [showTags, setShowTags] = useState(false)
|
||
const [aiTagging, setAiTagging] = useState(false)
|
||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||
|
||
// Text extraction state
|
||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||
const [extracting, setExtracting] = useState(false)
|
||
const [extractError, setExtractError] = useState<string | null>(null)
|
||
const [retranslating, setRetranslating] = useState(false)
|
||
|
||
// Text overlay state
|
||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||
const [showOriginal, setShowOriginal] = useState(false)
|
||
|
||
// Determine if this is an image file (for text extraction controls)
|
||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||
|
||
// Derived: what text to display in the overlay
|
||
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||
|
||
// Fetch existing AI fields on mount / item change
|
||
useEffect(() => {
|
||
if (!itemKey) return
|
||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||
.then((r) => r.json())
|
||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||
setExtractedText(data.extractedText)
|
||
setTranslatedText(data.extractedTextTranslated)
|
||
})
|
||
.catch(() => {})
|
||
}, [itemKey])
|
||
|
||
useEffect(() => {
|
||
const handleKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose()
|
||
if (e.key === 'ArrowLeft') onPrev?.()
|
||
if (e.key === 'ArrowRight') onNext?.()
|
||
}
|
||
document.addEventListener('keydown', handleKey)
|
||
document.body.style.overflow = 'hidden'
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKey)
|
||
document.body.style.overflow = ''
|
||
}
|
||
}, [onClose, onPrev, onNext])
|
||
|
||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||
if (e.target === overlayRef.current) onClose()
|
||
}
|
||
|
||
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' }}
|
||
onClick={handleOverlayClick}
|
||
>
|
||
{/* Toolbar */}
|
||
<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>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
{/* Text overlay button — only shown when extracted 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>
|
||
)}
|
||
{itemKey && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||
style={{
|
||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
||
color: showTags ? '#fff' : 'var(--text-primary)',
|
||
fontSize: '1.5rem',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||
}}
|
||
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
||
title="Tags"
|
||
>
|
||
🏷
|
||
</button>
|
||
)}
|
||
{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 ? (
|
||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full">
|
||
{/* Image */}
|
||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen relative">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={url}
|
||
alt={name}
|
||
className="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
|
||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<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} />
|
||
|
||
{/* Text extraction section — only for images */}
|
||
{isImage && (
|
||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||
Text Extraction
|
||
</p>
|
||
|
||
<button
|
||
onClick={async () => {
|
||
setExtracting(true)
|
||
setExtractError(null)
|
||
try {
|
||
const res = await fetch('/api/ai-tagging/extract-text', {
|
||
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 ?? 'Failed to extract text')
|
||
}
|
||
const result = await res.json()
|
||
setExtractedText(result.extractedText || null)
|
||
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}
|
||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||
onMouseEnter={(e) => {
|
||
if (!extracting) {
|
||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||
}}
|
||
>
|
||
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||
</button>
|
||
|
||
{extractError && (
|
||
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
|
||
)}
|
||
|
||
{extractedText && (
|
||
<div className="flex flex-col gap-2">
|
||
<div>
|
||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Extracted Text
|
||
</p>
|
||
<pre
|
||
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)' }}
|
||
>
|
||
{extractedText}
|
||
</pre>
|
||
</div>
|
||
|
||
{translatedText && (
|
||
<div>
|
||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Translation
|
||
</p>
|
||
<pre
|
||
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>
|
||
)}
|
||
|
||
<button
|
||
onClick={async () => {
|
||
setRetranslating(true)
|
||
try {
|
||
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 ?? 'Failed to translate')
|
||
}
|
||
const result = await res.json()
|
||
setTranslatedText(result.translatedText || null)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setRetranslating(false)
|
||
}
|
||
}}
|
||
disabled={retranslating}
|
||
className="self-start text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||
onMouseEnter={(e) => {
|
||
if (!retranslating) {
|
||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||
}}
|
||
>
|
||
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
|
||
</button>
|
||
</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>
|
||
)
|
||
}
|