detail page components

This commit is contained in:
2026-03-27 23:06:44 +02:00
parent 02f0f58ec9
commit a039249ede
41 changed files with 6470 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { motion } from 'framer-motion'
import { Eye, EyeOff, Heart, X, Disc3 } from '../../lib/icons'
import { getBestImage } from '../../api/jellyfin'
import type { BaseItemDto } from '../../api/types'
/**
* Floating ghost that follows the pointer while a drag is in progress.
* Shows the moving item count and a label so multi-row drags read clearly.
*/
export function DragGhost({ count, pointerY, label }: { count: number; pointerY: number; label: string }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12 }}
style={{ position: 'fixed', left: 16, top: pointerY - 18, pointerEvents: 'none', zIndex: 80 }}
className="bg-glass-strong backdrop-blur-xl border border-border-hover rounded-md px-3 py-1.5 shadow-2xl"
>
<p className="text-[12px] text-text-1 font-semibold tracking-tight">
{count > 1 ? `Moving ${count} items` : label || 'Moving item'}
</p>
</motion.div>
)
}
interface SelectionToolbarProps {
count: number
onClear: () => void
onRemove: () => void
onMarkPlayed: () => void
onMarkUnplayed: () => void
onToggleFavorite: () => void
}
/**
* Bottom-floating toolbar that appears whenever the user has rows
* selected. Bundles the bulk actions (mark watched / unwatched, favorite,
* remove) plus a Done button to clear the selection.
*/
export function SelectionToolbar({
count,
onClear,
onRemove,
onMarkPlayed,
onMarkUnplayed,
onToggleFavorite,
}: SelectionToolbarProps) {
return (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="fixed left-1/2 -translate-x-1/2 bottom-6 z-toast inline-flex items-center gap-2 h-12 pl-4 pr-2 rounded-full bg-black/85 backdrop-blur-xl border border-white/12 shadow-2xl"
role="toolbar"
aria-label="Playlist selection"
>
<span className="text-[12.5px] font-semibold text-white tabular-nums tracking-tight">
{count} selected
</span>
<span className="w-px h-6 bg-white/10 mx-1" />
<ToolbarButton onClick={onMarkPlayed} icon={<Eye size={13} />} label="Mark watched" />
<ToolbarButton onClick={onMarkUnplayed} icon={<EyeOff size={13} />} label="Mark unwatched" />
<ToolbarButton onClick={onToggleFavorite} icon={<Heart size={13} />} label="Favorite" />
<ToolbarButton
onClick={onRemove}
icon={<X size={13} />}
label="Remove"
tone="danger"
/>
<span className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={onClear}
className="h-8 px-3 rounded-full text-[11.5px] text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
>
Done
</button>
</motion.div>
)
}
function ToolbarButton({
onClick,
icon,
label,
tone,
}: {
onClick: () => void
icon: React.ReactNode
label: string
tone?: 'danger'
}) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-full text-[11.5px] font-medium transition-colors focus-ring ${
tone === 'danger'
? 'text-error hover:bg-error/15'
: 'text-white/85 hover:text-white hover:bg-white/10'
}`}
title={label}
aria-label={label}
>
{icon}
<span className="hidden lg:inline">{label}</span>
</button>
)
}
/**
* 2x2 poster mosaic fallback for playlists that don't have a primary
* image. Shows the first 4 item posters in a grid - if fewer than 4 are
* available, blanks fill the gaps. Empty state shows a music disc icon.
*/
export function PosterMosaic({ items, serverUrl }: { items: BaseItemDto[]; serverUrl: string }) {
const tiles = items
.slice(0, 4)
.map(it => getBestImage(serverUrl, it, 'primary', 300))
.filter(Boolean) as string[]
return (
<div className="w-[200px] aspect-square rounded-xl overflow-hidden ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)] grid grid-cols-2 grid-rows-2 gap-px bg-elevated">
{tiles.length > 0 ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-elevated relative overflow-hidden">
{tiles[i] ? (
<img src={tiles[i]} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-elevated to-surface" />
)}
</div>
))
) : (
<div className="col-span-2 row-span-2 grid place-items-center">
<Disc3 size={48} className="text-text-4 opacity-50" />
</div>
)}
</div>
)
}