[object Object]

← 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

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 →