← 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 &quot;watched through&quot;
      </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>;
}