← back to Nineoh Guide
feat: mobile bottom-tab nav (Episodes/Cast/News/Saved) + /api/cast + /api/news endpoints
e8b0cf65e533c18a429801c7ebd88eac9f7c92e8 · 2026-07-27 10:54:28 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/mobile/App.tsxA apps/web/app/api/cast/route.tsA apps/web/app/api/news/route.ts
Diff
commit e8b0cf65e533c18a429801c7ebd88eac9f7c92e8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 10:54:28 2026 -0700
feat: mobile bottom-tab nav (Episodes/Cast/News/Saved) + /api/cast + /api/news endpoints
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/mobile/App.tsx | 338 +++++++++++++++++++++++++++++++++--------
apps/web/app/api/cast/route.ts | 37 +++++
apps/web/app/api/news/route.ts | 31 ++++
3 files changed, 340 insertions(+), 66 deletions(-)
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index a558723..7c56c02 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -7,6 +7,8 @@ import {
ActivityIndicator,
Pressable,
Alert,
+ Linking,
+ Image,
} from "react-native";
import { StatusBar } from "expo-status-bar";
import Constants from "expo-constants";
@@ -30,6 +32,30 @@ const API_BASE =
const WATCHLIST_KEY = "nineoh.watchlist.v1";
const WATCHED_KEY = "nineoh.watchedThrough.v1";
+type TabKey = "episodes" | "cast" | "news" | "saved";
+const TABS: { key: TabKey; icon: string; label: string }[] = [
+ { key: "episodes", icon: "📺", label: "Episodes" },
+ { key: "cast", icon: "🎭", label: "Cast" },
+ { key: "news", icon: "📰", label: "News" },
+ { key: "saved", icon: "★", label: "Saved" },
+];
+
+type CastMember = {
+ id: string;
+ name: string;
+ characterName: string | null;
+ bio: string | null;
+ photoUrl: string | null;
+ attribution: string | null;
+};
+type NewsItem = {
+ id: string;
+ headline: string;
+ publisher: string | null;
+ url: string;
+ publishedAt: string | null;
+};
+
function isAhead(
p: { season: number; episode: number } | null,
s: number,
@@ -40,10 +66,16 @@ function isAhead(
}
export default function App() {
+ const [tab, setTab] = useState<TabKey>("episodes");
+
const [episodes, setEpisodes] = useState<Episode[]>([]);
+ const [cast, setCast] = useState<CastMember[]>([]);
+ const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
+ const [castLoaded, setCastLoaded] = useState(false);
+ const [newsLoaded, setNewsLoaded] = useState(false);
+
const [saved, setSaved] = useState<Set<string>>(new Set());
- const [savedOnly, setSavedOnly] = useState(false);
const [watchedThrough, setWatchedThrough] = useState<{
season: number;
episode: number;
@@ -70,7 +102,7 @@ export default function App() {
);
}, []);
- // Native feature #1: persisted watchlist (AsyncStorage) — survives app restarts.
+ // Native feature: persisted watchlist + spoiler cutoff (survive restarts).
useEffect(() => {
AsyncStorage.getItem(WATCHLIST_KEY).then((raw) => {
if (raw) {
@@ -96,7 +128,7 @@ export default function App() {
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
+ setRevealed(new Set());
}, []);
const reveal = useCallback((id: string) => {
@@ -112,6 +144,7 @@ export default function App() {
});
}, []);
+ // Episodes load on mount; cast + news lazy-load the first time their tab opens.
useEffect(() => {
fetch(`${API_BASE}/api/episodes`)
.then((r) => (r.ok ? r.json() : { episodes: [] }))
@@ -126,7 +159,63 @@ export default function App() {
.finally(() => setLoading(false));
}, []);
- const shown = savedOnly ? episodes.filter((e) => saved.has(e.id)) : episodes;
+ useEffect(() => {
+ if (tab === "cast" && !castLoaded) {
+ setCastLoaded(true);
+ fetch(`${API_BASE}/api/cast`)
+ .then((r) => (r.ok ? r.json() : { cast: [] }))
+ .then((d) => setCast(d.cast ?? []))
+ .catch(() => setCast([]));
+ }
+ if (tab === "news" && !newsLoaded) {
+ setNewsLoaded(true);
+ fetch(`${API_BASE}/api/news`)
+ .then((r) => (r.ok ? r.json() : { news: [] }))
+ .then((d) => setNews(d.news ?? []))
+ .catch(() => setNews([]));
+ }
+ }, [tab, castLoaded, newsLoaded]);
+
+ const savedEpisodes = episodes.filter((e) => saved.has(e.id));
+
+ const renderEpisode = (ep: Episode) => {
+ const isSaved = saved.has(ep.id);
+ return (
+ <BlurView key={ep.id} intensity={28} tint="light" style={styles.card}>
+ <View style={styles.cardRow}>
+ <Text style={styles.cardTitle}>
+ S{ep.seasonNumber}E{ep.episodeNumber} · {ep.title}
+ {ep.spoilerRating > 0 ? (
+ <Text style={styles.spoiler}> ⚠ spoilers</Text>
+ ) : null}
+ </Text>
+ <Pressable onPress={() => toggleSave(ep.id)} hitSlop={10}>
+ <Text style={styles.star}>{isSaved ? "★" : "☆"}</Text>
+ </Pressable>
+ </View>
+ {ep.summaryShortOriginal ? (
+ 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>
+ </BlurView>
+ );
+ };
return (
<View style={styles.root}>
@@ -160,16 +249,9 @@ export default function App() {
</Pressable>
) : null}
- <View style={styles.toolbar}>
- <Pressable
- onPress={() => setSavedOnly((v) => !v)}
- style={[styles.chip, savedOnly && styles.chipActive]}
- >
- <Text style={[styles.chipText, savedOnly && styles.chipTextActive]}>
- {savedOnly ? "★ Saved" : "☆ Saved only"} ({saved.size})
- </Text>
- </Pressable>
- {watchedThrough ? (
+ {/* Spoiler-guard status bar (only on episode-based tabs) */}
+ {(tab === "episodes" || tab === "saved") && watchedThrough ? (
+ <View style={styles.toolbar}>
<Pressable
onPress={() => {
setWatchedThrough(null);
@@ -178,63 +260,152 @@ export default function App() {
style={[styles.chip, styles.chipGuard]}
>
<Text style={styles.chipGuardText}>
- 🛡 after S{watchedThrough.season}E{watchedThrough.episode} ✕
+ 🛡 spoilers hidden after S{watchedThrough.season}E
+ {watchedThrough.episode} ✕
</Text>
</Pressable>
- ) : null}
- </View>
+ </View>
+ ) : null}
<ScrollView contentContainerStyle={styles.body}>
- <Text style={styles.disclaimer}>{APP.disclaimerShort}</Text>
- {loading ? (
- <ActivityIndicator style={{ marginTop: 24 }} />
- ) : shown.length === 0 ? (
- <Text style={styles.empty}>
- {savedOnly
- ? "No saved episodes yet — tap the star on any episode."
- : "Content is being curated. Original recaps and cast profiles will appear here as editors write them."}
- </Text>
- ) : (
- shown.map((ep) => {
- const isSaved = saved.has(ep.id);
- return (
- <BlurView key={ep.id} intensity={28} tint="light" style={styles.card}>
- <View style={styles.cardRow}>
- <Text style={styles.cardTitle}>
- S{ep.seasonNumber}E{ep.episodeNumber} · {ep.title}
- {ep.spoilerRating > 0 ? (
- <Text style={styles.spoiler}> ⚠ spoilers</Text>
- ) : null}
- </Text>
- <Pressable onPress={() => toggleSave(ep.id)} hitSlop={10}>
- <Text style={styles.star}>{isSaved ? "★" : "☆"}</Text>
- </Pressable>
- </View>
- {ep.summaryShortOriginal ? (
- 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>
+ {tab === "episodes" ? (
+ <>
+ <Text style={styles.disclaimer}>{APP.disclaimerShort}</Text>
+ {loading ? (
+ <ActivityIndicator style={{ marginTop: 24 }} />
+ ) : episodes.length === 0 ? (
+ <Text style={styles.empty}>
+ Content is being curated. Original recaps will appear here.
+ </Text>
+ ) : (
+ episodes.map(renderEpisode)
+ )}
+ </>
+ ) : null}
+
+ {tab === "saved" ? (
+ <>
+ <Text style={styles.sectionTitle}>★ Your watchlist</Text>
+ {savedEpisodes.length === 0 ? (
+ <Text style={styles.empty}>
+ No saved episodes yet — tap the ☆ star on any episode to add it.
+ </Text>
+ ) : (
+ savedEpisodes.map(renderEpisode)
+ )}
+ </>
+ ) : null}
+
+ {tab === "cast" ? (
+ <>
+ <Text style={styles.sectionTitle}>🎭 Cast</Text>
+ {!castLoaded || cast.length === 0 ? (
+ !castLoaded ? (
+ <ActivityIndicator style={{ marginTop: 24 }} />
+ ) : (
+ <Text style={styles.empty}>
+ Cast profiles are being written. Bios are original; photos appear
+ only once their licenses are cleared.
+ </Text>
+ )
+ ) : (
+ cast.map((p) => (
+ <BlurView
+ key={p.id}
+ intensity={28}
+ tint="light"
+ style={styles.card}
+ >
+ <View style={styles.castRow}>
+ {p.photoUrl ? (
+ <Image
+ source={{ uri: p.photoUrl }}
+ style={styles.headshot}
+ />
+ ) : (
+ <View style={[styles.headshot, styles.headshotBlank]}>
+ <Text style={styles.headshotInitial}>
+ {p.name.slice(0, 1)}
+ </Text>
+ </View>
+ )}
+ <View style={{ flex: 1 }}>
+ <Text style={styles.cardTitle}>{p.name}</Text>
+ {p.characterName ? (
+ <Text style={styles.castChar}>as {p.characterName}</Text>
+ ) : null}
+ </View>
+ </View>
+ {p.bio ? (
+ <Text style={styles.cardBody}>{p.bio}</Text>
) : (
- <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
- )
- ) : (
- <Text style={styles.pending}>Recap coming soon</Text>
- )}
+ <Text style={styles.pending}>Bio coming soon</Text>
+ )}
+ {p.attribution ? (
+ <Text style={styles.attribution}>{p.attribution}</Text>
+ ) : null}
+ </BlurView>
+ ))
+ )}
+ </>
+ ) : null}
+
+ {tab === "news" ? (
+ <>
+ <Text style={styles.sectionTitle}>📰 90210 in the news</Text>
+ {!newsLoaded ? (
+ <ActivityIndicator style={{ marginTop: 24 }} />
+ ) : news.length === 0 ? (
+ <Text style={styles.empty}>No news right now — check back soon.</Text>
+ ) : (
+ news.map((n) => (
<Pressable
- onPress={() => markWatched(ep.seasonNumber, ep.episodeNumber)}
- hitSlop={6}
+ key={n.id}
+ onPress={() => Linking.openURL(n.url)}
+ style={styles.newsCard}
>
- <Text style={styles.markWatched}>✓ watched to here</Text>
+ <Text style={styles.newsHeadline}>{n.headline}</Text>
+ <Text style={styles.newsMeta}>
+ {n.publisher ?? "Source"}
+ {n.publishedAt
+ ? " · " +
+ new Date(n.publishedAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : ""}
+ {" ↗"}
+ </Text>
</Pressable>
- </BlurView>
- );
- })
- )}
+ ))
+ )}
+ </>
+ ) : null}
</ScrollView>
+
+ {/* Bottom tab bar */}
+ <View style={styles.tabbar}>
+ {TABS.map((t) => {
+ const active = tab === t.key;
+ const count = t.key === "saved" ? saved.size : 0;
+ return (
+ <Pressable
+ key={t.key}
+ style={styles.tab}
+ onPress={() => setTab(t.key)}
+ >
+ <Text style={[styles.tabIcon, active && styles.tabIconActive]}>
+ {t.icon}
+ </Text>
+ <Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
+ {t.label}
+ {count > 0 ? ` (${count})` : ""}
+ </Text>
+ </Pressable>
+ );
+ })}
+ </View>
</View>
);
}
@@ -266,10 +437,13 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 5,
},
- chipActive: { backgroundColor: "#1a1a1a", borderColor: "#1a1a1a" },
- chipText: { fontSize: 13, color: "#333" },
- chipTextActive: { color: "#fff" },
- body: { padding: 20, paddingTop: 4 },
+ body: { padding: 20, paddingTop: 12, paddingBottom: 28 },
+ sectionTitle: {
+ fontSize: 17,
+ fontWeight: "700",
+ color: "#3a2540",
+ marginBottom: 12,
+ },
disclaimer: { fontSize: 12, color: "#666", marginBottom: 16 },
empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
card: {
@@ -300,6 +474,38 @@ const styles = StyleSheet.create({
markWatched: { fontSize: 11, color: "#888", marginTop: 6 },
chipGuard: { backgroundColor: "#f3e9e9", borderColor: "#d8b4b4" },
chipGuardText: { fontSize: 13, color: "#8a1c1c" },
+ // cast
+ castRow: { flexDirection: "row", alignItems: "center", gap: 12 },
+ headshot: { width: 52, height: 52, borderRadius: 26, backgroundColor: "#e8dce6" },
+ headshotBlank: { alignItems: "center", justifyContent: "center" },
+ headshotInitial: { fontSize: 22, fontWeight: "700", color: "#7b3f6e" },
+ castChar: { fontSize: 12, color: "#7b3f6e", marginTop: 2 },
+ attribution: { fontSize: 10, color: "#999", marginTop: 8, lineHeight: 14 },
+ // news
+ newsCard: {
+ backgroundColor: "rgba(255,255,255,0.55)",
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.7)",
+ borderRadius: 12,
+ padding: 13,
+ marginBottom: 9,
+ },
+ newsHeadline: { fontSize: 14, fontWeight: "600", color: "#2b1e3a", lineHeight: 19 },
+ newsMeta: { fontSize: 11, color: "#8a6d8a", marginTop: 6 },
+ // tab bar
+ tabbar: {
+ flexDirection: "row",
+ borderTopWidth: 1,
+ borderTopColor: "#e3d6de",
+ backgroundColor: "rgba(255,255,255,0.92)",
+ paddingBottom: 26,
+ paddingTop: 8,
+ },
+ tab: { flex: 1, alignItems: "center", gap: 3 },
+ tabIcon: { fontSize: 20, opacity: 0.5 },
+ tabIconActive: { opacity: 1 },
+ tabLabel: { fontSize: 10, color: "#9a8a96" },
+ tabLabelActive: { color: "#7b3f6e", fontWeight: "700" },
egg: {
position: "absolute",
top: 0,
diff --git a/apps/web/app/api/cast/route.ts b/apps/web/app/api/cast/route.ts
new file mode 100644
index 0000000..92d01de
--- /dev/null
+++ b/apps/web/app/api/cast/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { pool } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Cast list for the Expo app. Mirrors the web /cast page query.
+ * Photo attribution is carried through — CC-BY-SA requires the caption be shown.
+ */
+export async function GET() {
+ try {
+ const { rows } = await pool.query(
+ `select cp.id, cp.name, cp.biography_original,
+ ch.name as character_name,
+ a.file_url, a.attribution_text, a.license_type, a.license_url
+ from cast_people cp
+ left join credits cr on cr.person_id = cp.id and cr.credit_type = 'main-cast'
+ left join characters ch on ch.id = cr.character_id
+ left join assets a on a.id = cp.headshot_asset_id
+ order by cp.name
+ limit 200`
+ );
+ const cast = rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ characterName: r.character_name ?? null,
+ bio: r.biography_original ?? null,
+ photoUrl: r.file_url ?? null,
+ attribution: r.attribution_text ?? null,
+ licenseType: r.license_type ?? null,
+ licenseUrl: r.license_url ?? null,
+ }));
+ return NextResponse.json({ cast });
+ } catch {
+ return NextResponse.json({ cast: [] });
+ }
+}
diff --git a/apps/web/app/api/news/route.ts b/apps/web/app/api/news/route.ts
new file mode 100644
index 0000000..6404a29
--- /dev/null
+++ b/apps/web/app/api/news/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { pool } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * News index for the Expo app — headline + source + link only (no article
+ * bodies are stored, for copyright). Newest first.
+ */
+export async function GET() {
+ try {
+ const { rows } = await pool.query(
+ `select id, headline, publisher_name, canonical_url, published_at
+ from news_items
+ order by published_at desc nulls last, created_at desc
+ limit 100`
+ );
+ const news = rows.map((r) => ({
+ id: r.id,
+ headline: r.headline,
+ publisher: r.publisher_name ?? null,
+ url: r.canonical_url,
+ publishedAt: r.published_at
+ ? new Date(r.published_at).toISOString()
+ : null,
+ }));
+ return NextResponse.json({ news });
+ } catch {
+ return NextResponse.json({ news: [] });
+ }
+}
← 877569a chore(mobile): add preview-sim profile (iOS simulator build,
·
back to Nineoh Guide
·
fix(monorepo): keep isolated nodeLinker (web React 19 intact fd94bbd →