← back to Nineoh Guide
apps/web/components/SpoilerGuard.tsx
153 lines
"use client";
import { useEffect, useState } from "react";
/**
* Progress-based spoiler guard. The reader marks how far they've watched; any
* recap for an episode AHEAD of that point hides behind a reveal tap. Genuine
* native-style value a flat wiki page doesn't offer.
*
* Implemented with a tiny localStorage store + a window event rather than React
* Context — the monorepo mixes @types/react 18 (mobile) and 19 (web), and the
* typed Context.Provider JSX trips that skew; a plain store avoids it entirely
* and is SSR-safe (server renders with no progress, so recaps show, then hydrates).
*/
type Progress = { season: number; episode: number };
const KEY = "nineoh.watchedThrough.v1";
const EVT = "nineoh:watched-change";
function readWatched(): Progress | null {
try {
const r = localStorage.getItem(KEY);
return r ? (JSON.parse(r) as Progress) : null;
} catch {
return null;
}
}
function writeWatched(p: Progress | null) {
try {
if (p) localStorage.setItem(KEY, JSON.stringify(p));
else localStorage.removeItem(KEY);
} catch {
/* ignore */
}
window.dispatchEvent(new Event(EVT));
}
function useWatched(): Progress | null {
const [p, setP] = useState<Progress | null>(null);
useEffect(() => {
const sync = () => setP(readWatched());
sync();
window.addEventListener(EVT, sync);
return () => window.removeEventListener(EVT, sync);
}, []);
return p;
}
function isAhead(p: Progress | null, s: number, e: number) {
if (!p) return false;
return s > p.season || (s === p.season && e > p.episode);
}
export function SpoilerSettings({ seasons = 5 }: { seasons?: number }) {
const p = useWatched();
const [season, setSeason] = useState(1);
const [episode, setEpisode] = useState(1);
useEffect(() => {
if (p) {
setSeason(p.season);
setEpisode(p.episode);
}
}, [p]);
return (
<div
style={{
border: "1px solid #e5e5e5",
borderRadius: 8,
padding: "10px 12px",
background: "#fff",
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "wrap",
fontSize: 13,
margin: "8px 0 16px",
}}
>
<span style={{ fontWeight: 600 }}>🛡 Spoiler guard:</span>
{p ? (
<span>
hiding recaps after S{p.season}E{p.episode}.
</span>
) : (
<span style={{ color: "#666" }}>off — all recaps visible.</span>
)}
<label>
S
<input
type="number"
min={1}
max={seasons}
value={season}
onChange={(e) => setSeason(Number(e.target.value))}
style={{ width: 42, marginLeft: 2 }}
/>
</label>
<label>
E
<input
type="number"
min={1}
value={episode}
onChange={(e) => setEpisode(Number(e.target.value))}
style={{ width: 48, marginLeft: 2 }}
/>
</label>
<button onClick={() => writeWatched({ season, episode })}>
Set "watched through"
</button>
{p ? <button onClick={() => writeWatched(null)}>Turn off</button> : null}
</div>
);
}
export function SpoilerRecap({
season,
episode,
text,
}: {
season: number;
episode: number;
text: string | null;
}) {
const p = useWatched();
const [revealed, setRevealed] = useState(false);
if (!text)
return (
<span style={{ display: "block", color: "#aaa", fontSize: 12 }}>
Recap coming soon
</span>
);
if (isAhead(p, season, episode) && !revealed) {
return (
<button
onClick={() => setRevealed(true)}
style={{
display: "block",
marginTop: 2,
fontSize: 12,
color: "#8a1c1c",
background: "none",
border: "1px dashed #d8b4b4",
borderRadius: 4,
padding: "3px 8px",
cursor: "pointer",
}}
>
⚠ Recap hidden (ahead of your progress) — tap to reveal
</button>
);
}
return <span style={{ display: "block", color: "#555", fontSize: 13 }}>{text}</span>;
}