more-ui-adjustments #29
@@ -30,23 +30,30 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
{library.type !== 'mixed' && (
|
||||||
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center gap-2 mb-6">
|
||||||
Libraries
|
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
||||||
</a>
|
Libraries
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
</a>
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
{library.name}
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
</span>
|
{library.name}
|
||||||
{session.role === 'admin' && (
|
</span>
|
||||||
<div className="ml-auto">
|
{session.role === 'admin' && (
|
||||||
<ScanLibraryButton libraryId={id} />
|
<div className="ml-auto">
|
||||||
</div>
|
<ScanLibraryButton libraryId={id} />
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{library.type === 'mixed' && session.role === 'admin' && (
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<ScanLibraryButton libraryId={id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{library.type === 'games' && <GamesView libraryId={id} />}
|
{library.type === 'games' && <GamesView libraryId={id} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} />}
|
||||||
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
||||||
{library.type === 'tv' && <TvView libraryId={id} />}
|
{library.type === 'tv' && <TvView libraryId={id} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ interface Props {
|
|||||||
game: Game
|
game: Game
|
||||||
libraryId: string
|
libraryId: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onPrev?: () => void
|
||||||
|
onNext?: () => void
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onCoverUploaded?: () => void
|
onCoverUploaded?: () => void
|
||||||
onDeleted?: (gameId: string) => void
|
onDeleted?: (gameId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -178,7 +180,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
{/* ── Left pane — relative container for floating controls ── */}
|
{/* ── Left pane — relative container for floating controls ── */}
|
||||||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
||||||
{/* Scrollable card area */}
|
{/* Scrollable card area */}
|
||||||
<div className="h-full overflow-y-auto flex items-start justify-center p-4">
|
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
@@ -497,6 +499,28 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Prev / Next */}
|
||||||
|
{onPrev && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 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 z-10 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>
|
||||||
|
|
||||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ 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 [showFilters, setShowFilters] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
|
)
|
||||||
|
const [selectedGameIndex, setSelectedGameIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -147,6 +150,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
const filteredGames = filtered.filter((i): i is Game => !('games' in i))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -220,7 +224,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
<GameCard
|
<GameCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
game={item}
|
game={item}
|
||||||
onClick={() => setSelected(item)}
|
onClick={() => { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -231,11 +235,18 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
<GameDetailModal
|
<GameDetailModal
|
||||||
game={selected}
|
game={selected}
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
|
||||||
|
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
|
||||||
|
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
|
||||||
|
: undefined}
|
||||||
|
onNext={selectedGameIndex !== null && selectedGameIndex < filteredGames.length - 1
|
||||||
|
? () => { const g = filteredGames[selectedGameIndex + 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex + 1) }
|
||||||
|
: undefined}
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onCoverUploaded={() => fetchGames(true)}
|
onCoverUploaded={() => fetchGames(true)}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
|
setSelectedGameIndex(null)
|
||||||
fetchGames()
|
fetchGames()
|
||||||
fetchAssignments()
|
fetchAssignments()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { isBrowserPlayable } from '@/lib/browser-media'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
|
libraryName: string
|
||||||
initialPath: string
|
initialPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ type ModalState =
|
|||||||
|
|
||||||
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
||||||
|
|
||||||
export default function MixedView({ libraryId, initialPath }: Props) {
|
export default function MixedView({ libraryId, libraryName, initialPath }: Props) {
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath)
|
const [currentPath, setCurrentPath] = useState(initialPath)
|
||||||
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -33,7 +34,9 @@ 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 [showFilters, setShowFilters] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
|
)
|
||||||
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
|
||||||
const [recursiveLoading, setRecursiveLoading] = useState(false)
|
const [recursiveLoading, setRecursiveLoading] = useState(false)
|
||||||
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
||||||
@@ -339,12 +342,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
<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">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Libraries
|
||||||
|
</a>
|
||||||
|
<span style={{ color: 'var(--border)' }}>/</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => loadPath('')}
|
onClick={() => loadPath('')}
|
||||||
className="transition-colors"
|
className="transition-colors"
|
||||||
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
|
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
Root
|
{libraryName}
|
||||||
</button>
|
</button>
|
||||||
{breadcrumbs.map((segment, i) => {
|
{breadcrumbs.map((segment, i) => {
|
||||||
const isLast = i === breadcrumbs.length - 1
|
const isLast = i === breadcrumbs.length - 1
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
{/* ── Left pane — relative container for floating controls ── */}
|
{/* ── Left pane — relative container for floating controls ── */}
|
||||||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
||||||
{/* Scrollable card area */}
|
{/* Scrollable card area */}
|
||||||
<div className="h-full overflow-y-auto flex items-start justify-center p-4">
|
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ 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 [showFilters, setShowFilters] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
|
)
|
||||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(true)
|
const [showFilters, setShowFilters] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
|
)
|
||||||
|
const [selectedSeriesIndex, setSelectedSeriesIndex] = useState<number | null>(null)
|
||||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
@@ -93,6 +96,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
|
useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags])
|
||||||
|
|
||||||
const openSeries = (s: TvSeries) => {
|
const openSeries = (s: TvSeries) => {
|
||||||
|
setSelectedSeriesIndex(filteredSeries.indexOf(s))
|
||||||
setSelectedSeries(s)
|
setSelectedSeries(s)
|
||||||
setView('seasons')
|
setView('seasons')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -143,6 +147,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
setView('series')
|
setView('series')
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
setSelectedSeason(null)
|
setSelectedSeason(null)
|
||||||
|
setSelectedSeriesIndex(null)
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
setShowTagPanel(false)
|
setShowTagPanel(false)
|
||||||
@@ -601,7 +606,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
>
|
>
|
||||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
||||||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={goToSeries}>
|
<div className="flex-1 min-h-0 min-w-0 relative" onClick={goToSeries}>
|
||||||
<div className="h-full overflow-y-auto flex items-start justify-center p-4">
|
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl"
|
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
@@ -632,8 +637,28 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
<img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} />
|
<img src={selectedSeries.posterUrl} alt={selectedSeries.title} className="w-16 rounded-lg object-cover flex-shrink-0" style={{ aspectRatio: '2/3' }} />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedSeriesIndex !== null && selectedSeriesIndex > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => openSeries(filteredSeries[selectedSeriesIndex - 1])}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
|
aria-label="Previous series"
|
||||||
|
>‹</button>
|
||||||
|
)}
|
||||||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
|
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>{selectedSeries.title}</h2>
|
||||||
|
{selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => openSeries(filteredSeries[selectedSeriesIndex + 1])}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
|
aria-label="Next series"
|
||||||
|
>›</button>
|
||||||
|
)}
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
<div className="relative flex-shrink-0" ref={menuRef}>
|
<div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user