Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { ListVideo } from "lucide-react";
import { useWatchQueue } from "./WatchQueueProvider";
import { useQueuePanel } from "./QueuePanelProvider";
import { cn } from "@/lib/utils";
export function QueueIndicator() {
const q = useWatchQueue();
const { open, toggle } = useQueuePanel();
const count = q.ids.length;
return (
<button
type="button"
onClick={toggle}
aria-label="Watch queue"
aria-pressed={open}
title={count > 0 ? `Watch queue · ${count} cover${count === 1 ? "" : "s"}` : "Watch queue"}
className={cn(
"relative w-9 h-9 grid place-items-center rounded-lg border transition-colors shrink-0",
open
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: count > 0
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<ListVideo className="w-4 h-4" />
{count > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold grid place-items-center">
{count > 99 ? "99+" : count}
</span>
)}
</button>
);
}
+192
View File
@@ -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>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = { open: boolean; toggle: () => void; close: () => void };
const C = createContext<Ctx | null>(null);
export function QueuePanelProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const toggle = useCallback(() => setOpen((o) => !o), []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo<Ctx>(() => ({ open, toggle, close }), [open, toggle, close]);
return <C.Provider value={value}>{children}</C.Provider>;
}
export function useQueuePanel() {
const ctx = useContext(C);
if (!ctx) throw new Error("useQueuePanel must be used within QueuePanelProvider");
return ctx;
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ListVideo, Eye, Trash2, X, Loader2 } from "lucide-react";
import { useWatchQueue } from "./WatchQueueProvider";
import { fetchQueueCovers } from "@/app/actions/queue";
import { ImageCard, type CardImage } from "@/components/grid/ImageCard";
import { bulkSetWatched } from "@/app/actions/bulk";
export function QueueView() {
const router = useRouter();
const queue = useWatchQueue();
const [covers, setCovers] = useState<CardImage[]>([]);
const [loading, setLoading] = useState(true);
const [pending, start] = useTransition();
// Re-fetch when the queue id list changes. Cheap — single query keyed by IN(...).
useEffect(() => {
let live = true;
setLoading(true);
fetchQueueCovers(queue.ids).then((c) => {
if (!live) return;
setCovers(c);
setLoading(false);
});
return () => { live = false; };
}, [queue.ids]);
// If a server-side delete or watched flip means a queued id no longer
// resolves to a cover, prune it locally so the count stays honest.
useEffect(() => {
if (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);
}, [loading, covers, queue]);
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="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight flex items-center gap-2">
<ListVideo className="w-7 h-7 text-[var(--color-cyan)]" />
Watch queue
</h1>
<p className="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>
{queue.ids.length > 0 && (
<div className="flex items-center gap-2">
<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>
</div>
)}
</div>
{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">
<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 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>
)}
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { subscribeQueueRemove } from "./watchQueueEvents";
const STORAGE_KEY = "pinkudex.watch-queue";
type Ctx = {
ids: number[];
has: (id: number) => boolean;
add: (id: number) => void;
addMany: (ids: number[]) => void;
remove: (id: number) => void;
removeMany: (ids: number[]) => void;
toggle: (id: number) => void;
clear: () => void;
};
const WatchQueueCtx = createContext<Ctx | null>(null);
function readStorage(): number[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((n): n is number => typeof n === "number");
} catch {
return [];
}
}
export function WatchQueueProvider({ children }: { children: React.ReactNode }) {
const [ids, setIds] = useState<number[]>([]);
const [hydrated, setHydrated] = useState(false);
// Hydrate from localStorage. SSR renders an empty queue; this fills it
// on mount so the server-rendered HTML always matches initial paint.
useEffect(() => {
setIds(readStorage());
setHydrated(true);
}, []);
// Persist on change (after hydration, so we don't blow away storage with
// the empty-array initial state).
useEffect(() => {
if (!hydrated) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} catch {}
}, [ids, hydrated]);
// Cross-tab sync.
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== STORAGE_KEY) return;
setIds(readStorage());
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
// External signal — covers fire this after their watched flag flips true.
useEffect(() => subscribeQueueRemove((detail) => {
const drop = new Set(detail);
setIds((cur) => cur.filter((id) => !drop.has(id)));
}), []);
const add = useCallback((id: number) => {
setIds((cur) => (cur.includes(id) ? cur : [...cur, id]));
}, []);
const addMany = useCallback((newIds: number[]) => {
setIds((cur) => {
const have = new Set(cur);
const merged = [...cur];
for (const id of newIds) if (!have.has(id)) { merged.push(id); have.add(id); }
return merged;
});
}, []);
const remove = useCallback((id: number) => {
setIds((cur) => cur.filter((x) => x !== id));
}, []);
const removeMany = useCallback((dropIds: number[]) => {
const drop = new Set(dropIds);
setIds((cur) => cur.filter((id) => !drop.has(id)));
}, []);
const toggle = useCallback((id: number) => {
setIds((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]));
}, []);
const clear = useCallback(() => setIds([]), []);
const value = useMemo<Ctx>(() => ({
ids,
has: (id) => ids.includes(id),
add,
addMany,
remove,
removeMany,
toggle,
clear,
}), [ids, add, addMany, remove, removeMany, toggle, clear]);
return <WatchQueueCtx.Provider value={value}>{children}</WatchQueueCtx.Provider>;
}
export function useWatchQueue(): Ctx {
const ctx = useContext(WatchQueueCtx);
if (!ctx) throw new Error("useWatchQueue must be used within WatchQueueProvider");
return ctx;
}
+21
View File
@@ -0,0 +1,21 @@
// Event helpers split out of WatchQueueProvider.tsx so that the
// provider file's only exports are the Component and the `useXxx`
// hook — the shape Next.js Fast Refresh needs to swap in place
// instead of forcing a full reload.
const REMOVE_EVENT = "pinkudex:queue-remove";
export function dispatchQueueRemove(ids: number | number[]) {
if (typeof window === "undefined") return;
const arr = Array.isArray(ids) ? ids : [ids];
window.dispatchEvent(new CustomEvent(REMOVE_EVENT, { detail: arr }));
}
export function subscribeQueueRemove(cb: (ids: number[]) => void): () => void {
function onRemove(e: Event) {
const detail = (e as CustomEvent<number[]>).detail;
if (Array.isArray(detail) && detail.length > 0) cb(detail);
}
window.addEventListener(REMOVE_EVENT, onRemove);
return () => window.removeEventListener(REMOVE_EVENT, onRemove);
}