← back to Nineoh Guide
contrarian fixes: correct Expo API-base wiring (expo-constants + EXPO_PUBLIC), add AsyncStorage watchlist + spoiler tag (native value for Apple 4.2), parameterize legal emails
1b02ed5f77bb2ce6f7d36f38db83bf43935f400c · 2026-07-25 10:12:13 -0700 · Steve
Files touched
M apps/mobile/App.tsxM apps/mobile/package.jsonM apps/web/app/legal/dmca/page.tsxM apps/web/app/legal/privacy/page.tsxM pnpm-lock.yaml
Diff
commit 1b02ed5f77bb2ce6f7d36f38db83bf43935f400c
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 25 10:12:13 2026 -0700
contrarian fixes: correct Expo API-base wiring (expo-constants + EXPO_PUBLIC), add AsyncStorage watchlist + spoiler tag (native value for Apple 4.2), parameterize legal emails
---
apps/mobile/App.tsx | 122 ++++++++++++++++++++++++++++++------
apps/mobile/package.json | 2 +
apps/web/app/legal/dmca/page.tsx | 6 +-
apps/web/app/legal/privacy/page.tsx | 8 ++-
pnpm-lock.yaml | 30 +++++++++
5 files changed, 146 insertions(+), 22 deletions(-)
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index c94e41f..fc94bb5 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -1,27 +1,63 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import {
ScrollView,
Text,
View,
StyleSheet,
ActivityIndicator,
+ Pressable,
} from "react-native";
import { StatusBar } from "expo-status-bar";
+import Constants from "expo-constants";
+import AsyncStorage from "@react-native-async-storage/async-storage";
import { APP, EpisodeSchema, type Episode } from "@nineoh/core";
-// In prod this comes from app.json → extra.apiBaseUrl (the Vercel URL).
+// 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 ?? "http://localhost:4090";
+ process.env.EXPO_PUBLIC_API_BASE ?? configured ?? "http://localhost:4090";
+
+const WATCHLIST_KEY = "nineoh.watchlist.v1";
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);
+
+ // Native feature #1: persisted watchlist (AsyncStorage) — survives app restarts.
+ useEffect(() => {
+ AsyncStorage.getItem(WATCHLIST_KEY).then((raw) => {
+ if (raw) {
+ try {
+ setSaved(new Set(JSON.parse(raw)));
+ } catch {
+ /* ignore corrupt store */
+ }
+ }
+ });
+ }, []);
+
+ 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;
+ });
+ }, []);
useEffect(() => {
fetch(`${API_BASE}/api/episodes`)
.then((r) => (r.ok ? r.json() : { episodes: [] }))
.then((data) => {
- // Validate against the SHARED contract — same zod schema as the web app.
const parsed = (data.episodes ?? [])
.map((e: unknown) => EpisodeSchema.safeParse(e))
.filter((p: any) => p.success)
@@ -32,6 +68,8 @@ export default function App() {
.finally(() => setLoading(false));
}, []);
+ const shown = savedOnly ? episodes.filter((e) => saved.has(e.id)) : episodes;
+
return (
<View style={styles.root}>
<StatusBar style="auto" />
@@ -39,26 +77,52 @@ export default function App() {
<Text style={styles.title}>{APP.displayName}</Text>
<Text style={styles.badge}>UNOFFICIAL</Text>
</View>
+
+ <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>
+ </View>
+
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.disclaimer}>{APP.disclaimerShort}</Text>
{loading ? (
<ActivityIndicator style={{ marginTop: 24 }} />
- ) : episodes.length === 0 ? (
+ ) : shown.length === 0 ? (
<Text style={styles.empty}>
- Content is being curated. Original recaps and cast profiles will
- appear here as editors write them and image rights are cleared.
+ {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>
) : (
- episodes.map((ep) => (
- <View key={ep.id} style={styles.card}>
- <Text style={styles.cardTitle}>
- S{ep.seasonNumber}E{ep.episodeNumber} · {ep.title}
- </Text>
- {ep.summaryShortOriginal ? (
- <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
- ) : null}
- </View>
- ))
+ shown.map((ep) => {
+ const isSaved = saved.has(ep.id);
+ return (
+ <View key={ep.id} 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 ? (
+ <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
+ ) : (
+ <Text style={styles.pending}>Recap coming soon</Text>
+ )}
+ </View>
+ );
+ })
)}
</ScrollView>
</View>
@@ -79,7 +143,23 @@ const styles = StyleSheet.create({
},
title: { fontSize: 20, fontWeight: "700" },
badge: { fontSize: 11, fontWeight: "700", color: "#8a1c1c" },
- body: { padding: 20 },
+ toolbar: {
+ paddingHorizontal: 20,
+ paddingVertical: 8,
+ flexDirection: "row",
+ gap: 8,
+ },
+ chip: {
+ borderWidth: 1,
+ borderColor: "#ccc",
+ borderRadius: 16,
+ paddingHorizontal: 12,
+ paddingVertical: 5,
+ },
+ chipActive: { backgroundColor: "#1a1a1a", borderColor: "#1a1a1a" },
+ chipText: { fontSize: 13, color: "#333" },
+ chipTextActive: { color: "#fff" },
+ body: { padding: 20, paddingTop: 4 },
disclaimer: { fontSize: 12, color: "#666", marginBottom: 16 },
empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
card: {
@@ -90,6 +170,10 @@ const styles = StyleSheet.create({
padding: 14,
marginBottom: 10,
},
- cardTitle: { fontSize: 15, fontWeight: "600" },
+ 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 },
});
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 2881ada..49a669d 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -11,7 +11,9 @@
},
"dependencies": {
"@nineoh/core": "workspace:*",
+ "@react-native-async-storage/async-storage": "1.23.1",
"expo": "~52.0.0",
+ "expo-constants": "~17.0.0",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-native": "0.76.5"
diff --git a/apps/web/app/legal/dmca/page.tsx b/apps/web/app/legal/dmca/page.tsx
index 0fd618a..fcb3397 100644
--- a/apps/web/app/legal/dmca/page.tsx
+++ b/apps/web/app/legal/dmca/page.tsx
@@ -12,7 +12,11 @@ export default function Dmca() {
<h2>Designated agent</h2>
<p>
Send notices to our designated DMCA contact:{" "}
- <strong>dmca@[app-domain]</strong> (set at launch).
+ <strong>
+ {process.env.NEXT_PUBLIC_DMCA_EMAIL ??
+ "our published legal contact (configured before public launch)"}
+ </strong>
+ .
</p>
<h2>A valid notice must include</h2>
<ol>
diff --git a/apps/web/app/legal/privacy/page.tsx b/apps/web/app/legal/privacy/page.tsx
index b1df225..07423c8 100644
--- a/apps/web/app/legal/privacy/page.tsx
+++ b/apps/web/app/legal/privacy/page.tsx
@@ -26,8 +26,12 @@ export default function Privacy() {
<h2>Your rights</h2>
<p>
Request access, correction, or deletion via{" "}
- <strong>privacy@[app-domain]</strong> (set at launch). We honor
- applicable CCPA/CPRA rights and do not sell personal information.
+ <strong>
+ {process.env.NEXT_PUBLIC_PRIVACY_EMAIL ??
+ "our published privacy contact (configured before public launch)"}
+ </strong>
+ . We honor applicable CCPA/CPRA rights and do not sell personal
+ information.
</p>
<h2>Children</h2>
<p>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 42a1355..8276148 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,9 +21,15 @@ importers:
'@nineoh/core':
specifier: workspace:*
version: link:../../packages/core
+ '@react-native-async-storage/async-storage':
+ specifier: 1.23.1
+ version: 1.23.1(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))
expo:
specifier: ~52.0.0
version: 52.0.49(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)(supports-color@8.1.1)
+ expo-constants:
+ specifier: ~17.0.0
+ version: 17.0.8(expo@52.0.49(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)(supports-color@8.1.1))(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(supports-color@8.1.1)
expo-status-bar:
specifier: ~2.0.0
version: 2.0.1(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)
@@ -1190,6 +1196,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@react-native-async-storage/async-storage@1.23.1':
+ resolution: {integrity: sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==}
+ peerDependencies:
+ react-native: ^0.0.0-0 || >=0.60 <1.0
+
'@react-native/assets-registry@0.76.5':
resolution: {integrity: sha512-MN5dasWo37MirVcKWuysRkRr4BjNc81SXwUtJYstwbn8oEkfnwR9DaqdDTo/hHOnTdhafffLIa2xOOHcjDIGEw==}
engines: {node: '>=18'}
@@ -2346,6 +2357,10 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
+ is-plain-obj@2.1.0:
+ resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
+ engines: {node: '>=8'}
+
is-plain-object@2.0.4:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
@@ -2607,6 +2622,10 @@ packages:
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
+ merge-options@3.0.4:
+ resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
+ engines: {node: '>=10'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -5208,6 +5227,11 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@react-native-async-storage/async-storage@1.23.1(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))':
+ dependencies:
+ merge-options: 3.0.4
+ react-native: 0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1)
+
'@react-native/assets-registry@0.76.5': {}
'@react-native/babel-plugin-codegen@0.76.5(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)':
@@ -6577,6 +6601,8 @@ snapshots:
is-path-inside@3.0.3: {}
+ is-plain-obj@2.1.0: {}
+
is-plain-object@2.0.4:
dependencies:
isobject: 3.0.1
@@ -6859,6 +6885,10 @@ snapshots:
memoize-one@5.2.1: {}
+ merge-options@3.0.4:
+ dependencies:
+ is-plain-obj: 2.1.0
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
← a3ffaa4 gitignore + untrack tsconfig.tsbuildinfo (build artifact)
·
back to Nineoh Guide
·
ingest factual cast + character names (TVmaze, names-only, n fc01815 →