add tag selector to image and video viewers
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user