Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user