111 lines
3.3 KiB
TypeScript
111 lines
3.3 KiB
TypeScript
"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;
|
|
}
|