Files
pinkudex/components/queue/QueuePanel.tsx
T
2026-05-26 22:46:00 +02:00

193 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ListVideo, Eye, Trash2, X, Loader2 } from "lucide-react";
import { useQueuePanel } from "./QueuePanelProvider";
import { useWatchQueue } from "./WatchQueueProvider";
import { fetchQueueCovers } from "@/app/actions/queue";
import { ImageCard, type CardImage } from "@/components/grid/ImageCard";
import { bulkSetWatched } from "@/app/actions/bulk";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
/**
* Watch-queue panel. Mirrors `TrashPanel` and `SettingsPanel`: a centered
* modal capped at 1400×900, dimmed backdrop, click-outside / Escape to
* close. Triggered by the topnav indicator (which now toggles instead of
* navigating to /queue).
*/
export function QueuePanel() {
const { open, close } = useQueuePanel();
const queue = useWatchQueue();
const router = useRouter();
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, close, open);
const [covers, setCovers] = useState<CardImage[]>([]);
const [loading, setLoading] = useState(true);
const [pending, start] = useTransition();
// Re-fetch when the panel opens or the underlying queue changes. The
// fetch is keyed by the comma-joined id list so order changes also
// refresh the displayed grid.
useEffect(() => {
if (!open) return;
let live = true;
setLoading(true);
fetchQueueCovers(queue.ids).then((c) => {
if (!live) return;
setCovers(c);
setLoading(false);
});
return () => { live = false; };
}, [open, queue.ids]);
// Prune any queued ids that no longer resolve to a live cover.
useEffect(() => {
if (!open || loading) return;
const present = new Set(covers.map((c) => c.id));
const stale = queue.ids.filter((id) => !present.has(id));
if (stale.length > 0) queue.removeMany(stale);
}, [open, loading, covers, queue]);
// Lock background scroll + Escape to close while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, close]);
if (!open) return null;
function markAllWatched() {
if (covers.length === 0) return;
if (!confirm(`Mark all ${covers.length} covers as watched and clear the queue?`)) return;
const ids = covers.map((c) => c.id);
start(async () => {
await bulkSetWatched(ids, true);
queue.removeMany(ids);
router.refresh();
});
}
function clearQueue() {
if (queue.ids.length === 0) return;
if (!confirm("Clear the watch queue? Covers themselves are not affected.")) return;
queue.clear();
}
return (
<div
className="fixed inset-0 z-50 backdrop-blur-sm grid place-items-center p-4 sm:p-8"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 65%, transparent)" }}
>
<div
ref={ref}
className="w-full max-w-[1400px] h-[min(900px,calc(100vh-4rem))] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden shadow-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<header className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-glass-border)] shrink-0">
<div>
<div className="flex items-center gap-2">
<ListVideo className="w-5 h-5 text-[var(--color-cyan)]" />
<h2 className="text-xl font-semibold tracking-tight">Watch Queue</h2>
</div>
<p className="text-xs text-[var(--color-fg-dim)] mt-1">
{queue.ids.length === 0
? "Empty — right-click any cover and choose “Add to queue”."
: `${covers.length} cover${covers.length === 1 ? "" : "s"} queued · marking watched removes them automatically`}
</p>
</div>
<div className="flex items-center gap-2">
{queue.ids.length > 0 && (
<>
<button
onClick={markAllWatched}
disabled={pending}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-mint)]/15 text-[var(--color-mint)] border border-[var(--color-mint)]/40 hover:bg-[var(--color-mint)]/25 disabled:opacity-50"
>
{pending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
Mark All Watched ({covers.length})
</button>
<button
onClick={clearQueue}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-coral)]"
>
<X className="w-4 h-4" />
Clear Queue
</button>
</>
)}
<button
onClick={close}
aria-label="Close watch queue"
className="w-8 h-8 grid place-items-center rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
<X className="w-4 h-4" />
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)]">
<Loader2 className="w-6 h-6 mx-auto animate-spin mb-label" />
Loading queue
</div>
) : covers.length === 0 ? (
<div className="glass rounded-2xl p-card text-center max-w-md mx-auto">
<ListVideo className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">
{queue.ids.length > 0
? "Queue items couldn't be resolved (covers may have been deleted)."
: "Queue is empty."}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{covers.map((c) => (
<div key={c.id} className="relative group/queue">
<ImageCard image={c} />
<div className="absolute inset-0 z-20 rounded-2xl bg-black/80 opacity-0 group-hover/queue:opacity-100 transition-opacity grid place-items-center cursor-default">
<div className="grid grid-cols-2 rounded-full overflow-hidden shadow-2xl border border-white/10" style={{ width: "min(86%, 420px)" }}>
<button
type="button"
onClick={(e) => {
e.preventDefault(); e.stopPropagation();
start(async () => {
await bulkSetWatched([c.id], true);
queue.remove(c.id);
router.refresh();
});
}}
title="Mark as watched (removes from queue)"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-mint)]/90 hover:bg-[var(--color-mint)] text-black font-semibold text-sm cursor-pointer"
>
<Eye className="w-4 h-4" />
Mark As Watched
</button>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); queue.remove(c.id); }}
title="Remove from queue"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-coral)]/90 hover:bg-[var(--color-coral)] text-black font-semibold text-sm cursor-pointer border-l border-black/20"
>
<Trash2 className="w-4 h-4" />
Remove From Queue
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}