detail page components
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user