add tag selector to image and video viewers

This commit is contained in:
Garret Patti
2026-04-05 14:01:32 -04:00
parent 2ba887dba6
commit 427aade21a
4 changed files with 177 additions and 50 deletions

View File

@@ -1,15 +1,21 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector'
interface Props {
url: string
name: string
onClose: () => void
mediaKey?: string
onTagsChanged?: () => void
}
export default function ImageLightbox({ url, name, onClose }: Props) {
export default function ImageLightbox({ url, name, onClose, mediaKey, onTagsChanged }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -35,30 +41,77 @@ export default function ImageLightbox({ url, name, onClose }: Props) {
onClick={handleOverlayClick}
>
{/* Toolbar */}
<div className="flex items-center justify-between w-full max-w-4xl">
<div className={`flex items-center justify-between w-full ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<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 className="flex items-center gap-2 flex-shrink-0">
{mediaKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
color: showTags ? '#fff' : 'var(--text-primary)',
}}
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-8 h-8 rounded-full flex items-center justify-center text-sm 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>
{/* Image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
{showTags ? (
<div className="flex gap-4 w-full flex-1 min-h-0 items-start">
{/* Image */}
<div className="flex-1 min-w-0 flex items-center justify-center">
{/* 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()}
/>
</div>
{/* Tag panel */}
<div
className="w-80 flex-shrink-0 rounded-xl overflow-y-auto max-h-[80vh] 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 mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
</div>
</div>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={url}
alt={name}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
)
}

View File

@@ -13,8 +13,8 @@ interface Props {
}
type ModalState =
| { type: 'video'; url: string; name: string }
| { type: 'image'; url: string; name: string }
| { type: 'video'; url: string; name: string; mediaKey: string }
| { type: 'image'; url: string; name: string; mediaKey: string }
| null
type TagPanelState = { entry: FileEntry; mediaKey: string } | null
@@ -80,9 +80,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
}
if (!entry.url) return
if (entry.mediaType === 'video') {
setModal({ type: 'video', url: entry.url, name: entry.name })
setModal({ type: 'video', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
} else if (entry.mediaType === 'image') {
setModal({ type: 'image', url: entry.url, name: entry.name })
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey: mediaKeyFor(entry) })
} else {
// Download other file types
window.open(entry.url, '_blank')
@@ -201,10 +201,22 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)}
{modal?.type === 'video' && (
<VideoPlayerModal url={modal.url} name={modal.name} onClose={() => setModal(null)} />
<VideoPlayerModal
url={modal.url}
name={modal.name}
mediaKey={modal.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
/>
)}
{modal?.type === 'image' && (
<ImageLightbox url={modal.url} name={modal.name} onClose={() => setModal(null)} />
<ImageLightbox
url={modal.url}
name={modal.name}
mediaKey={modal.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
/>
)}
{/* Tag panel */}

View File

@@ -1,15 +1,21 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector'
interface Props {
url: string
name: string
onClose: () => void
mediaKey?: string
onTagsChanged?: () => void
}
export default function VideoPlayerModal({ url, name, onClose }: Props) {
export default function VideoPlayerModal({ url, name, onClose, mediaKey, onTagsChanged }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -35,31 +41,79 @@ export default function VideoPlayerModal({ url, name, onClose }: Props) {
onClick={handleOverlayClick}
>
{/* Toolbar */}
<div className="flex items-center justify-between w-full max-w-4xl">
<div className={`flex items-center justify-between w-full ${showTags ? '' : 'max-w-4xl'}`}>
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<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 className="flex items-center gap-2 flex-shrink-0">
{mediaKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
color: showTags ? '#fff' : 'var(--text-primary)',
}}
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-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>
{/* Video */}
<video
src={url}
controls
autoPlay
className="w-full max-w-4xl max-h-[80vh] rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
{showTags ? (
<div className="flex gap-4 w-full flex-1 min-h-0 items-start">
{/* Video */}
<div className="flex-1 min-w-0 flex items-center justify-center">
<video
src={url}
controls
autoPlay
className="w-full max-h-full rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Tag panel */}
<div
className="w-80 flex-shrink-0 rounded-xl overflow-y-auto max-h-[80vh] 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 mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
</div>
</div>
) : (
<video
src={url}
controls
autoPlay
className="w-full max-w-4xl max-h-[80vh] rounded-lg"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
)
}

View File

@@ -65,7 +65,15 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
}
if (playing) {
return <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} />
return (
<VideoPlayerModal
url={videoUrl}
name={movie.title}
mediaKey={`${libraryId}:${movie.id}`}
onTagsChanged={onTagsChanged}
onClose={() => setPlaying(false)}
/>
)
}
const heroUrl = movie.backdropUrl ?? movie.posterUrl