diff --git a/app/_layout.tsx b/app/_layout.tsx index 02a5fc8..c12bbcd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,6 +14,8 @@ import { } from "@expo-google-fonts/space-grotesk"; import { ZenTokyoZoo_400Regular } from "@expo-google-fonts/zen-tokyo-zoo"; import { usePreferences } from "@/state/preferences"; +import { useAuthEmail } from "@/state/auth"; +import type { PressableState } from "@/types"; import { FONT } from "@/theme"; SplashScreen.preventAutoHideAsync().catch(() => {}); @@ -30,6 +32,7 @@ function GlobalHeader() { const isHome = pathname === "/"; const japanese = usePreferences((s) => s.japanese); const toggleJapanese = usePreferences((s) => s.toggleJapanese); + const email = useAuthEmail(); return ( @@ -71,6 +74,17 @@ function GlobalHeader() { {japanese ? "JP" : "EN"} + router.push("/login")} + style={({ hovered, pressed }: PressableState) => [ + headerStyles.accountPill, + { opacity: pressed ? 0.7 : hovered ? 0.9 : 1 }, + ]} + > + + {email ? email.charAt(0).toUpperCase() : "Sign in"} + + ); } @@ -103,6 +117,7 @@ export default function RootLayout() { }} > + @@ -179,4 +194,20 @@ const headerStyles = StyleSheet.create({ letterSpacing: 2, fontFamily: FONT.bold, }, + accountPill: { + paddingHorizontal: 14, + paddingTop: 12, + paddingBottom: 8, + backgroundColor: "#17181b", + borderLeftWidth: 2, + borderLeftColor: "#7c5cff", + alignSelf: "flex-start", + }, + accountPillText: { + color: "#7c5cff", + fontSize: 13, + letterSpacing: 2, + textTransform: "uppercase", + fontFamily: FONT.bold, + }, }); diff --git a/app/login.tsx b/app/login.tsx new file mode 100644 index 0000000..1e30a2e --- /dev/null +++ b/app/login.tsx @@ -0,0 +1,253 @@ +import { useState } from "react"; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { useAuth, useAuthEmail, useAuthStatus } from "@/state/auth"; +import type { PressableState } from "@/types"; +import { FONT } from "@/theme"; + +type Mode = "signIn" | "signUp"; + +const MODE_COPY: Record< + Mode, + { title: string; submit: string; switchLabel: string } +> = { + signIn: { + title: "Sign in", + submit: "Sign in", + switchLabel: "Need an account? Create one", + }, + signUp: { + title: "Create account", + submit: "Create account", + switchLabel: "Already have an account? Sign in", + }, +}; + +export default function LoginScreen() { + const status = useAuthStatus(); + + return ( + + + Account + {status === "loading" && } + {status === "signedIn" && } + {status === "signedOut" && } + + + ); +} + +function AccountView() { + const email = useAuthEmail(); + const signOut = useAuth((s) => s.signOut); + const [error, setError] = useState(null); + + const onSignOut = async () => { + setError(null); + const message = await signOut(); + if (message) setError(message); + }; + + return ( + + Signed in + {email ?? "—"} + {!!error && {error}} + [ + styles.button, + { opacity: pressed ? 0.7 : hovered ? 0.9 : 1 }, + ]} + > + Sign out + + + ); +} + +function AuthForm() { + const signIn = useAuth((s) => s.signIn); + const signUp = useAuth((s) => s.signUp); + const [mode, setMode] = useState("signIn"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmationSent, setConfirmationSent] = useState(false); + + const copy = MODE_COPY[mode]; + const canSubmit = !busy && !!email.trim() && !!password; + + const submit = async () => { + if (!canSubmit) return; + setBusy(true); + setError(null); + const action = mode === "signIn" ? signIn : signUp; + const message = await action(email.trim(), password); + setBusy(false); + if (message) { + setError(message); + } else if (mode === "signUp") { + setConfirmationSent(true); + } + }; + + const switchMode = () => { + setMode((m) => (m === "signIn" ? "signUp" : "signIn")); + setError(null); + }; + + if (confirmationSent) { + return ( + + Check your email + + We sent a confirmation link to {email.trim()}. Click it, then sign in + here. + + { + setConfirmationSent(false); + setMode("signIn"); + setPassword(""); + }} + style={({ hovered, pressed }: PressableState) => [ + styles.button, + { opacity: pressed ? 0.7 : hovered ? 0.9 : 1 }, + ]} + > + Back to sign in + + + ); + } + + return ( + + {copy.title} + + + {!!error && {error}} + [ + styles.button, + !canSubmit && styles.buttonDisabled, + { opacity: pressed ? 0.7 : hovered ? 0.9 : 1 }, + ]} + > + {busy ? ( + + ) : ( + {copy.submit} + )} + + [ + { opacity: pressed ? 0.6 : hovered ? 0.85 : 1 }, + ]} + > + {copy.switchLabel} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, padding: 16, alignItems: "center" }, + panel: { + width: "100%", + maxWidth: 420, + gap: 16, + paddingTop: 32, + }, + stack: { gap: 16 }, + eyebrow: { + color: "#7c5cff", + fontSize: 11, + letterSpacing: 1.8, + textTransform: "uppercase", + fontFamily: FONT.bold, + }, + title: { + color: "#f5f5f5", + fontSize: 22, + letterSpacing: -0.4, + fontFamily: FONT.bold, + }, + emailLine: { + color: "#cfd2d6", + fontSize: 16, + fontFamily: FONT.medium, + }, + hint: { + color: "#cfd2d6", + fontSize: 14, + lineHeight: 20, + fontFamily: FONT.regular, + }, + input: { + backgroundColor: "#17181b", + color: "#f5f5f5", + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + fontFamily: FONT.medium, + borderLeftWidth: 4, + borderLeftColor: "#7c5cff", + }, + error: { + color: "#ff7c5c", + fontSize: 14, + fontFamily: FONT.medium, + }, + button: { + alignItems: "center", + paddingVertical: 14, + backgroundColor: "#7c5cff", + }, + buttonDisabled: { backgroundColor: "#2a2c30" }, + buttonText: { + color: "#0c0c0e", + fontSize: 13, + letterSpacing: 1.4, + textTransform: "uppercase", + fontFamily: FONT.bold, + }, + switchLabel: { + color: "#9aa0a6", + fontSize: 13, + fontFamily: FONT.medium, + }, +}); diff --git a/bun.lock b/bun.lock index 1f45f1f..e3682a9 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@expo-google-fonts/zen-tokyo-zoo": "0.4.1", "@expo/metro-runtime": "56.0.13", "@react-native-async-storage/async-storage": "3.1.0", + "@supabase/supabase-js": "^2.110.2", "@tanstack/react-query": "5.100.14", "expo": "56.0.6", "expo-constants": "56.0.16", @@ -482,6 +483,20 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@supabase/auth-js": ["@supabase/auth-js@2.110.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Qj7a6EDP+AMMQFWqGv+qFa8r6re//dk+qQI5bA0KK+PZmnI3JPu97TDeNt6SMiQ2FkklP79hP2yDFYSnA989OA=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.110.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ZjjqrXpxM9/rE+eAtZxiK45EWy9EBoJQ322Q5Y75LccYQNh212neHTgXP/o4MIzmH0LNXT8UzvTZtQOfOzyoeQ=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.4", "", {}, "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.110.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-++LBmcIMwCtgO4tISQUmo9+2xkRwHQqS8ZKMCnhXLe9P8k8YQRXuMoh/RiSzQSoev8gqet0W7yOboW0cUxnt0Q=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.110.2", "", { "dependencies": { "@supabase/phoenix": "0.4.4", "tslib": "2.8.1" } }, "sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.110.2", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-EhsRSwSnmQefKJsAxoRUZ0hvHr92ECM8DDGAKR5z0HdoJx4heI60PjHUTruVNZxKX6XeobLGDyLud020Bw1iwg=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.110.2", "", { "dependencies": { "@supabase/auth-js": "2.110.2", "@supabase/functions-js": "2.110.2", "@supabase/postgrest-js": "2.110.2", "@supabase/realtime-js": "2.110.2", "@supabase/storage-js": "2.110.2" } }, "sha512-r9q9w4ZQ6mOjh36aqUNFSisBF611vzpO8JphBESr2Q1SWvmGFQeI7Jq7Y+PaNMZ6Zszz+S2yTlJStCpnaMSnQg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], @@ -1132,6 +1147,8 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], diff --git a/package.json b/package.json index c60e9a7..2581828 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@expo-google-fonts/zen-tokyo-zoo": "0.4.1", "@expo/metro-runtime": "56.0.13", "@react-native-async-storage/async-storage": "3.1.0", + "@supabase/supabase-js": "^2.110.2", "@tanstack/react-query": "5.100.14", "expo": "56.0.6", "expo-constants": "56.0.16", @@ -67,7 +68,8 @@ "jest": { "preset": "jest-expo", "moduleNameMapper": { - "^ansi-styles$": "/node_modules/pretty-format/node_modules/ansi-styles" + "^ansi-styles$": "/node_modules/pretty-format/node_modules/ansi-styles", + "^@/(.*)$": "/src/$1" } } } diff --git a/src/api/supabase.ts b/src/api/supabase.ts new file mode 100644 index 0000000..1745541 --- /dev/null +++ b/src/api/supabase.ts @@ -0,0 +1,29 @@ +import { AppState, Platform } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createClient } from "@supabase/supabase-js"; + +// Publishable key — safe to ship in the client bundle. +const SUPABASE_URL = "https://obtgldkascmxbtpnvscn.supabase.co"; +const SUPABASE_PUBLISHABLE_KEY = + "sb_publishable_4z9kuzXtE3PeVgbPDtQUWw_cSrKxsu-"; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}); + +// Native apps must pause token refresh while backgrounded; on web the SDK +// already handles visibility itself. +if (Platform.OS !== "web") { + AppState.addEventListener("change", (state) => { + if (state === "active") { + supabase.auth.startAutoRefresh(); + } else { + supabase.auth.stopAutoRefresh(); + } + }); +} diff --git a/src/data/index.ts b/src/data/index.ts index 0a2ff27..26ca385 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -406,6 +406,7 @@ import aSistersAllYouNeed from "@/data/mappings/a-sisters-all-you-need.json"; import yosugaNoSora from "@/data/mappings/yosuga-no-sora.json"; import schoolBabysitters from "@/data/mappings/school-babysitters.json"; import pluto from "@/data/mappings/pluto.json"; +import ghostInTheShell from "@/data/mappings/ghost-in-the-shell.json"; // JSON imports lose tuple types — `[1, 100]` becomes `number[]` instead of // `[number, number]`. `normalizeMapping` rebuilds tuples literally. @@ -881,6 +882,7 @@ const ALL_MAPPINGS: SeriesMapping[] = [ yosugaNoSora, schoolBabysitters, pluto, + ghostInTheShell, ].map(normalizeMapping); export function findMappingByMediaId(mediaId: number): SeriesMapping | null { diff --git a/src/data/mappings/ghost-in-the-shell.json b/src/data/mappings/ghost-in-the-shell.json new file mode 100644 index 0000000..17bde3f --- /dev/null +++ b/src/data/mappings/ghost-in-the-shell.json @@ -0,0 +1,40 @@ +{ + "anilistAnimeId": 177699, + "anilistMangaId": 31023, + "title": "The Ghost in the Shell", + "sourceNotes": "2026 Science SARU TV series (THE GHOST IN THE SHELL), the first faithful TV adaptation of Shirow's original manga; premiered 2026-07-07 and airs weekly, total episode count TBA — extend episode ranges via PR as episodes air. Episode titles name the manga chapters they adapt (ep 1 = 'Prologue + Super Spartan i', ep 2 = 'Super Spartan ii + Junk Jungle i'), so boundaries fall on chapter halves; ranges are rounded to whole chapters. Manga complete at 11 chapters / 1 volume; arcs are the manga's own episodic case chapters, with the Puppet Master storyline seeded in ch 3 and concluding in ch 9-11. The Oshii films are separate-continuity theatrical adaptations of the same manga; SAC, Arise, and SAC_2045 films belong to other continuities and are excluded.", + "mappings": [ + { + "episodes": [1, 1], + "chapters": [1, 2], + "arc": "Prologue / Super Spartan" + }, + { "chapters": [3, 3], "arc": "Junk Jungle" }, + { + "chapters": [4, 5], + "arc": "Megatech Machine (Revolt of the Robot / The Making of a Cyborg)" + }, + { "chapters": [6, 6], "arc": "Robot Rondo" }, + { "chapters": [7, 7], "arc": "Phantom Fund" }, + { "chapters": [8, 8], "arc": "Dumb Barter" }, + { + "chapters": [9, 11], + "arc": "Puppet Master (Bye Bye Clay / Brain Drain / Ghost Coast)" + } + ], + "movies": [ + { + "anilistId": 43, + "title": "Ghost in the Shell", + "year": 1995, + "note": "Oshii's separate-continuity theatrical adaptation; condenses the Puppet Master plot (ch 3 + 9-11) with the cyborg-birth imagery of ch 5. The 2008 'Ghost in the Shell 2.0' re-edit is excluded." + }, + { + "anilistId": 468, + "title": "Ghost in the Shell 2: Innocence", + "year": 2004, + "chapters": [6, 6], + "note": "Sequel to the 1995 film; expands manga ch 6 'Robot Rondo' (the Locus Solus gynoid case)." + } + ] +} diff --git a/src/state/auth.test.ts b/src/state/auth.test.ts new file mode 100644 index 0000000..0ef2190 --- /dev/null +++ b/src/state/auth.test.ts @@ -0,0 +1,109 @@ +import { AuthError } from "@supabase/supabase-js"; +import type { Session, User } from "@supabase/supabase-js"; +import { useAuth } from "./auth"; +import { supabase } from "@/api/supabase"; + +jest.mock("@/api/supabase", () => ({ + supabase: { + auth: { + getSession: jest.fn(() => Promise.resolve({ data: { session: null } })), + onAuthStateChange: jest.fn(() => ({ + data: { subscription: { unsubscribe: jest.fn() } }, + })), + signInWithPassword: jest.fn(), + signUp: jest.fn(), + signOut: jest.fn(), + }, + }, +})); + +const fakeUser: User = { + id: "user-1", + aud: "authenticated", + email: "cj@example.com", + app_metadata: {}, + user_metadata: {}, + created_at: "2026-01-01T00:00:00Z", +}; + +const fakeSession: Session = { + access_token: "access", + refresh_token: "refresh", + expires_in: 3600, + token_type: "bearer", + user: fakeUser, +}; + +beforeEach(() => { + useAuth.setState({ session: null, status: "loading" }); +}); + +describe("applySession", () => { + it("marks a session as signed in", () => { + useAuth.getState().applySession(fakeSession); + expect(useAuth.getState().status).toBe("signedIn"); + expect(useAuth.getState().session).toBe(fakeSession); + }); + + it("marks a null session as signed out", () => { + useAuth.getState().applySession(null); + expect(useAuth.getState().status).toBe("signedOut"); + expect(useAuth.getState().session).toBeNull(); + }); +}); + +describe("auth state subscription", () => { + it("routes onAuthStateChange sessions into the store", () => { + const subscribe = jest.mocked(supabase.auth.onAuthStateChange); + expect(subscribe).toHaveBeenCalledTimes(1); + + const callback = subscribe.mock.calls[0][0]; + callback("SIGNED_IN", fakeSession); + expect(useAuth.getState().status).toBe("signedIn"); + + callback("SIGNED_OUT", null); + expect(useAuth.getState().status).toBe("signedOut"); + }); +}); + +describe("actions", () => { + it("signIn returns null on success", async () => { + jest.mocked(supabase.auth.signInWithPassword).mockResolvedValue({ + data: { user: fakeUser, session: fakeSession }, + error: null, + }); + const result = await useAuth.getState().signIn("cj@example.com", "pw"); + expect(result).toBeNull(); + }); + + it("signIn returns the error message on failure", async () => { + jest.mocked(supabase.auth.signInWithPassword).mockResolvedValue({ + data: { user: null, session: null }, + error: new AuthError("Invalid login credentials"), + }); + const result = await useAuth.getState().signIn("cj@example.com", "nope"); + expect(result).toBe("Invalid login credentials"); + }); + + it("signUp sends the email confirmation redirect", async () => { + const signUp = jest.mocked(supabase.auth.signUp).mockResolvedValue({ + data: { user: fakeUser, session: null }, + error: null, + }); + const result = await useAuth.getState().signUp("cj@example.com", "pw"); + expect(result).toBeNull(); + expect(signUp).toHaveBeenCalledWith({ + email: "cj@example.com", + password: "pw", + options: { emailRedirectTo: "https://kasane.netlify.app/login" }, + }); + }); + + it("signOut returns the error message on failure", async () => { + jest.mocked(supabase.auth.signOut).mockResolvedValue({ + error: new AuthError("Network failure"), + }); + const result = await useAuth.getState().signOut(); + expect(result).toBe("Network failure"); + }); +}); diff --git a/src/state/auth.ts b/src/state/auth.ts new file mode 100644 index 0000000..c92d09c --- /dev/null +++ b/src/state/auth.ts @@ -0,0 +1,56 @@ +import type { Session } from "@supabase/supabase-js"; +import { create } from "zustand"; +import { supabase } from "@/api/supabase"; + +export type AuthStatus = "loading" | "signedIn" | "signedOut"; + +// Must be listed in the Supabase project's redirect allow-list. +const EMAIL_CONFIRM_REDIRECT = "https://kasane.netlify.app/login"; + +type State = { + session: Session | null; + status: AuthStatus; + applySession: (session: Session | null) => void; + signIn: (email: string, password: string) => Promise; + signUp: (email: string, password: string) => Promise; + signOut: () => Promise; +}; + +export const useAuth = create()((set) => ({ + session: null, + status: "loading", + applySession: (session) => + set({ session, status: session ? "signedIn" : "signedOut" }), + signIn: async (email, password) => { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + return error?.message ?? null; + }, + signUp: async (email, password) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { emailRedirectTo: EMAIL_CONFIRM_REDIRECT }, + }); + return error?.message ?? null; + }, + signOut: async () => { + const { error } = await supabase.auth.signOut(); + return error?.message ?? null; + }, +})); + +supabase.auth.getSession().then(({ data }) => { + useAuth.getState().applySession(data.session); +}); + +supabase.auth.onAuthStateChange((_event, session) => { + useAuth.getState().applySession(session); +}); + +export const useAuthStatus = (): AuthStatus => useAuth((s) => s.status); + +export const useAuthEmail = (): string | null => + useAuth((s) => s.session?.user.email ?? null);