viewer-improvements #6
@@ -21,6 +21,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -83,9 +84,27 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 items-start">
|
<>
|
||||||
<div className="w-52 flex-shrink-0">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||||||
|
>
|
||||||
|
Filters{filtersActive ? ' ●' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||||
|
{showFilters && (
|
||||||
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
@@ -96,6 +115,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Breadcrumb when inside a series */}
|
{/* Breadcrumb when inside a series */}
|
||||||
{selectedSeries && (
|
{selectedSeries && (
|
||||||
@@ -156,6 +176,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -30,19 +36,41 @@ export default function ImageLightbox({ url, name, onClose }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 gap-3"
|
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)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||||
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 flex-shrink-0 ${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-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
|
<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-12 h-12 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)', fontSize: '1.5rem' }}
|
||||||
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)')}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
@@ -50,15 +78,43 @@ 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 overflow-hidden max-h-full">
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
|
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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="object-contain rounded-lg"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</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 mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full">
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,6 +30,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -80,9 +81,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')
|
||||||
@@ -120,9 +121,27 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 items-start">
|
<>
|
||||||
<div className="w-52 flex-shrink-0">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||||||
|
>
|
||||||
|
Filters{filtersActive ? ' ●' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||||
|
{showFilters && (
|
||||||
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
@@ -133,6 +152,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
|
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
|
||||||
@@ -201,10 +221,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 */}
|
||||||
@@ -249,6 +281,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -30,15 +36,36 @@ export default function VideoPlayerModal({ url, name, onClose }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 gap-3"
|
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)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||||
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 flex-shrink-0 ${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,16 +77,49 @@ 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 overflow-hidden">
|
||||||
{/* Video */}
|
{/* Video */}
|
||||||
|
<div className="flex-1 min-w-0 min-h-0 flex items-center justify-center max-h-full">
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={url}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
className="w-full max-w-4xl max-h-[80vh] rounded-lg"
|
muted
|
||||||
|
loop
|
||||||
|
className="w-full h-full object-contain rounded-lg"
|
||||||
style={{ backgroundColor: '#000' }}
|
style={{ backgroundColor: '#000' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</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 mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-full">
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
className="w-full h-full max-w-4xl object-contain rounded-lg"
|
||||||
|
style={{ backgroundColor: '#000' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -64,9 +65,27 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 items-start">
|
<>
|
||||||
<div className="w-52 flex-shrink-0">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||||||
|
>
|
||||||
|
Filters{filtersActive ? ' ●' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||||
|
{showFilters && (
|
||||||
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
@@ -77,6 +96,7 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingGrid />
|
<LoadingGrid />
|
||||||
@@ -150,6 +170,7 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
const [showFilters, setShowFilters] = useState(true)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
@@ -123,6 +124,8 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
.catch(() => setDeleting(false))
|
.catch(() => setDeleting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
const filteredSeries = series.filter((s) => {
|
const filteredSeries = series.filter((s) => {
|
||||||
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
@@ -179,8 +182,24 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'series' && (
|
{view === 'series' && (
|
||||||
<div className="flex gap-6 items-start">
|
<>
|
||||||
<div className="w-52 flex-shrink-0">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||||||
|
>
|
||||||
|
Filters{filtersActive ? ' ●' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||||
|
{showFilters && (
|
||||||
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
@@ -191,6 +210,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SeriesLoadingGrid />
|
<SeriesLoadingGrid />
|
||||||
@@ -240,6 +260,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'seasons' && selectedSeries && (
|
{view === 'seasons' && selectedSeries && (
|
||||||
|
|||||||
Reference in New Issue
Block a user