Files
jellybloom/src/pages/DownloadsPage.tsx
T
2026-03-30 13:40:42 +03:00

168 lines
6.8 KiB
TypeScript

import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Download, Trash2, Play, AlertCircle, Check, Clock } from '../lib/icons'
import { useDownloads } from '../stores/downloads-store'
import { isTauri } from '../lib/tauri'
export default function DownloadsPage() {
const navigate = useNavigate()
const items = useDownloads(s => s.items)
const remove = useDownloads(s => s.remove)
const clearCompleted = useDownloads(s => s.clearCompleted)
const grouped = useMemo(() => {
const active = items.filter(i => i.status === 'queued' || i.status === 'downloading')
const done = items.filter(i => i.status === 'done')
const error = items.filter(i => i.status === 'error')
return { active, done, error }
}, [items])
return (
<div className="px-7 pt-4 pb-12">
<header className="mb-7 flex items-end justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Offline</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Downloads
</h1>
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
{isTauri
? 'Items saved locally for offline playback.'
: 'Downloads are stored in the browser cache for offline playback.'}
</p>
</div>
{grouped.done.length > 0 && (
<button
onClick={clearCompleted}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-error/50 hover:text-error transition-all focus-ring"
>
<Trash2 size={13} stroke={2} />
Clear completed
</button>
)}
</header>
{items.length === 0 && (
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
<div className="relative w-14 h-14 mx-auto mb-4">
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
<Download size={20} className="text-accent" />
</div>
</div>
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No downloads yet</p>
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
Use the download button in the player to save items for offline viewing.
</p>
</div>
)}
<div className="space-y-8">
{grouped.active.length > 0 && (
<section>
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
<Clock size={13} className="text-accent" /> In progress
</h2>
<div className="space-y-2">
{grouped.active.map(item => (
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
))}
</div>
</section>
)}
{grouped.done.length > 0 && (
<section>
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
<Check size={13} className="text-success" /> Completed
</h2>
<div className="space-y-2">
{grouped.done.map(item => (
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} onPlay={() => navigate(`/play/${item.itemId}`)} />
))}
</div>
</section>
)}
{grouped.error.length > 0 && (
<section>
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
<AlertCircle size={13} className="text-error" /> Failed
</h2>
<div className="space-y-2">
{grouped.error.map(item => (
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
))}
</div>
</section>
)}
</div>
</div>
)
}
function DownloadRow({
item,
onRemove,
onPlay,
}: {
item: import('../stores/downloads-store').DownloadItem
onRemove: () => void
onPlay?: () => void
}) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-3 p-3 rounded-xl bg-elevated/30 ring-1 ring-border hover:ring-border-strong transition"
>
{item.posterUrl ? (
<img src={item.posterUrl} alt="" className="w-10 aspect-[2/3] rounded object-cover shrink-0 bg-void" loading="lazy" />
) : (
<div className="w-10 aspect-[2/3] rounded bg-void grid place-items-center shrink-0">
<Download size={14} className="text-text-4" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-[13px] text-text-1 font-medium truncate">{item.name}</p>
<div className="flex items-center gap-2 mt-1">
{item.status === 'downloading' && (
<>
<div className="flex-1 h-1.5 rounded-full bg-elevated overflow-hidden max-w-[160px]">
<div className="h-full bg-accent transition-[width] duration-300" style={{ width: `${item.progress}%` }} />
</div>
<span className="text-[10.5px] text-text-3 tabular-nums">{item.progress}%</span>
</>
)}
{item.status === 'done' && <span className="text-[10.5px] text-success font-medium">Ready</span>}
{item.status === 'error' && <span className="text-[10.5px] text-error font-medium truncate">{item.error || 'Failed'}</span>}
{item.status === 'queued' && <span className="text-[10.5px] text-text-3 font-medium">Queued</span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{onPlay && (
<button
onClick={onPlay}
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
aria-label="Play"
>
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={onRemove}
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-error hover:bg-elevated transition focus-ring"
aria-label="Remove"
>
<Trash2 size={13} stroke={2} />
</button>
</div>
</motion.div>
)
}