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

View File

@@ -13,8 +13,8 @@ interface Props {
} }
type ModalState = type ModalState =
| { type: 'video'; url: string; name: string } | { type: 'video'; url: string; name: string; mediaKey: string }
| { type: 'image'; url: string; name: string } | { type: 'image'; url: string; name: string; mediaKey: string }
| null | null
type TagPanelState = { entry: FileEntry; 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.url) return
if (entry.mediaType === 'video') { 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') { } 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 { } else {
// Download other file types // Download other file types
window.open(entry.url, '_blank') window.open(entry.url, '_blank')
@@ -201,10 +201,22 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)} )}
{modal?.type === 'video' && ( {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' && ( {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 */} {/* Tag panel */}

View File

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

View File

@@ -65,7 +65,15 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChan
} }
if (playing) { 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 const heroUrl = movie.backdropUrl ?? movie.posterUrl