Initial commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user