← back to Nineoh Guide
apps/mobile/App.tsx
708 lines
import { useCallback, useEffect, useState } from "react";
import {
ScrollView,
Text,
View,
StyleSheet,
ActivityIndicator,
Pressable,
Alert,
Linking,
Image,
} from "react-native";
import { StatusBar } from "expo-status-bar";
import Constants from "expo-constants";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { APP, EpisodeSchema, type Episode } from "@nineoh/core";
// API base resolution (in precedence order):
// 1. EXPO_PUBLIC_API_BASE — set as an EAS Secret for TestFlight/prod builds
// 2. app.json → extra.apiBaseUrl — read via expo-constants (ignored if placeholder)
// 3. localhost:4090 — local dev fallback
const extra = Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined;
const configured =
extra?.apiBaseUrl && !extra.apiBaseUrl.includes("REPLACE")
? extra.apiBaseUrl
: undefined;
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";
type TabKey = "episodes" | "cast" | "media" | "news" | "saved";
const TABS: { key: TabKey; icon: string; label: string }[] = [
{ key: "episodes", icon: "📺", label: "Episodes" },
{ key: "cast", icon: "🎭", label: "Cast" },
{ key: "media", icon: "🎬", label: "Watch" },
{ key: "news", icon: "📰", label: "News" },
{ key: "saved", icon: "★", label: "Saved" },
];
// Curated external media — YouTube, podcasts, streaming & reference.
// Landing/search URLs (not fabricated deep links) so nothing 404s.
type MediaLink = { label: string; sub: string; url: string };
const MEDIA: { section: string; items: MediaLink[] }[] = [
{
section: "▶ YouTube",
items: [
{
label: "Official clips & iconic scenes",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+official+clip",
},
{
label: "Cast reunions & interviews",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+cast+reunion+interview",
},
{
label: "Opening theme & title sequences",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+opening+theme",
},
{
label: "Retrospectives & where-are-they-now",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+retrospective+where+are+they+now",
},
],
},
{
section: "🎧 Podcasts",
items: [
{
label: "9021OMG — Jennie Garth & Tori Spelling",
sub: "The official rewatch podcast",
url: "https://podcasts.apple.com/us/search?term=9021OMG",
},
{
label: "All 90210 rewatch podcasts",
sub: "Apple Podcasts",
url: "https://podcasts.apple.com/us/search?term=beverly%20hills%2090210",
},
{
label: "Listen on Spotify",
sub: "Spotify",
url: "https://open.spotify.com/search/9021OMG",
},
],
},
{
section: "📺 Where to watch",
items: [
{
label: "Streaming availability",
sub: "JustWatch",
url: "https://www.justwatch.com/us/search?q=Beverly%20Hills%2090210",
},
{
label: "Watch on Hulu",
sub: "hulu.com",
url: "https://www.hulu.com/search?q=beverly%20hills%2090210",
},
],
},
{
section: "🔎 More",
items: [
{
label: "Series overview & history",
sub: "Wikipedia",
url: "https://en.wikipedia.org/wiki/Beverly_Hills,_90210",
},
{
label: "Full cast & episode guide",
sub: "IMDb",
url: "https://www.imdb.com/find/?q=Beverly%20Hills%2090210",
},
],
},
];
type CastMember = {
id: string;
name: string;
characterName: string | null;
bio: string | null;
photoUrl: string | null;
attribution: string | null;
kind?: string; // "main-cast" | "recurring"
};
type NewsItem = {
id: string;
headline: string;
publisher: string | null;
url: string;
publishedAt: string | null;
};
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 [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 [watchedThrough, setWatchedThrough] = useState<{
season: number;
episode: number;
} | null>(null);
const [revealed, setRevealed] = useState<Set<string>>(new Set());
const [eggTaps, setEggTaps] = useState(0);
const [egg, setEgg] = useState(false);
// Easter egg #1: tap the UNOFFICIAL badge 5× to unlock the hidden 90210 card.
const tapBadge = useCallback(() => {
setEggTaps((n) => {
if (n + 1 >= 5) {
setEgg(true);
return 0;
}
return n + 1;
});
}, []);
// Easter egg #2: long-press the title for a ZIP-code fun fact.
const titleFact = useCallback(() => {
Alert.alert(
"📟 90210",
"It's a real ZIP code — Beverly Hills, California — and one of the most famous postal codes in the world."
);
}, []);
// Native feature: persisted watchlist + spoiler cutoff (survive restarts).
useEffect(() => {
AsyncStorage.getItem(WATCHLIST_KEY).then((raw) => {
if (raw) {
try {
setSaved(new Set(JSON.parse(raw)));
} catch {
/* ignore corrupt store */
}
}
});
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());
}, []);
const reveal = useCallback((id: string) => {
setRevealed((prev) => new Set(prev).add(id));
}, []);
const toggleSave = useCallback((id: string) => {
setSaved((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
AsyncStorage.setItem(WATCHLIST_KEY, JSON.stringify([...next]));
return next;
});
}, []);
// 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: [] }))
.then((data) => {
const parsed = (data.episodes ?? [])
.map((e: unknown) => EpisodeSchema.safeParse(e))
.filter((p: any) => p.success)
.map((p: any) => p.data);
setEpisodes(parsed);
})
.catch(() => setEpisodes([]))
.finally(() => setLoading(false));
}, []);
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>
)}
{ep.cast && ep.cast.length > 0 ? (
<Text style={styles.epCast}>
<Text style={styles.epCastLabel}>★ </Text>
{ep.cast
.slice(0, 8)
.map((c) => c.name)
.join(", ")}
{ep.cast.length > 8 ? ` +${ep.cast.length - 8} more` : ""}
</Text>
) : null}
<View style={styles.epActions}>
<Pressable
onPress={() => markWatched(ep.seasonNumber, ep.episodeNumber)}
hitSlop={6}
>
<Text style={styles.markWatched}>✓ watched to here</Text>
</Pressable>
{ep.externalIds?.tvmaze ? (
<Pressable
onPress={() =>
Linking.openURL(
`https://www.tvmaze.com/episodes/${ep.externalIds.tvmaze}`
)
}
hitSlop={6}
>
<Text style={styles.epLink}>▶ View this episode ↗</Text>
</Pressable>
) : null}
</View>
</BlurView>
);
};
const renderCastCard = (p: CastMember) => (
<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.pending}>Bio coming soon</Text>
)}
{p.attribution ? (
<Text style={styles.attribution}>{p.attribution}</Text>
) : null}
</BlurView>
);
const mainCast = cast.filter((p) => p.kind === "main-cast");
const recurringCast = cast.filter((p) => p.kind !== "main-cast");
return (
<View style={styles.root}>
<LinearGradient
colors={["#f4e9f2", "#faf1ea", "#fdf3e0"]}
style={StyleSheet.absoluteFill}
/>
<StatusBar style="light" />
<LinearGradient
colors={["#2b1e3a", "#7b3f6e", "#d9668a"]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<Pressable onLongPress={titleFact} delayLongPress={450}>
<Text style={styles.title}>{APP.displayName}</Text>
</Pressable>
<Pressable onPress={tapBadge} hitSlop={8}>
<Text style={styles.badge}>UNOFFICIAL</Text>
</Pressable>
</LinearGradient>
{egg ? (
<Pressable style={styles.egg} onPress={() => setEgg(false)}>
<Text style={styles.eggEmoji}>📟✨</Text>
<Text style={styles.eggTitle}>You found the 90210 easter egg!</Text>
<Text style={styles.eggBody}>
"We're not in Kansas anymore." Thanks for tapping around — you're a real
fan. Tap anywhere to close.
</Text>
</Pressable>
) : null}
{/* Spoiler-guard status bar (only on episode-based tabs) */}
{(tab === "episodes" || tab === "saved") && watchedThrough ? (
<View style={styles.toolbar}>
<Pressable
onPress={() => {
setWatchedThrough(null);
AsyncStorage.removeItem(WATCHED_KEY);
}}
style={[styles.chip, styles.chipGuard]}
>
<Text style={styles.chipGuardText}>
🛡 spoilers hidden after S{watchedThrough.season}E
{watchedThrough.episode} ✕
</Text>
</Pressable>
</View>
) : null}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
>
{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>
)
) : (
<>
{mainCast.length > 0 ? (
<Text style={styles.castGroup}>Main cast</Text>
) : null}
{mainCast.map(renderCastCard)}
{recurringCast.length > 0 ? (
<Text style={styles.castGroup}>
Recurring & guest stars ({recurringCast.length})
</Text>
) : null}
{recurringCast.map(renderCastCard)}
</>
)}
</>
) : 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
key={n.id}
onPress={() => Linking.openURL(n.url)}
style={styles.newsCard}
>
<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>
))
)}
</>
) : null}
{tab === "media" ? (
<>
<Text style={styles.sectionTitle}>🎬 Watch, listen & explore</Text>
<Text style={styles.disclaimer}>
Curated links out to YouTube, podcast apps and streaming guides. An
unofficial fan guide — not affiliated with or endorsed by the show.
</Text>
{MEDIA.map((g) => (
<View key={g.section}>
<Text style={styles.mediaGroup}>{g.section}</Text>
{g.items.map((m) => (
<Pressable
key={m.url}
onPress={() => Linking.openURL(m.url)}
style={styles.newsCard}
>
<Text style={styles.newsHeadline}>{m.label}</Text>
<Text style={styles.newsMeta}>
{m.sub}
{" ↗"}
</Text>
</Pressable>
))}
</View>
))}
</>
) : 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>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "transparent" },
header: {
paddingTop: 64,
paddingHorizontal: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
flexDirection: "row",
alignItems: "baseline",
gap: 10,
},
title: { fontSize: 20, fontWeight: "700", color: "#fff8f0" },
badge: { fontSize: 11, fontWeight: "700", color: "#ffd27a" },
toolbar: {
paddingHorizontal: 20,
paddingVertical: 8,
flexDirection: "row",
gap: 8,
},
chip: {
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 16,
paddingHorizontal: 12,
paddingVertical: 5,
},
scroll: { flex: 1 },
body: { padding: 20, paddingTop: 12, paddingBottom: 28 },
sectionTitle: {
fontSize: 17,
fontWeight: "700",
color: "#3a2540",
marginBottom: 12,
},
disclaimer: { fontSize: 12, color: "#666", marginBottom: 16 },
mediaGroup: {
fontSize: 14,
fontWeight: "700",
color: "#7b3f6e",
marginTop: 16,
marginBottom: 8,
},
empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
card: {
backgroundColor: "rgba(255,255,255,0.5)",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.65)",
borderRadius: 14,
padding: 14,
marginBottom: 10,
overflow: "hidden",
},
cardRow: { flexDirection: "row", justifyContent: "space-between", gap: 8 },
cardTitle: { fontSize: 15, fontWeight: "600", flex: 1 },
spoiler: { fontSize: 11, color: "#b8860b", fontWeight: "400" },
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,
},
epActions: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 8,
gap: 12,
},
markWatched: { fontSize: 11, color: "#888" },
epLink: { fontSize: 12, color: "#7b3f6e", fontWeight: "700" },
epCast: {
fontSize: 12,
color: "#5a4a56",
marginTop: 8,
lineHeight: 17,
},
epCastLabel: { color: "#b8860b", fontWeight: "700" },
castGroup: {
fontSize: 13,
fontWeight: "700",
color: "#7b3f6e",
marginTop: 10,
marginBottom: 8,
textTransform: "uppercase",
letterSpacing: 0.5,
},
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,
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
backgroundColor: "rgba(43,30,58,0.96)",
alignItems: "center",
justifyContent: "center",
padding: 36,
},
eggEmoji: { fontSize: 60, marginBottom: 12 },
eggTitle: {
color: "#ffd27a",
fontSize: 22,
fontWeight: "700",
textAlign: "center",
marginBottom: 10,
},
eggBody: { color: "#fff6ec", fontSize: 14, textAlign: "center", lineHeight: 21 },
});