media_key was a lossy shortening of item_key (libraryId:lastSegment) that introduced a real collision bug: two TV episodes from different series with the same filename would share the same media_key and each other's tags. - DB migration converts existing media_tags rows from short format to full item_key by joining against media_items; ambiguous/orphaned rows are dropped - media_tags column renamed media_key → item_key - Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes item_key directly to reKeyMediaItem - DB reader functions (tv, movies, games) now expose item_key on returned entities; frontend components use entity.item_key instead of constructing the short libraryId:id form - MixedView now constructs the full mixed_file: item_key format - Tag API renamed mediaKey param → itemKey throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
6.5 KiB
TypeScript
165 lines
6.5 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
|
||
}
|
||
|
||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged }: Props) {
|
||
const overlayRef = useRef<HTMLDivElement>(null)
|
||
const [showTags, setShowTags] = useState(
|
||
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
||
)
|
||
|
||
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">
|
||
{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>
|
||
)}
|
||
<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>
|
||
)}
|
||
</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} />
|
||
</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>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|