142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
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>
|
|
)
|
|
}
|