more-ui-adjustments #29

Merged
gpatti merged 4 commits from more-ui-adjustments into main 2026-04-18 04:38:34 +00:00
7 changed files with 108 additions and 28 deletions
Showing only changes of commit b2e9df8ab8 - Show all commits

View File

@@ -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>

View File

@@ -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 ── */}

View File

@@ -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()
}} }}

View File

@@ -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

View File

@@ -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)' }}

View File

@@ -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[]>([])

View File

@@ -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