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);