← back to Nineoh Guide
spoiler guard: progress-based recap hiding on web (SpoilerGuard, localStorage+event, no Context to dodge @types/react skew) + mobile (watched-through + reveal, AsyncStorage)
d2ccd5a17bccf493cd46b3ccf1ee0937e8030484 · 2026-07-25 11:21:37 -0700 · Steve
Files touched
M apps/mobile/App.tsxM apps/web/app/episodes/page.tsxA apps/web/components/SpoilerGuard.tsx
Diff
commit d2ccd5a17bccf493cd46b3ccf1ee0937e8030484
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 25 11:21:37 2026 -0700
spoiler guard: progress-based recap hiding on web (SpoilerGuard, localStorage+event, no Context to dodge @types/react skew) + mobile (watched-through + reveal, AsyncStorage)
---
apps/mobile/App.tsx | 78 +++++++++++++++++-
apps/web/app/episodes/page.tsx | 16 ++--
apps/web/components/SpoilerGuard.tsx | 152 +++++++++++++++++++++++++++++++++++
3 files changed, 236 insertions(+), 10 deletions(-)
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index fc94bb5..d8194c3 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -25,12 +25,27 @@ const API_BASE =
process.env.EXPO_PUBLIC_API_BASE ?? configured ?? "http://localhost:4090";
const WATCHLIST_KEY = "nineoh.watchlist.v1";
+const WATCHED_KEY = "nineoh.watchedThrough.v1";
+
+function isAhead(
+ p: { season: number; episode: number } | null,
+ s: number,
+ e: number
+) {
+ if (!p) return false;
+ return s > p.season || (s === p.season && e > p.episode);
+}
export default function App() {
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [loading, setLoading] = useState(true);
const [saved, setSaved] = useState<Set<string>>(new Set());
const [savedOnly, setSavedOnly] = useState(false);
+ const [watchedThrough, setWatchedThrough] = useState<{
+ season: number;
+ episode: number;
+ } | null>(null);
+ const [revealed, setRevealed] = useState<Set<string>>(new Set());
// Native feature #1: persisted watchlist (AsyncStorage) — survives app restarts.
useEffect(() => {
@@ -43,6 +58,26 @@ export default function App() {
}
}
});
+ AsyncStorage.getItem(WATCHED_KEY).then((raw) => {
+ if (raw) {
+ try {
+ setWatchedThrough(JSON.parse(raw));
+ } catch {
+ /* ignore */
+ }
+ }
+ });
+ }, []);
+
+ const markWatched = useCallback((season: number, episode: number) => {
+ const p = { season, episode };
+ setWatchedThrough(p);
+ AsyncStorage.setItem(WATCHED_KEY, JSON.stringify(p));
+ setRevealed(new Set()); // re-hide anything now behind the new progress line
+ }, []);
+
+ const reveal = useCallback((id: string) => {
+ setRevealed((prev) => new Set(prev).add(id));
}, []);
const toggleSave = useCallback((id: string) => {
@@ -87,6 +122,19 @@ export default function App() {
{savedOnly ? "★ Saved" : "☆ Saved only"} ({saved.size})
</Text>
</Pressable>
+ {watchedThrough ? (
+ <Pressable
+ onPress={() => {
+ setWatchedThrough(null);
+ AsyncStorage.removeItem(WATCHED_KEY);
+ }}
+ style={[styles.chip, styles.chipGuard]}
+ >
+ <Text style={styles.chipGuardText}>
+ 🛡 after S{watchedThrough.season}E{watchedThrough.episode} ✕
+ </Text>
+ </Pressable>
+ ) : null}
</View>
<ScrollView contentContainerStyle={styles.body}>
@@ -116,10 +164,25 @@ export default function App() {
</Pressable>
</View>
{ep.summaryShortOriginal ? (
- <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
+ isAhead(watchedThrough, ep.seasonNumber, ep.episodeNumber) &&
+ !revealed.has(ep.id) ? (
+ <Pressable onPress={() => reveal(ep.id)}>
+ <Text style={styles.hidden}>
+ ⚠ Recap hidden (ahead of your progress) — tap to reveal
+ </Text>
+ </Pressable>
+ ) : (
+ <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
+ )
) : (
<Text style={styles.pending}>Recap coming soon</Text>
)}
+ <Pressable
+ onPress={() => markWatched(ep.seasonNumber, ep.episodeNumber)}
+ hitSlop={6}
+ >
+ <Text style={styles.markWatched}>✓ watched to here</Text>
+ </Pressable>
</View>
);
})
@@ -176,4 +239,17 @@ const styles = StyleSheet.create({
star: { fontSize: 18, color: "#8a1c1c" },
cardBody: { fontSize: 13, color: "#444", marginTop: 4, lineHeight: 18 },
pending: { fontSize: 12, color: "#aaa", marginTop: 4 },
+ hidden: {
+ fontSize: 12,
+ color: "#8a1c1c",
+ marginTop: 4,
+ borderWidth: 1,
+ borderColor: "#d8b4b4",
+ borderStyle: "dashed",
+ borderRadius: 4,
+ padding: 6,
+ },
+ markWatched: { fontSize: 11, color: "#888", marginTop: 6 },
+ chipGuard: { backgroundColor: "#f3e9e9", borderColor: "#d8b4b4" },
+ chipGuardText: { fontSize: 13, color: "#8a1c1c" },
});
diff --git a/apps/web/app/episodes/page.tsx b/apps/web/app/episodes/page.tsx
index 2b2b951..3bd5df4 100644
--- a/apps/web/app/episodes/page.tsx
+++ b/apps/web/app/episodes/page.tsx
@@ -1,4 +1,5 @@
import { pool } from "@/lib/db";
+import { SpoilerSettings, SpoilerRecap } from "@/components/SpoilerGuard";
export const dynamic = "force-dynamic";
export const metadata = { title: "Episode Guide" };
@@ -31,6 +32,7 @@ export default async function Episodes() {
Factual episode index. Original spoiler-safe recaps are being written by
our editors and will appear per episode.
</p>
+ <SpoilerSettings seasons={Math.max(5, ...seasons, 1)} />
{seasons.length === 0 && <p>No episodes indexed yet.</p>}
{seasons.map((s) => (
<div key={s} style={{ marginTop: 20 }}>
@@ -51,15 +53,11 @@ export default async function Episodes() {
</span>
<span style={{ flex: 1 }}>
<a href={`/episodes/${s}/${ep.episode_number}`}>{ep.title}</a>
- {ep.summary_short_original ? (
- <span style={{ display: "block", color: "#555", fontSize: 13 }}>
- {ep.summary_short_original}
- </span>
- ) : (
- <span style={{ display: "block", color: "#aaa", fontSize: 12 }}>
- Recap coming soon
- </span>
- )}
+ <SpoilerRecap
+ season={s}
+ episode={ep.episode_number}
+ text={ep.summary_short_original}
+ />
</span>
<span style={{ color: "#999", fontSize: 12 }}>
{ep.air_date ? new Date(ep.air_date).toLocaleDateString() : ""}
diff --git a/apps/web/components/SpoilerGuard.tsx b/apps/web/components/SpoilerGuard.tsx
new file mode 100644
index 0000000..6864c85
--- /dev/null
+++ b/apps/web/components/SpoilerGuard.tsx
@@ -0,0 +1,152 @@
+"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>;
+}
← 5d109ee contrarian fixes: add recap_status/bio_status to canonical s
·
back to Nineoh Guide
·
internal search: /search (SSR) across episode titles+recaps 2a1d19a →