Add @sofa/native Expo React Native app (#7)

This commit is contained in:
2026-03-12 19:39:54 -04:00
committed by GitHub
parent a326c968b7
commit 83839a0cd7
130 changed files with 9015 additions and 971 deletions
+2
View File
@@ -0,0 +1,2 @@
EXPO_PUBLIC_POSTHOG_KEY=
EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
+33
View File
@@ -0,0 +1,33 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store
# Expo prebuild
/android
/ios
# Google Play service account key
google-service-account.json
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
credentials.json
credentials/
+138
View File
@@ -0,0 +1,138 @@
{
"expo": {
"name": "Sofa",
"slug": "sofa",
"owner": "jakejarvis",
"scheme": "sofa",
"orientation": "portrait",
"userInterfaceStyle": "dark",
"ios": {
"config": {
"usesNonExemptEncryption": false
},
"icon": "./assets/images/sofa.icon",
"bundleIdentifier": "com.jakejarvis.sofa",
"appleTeamId": "B5ZWKBCUTU"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"package": "com.jakejarvis.sofa"
},
"plugins": [
[
"expo-router",
{
"root": "./src/app"
}
],
[
"expo-secure-store",
{
"configureAndroidBackup": true
}
],
[
"expo-font",
{
"ios": {
"fonts": [
"../../node_modules/@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
"../../node_modules/@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
"../../node_modules/@expo-google-fonts/dm-sans/600SemiBold/DMSans_600SemiBold.ttf",
"../../node_modules/@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
"../../node_modules/@expo-google-fonts/dm-serif-display/400Regular/DMSerifDisplay_400Regular.ttf",
"../../node_modules/@expo-google-fonts/geist-mono/400Regular/GeistMono_400Regular.ttf"
]
},
"android": {
"fonts": [
{
"fontFamily": "DMSans-Regular",
"fontDefinitions": [
{
"path": "../../node_modules/@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
"weight": 400
}
]
},
{
"fontFamily": "DMSans-Medium",
"fontDefinitions": [
{
"path": "../../node_modules/@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf",
"weight": 500
}
]
},
{
"fontFamily": "DMSans-SemiBold",
"fontDefinitions": [
{
"path": "../../node_modules/@expo-google-fonts/dm-sans/600SemiBold/DMSans_600SemiBold.ttf",
"weight": 600
}
]
},
{
"fontFamily": "DMSans-Bold",
"fontDefinitions": [
{
"path": "../../node_modules/@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
"weight": 700
}
]
},
{
"fontFamily": "DMSerifDisplay-Regular",
"fontDefinitions": [
{
"path": "../../node_modules/@expo-google-fonts/dm-serif-display/400Regular/DMSerifDisplay_400Regular.ttf",
"weight": 400
}
]
},
{
"fontFamily": "GeistMono-Regular",
"fontDefinitions": [
{
"path": "../../node_modules/@expo-google-fonts/geist-mono/400Regular/GeistMono_400Regular.ttf",
"weight": 400
}
]
}
]
}
}
],
[
"expo-splash-screen",
{
"backgroundColor": "#101010",
"image": "./assets/images/splash-icon.png",
"imageWidth": 200
}
],
["expo-system-ui"],
[
"expo-image-picker",
{
"microphonePermission": false
}
],
["expo-localization"]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"eas": {
"projectId": "a9f021eb-f58d-49af-b291-d72f7dc34051"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<radialGradient id="b" cx=".5" cy=".33" r=".4">
<stop offset="0%" stop-color="#e8a87c" stop-opacity=".2"/>
<stop offset="50%" stop-color="#c48060" stop-opacity=".06"/>
<stop offset="100%" stop-opacity="0"/>
</radialGradient>
<radialGradient id="f" cx=".5" cy=".5" r=".7">
<stop offset="55%" stop-opacity="0"/>
<stop offset="100%" stop-opacity=".3"/>
</radialGradient>
<radialGradient id="c" cx=".5" cy=".5" r=".5">
<stop offset="0%" stop-opacity=".25"/>
<stop offset="100%" stop-opacity="0"/>
</radialGradient>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#151210"/>
<stop offset="100%" stop-color="#080605"/>
</linearGradient>
<linearGradient id="d" x1="12" y1="5" x2="12" y2="13" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#f0c9a4"/>
<stop offset="100%" stop-color="#dda278"/>
</linearGradient>
<linearGradient id="e" x1="12" y1="11" x2="12" y2="19.5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#e8a87c"/>
<stop offset="100%" stop-color="#ba7d52"/>
</linearGradient>
</defs>
<g clip-path="url(#m)">
<path fill="url(#a)" d="M0 0h1024v1024H0z"/>
<path fill="url(#b)" d="M0 0h1024v1024H0z"/>
<ellipse cx="512" cy="710" rx="220" ry="22" fill="url(#c)"/>
<path fill="url(#d)" d="M7 12v1h10v-1a3 3 0 0 1 2.993-3 4.6 4.6 0 0 0-.07-.78 4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12" transform="matrix(28 0 0 28 176 172)"/>
<path fill="url(#e)" d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092" transform="matrix(28 0 0 28 176 172)"/>
<path fill="url(#f)" d="M0 0h1024v1024H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,47 @@
{
"fill": {
"linear-gradient": [
"display-p3:0.89697,0.77637,0.64404,1.00000",
"display-p3:0.07726,0.07726,0.07726,1.00000"
],
"orientation": {
"start": {
"x": 0.5,
"y": 0
},
"stop": {
"x": 0.5,
"y": 0.7
}
}
},
"groups": [
{
"blend-mode": "normal",
"blur-material": null,
"hidden": false,
"layers": [
{
"blend-mode": "normal",
"fill": "none",
"glass": true,
"image-name": "couch-with-glow.svg",
"name": "couch-with-glow"
}
],
"lighting": "individual",
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"squares": ["iOS"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+29
View File
@@ -0,0 +1,29 @@
{
"cli": {
"version": ">= 18.3.0",
"appVersionSource": "remote"
},
"build": {
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m-medium"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "6760432427"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withUniwindConfig } = require("uniwind/metro");
const {
wrapWithReanimatedMetroConfig,
} = require("react-native-reanimated/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
cssEntryFile: "./src/global.css",
dtsFile: "./uniwind-types.d.ts",
});
module.exports = uniwindConfig;
+81
View File
@@ -0,0 +1,81 @@
{
"name": "@sofa/native",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@expo-google-fonts/dm-sans": "0.4.2",
"@expo-google-fonts/dm-serif-display": "0.4.2",
"@expo-google-fonts/geist-mono": "0.4.1",
"@expo/metro-runtime": "55.0.6",
"@gorhom/bottom-sheet": "5.2.8",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:",
"@react-native-menu/menu": "2.0.0",
"@react-navigation/elements": "2.9.10",
"@sofa/api": "workspace:*",
"@sofa/tmdb": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.24",
"@tanstack/react-form": "1.28.5",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.24",
"better-auth": "catalog:",
"expo": "55.0.6",
"expo-application": "55.0.9",
"expo-clipboard": "55.0.8",
"expo-constants": "55.0.7",
"expo-dev-client": "55.0.16",
"expo-device": "55.0.9",
"expo-font": "55.0.4",
"expo-haptics": "55.0.8",
"expo-image": "55.0.6",
"expo-image-picker": "55.0.12",
"expo-linking": "55.0.7",
"expo-localization": "55.0.8",
"expo-network": "55.0.8",
"expo-router": "55.0.5",
"expo-secure-store": "55.0.8",
"expo-splash-screen": "55.0.10",
"expo-status-bar": "55.0.4",
"expo-system-ui": "55.0.9",
"expo-web-browser": "55.0.9",
"posthog-react-native": "4.37.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-gesture-handler": "2.30.0",
"react-native-ios-context-menu": "3.2.0",
"react-native-ios-utilities": "5.2.0",
"react-native-keyboard-controller": "1.20.7",
"react-native-mmkv": "4.2.0",
"react-native-nitro-modules": "0.35.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.23.0",
"react-native-svg": "15.15.3",
"react-native-worklets": "0.7.2",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:",
"uniwind": "1.5.0",
"zeego": "3.0.6",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Stack } from "expo-router";
import { useResolveClassNames } from "uniwind";
import { hasStoredServerUrl } from "@/lib/server-url";
export const unstable_settings = {
initialRouteName:
process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl()
? "login"
: "server-url",
};
export default function AuthLayout() {
const contentStyle = useResolveClassNames("bg-background");
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle,
animation: "fade",
}}
/>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import { useRef } from "react";
import { Alert, Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import {
FieldError,
Input,
Label,
TextField,
} from "@/components/ui/text-field";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
const signInSchema = z.object({
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
function formatFormErrors(errors: unknown): string | null {
if (!errors) return null;
if (typeof errors === "string") return errors;
if (typeof errors === "object") {
const first = Object.values(errors as Record<string, { message: string }[]>)
.flat()
.find((e) => e.message);
if (first) return first.message;
}
return null;
}
export default function LoginScreen() {
const passwordRef = useRef<TextInput>(null);
const authConfig = useQuery(orpc.system.authConfig.queryOptions());
const form = useForm({
defaultValues: { email: "", password: "" },
validators: { onSubmit: signInSchema },
onSubmit: async ({ value, formApi }) => {
await authClient.signIn.email(
{ email: value.email.trim(), password: value.password },
{
onError(error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", error.error?.message || "Failed to sign in");
},
onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
formApi.reset();
queryClient.invalidateQueries();
},
},
);
},
});
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
const showOidc = authConfig.data?.oidcEnabled;
const showRegister = authConfig.data?.registrationOpen;
return (
<AuthScreen title="Sofa" subtitle="Sign in to continue">
{showOidc && (
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
className="mb-4"
>
<Button
onPress={() => {
authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/(tabs)/(home)",
});
}}
variant="secondary"
className="w-full"
>
<ButtonLabel>
Sign in with {authConfig.data?.oidcProviderName ?? "SSO"}
</ButtonLabel>
</Button>
{showPasswordLogin && (
<View className="my-4 flex-row items-center">
<View className="h-px flex-1 bg-border" />
<Text className="px-3 text-muted-foreground text-xs">OR</Text>
<View className="h-px flex-1 bg-border" />
</View>
)}
</Animated.View>
)}
{showPasswordLogin && (
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: formatFormErrors(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, validationError }) => (
<View className="gap-3">
{validationError && (
<FieldError isInvalid className="mb-1">
{validationError}
</FieldError>
)}
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="email@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
ref={passwordRef}
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="password"
textContentType="password"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<Button
onPress={form.handleSubmit}
disabled={isSubmitting}
className="mt-1 bg-primary"
>
{isSubmitting ? (
<Spinner size="sm" />
) : (
<ButtonLabel>Sign In</ButtonLabel>
)}
</Button>
</Animated.View>
</View>
)}
</form.Subscribe>
)}
{showRegister && (
<Animated.View
entering={FadeIn.duration(300).delay(500)}
className="mt-6 items-center"
>
<Link href="/(auth)/register" asChild>
<Pressable>
<Text className="text-primary text-sm">Create an account</Text>
</Pressable>
</Link>
</Animated.View>
)}
<Animated.View
entering={FadeIn.duration(300).delay(500)}
className="mt-4 items-center"
>
<Link href="/(auth)/server-url" asChild>
<Pressable>
<Text className="text-muted-foreground text-xs">Change server</Text>
</Pressable>
</Link>
</Animated.View>
</AuthScreen>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import { useRef } from "react";
import { Alert, Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import {
FieldError,
Input,
Label,
TextField,
} from "@/components/ui/text-field";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
const signUpSchema = z.object({
name: z
.string()
.trim()
.min(1, "Name is required")
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
password: z
.string()
.min(1, "Password is required")
.min(8, "Use at least 8 characters"),
});
function formatFormErrors(errors: unknown): string | null {
if (!errors) return null;
if (typeof errors === "string") return errors;
if (typeof errors === "object") {
const first = Object.values(errors as Record<string, { message: string }[]>)
.flat()
.find((e) => e.message);
if (first) return first.message;
}
return null;
}
export default function RegisterScreen() {
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const publicInfo = useQuery(orpc.system.publicInfo.queryOptions());
const registrationOpen = publicInfo.data?.registrationOpen ?? false;
const form = useForm({
defaultValues: { name: "", email: "", password: "" },
validators: { onSubmit: signUpSchema },
onSubmit: async ({ value, formApi }) => {
await authClient.signUp.email(
{
name: value.name.trim(),
email: value.email.trim(),
password: value.password,
},
{
onError(error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(
"Error",
error.error?.message || "Failed to create account",
);
},
onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
formApi.reset();
queryClient.invalidateQueries();
},
},
);
},
});
if (!registrationOpen && !publicInfo.isPending) {
return (
<AuthScreen
title="Registration Closed"
subtitle="New account creation is currently disabled."
>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/(auth)/login" asChild>
<Button className="mt-6 bg-primary">
<ButtonLabel className="text-primary-foreground">
Back to Login
</ButtonLabel>
</Button>
</Link>
</Animated.View>
</AuthScreen>
);
}
return (
<AuthScreen title="Create Account">
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: formatFormErrors(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, validationError }) => (
<View className="gap-3">
{validationError && (
<FieldError isInvalid className="mb-1">
{validationError}
</FieldError>
)}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="name">
{(field) => (
<TextField>
<Label>Name</Label>
<Input
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="Your name"
autoComplete="name"
textContentType="name"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => emailRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
ref={emailRef}
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="email@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
ref={passwordRef}
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<Button
onPress={form.handleSubmit}
disabled={isSubmitting}
className="mt-1 bg-primary"
>
{isSubmitting ? (
<Spinner size="sm" />
) : (
<ButtonLabel>Create Account</ButtonLabel>
)}
</Button>
</Animated.View>
</View>
)}
</form.Subscribe>
<Animated.View
entering={FadeIn.duration(300).delay(500)}
className="mt-6 items-center"
>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text className="text-primary text-sm">
Already have an account? Sign in
</Text>
</Pressable>
</Link>
</Animated.View>
</AuthScreen>
);
}
+230
View File
@@ -0,0 +1,230 @@
import {
IconAlertCircle,
IconCircleCheck,
IconInfoCircle,
} from "@tabler/icons-react-native";
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Linking, Pressable, TextInput, View } from "react-native";
import Animated, {
FadeIn,
FadeInDown,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { AuthScreen } from "@/components/auth-screen";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import {
getServerUrl,
hasStoredServerUrl,
normalizeUrl,
setServerUrl,
type ValidationError,
validateServerUrl,
} from "@/lib/server-url";
import * as Haptics from "@/utils/haptics";
type ConnectionState =
| { phase: "idle" }
| { phase: "connecting" }
| { phase: "success" }
| { phase: "error"; error: ValidationError };
const ERROR_MESSAGES: Record<ValidationError, string> = {
network_unreachable:
"Could not reach the server. Check your connection and the URL.",
timeout: "Connection timed out. The server might be down or unreachable.",
not_sofa_server:
"This doesn't appear to be a Sofa server. Double-check the URL.",
server_unhealthy:
"Server found but reporting an issue. Try again in a moment.",
invalid_url: "That URL doesn't look right. Include the full server address.",
};
export default function ServerUrlScreen() {
const { replace } = useRouter();
const inputRef = useRef<TextInput>(null);
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [url, setUrl] = useState(() =>
hasStoredServerUrl() ? getServerUrl() : "",
);
const [connection, setConnection] = useState<ConnectionState>({
phase: "idle",
});
const statusCompletedColor = useCSSVariable(
"--color-status-completed",
) as string;
const destructiveColor = useCSSVariable("--color-destructive") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const [isFirstLaunch] = useState(() => !hasStoredServerUrl());
// Icon pulse animation
const iconOpacity = useSharedValue(1);
const iconAnimatedStyle = useAnimatedStyle(() => ({
opacity: iconOpacity.get(),
}));
// Pulsing dot animation for connecting state
const dotOpacity = useSharedValue(0.4);
const dotAnimatedStyle = useAnimatedStyle(() => ({
opacity: dotOpacity.get(),
}));
useEffect(() => {
if (connection.phase === "connecting") {
iconOpacity.set(withRepeat(withTiming(0.4, { duration: 600 }), -1, true));
dotOpacity.set(withRepeat(withTiming(1, { duration: 600 }), -1, true));
} else {
iconOpacity.set(withTiming(1, { duration: 200 }));
dotOpacity.set(0.4);
}
}, [connection.phase, iconOpacity, dotOpacity]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (successTimeout.current !== null) clearTimeout(successTimeout.current);
};
}, []);
// Auto-focus on first launch
useEffect(() => {
if (isFirstLaunch) {
const timer = setTimeout(() => inputRef.current?.focus(), 500);
return () => clearTimeout(timer);
}
}, [isFirstLaunch]);
const handleChangeText = (text: string) => {
setUrl(text);
if (connection.phase === "error") {
setConnection({ phase: "idle" });
}
};
const handleConnect = async () => {
const trimmed = url.trim().replace(/\/+$/, "");
if (!trimmed) return;
const fullUrl = normalizeUrl(trimmed);
setConnection({ phase: "connecting" });
const result = await validateServerUrl(fullUrl);
if (result.status === "success") {
setConnection({ phase: "success" });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setServerUrl(fullUrl);
successTimeout.current = setTimeout(() => {
replace("/(auth)/login");
}, 800);
} else {
setConnection({ phase: "error", error: result.error });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
};
const isConnecting = connection.phase === "connecting";
const isSuccess = connection.phase === "success";
const isDisabled = isConnecting || isSuccess;
return (
<AuthScreen
title="Sofa"
subtitle="Enter your Sofa server URL to get started"
logoStyle={iconAnimatedStyle}
>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View
className="h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
style={{ borderCurve: "continuous" }}
>
<TextInput
ref={inputRef}
value={url}
onChangeText={handleChangeText}
placeholder="https://sofa.example.com"
placeholderTextColorClassName="accent-muted-foreground/50"
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
textContentType="URL"
returnKeyType="go"
editable={!isDisabled}
onSubmitEditing={handleConnect}
className="flex-1 py-0 font-mono text-[15px] text-foreground"
/>
</View>
</Animated.View>
{/* Connect Button / Status */}
<Animated.View
entering={FadeInDown.duration(300).delay(300)}
className="mt-4"
>
{isConnecting ? (
<View className="h-12 flex-row items-center justify-center gap-2">
<Animated.View
className="size-1.5 rounded-full bg-primary"
style={dotAnimatedStyle}
/>
<Text className="text-[13px] text-muted-foreground">
Connecting to server...
</Text>
</View>
) : isSuccess ? (
<View className="h-12 flex-row items-center justify-center gap-1.5">
<IconCircleCheck size={16} color={statusCompletedColor} />
<Text className="font-sans-medium text-[13px] text-status-completed">
Connected
</Text>
</View>
) : (
<Button onPress={handleConnect} className="bg-primary">
<ButtonLabel>Connect</ButtonLabel>
</Button>
)}
</Animated.View>
<View className="mt-3 min-h-10">
{connection.phase === "error" && (
<Animated.View
entering={FadeIn.duration(200)}
className="flex-row items-start gap-2 px-1"
>
<IconAlertCircle
size={16}
color={destructiveColor}
style={{ marginTop: 1 }}
/>
<Text className="flex-1 text-[13px] text-destructive">
{ERROR_MESSAGES[connection.error]}
</Text>
</Animated.View>
)}
</View>
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-4 flex items-center justify-center gap-1"
>
<Pressable
onPress={() => Linking.openURL("https://sofa.watch")}
className="flex-row items-center gap-1.5"
>
<IconInfoCircle size={16} color={mutedFgColor} />
<Text className="font-sans-medium text-[13px] text-primary">
Don't have a server?
</Text>
</Pressable>
</Animated.View>
</AuthScreen>
);
}
@@ -0,0 +1,6 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
export default function ExploreLayout() {
return <Stack screenOptions={useTabScreenOptions()} />;
}
@@ -0,0 +1,106 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
export default function ExploreScreen() {
const trending = useQuery(
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
);
const popularMovies = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
const popularTv = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
const movieGenres = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
);
const tvGenres = useQuery(
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
);
const isRefreshing =
trending.isRefetching ||
popularMovies.isRefetching ||
popularTv.isRefetching;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.explore.key() });
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
}, []);
const heroItem = trending.data?.hero;
return (
<ScrollView
className="bg-background"
contentContainerStyle={{
paddingTop: 8,
paddingBottom: 16,
}}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
}
>
<Stack.Screen options={{ title: "Explore" }} />
<View className="gap-8">
{heroItem && (
<Animated.View entering={FadeIn.duration(400)}>
<HeroBanner item={heroItem} />
</Animated.View>
)}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<FilterableTitleRow
title="Trending Today"
icon={IconFlame}
mediaType="movie"
defaultItems={trending.data?.items ?? []}
defaultUserStatuses={trending.data?.userStatuses ?? {}}
defaultEpisodeProgress={trending.data?.episodeProgress ?? {}}
isLoading={trending.isPending}
/>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<FilterableTitleRow
title="Popular Movies"
icon={IconMovie}
mediaType="movie"
defaultItems={popularMovies.data?.items ?? []}
defaultUserStatuses={popularMovies.data?.userStatuses ?? {}}
defaultEpisodeProgress={popularMovies.data?.episodeProgress ?? {}}
genres={movieGenres.data?.genres}
isLoading={popularMovies.isPending}
/>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<FilterableTitleRow
title="Popular TV Shows"
icon={IconDeviceTv}
mediaType="tv"
defaultItems={popularTv.data?.items ?? []}
defaultUserStatuses={popularTv.data?.userStatuses ?? {}}
defaultEpisodeProgress={popularTv.data?.episodeProgress ?? {}}
genres={tvGenres.data?.genres}
isLoading={popularTv.isPending}
/>
</Animated.View>
</View>
</ScrollView>
);
}
@@ -0,0 +1,6 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
export default function HomeLayout() {
return <Stack screenOptions={useTabScreenOptions()} />;
}
+140
View File
@@ -0,0 +1,140 @@
import {
IconBooks,
IconPlayerPlay,
IconThumbUp,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { FlatList, RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
import { HorizontalPosterRow } from "@/components/dashboard/horizontal-poster-row";
import { StatsCard } from "@/components/dashboard/stats-card";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeader } from "@/components/ui/section-header";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
export default function DashboardScreen() {
const { push } = useRouter();
authClient.useSession();
const stats = useQuery(orpc.dashboard.stats.queryOptions());
const continueWatching = useQuery(
orpc.dashboard.continueWatching.queryOptions(),
);
const library = useQuery(orpc.dashboard.library.queryOptions());
const recommendations = useQuery(
orpc.dashboard.recommendations.queryOptions(),
);
const isRefreshing =
stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
}, []);
const hasLibrary = (library.data?.items?.length ?? 0) > 0;
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0;
const statsData = useMemo(
() => [
{ label: "Movies this month", value: stats.data?.moviesThisMonth },
{ label: "Episodes this week", value: stats.data?.episodesThisWeek },
{ label: "In library", value: stats.data?.librarySize },
{ label: "Completed", value: stats.data?.completed },
],
[stats.data],
);
return (
<ScrollView
className="bg-background"
contentContainerStyle={{
paddingTop: 8,
paddingBottom: 16,
}}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
}
>
<Stack.Screen options={{ title: "Home" }} />
<View className="gap-8">
{/* Stats */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={statsData}
keyExtractor={(item) => item.label}
renderItem={({ item }) => (
<StatsCard label={item.label} value={item.value} />
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
</Animated.View>
{/* Continue Watching */}
{hasContinueWatching && (
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View className="px-4">
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
</View>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={continueWatching.data?.items ?? []}
keyExtractor={(item) => item.title.id}
renderItem={({ item }) => <ContinueWatchingCard item={item} />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
</Animated.View>
)}
{/* Library */}
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<View className="px-4">
<SectionHeader title="In Your Library" icon={IconBooks} />
</View>
{library.isPending ? (
<HorizontalPosterRow items={[]} isLoading />
) : hasLibrary ? (
<HorizontalPosterRow items={library.data?.items ?? []} />
) : (
<EmptyState
title="Your library is empty"
description="Start tracking movies and shows"
actionLabel="Explore"
onAction={() => push("/(tabs)/(explore)")}
/>
)}
</Animated.View>
{/* Recommendations */}
{hasRecommendations && (
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<View className="px-4">
<SectionHeader title="Recommended for You" icon={IconThumbUp} />
</View>
<HorizontalPosterRow
items={recommendations.data?.items ?? []}
isLoading={recommendations.isPending}
/>
</Animated.View>
)}
</View>
</ScrollView>
);
}
@@ -0,0 +1,10 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
export default function SearchLayout() {
return (
<Stack screenOptions={useTabScreenOptions()}>
<Stack.Screen name="index" options={{ title: "Search" }} />
</Stack>
);
}
@@ -0,0 +1,177 @@
import { IconSearch } from "@tabler/icons-react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { FlatList, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import {
type SearchResultItem,
SearchResultRow,
} from "@/components/search/search-result-row";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { useDebounce } from "@/hooks/use-debounce";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
export default function SearchScreen() {
const { navigate } = useRouter();
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query.trim(), 300);
const searchResults = useQuery({
...orpc.search.queryOptions({ input: { query: debouncedQuery } }),
enabled: debouncedQuery.length > 0,
});
// Track which item is currently being resolved/added
const [resolvingId, setResolvingId] = useState<string | null>(null);
const [addingId, setAddingId] = useState<string | null>(null);
const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
setResolvingId(null);
if (id) navigate(`/title/${id}`);
},
onError: () => {
setResolvingId(null);
toast.error("Failed to load title");
},
}),
);
const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => {
setResolvingId(null);
if (id) navigate(`/person/${id}`);
},
onError: () => {
setResolvingId(null);
toast.error("Failed to load person");
},
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
setAddingId(null);
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => {
setAddingId(null);
toast.error("Failed to add to watchlist");
},
}),
);
// Use refs for mutation.mutate to keep callbacks stable across renders
const resolveTitleMutateRef = useRef(resolveTitleMutation.mutate);
resolveTitleMutateRef.current = resolveTitleMutation.mutate;
const resolvePersonMutateRef = useRef(resolvePersonMutation.mutate);
resolvePersonMutateRef.current = resolvePersonMutation.mutate;
const quickAddMutateRef = useRef(quickAddMutation.mutate);
quickAddMutateRef.current = quickAddMutation.mutate;
const handleResolve = useCallback((item: SearchResultItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setResolvingId(`${item.type}-${item.tmdbId}`);
if (item.type === "person") {
resolvePersonMutateRef.current({ tmdbId: item.tmdbId });
} else {
resolveTitleMutateRef.current({ tmdbId: item.tmdbId, type: item.type });
}
}, []);
const handleQuickAdd = useCallback((tmdbId: number, type: "movie" | "tv") => {
setAddingId(`${type}-${tmdbId}`);
quickAddMutateRef.current({ tmdbId, type });
}, []);
// Memoize mapped results to maintain stable references
const allResults = useMemo<SearchResultItem[]>(
() =>
searchResults.data?.results?.map((r) => ({
tmdbId: r.tmdbId,
title: r.title,
type: r.type,
posterPath: r.posterPath,
profilePath: r.profilePath,
releaseDate: r.releaseDate,
})) ?? [],
[searchResults.data?.results],
);
const renderItem = useCallback(
({ item }: { item: SearchResultItem }) => (
<SearchResultRow
item={item}
onResolve={handleResolve}
onQuickAdd={handleQuickAdd}
isResolving={resolvingId === `${item.type}-${item.tmdbId}`}
isAdding={addingId === `${item.type}-${item.tmdbId}`}
/>
),
[handleResolve, handleQuickAdd, resolvingId, addingId],
);
const keyExtractor = useCallback(
(item: SearchResultItem) => `${item.type}-${item.tmdbId}`,
[],
);
return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
headerSearchBarOptions: {
placeholder: "Search movies, shows, people...",
onChangeText: (e) => setQuery(e.nativeEvent.text),
hideWhenScrolling: false,
},
}}
/>
{debouncedQuery.length === 0 ? (
<Animated.View
entering={FadeIn.duration(400)}
className="flex-1 items-center justify-center"
>
<IconSearch size={64} color={mutedForeground} />
<Text className="mt-3 text-[15px] text-muted-foreground">
Search for movies, shows, or people
</Text>
</Animated.View>
) : searchResults.isPending ? (
<View className="flex-1 items-center justify-center">
<Spinner colorClassName="accent-primary" />
</View>
) : allResults.length === 0 ? (
<Animated.View
entering={FadeIn.duration(300)}
className="flex-1 items-center justify-center"
>
<Text className="text-[15px] text-muted-foreground">
No results for "{debouncedQuery}"
</Text>
</Animated.View>
) : (
<FlatList
data={allResults}
keyExtractor={keyExtractor}
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
/>
)}
</View>
);
}
@@ -0,0 +1,6 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
export default function SettingsLayout() {
return <Stack screenOptions={useTabScreenOptions()} />;
}
@@ -0,0 +1,522 @@
import {
IconArrowUpRight,
IconBrandGithub,
IconCamera,
IconChartBar,
IconCloud,
IconDatabase,
IconDeviceMobileCog,
IconDots,
IconLink,
IconLogout,
IconPhoto,
IconServer,
IconShield,
IconUser,
IconUserPlus,
IconWorld,
} from "@tabler/icons-react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import * as Application from "expo-application";
import * as ImagePicker from "expo-image-picker";
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Linking,
Pressable,
RefreshControl,
ScrollView,
TextInput,
View,
} from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import * as DropdownMenu from "zeego/dropdown-menu";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { SettingsRow } from "@/components/settings/settings-row";
import { SettingsSection } from "@/components/settings/settings-section";
import { TmdbLogo } from "@/components/tmdb-logo";
import { Image } from "@/components/ui/image";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { getServerUrl } from "@/lib/server-url";
import { toast } from "@/lib/toast";
export default function SettingsScreen() {
const { push } = useRouter();
const { data: session, refetch: refetchSession } = authClient.useSession();
const [isEditingName, setIsEditingName] = useState(false);
const [nameInput, setNameInput] = useState("");
useEffect(() => {
if (session?.user?.name) setNameInput(session.user.name);
}, [session?.user?.name]);
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
const isAdmin = session?.user?.role === "admin";
const serverUrl = getServerUrl();
const systemHealth = useQuery({
...orpc.system.health.queryOptions(),
enabled: isAdmin,
});
const updateName = useMutation(
orpc.account.updateName.mutationOptions({
onSuccess: () => {
toast.success("Name updated");
setIsEditingName(false);
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
refetchSession();
},
onError: () => toast.error("Failed to update name"),
}),
);
const uploadAvatar = useMutation(
orpc.account.uploadAvatar.mutationOptions({
onSuccess: () => {
toast.success("Profile picture updated");
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
refetchSession();
},
onError: () => toast.error("Failed to upload avatar"),
}),
);
const removeAvatar = useMutation(
orpc.account.removeAvatar.mutationOptions({
onSuccess: () => {
toast.success("Profile picture removed");
queryClient.invalidateQueries({ queryKey: orpc.account.key() });
refetchSession();
},
onError: () => toast.error("Failed to remove profile picture"),
}),
);
const pickAvatar = useCallback(async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];
const response = await fetch(asset.uri);
const blob = await response.blob();
const file = new File([blob], asset.fileName ?? "avatar.jpg", {
type: asset.mimeType ?? "image/jpeg",
});
uploadAvatar.mutate(file);
}, [uploadAvatar]);
const hasAvatarImage = !!session?.user?.image;
const registration = useQuery({
...orpc.admin.registration.queryOptions(),
enabled: isAdmin,
});
const toggleRegistration = useMutation(
orpc.admin.toggleRegistration.mutationOptions({
onSuccess: (_data, { open }) => {
toast.success(open ? "Registration opened" : "Registration closed");
queryClient.invalidateQueries({
queryKey: orpc.admin.registration.key(),
});
},
onError: () => toast.error("Failed to update registration setting"),
}),
);
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
});
const toggleUpdateCheck = useMutation(
orpc.admin.toggleUpdateCheck.mutationOptions({
onSuccess: (_data, { enabled }) => {
toast.success(
enabled ? "Update checks enabled" : "Update checks disabled",
);
queryClient.invalidateQueries({
queryKey: orpc.admin.updateCheck.key(),
});
},
onError: () => toast.error("Failed to update setting"),
}),
);
const primaryFgColor = useCSSVariable("--color-primary-foreground") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await queryClient.invalidateQueries();
setRefreshing(false);
}, []);
const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: () => {
authClient.signOut();
queryClient.clear();
},
},
]);
};
return (
<ScrollView
className="bg-background"
contentContainerStyle={{
paddingTop: 8,
paddingBottom: 32,
paddingHorizontal: 16,
}}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
}
>
<Stack.Screen options={{ title: "Settings" }} />
{/* Account */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<SettingsSection title="Account" icon={IconUser}>
<View className="flex-row items-center py-3.5">
{hasAvatarImage ? (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Pressable className="mr-3">
<View className="size-11 overflow-hidden rounded-full bg-secondary">
{uploadAvatar.isPending ? (
<View className="flex-1 items-center justify-center">
<Spinner size="sm" colorClassName="accent-primary" />
</View>
) : (
<Image
source={{ uri: session.user.image ?? undefined }}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
/>
)}
</View>
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
<IconCamera size={10} color={primaryFgColor} />
</View>
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item key="change" onSelect={() => pickAvatar()}>
<DropdownMenu.ItemIcon
ios={{ name: "photo.on.rectangle.angled" }}
/>
<DropdownMenu.ItemTitle>
Change Photo
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="remove"
destructive
onSelect={() => removeAvatar.mutate()}
>
<DropdownMenu.ItemIcon ios={{ name: "trash" }} />
<DropdownMenu.ItemTitle>
Remove Photo
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
) : (
<Pressable onPress={pickAvatar} className="mr-3">
<View className="size-11 overflow-hidden rounded-full bg-secondary">
{uploadAvatar.isPending ? (
<View className="flex-1 items-center justify-center">
<Spinner size="sm" colorClassName="accent-primary" />
</View>
) : (
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
<Text className="font-sans-medium text-lg text-primary">
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
</Text>
</View>
)}
</View>
<View className="absolute right-0 bottom-0 size-[18px] items-center justify-center rounded-full bg-primary">
<IconCamera size={10} color={primaryFgColor} />
</View>
</Pressable>
)}
<View className="flex-1">
{isEditingName ? (
<View className="flex-row items-center gap-2">
<TextInput
value={nameInput}
onChangeText={setNameInput}
className="flex-1 border-primary border-b py-0.5 font-sans text-[15px] text-foreground"
autoFocus
/>
<Pressable
onPress={() => updateName.mutate({ name: nameInput })}
>
<Text className="text-primary text-sm">Save</Text>
</Pressable>
<Pressable
onPress={() => {
setNameInput(session?.user?.name ?? "");
setIsEditingName(false);
}}
>
<Text className="text-muted-foreground text-sm">
Cancel
</Text>
</Pressable>
</View>
) : (
<Pressable onPress={() => setIsEditingName(true)}>
<Text className="font-sans-medium text-base text-foreground">
{session?.user?.name}
</Text>
</Pressable>
)}
<Text
selectable
className="mt-0.5 text-[13px] text-muted-foreground"
>
{session?.user?.email}
</Text>
</View>
{isAdmin && (
<View className="rounded-full bg-primary/10 px-2 py-0.5">
<Text className="font-sans-medium text-[10px] text-primary">
Admin
</Text>
</View>
)}
</View>
<SettingsRow
label="Sign out"
icon={IconLogout}
onPress={handleSignOut}
destructive
/>
</SettingsSection>
</Animated.View>
{/* Server */}
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<SettingsSection title="Application" icon={IconDeviceMobileCog}>
<SettingsRow
label="Server URL"
value={serverUrl}
icon={IconLink}
onPress={() => {
Alert.alert(
"Change Server",
"You'll be signed out to change the server URL.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Continue",
style: "destructive",
onPress: async () => {
await authClient.signOut();
queryClient.clear();
push("/(auth)/server-url");
},
},
],
);
}}
/>
<SettingsRow
label="Anonymous usage reporting"
icon={IconChartBar}
right={
<Switch
value={analyticsEnabled}
onValueChange={(enabled) => {
setAnalyticsToggle(enabled);
setAnalyticsEnabled(enabled);
}}
/>
}
/>
</SettingsSection>
</Animated.View>
{/* Integrations */}
<Animated.View entering={FadeInDown.duration(300).delay(300)}>
<IntegrationsSection />
</Animated.View>
{/* Admin: Server Health */}
{isAdmin && (
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<SettingsSection
title="Server Health"
icon={IconServer}
badge="Admin"
>
{systemHealth.isPending ? (
<View className="items-center py-4">
<Spinner colorClassName="accent-primary" />
</View>
) : systemHealth.data ? (
<>
<SettingsRow
label="Database"
value={
systemHealth.data?.database
? `${systemHealth.data.database.titleCount} titles`
: "—"
}
icon={IconDatabase}
/>
<SettingsRow
label="TMDB"
value={systemHealth.data?.tmdb?.connected ? "Connected" : "—"}
icon={IconCloud}
/>
<SettingsRow
label="Image Cache"
value={
systemHealth.data?.imageCache
? `${systemHealth.data.imageCache.imageCount} images`
: "—"
}
icon={IconPhoto}
/>
</>
) : null}
</SettingsSection>
</Animated.View>
)}
{/* Admin: Security */}
{isAdmin && (
<Animated.View entering={FadeInDown.duration(300).delay(500)}>
<SettingsSection title="Security" icon={IconShield} badge="Admin">
<SettingsRow
label="Open registration"
icon={IconUserPlus}
right={
<Switch
value={registration.data?.open ?? false}
onValueChange={(open) => toggleRegistration.mutate({ open })}
/>
}
/>
<SettingsRow
label="Check for updates"
icon={IconCloud}
right={
<Switch
value={updateCheck.data?.enabled ?? false}
onValueChange={(enabled) =>
toggleUpdateCheck.mutate({ enabled })
}
/>
}
/>
{updateCheck.data?.updateCheck?.updateAvailable && (
<View className="py-3.5">
<Text className="font-sans-medium text-[13px] text-status-completed">
Update available: {updateCheck.data.updateCheck.latestVersion}
</Text>
</View>
)}
</SettingsSection>
</Animated.View>
)}
{/* More Settings */}
<Animated.View entering={FadeInDown.duration(300).delay(400)}>
<SettingsSection title="More Settings" icon={IconDots}>
<Pressable
onPress={() => Linking.openURL(`${serverUrl}/settings`)}
className="flex-row items-center justify-center py-3.5 active:opacity-70"
>
<IconWorld size={18} color={mutedFgColor} />
<Text className="ml-2 flex-1 text-[15px] text-foreground">
Open in browser
</Text>
<IconArrowUpRight size={16} color={mutedFgColor} />
</Pressable>
</SettingsSection>
</Animated.View>
{/* Version */}
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6 items-center"
>
<Text className="text-muted-foreground text-xs">
Native
{Application.nativeApplicationVersion
? ` v${Application.nativeApplicationVersion}`
: ""}
{Application.nativeBuildVersion
? ` (${Application.nativeBuildVersion})`
: ""}
{updateCheck.data?.updateCheck?.currentVersion
? ` · Server v${updateCheck.data.updateCheck.currentVersion}`
: ""}
</Text>
</Animated.View>
{/* GitHub */}
<Animated.View
entering={FadeInDown.duration(300).delay(450)}
className="mt-3 items-center"
>
<Pressable
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
className="flex-row items-center gap-1.5 active:opacity-70"
>
<IconBrandGithub size={14} color={mutedFgColor} />
<Text className="text-muted-foreground text-xs">GitHub</Text>
</Pressable>
</Animated.View>
{/* TMDB Attribution */}
<Animated.View
entering={FadeInDown.duration(300).delay(500)}
className="mt-4 items-center gap-2"
>
<Pressable
onPress={() => Linking.openURL("https://www.themoviedb.org/")}
className="items-center gap-2 active:opacity-70"
>
<TmdbLogo height={12} />
<Text className="text-center text-[10px] text-muted-foreground leading-relaxed">
This product uses the TMDB API but is not endorsed or certified by
TMDB.
</Text>
</Pressable>
</Animated.View>
</ScrollView>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useCSSVariable } from "uniwind";
import * as Haptics from "@/utils/haptics";
export default function TabLayout() {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
return (
<NativeTabs
iconColor={{
default: mutedFgColor,
selected: primaryColor,
}}
labelStyle={{
default: { color: mutedFgColor },
selected: { color: primaryColor },
}}
screenListeners={{
tabPress: () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
}}
>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { IconAlertTriangle } from "@tabler/icons-react-native";
import { Link, Stack } from "expo-router";
import { View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Container } from "@/components/container";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
export default function NotFoundScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
return (
<>
<Stack.Screen options={{ title: "Not Found" }} />
<Container>
<View className="flex-1 items-center justify-center p-4">
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
>
<IconAlertTriangle size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
Page Not Found
</Text>
<Text className="mt-1 mb-4 text-center text-muted-foreground text-sm">
The page you're looking for doesn't exist.
</Text>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/" asChild>
<Button size="sm" className="bg-primary">
<ButtonLabel className="text-primary-foreground">
Go Home
</ButtonLabel>
</Button>
</Link>
</Animated.View>
</View>
</Container>
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
import "@/global.css";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
import { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ToastProvider } from "@/components/ui/toast-provider";
import { authClient } from "@/lib/auth-client";
import { queryPersister } from "@/lib/mmkv";
import { posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
SplashScreen.preventAutoHideAsync();
export const unstable_settings = {
initialRouteName: "(tabs)",
};
function AppContent() {
const contentStyle = useResolveClassNames("bg-background");
// Force re-render when server URL changes so useSession()
// re-subscribes to the rebuilt authClient's session atom.
const [, setUrlVersion] = useState(0);
useEffect(() => onServerUrlChange(() => setUrlVersion((n) => n + 1)), []);
const { data: session, isPending } = authClient.useSession();
const hasServerUrl =
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
// --- PostHog screen tracking ---
const pathname = usePathname();
const params = useGlobalSearchParams();
useEffect(() => {
if (posthog && pathname) {
posthog.screen(pathname, params);
}
}, [pathname, params]);
useEffect(() => {
Uniwind.setTheme("dark");
}, []);
useEffect(() => {
if (!isPending || !hasServerUrl) {
SplashScreen.hideAsync();
}
}, [isPending, hasServerUrl]);
return (
<>
<StatusBar style="light" />
<OfflineBanner />
<ToastProvider />
<Stack
screenOptions={{
headerShown: false,
contentStyle,
animation: "slide_from_right",
}}
>
<Stack.Protected guard={!session}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="title/[id]"
dangerouslySingular
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
headerTitle: "",
animation: "slide_from_right",
}}
/>
<Stack.Screen
name="person/[id]"
dangerouslySingular
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
headerTitle: "",
animation: "slide_from_right",
}}
/>
</Stack.Protected>
</Stack>
</>
);
}
export default function RootLayout() {
const inner = (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: queryPersister }}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppContent />
</KeyboardProvider>
</GestureHandlerRootView>
</PersistQueryClientProvider>
);
if (!posthog) return inner;
return (
<PostHogProvider client={posthog} autocapture={{ captureScreens: false }}>
<PostHogErrorBoundary>{inner}</PostHogErrorBoundary>
</PostHogProvider>
);
}
+236
View File
@@ -0,0 +1,236 @@
import {
IconAlertTriangle,
IconMovie,
IconUser,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback } from "react";
import { FlatList, Pressable, useWindowDimensions, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { ExpandableText } from "@/components/ui/expandable-text";
import { Image } from "@/components/ui/image";
import { PosterCard } from "@/components/ui/poster-card";
import { SectionHeader } from "@/components/ui/section-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
const FILMOGRAPHY_GAP = 12;
const FILMOGRAPHY_PADDING = 16;
export default function PersonDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const { back } = useRouter();
const { width: screenWidth } = useWindowDimensions();
const columnWidth =
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2;
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const { data, isPending, isError } = useQuery(
orpc.people.detail.queryOptions({ input: { id } }),
);
const person = data?.person;
const filmography = data?.filmography ?? [];
const renderFilmographyItem = useCallback(
({ item: credit }: { item: (typeof filmography)[number] }) => (
<View style={{ width: columnWidth }}>
<PosterCard
id={credit.titleId}
tmdbId={credit.tmdbId}
title={credit.title}
type={credit.type}
posterPath={credit.posterPath}
releaseDate={credit.releaseDate ?? credit.firstAirDate}
voteAverage={credit.voteAverage}
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
width={undefined}
/>
{credit.character ? (
<Text
numberOfLines={1}
className="mt-1 text-center text-[11px] text-muted-foreground"
>
as {credit.character}
</Text>
) : null}
</View>
),
[columnWidth, data?.userStatuses],
);
if (isPending) {
return (
<View
className="flex-1 items-center bg-background"
style={{ paddingTop: insets.top + 56 }}
>
{/* Profile photo skeleton */}
<Skeleton width={120} height={120} borderRadius={60} />
{/* Name skeleton */}
<Skeleton
width={180}
height={28}
borderRadius={6}
style={{ marginTop: 16 }}
/>
{/* Department badge skeleton */}
<Skeleton
width={80}
height={24}
borderRadius={12}
style={{ marginTop: 8 }}
/>
{/* Bio skeleton */}
<View className="mt-6 gap-2 self-stretch px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="60%" height={14} />
</View>
</View>
);
}
if (isError && !data) {
return (
<View
className="flex-1 items-center justify-center bg-background"
style={{ paddingTop: insets.top }}
>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<IconAlertTriangle size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
Something went wrong
</Text>
<Text className="mt-1 text-center text-muted-foreground text-sm">
Could not load person details
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text>
</Pressable>
</Animated.View>
</View>
);
}
if (!person) {
return (
<View
className="flex-1 items-center justify-center bg-background"
style={{ paddingTop: insets.top }}
>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<IconUser size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
Person not found
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text>
</Pressable>
</Animated.View>
</View>
);
}
const listHeader = (
<>
{/* Profile hero */}
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
style={{ paddingTop: insets.top + 56, paddingBottom: 24 }}
>
<View className="size-[120px] overflow-hidden rounded-full bg-secondary">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
/>
)}
</View>
<Text
selectable
className="mt-4 text-center font-display text-[28px] text-foreground"
>
{person.name}
</Text>
{person.knownForDepartment ? (
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
<Text className="text-muted-foreground text-xs">
{person.knownForDepartment}
</Text>
</View>
) : null}
{person.birthday || person.deathday ? (
<Text selectable className="mt-2 text-[13px] text-muted-foreground">
{person.birthday?.slice(0, 4)}
{person.deathday ? `${person.deathday.slice(0, 4)}` : ""}
</Text>
) : null}
</Animated.View>
{/* Biography */}
{person.biography ? (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<View className="mb-6 px-4">
<ExpandableText text={person.biography} maxLines={4} />
</View>
</Animated.View>
) : null}
{/* Filmography section header */}
{filmography.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="px-4"
>
<SectionHeader title="Filmography" icon={IconMovie} />
</Animated.View>
)}
</>
);
return (
<>
<Stack.Screen
options={{
title: person?.name ?? "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}}
/>
<FlatList
data={filmography}
keyExtractor={(item) => item.titleId}
renderItem={renderFilmographyItem}
numColumns={2}
columnWrapperStyle={{
gap: FILMOGRAPHY_GAP,
paddingHorizontal: FILMOGRAPHY_PADDING,
}}
contentContainerStyle={{
paddingBottom: insets.bottom + 32,
gap: FILMOGRAPHY_GAP,
}}
ListHeaderComponent={listHeader}
className="bg-background"
initialNumToRender={6}
maxToRenderPerBatch={8}
windowSize={5}
/>
</>
);
}
+569
View File
@@ -0,0 +1,569 @@
import {
IconCheck,
IconList,
IconMovie,
IconPlayerPlay,
IconStarFilled,
IconThumbUp,
IconUsers,
} from "@tabler/icons-react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FlatList,
Pressable,
RefreshControl,
ScrollView,
View,
} from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { CastCard } from "@/components/titles/cast-card";
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
import { SeasonAccordion } from "@/components/titles/season-accordion";
import { StatusActionButton } from "@/components/titles/status-action-button";
import { ExpandableText } from "@/components/ui/expandable-text";
import { Image } from "@/components/ui/image";
import { PosterCard } from "@/components/ui/poster-card";
import { SectionHeader } from "@/components/ui/section-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { StarRating } from "@/components/ui/star-rating";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
export default function TitleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const { back } = useRouter();
const [primary, mutedForeground, primaryForeground] = useCSSVariable([
"--color-primary",
"--color-muted-foreground",
"--color-primary-foreground",
]) as [string, string, string];
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
const userInfo = useQuery(
orpc.titles.userInfo.queryOptions({ input: { id } }),
);
const recommendations = useQuery(
orpc.titles.recommendations.queryOptions({ input: { id } }),
);
const updateStatus = useMutation(
orpc.titles.updateStatus.mutationOptions({
onSuccess: (_data, { status }) => {
const statusMessages: Record<string, string> = {
watchlist: "Added to watchlist",
in_progress: "Marked as watching",
completed: "Marked as completed",
};
toast.success(
status
? (statusMessages[status] ?? "Status updated")
: "Removed from library",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to update status"),
}),
);
const updateRating = useMutation(
orpc.titles.updateRating.mutationOptions({
onSuccess: (_data, { stars }) => {
toast.success(
stars > 0
? `Rated ${stars} star${stars > 1 ? "s" : ""}`
: "Rating removed",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
onError: () => toast.error("Failed to update rating"),
}),
);
const watchMovie = useMutation(
orpc.titles.watchMovie.mutationOptions({
onSuccess: () => {
toast.success(
title?.title
? `Marked "${title.title}" as watched`
: "Marked as watched",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to mark as watched"),
}),
);
const watchAll = useMutation(
orpc.titles.watchAll.mutationOptions({
onSuccess: () => {
toast.success("Marked all episodes as watched");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to mark all episodes as watched"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to add to watchlist"),
}),
);
const hydrateMutation = useMutation(
orpc.titles.hydrateSeasons.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
setRefreshing(false);
}, []);
const title = detail.data?.title;
const seasons = detail.data?.seasons ?? [];
const cast = detail.data?.cast ?? [];
const availability = detail.data?.availability ?? [];
const watchedEpisodeIds = useMemo(
() => new Set(userInfo.data?.episodeWatches ?? []),
[userInfo.data?.episodeWatches],
);
const hydratedTitleId = useRef<string | null>(null);
useEffect(() => {
if (
detail.data?.needsHydration &&
title?.type === "tv" &&
hydratedTitleId.current !== id
) {
hydratedTitleId.current = id;
hydrateMutation.mutate({ id, tmdbId: title.tmdbId });
}
}, [
detail.data?.needsHydration,
title?.type,
title?.tmdbId,
id,
hydrateMutation.mutate,
]);
if (detail.isPending) {
return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}}
/>
{/* Hero skeleton */}
<Skeleton width="100%" height={300} borderRadius={0} />
{/* Genre chips skeleton */}
<View className="mt-3 flex-row gap-2 px-4">
<Skeleton width={60} height={24} borderRadius={12} />
<Skeleton width={80} height={24} borderRadius={12} />
<Skeleton width={50} height={24} borderRadius={12} />
</View>
{/* Actions skeleton */}
<View className="mt-4 flex-row gap-2 px-4">
<Skeleton width={100} height={36} borderRadius={18} />
<Skeleton width={90} height={36} borderRadius={18} />
<Skeleton width={105} height={36} borderRadius={18} />
</View>
{/* Overview skeleton */}
<View className="mt-5 gap-2 px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="70%" height={14} />
</View>
</View>
);
}
if (!title) {
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}}
/>
<IconMovie size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
Title not found
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text>
</Pressable>
</View>
);
}
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
return (
<ScrollView
className="bg-background"
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
}
>
<Stack.Screen
options={{
title: title?.title ?? "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}}
/>
{/* Hero */}
<View className="h-[300px]">
{title.backdropPath && (
<Image
source={{ uri: title.backdropPath }}
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
/>
{title.trailerVideoKey && (
<Pressable
onPress={() =>
WebBrowser.openBrowserAsync(
`https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
)
}
className="absolute inset-0 items-center justify-center"
>
<View
className="h-14 w-14 items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</View>
</Pressable>
)}
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
{title.posterPath && (
<View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={{ borderCurve: "continuous" }}
>
<Image
source={{ uri: title.posterPath }}
style={{
width: "100%",
height: "100%",
}}
contentFit="cover"
/>
</View>
)}
<View className="flex-1 pb-1">
<Text
selectable
className="font-display text-2xl text-white"
numberOfLines={2}
>
{title.title}
</Text>
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
<View className="rounded-full bg-primary px-2 py-0.5">
<Text className="font-sans-medium text-[10px] text-primary-foreground">
{title.type === "movie" ? "Movie" : "TV"}
</Text>
</View>
{year ? (
<Text className="text-[13px] text-white/70">{year}</Text>
) : null}
{title.contentRating ? (
<Text className="text-white/50 text-xs">
{title.contentRating}
</Text>
) : null}
{title.voteAverage != null && title.voteAverage > 0 && (
<View className="flex-row items-center gap-0.5">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{title.voteAverage.toFixed(1)}
</Text>
</View>
)}
</View>
</View>
</View>
</View>
{/* Genres */}
{title.genres && title.genres.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-3"
contentContainerStyle={{ paddingHorizontal: 16 }}
>
{title.genres.map((genre: string) => (
<View
key={genre}
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
>
<Text className="text-[11px] text-muted-foreground">
{genre}
</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Actions */}
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="mt-4 px-4"
>
<StatusActionButton
currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => {
if (status === null) {
updateStatus.mutate({ id, status: null });
} else if (status === "watchlist") {
quickAddMutation.mutate({
tmdbId: title.tmdbId,
type: title.type,
});
} else if (status === "completed" && title.type === "movie") {
watchMovie.mutate({ id });
} else if (status === "completed" && title.type === "tv") {
watchAll.mutate({ id });
} else {
updateStatus.mutate({ id, status });
}
}}
isPending={
updateStatus.isPending ||
quickAddMutation.isPending ||
watchMovie.isPending ||
watchAll.isPending
}
/>
<View className="mt-4 flex-row items-center justify-between">
<StarRating
rating={userInfo.data?.rating ?? 0}
onRate={(stars) => updateRating.mutate({ id, stars })}
/>
{title.type === "movie" && (
<Pressable
onPress={() => watchMovie.mutate({ id })}
disabled={watchMovie.isPending}
className="flex-row items-center gap-1.5 rounded-full bg-primary px-4 py-2"
>
{watchMovie.isPending ? (
<Spinner size="sm" />
) : (
<>
<IconCheck size={16} color={primaryForeground} />
<Text className="font-sans-medium text-[13px] text-primary-foreground">
Mark Watched
</Text>
</>
)}
</Pressable>
)}
</View>
</Animated.View>
{/* Overview */}
{title.overview ? (
<Animated.View entering={FadeIn.duration(300).delay(300)}>
<View className="mt-5 px-4">
<ExpandableText text={title.overview} />
</View>
</Animated.View>
) : null}
{/* Availability */}
{availability.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6"
>
<View className="px-4">
<SectionHeader title="Where to Watch" icon={IconPlayerPlay} />
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}
>
{availability.map((offer) => (
<View
key={`${offer.providerId}-${offer.offerType}`}
className="items-center"
>
{offer.logoPath && (
<Image
source={{ uri: offer.logoPath }}
style={{ width: 44, height: 44, borderRadius: 10 }}
contentFit="cover"
/>
)}
<Text
className="mt-1 max-w-[60px] text-center text-[10px] text-muted-foreground"
numberOfLines={1}
>
{offer.providerName}
</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Continue Watching */}
{title.type === "tv" && (
<ContinueWatchingBanner
seasons={seasons}
watchedEpisodeIds={watchedEpisodeIds}
userStatus={userInfo.data?.status ?? null}
backdropPath={title.backdropPath}
/>
)}
{/* Seasons & Episodes */}
{title.type === "tv" && seasons.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6 px-4"
>
<SectionHeader title="Seasons" icon={IconList} />
{seasons.map((season) => (
<SeasonAccordion
key={season.id}
season={season}
episodes={season.episodes ?? []}
watchedEpisodeIds={watchedEpisodeIds}
/>
))}
</Animated.View>
)}
{hydrateMutation.isPending && (
<View className="items-center py-6">
<Spinner colorClassName="accent-primary" />
<Text className="mt-2 text-[13px] text-muted-foreground">
Loading season data...
</Text>
</View>
)}
{/* Cast */}
{cast.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(500)}
className="mt-6"
>
<View className="px-4">
<SectionHeader title="Cast" icon={IconUsers} />
</View>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={cast}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({ item }) => <CastCard person={item} />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
</Animated.View>
)}
{/* Recommendations */}
{recommendations.data &&
recommendations.data.recommendations?.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(600)}
className="mt-6"
>
<View className="px-4">
<SectionHeader title="More Like This" icon={IconThumbUp} />
</View>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={recommendations.data.recommendations}
keyExtractor={(item) => item.id ?? String(item.tmdbId)}
renderItem={({ item }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type}
posterPath={item.posterPath}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={
item.id
? (recommendations.data?.userStatuses?.[item.id] ?? null)
: null
}
/>
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
</Animated.View>
)}
</ScrollView>
);
}
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { ScrollView } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SofaLogo } from "@/components/ui/sofa-logo";
import { Text } from "@/components/ui/text";
interface AuthScreenProps {
title: string;
subtitle?: string;
logoStyle?: StyleProp<ViewStyle>;
children: ReactNode;
}
export function AuthScreen({
title,
subtitle,
logoStyle,
children,
}: AuthScreenProps) {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingTop: insets.top,
paddingBottom: insets.bottom,
}}
keyboardShouldPersistTaps="handled"
bounces={false}
className="bg-background"
>
<Animated.View
entering={FadeIn.duration(400)}
className="mb-4 items-center"
>
{logoStyle ? (
<Animated.View style={logoStyle}>
<SofaLogo size={48} />
</Animated.View>
) : (
<SofaLogo size={48} />
)}
<Text className="mt-1 font-display text-[32px] text-foreground">
{title}
</Text>
{subtitle && (
<Text className="mt-2 text-muted-foreground text-sm">{subtitle}</Text>
)}
</Animated.View>
{children}
</ScrollView>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { PropsWithChildren } from "react";
import {
ScrollView,
type ScrollViewProps,
View,
type ViewProps,
} from "react-native";
import Animated, { type AnimatedProps } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cn } from "@/utils/cn";
const AnimatedView = Animated.createAnimatedComponent(View);
type Props = AnimatedProps<ViewProps> & {
className?: string;
isScrollable?: boolean;
scrollViewProps?: Omit<ScrollViewProps, "contentContainerStyle">;
};
export function Container({
children,
className,
isScrollable = true,
scrollViewProps,
...props
}: PropsWithChildren<Props>) {
const insets = useSafeAreaInsets();
return (
<AnimatedView
className={cn("flex-1 bg-background", className)}
{...props}
style={[{ paddingBottom: insets.bottom }, props.style]}
>
{isScrollable ? (
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
{...scrollViewProps}
>
{children}
</ScrollView>
) : (
<View className="flex-1">{children}</View>
)}
</AnimatedView>
);
}
@@ -0,0 +1,117 @@
import { Link } from "expo-router";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
export interface ContinueWatchingItem {
title: {
id: string;
title: string;
backdropPath: string | null;
};
watchedEpisodes: number;
totalEpisodes: number;
nextEpisode?: {
seasonNumber: number;
episodeNumber: number;
name: string | null;
stillPath: string | null;
} | null;
}
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.97]) }],
}));
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
return (
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
<Link.Trigger>
<GestureDetector gesture={tapGesture}>
<Animated.View
className="w-[200px] overflow-hidden rounded-[12px] border bg-card"
style={[
animatedStyle,
{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
},
]}
>
<View className="h-28 w-[200px]">
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
<Image
source={{
uri: (item.nextEpisode?.stillPath ??
item.title.backdropPath) as string,
}}
className="h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
/>
{item.nextEpisode && (
<View className="absolute right-2.5 bottom-3 left-2.5">
<Text
numberOfLines={1}
className="font-sans-medium text-[11px] text-white/60"
>
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
</Text>
</View>
)}
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
style={{
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
}}
/>
</View>
</View>
<View className="p-2.5">
<Text
numberOfLines={1}
className="font-sans-medium text-[13px] text-foreground"
>
{item.title.title}
</Text>
{item.nextEpisode && (
<Text
numberOfLines={1}
className="mt-0.5 text-[11px] text-muted-foreground"
>
{item.nextEpisode.name}
</Text>
)}
</View>
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
);
}
@@ -0,0 +1,62 @@
import { FlatList } from "react-native";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
export interface PosterRowItem {
id: string;
tmdbId: number;
title: string;
type: string;
posterPath: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
voteAverage?: number | null;
userStatus?: "watchlist" | "in_progress" | "completed" | null;
episodeProgress?: { watched: number; total: number } | null;
}
export function HorizontalPosterRow({
items,
isLoading,
}: {
items: PosterRowItem[];
isLoading?: boolean;
}) {
if (isLoading) {
return (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={[1, 2, 3, 4]}
keyExtractor={(item) => String(item)}
renderItem={() => <PosterCardSkeleton />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
);
}
return (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={item.userStatus}
episodeProgress={item.episodeProgress}
/>
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
);
}
@@ -0,0 +1,25 @@
import { View } from "react-native";
import { Text } from "@/components/ui/text";
export function StatsCard({
label,
value,
}: {
label: string;
value: number | undefined;
}) {
return (
<View
className="min-w-[120px] rounded-xl border border-white/[0.06] bg-card px-4 py-3"
style={{ borderCurve: "continuous" }}
>
<Text
className="font-sans-bold text-[24px] text-primary"
style={{ fontVariant: ["tabular-nums"] }}
>
{value ?? "—"}
</Text>
<Text className="mt-0.5 text-[11px] text-muted-foreground">{label}</Text>
</View>
);
}
@@ -0,0 +1,135 @@
import type { Icon } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { FlatList, ScrollView, View } from "react-native";
import { GenreChip } from "@/components/explore/genre-chip";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
type TitleStatus = "watchlist" | "in_progress" | "completed";
export function FilterableTitleRow({
title,
icon,
mediaType,
defaultItems,
defaultUserStatuses,
defaultEpisodeProgress,
genres,
isLoading,
}: {
title: string;
icon: Icon;
mediaType: "movie" | "tv";
defaultItems: Array<{
tmdbId: number;
title: string;
type: string;
posterPath: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
voteAverage?: number | null;
}>;
defaultUserStatuses: Record<string, TitleStatus>;
defaultEpisodeProgress: Record<string, { watched: number; total: number }>;
genres?: Array<{ id: number; name: string }>;
isLoading?: boolean;
}) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const discover = useQuery({
...orpc.discover.queryOptions({
input: { type: mediaType, genreId: selectedGenre ?? 0 },
}),
enabled: selectedGenre !== null,
});
const items =
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
const userStatuses =
selectedGenre === null
? defaultUserStatuses
: (discover.data?.userStatuses ?? {});
const episodeProgress =
selectedGenre === null
? defaultEpisodeProgress
: (discover.data?.episodeProgress ?? {});
const showLoading =
isLoading || (selectedGenre !== null && discover.isPending);
return (
<View>
<View className="px-4">
<SectionHeader title={title} icon={icon} />
</View>
{genres && genres.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mb-3"
contentContainerStyle={{ paddingHorizontal: 16 }}
>
<GenreChip
label="All"
isSelected={selectedGenre === null}
onPress={() => setSelectedGenre(null)}
/>
{genres.map((genre) => (
<GenreChip
key={genre.id}
label={genre.name}
isSelected={selectedGenre === genre.id}
onPress={() =>
setSelectedGenre(selectedGenre === genre.id ? null : genre.id)
}
/>
))}
</ScrollView>
)}
{showLoading ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={[1, 2, 3, 4]}
keyExtractor={(item) => String(item)}
renderItem={() => <PosterCardSkeleton />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
) : items.length === 0 && selectedGenre !== null ? (
<View className="items-center py-6">
<Text className="text-[13px] text-muted-foreground">
No titles found for this genre.
</Text>
</View>
) : (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={items}
keyExtractor={(item) => `${item.tmdbId}-${item.type}`}
renderItem={({ item }) => (
<PosterCard
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`] ?? null}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`] ?? null
}
/>
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
)}
</View>
);
}
@@ -0,0 +1,29 @@
import { Pressable } from "react-native";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
export function GenreChip({
label,
isSelected,
onPress,
}: {
label: string;
isSelected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
>
<Text
className={`font-sans-medium text-[12px] ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
>
{label}
</Text>
</Pressable>
);
}
@@ -0,0 +1,115 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
tmdbId: number;
title: string;
type: string;
backdropPath?: string | null;
overview?: string | null;
voteAverage?: number | null;
releaseDate?: string | null;
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string;
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.98]) }],
}));
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const handlePress = useCallback(() => {
resolveMutation.mutate({
tmdbId: item.tmdbId,
type: item.type as "movie" | "tv",
});
}, [item.tmdbId, item.type, resolveMutation]);
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
runOnJS(handlePress)();
});
return (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
opacity: resolveMutation.isPending ? 0.7 : 1,
borderCurve: "continuous",
},
]}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text className="font-display text-2xl text-white" numberOfLines={2}>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</Animated.View>
</GestureDetector>
);
}
@@ -0,0 +1,80 @@
import { useRouter } from "expo-router";
import { Alert, Pressable, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
export function HeaderAvatar() {
const { data: session } = authClient.useSession();
const router = useRouter();
if (!session?.user) return null;
const { user } = session;
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
>
<View className="size-8 overflow-hidden rounded-full">
{user.image ? (
<Image
source={{ uri: user.image }}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
/>
) : (
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
<Text className="font-sans-medium text-[13px] text-primary">
{user.name?.charAt(0)?.toUpperCase() ?? "?"}
</Text>
</View>
)}
</View>
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
key="settings"
onSelect={() => router.navigate("/(tabs)/(settings)")}
>
<DropdownMenu.ItemIcon
ios={{ name: "gear" }}
androidIconName="ic_menu_preferences"
/>
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="sign-out"
destructive
onSelect={() => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: () => {
authClient.signOut();
queryClient.clear();
},
},
]);
}}
>
<DropdownMenu.ItemIcon
ios={{ name: "rectangle.portrait.and.arrow.right" }}
androidIconName="ic_menu_close_clear_cancel"
/>
<DropdownMenu.ItemTitle>Sign out</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
@@ -0,0 +1,107 @@
import { IconLoader, IconPlus } from "@tabler/icons-react-native";
import { memo } from "react";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
export interface SearchResultItem {
tmdbId: number;
title: string;
type: "movie" | "tv" | "person";
posterPath?: string | null;
profilePath?: string | null;
releaseDate?: string | null;
}
export const SearchResultRow = memo(function SearchResultRow({
item,
onResolve,
onQuickAdd,
isResolving,
isAdding,
}: {
item: SearchResultItem;
onResolve: (item: SearchResultItem) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isResolving: boolean;
isAdding: boolean;
}) {
const primary = useCSSVariable("--color-primary") as string;
const imageSrc = item.posterPath ?? item.profilePath;
return (
<Pressable
onPress={() => onResolve(item)}
disabled={isResolving}
className="flex-row items-center border-border border-b px-4 py-3"
style={{
borderBottomWidth: 0.5,
opacity: isResolving ? 0.6 : 1,
}}
>
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: 44,
height: item.type === "person" ? 44 : 66,
borderRadius: item.type === "person" ? 22 : 8,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{imageSrc ? (
<Image
source={{ uri: imageSrc }}
className="h-full w-full"
contentFit="cover"
/>
) : null}
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className="font-sans-medium text-[15px] text-foreground"
>
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text className="text-[10px] text-muted-foreground">
{item.type === "movie"
? "Movie"
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null}
</View>
</View>
{item.type !== "person" && (
<Pressable
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")}
disabled={isAdding}
hitSlop={8}
className="ml-2"
>
{isAdding ? (
<IconLoader size={22} color={primary} />
) : (
<IconPlus size={22} color={primary} />
)}
</Pressable>
)}
{isResolving && (
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
)}
</Pressable>
);
});
@@ -0,0 +1,62 @@
import type { SvgProps } from "react-native-svg";
import Svg, { Path } from "react-native-svg";
export function PlexIcon(props: SvgProps) {
return (
<Svg width={24} height={24} viewBox="0 0 32 32" {...props}>
{/* Icon from CoreUI Brands by creativeLabs Łukasz Holeczek - https://creativecommons.org/publicdomain/zero/1.0/ */}
<Path
fill={props.color ?? "currentColor"}
d="M15.527 0H6.24l10.239 16L6.24 32h9.287L25.76 16z"
/>
</Svg>
);
}
export function JellyfinIcon(props: SvgProps) {
return (
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<Path
fill={props.color ?? "currentColor"}
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
/>
</Svg>
);
}
export function EmbyIcon(props: SvgProps) {
return (
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<Path
fill={props.color ?? "currentColor"}
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513l.512.512l-2.819 2.791L0 12.961l1.83 1.848l3.182 3.209l1.351 1.359l.508-.496c.28-.273.515-.498.524-.498c.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516l-.502-.513c-.276-.282-.5-.52-.496-.53c.003-.009 1.264-1.26 2.802-2.783s2.8-2.776 2.803-2.785c.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497s-1.27-1.267-2.793-2.805A450 450 0 0 0 11.041 0M9.223 7.367c.091.038 7.951 4.608 7.957 4.627c.003.013-1.781 1.056-3.965 2.32a1000 1000 0 0 1-3.996 2.307c-.019.006-.026-1.266-.026-4.629c0-3.7.007-4.634.03-4.625"
/>
</Svg>
);
}
export function SonarrIcon(props: SvgProps) {
return (
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<Path
fill={props.color ?? "currentColor"}
d="m7.338 16.322l.165.159l-2.491 2.495l.13.129l2.493-2.498l.164.159l1.531-1.59l-.461-.444zm.106-8.651l-.161.161L8.855 9.4l.452-.453l-1.572-1.568l-.162.162L5 4.976q-.064.065-.127.132ZM5 4.976l-.128.131c.043-.043.083-.088.128-.131m0-.001l-.129.13l.128-.131ZM16.631 16.24l-1.648-1.64l-.451.453l1.647 1.64l.161-.162l2.533 2.621l.007-.006l.053-.052q.035-.035.067-.073L16.469 16.4ZM19 19.025q-.032.038-.067.073zm-.127.127l.007-.006zm-2.397-11.69l.062.065zl-.163-.162l-1.549 1.575l.455.449l1.549-1.572l-.163-.16l2.544-2.476q-.062-.067-.126-.132Zm2.672-2.346l-.127-.132q.065.065.127.132m.024-.023l-.128-.131l-.022.021l.127.132zm-7.156 4.139a2.66 2.66 0 0 0-1.941.8a2.62 2.62 0 0 0-.795 1.745v.384a3 3 0 0 0 .037.325a2.6 2.6 0 0 0 .763 1.434a2.4 2.4 0 0 0 .342.292a2.76 2.76 0 0 0 3.2 0a2.4 2.4 0 0 0 .279-.233l.059-.059a2.76 2.76 0 0 0 0-3.888a2.65 2.65 0 0 0-1.944-.8m6.917 9.862l-.053.052q.029-.025.053-.052M5.823 4.238l-.008.007Zm-.822.736l-.002.002zm.307 14.333l-.02-.018ZM7.505 12.1a5.64 5.64 0 0 0-1.426-4.257c-.806-.806-1.92-1.916-1.923-1.919a9.3 9.3 0 0 0-2.024 5.35a.13.13 0 0 0-.018.064Q2.1 11.653 2.1 12c0 .219 0 .439.014.658a10 10 0 0 0 .132 1.169a9.3 9.3 0 0 0 2.038 4.4c.007-.007.9-.9 1.75-1.754A5.63 5.63 0 0 0 7.505 12.1m4.527 4.587c-1.806 0-3.036.167-4.358 1.49a432 432 0 0 0-1.694 1.7q.125.098.255.189a9.43 9.43 0 0 0 5.774 1.846a9.5 9.5 0 0 0 5.784-1.846c.1-.068.189-.139.282-.211l-1.6-1.6c-1.428-1.431-2.56-1.568-4.443-1.568m-6.113 3.142L5.9 19.81Zm-.31-.252l-.023-.021Zm6.423-11.986a5.86 5.86 0 0 0 4.441-1.562c.753-.753 1.744-1.74 1.762-1.758a9.52 9.52 0 0 0-6.226-2.18a9.56 9.56 0 0 0-6.186 2.147L7.683 6.1a5.8 5.8 0 0 0 4.349 1.491m6.99-2.607v-.001l-.009-.009l-.002-.002l.002.002Zm-1.183 3.037c-1.2 1.2-1.3 2.238-1.3 4.075a5.7 5.7 0 0 0 1.48 4.358c.879.879 1.712 1.708 1.734 1.73A9.55 9.55 0 0 0 21.9 12a9.6 9.6 0 0 0-2.429-6.531q.217.25.414.5zm1.525 10.616l-.022.023zM19.233 5.2l-.084-.089z"
/>
</Svg>
);
}
export function RadarrIcon(props: SvgProps) {
return (
<Svg width={24} height={24} viewBox="0 0 24 24" {...props}>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<Path
fill={props.color ?? "currentColor"}
d="m8.06 16.01l7.199-4.113l-7.052-3.966Zm-1.028 3.82a2.96 2.96 0 0 1-2.5.294A3.37 3.37 0 0 0 8.648 21.3l10.136-5.876a1.73 1.73 0 0 0 .294-2.645zM19.225 9.106L8.8 3.083C6.738 1.614 3.359 2.5 3.359 6.168l.147 11.605c0 1.175.882 1.763 2.057 1.616L5.416 5.433c0-1.322.735-1.469 1.616-.881l11.752 6.61a2.9 2.9 0 0 1 1.47 2.2a3.307 3.307 0 0 0-1.029-4.256"
/>
</Svg>
);
}
@@ -0,0 +1,310 @@
import {
IconCheck,
IconChevronDown,
IconCopy,
IconInfoCircle,
IconRefresh,
IconTrash,
} from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useState } from "react";
import { Alert, Pressable, View } from "react-native";
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import type { IntegrationConfig } from "@/components/settings/integration-configs";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
interface IntegrationCardProps {
config: IntegrationConfig;
connection: {
provider: string;
type: string;
token: string;
enabled: boolean;
lastEventAt: string | null;
} | null;
}
export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const { provider, label } = config;
const providerInput = provider as
| "plex"
| "jellyfin"
| "emby"
| "sonarr"
| "radarr";
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const [expanded, setExpanded] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [copied, setCopied] = useState(false);
const chevronRotation = useSharedValue(0);
const setupChevronRotation = useSharedValue(0);
useEffect(() => {
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 }));
}, [expanded, chevronRotation]);
useEffect(() => {
setupChevronRotation.set(
withTiming(setupOpen ? 180 : 0, { duration: 200 }),
);
}, [setupOpen, setupChevronRotation]);
const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${chevronRotation.get()}deg` }],
}));
const setupChevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${setupChevronRotation.get()}deg` }],
}));
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
const toggleSetup = useCallback(() => setSetupOpen((v) => !v), []);
const connectMutation = useMutation(
orpc.integrations.create.mutationOptions({
onSuccess: () => {
toast.success(`${label} connected`);
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
},
onError: () => toast.error(`Failed to connect ${label}`),
}),
);
const deleteMutation = useMutation(
orpc.integrations.delete.mutationOptions({
onSuccess: () => {
toast.success(`${label} disconnected`);
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
},
onError: () => toast.error(`Failed to disconnect ${label}`),
}),
);
const regenerateMutation = useMutation(
orpc.integrations.regenerateToken.mutationOptions({
onSuccess: () => {
toast.success(`${label} URL regenerated`);
queryClient.invalidateQueries({ queryKey: orpc.integrations.key() });
},
onError: () => toast.error(`Failed to regenerate ${label} URL`),
}),
);
const url = connection ? config.buildUrl(connection.token) : null;
const handleCopy = useCallback(async () => {
if (!url) return;
await Clipboard.setStringAsync(url);
setCopied(true);
toast.success("URL copied to clipboard");
setTimeout(() => setCopied(false), 2000);
}, [url]);
const handleRegenerate = useCallback(() => {
Alert.alert(
"Regenerate URL",
`This will invalidate the current ${label} URL. You'll need to update it in ${label}.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Regenerate",
style: "destructive",
onPress: () => regenerateMutation.mutate({ provider: providerInput }),
},
],
);
}, [label, providerInput, regenerateMutation]);
const handleDisconnect = useCallback(() => {
Alert.alert(
`Disconnect ${label}`,
`Are you sure you want to disconnect ${label}? The current URL will stop working.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Disconnect",
style: "destructive",
onPress: () => deleteMutation.mutate({ provider: providerInput }),
},
],
);
}, [label, providerInput, deleteMutation]);
const Icon = config.icon;
return (
<View
className="mb-2 overflow-hidden rounded-xl border bg-card"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
}}
>
{/* Header */}
<Pressable
onPress={toggleExpanded}
className="flex-row items-center justify-between p-3"
>
<View className="flex-row items-center gap-3">
<Icon
width={18}
height={18}
color={connection ? primaryColor : mutedFgColor}
/>
<View>
<Text className="font-sans-medium text-[15px] text-foreground">
{label}
</Text>
<Text className="mt-0.5 text-muted-foreground text-xs">
{connection
? config.connectedStatus(connection.lastEventAt)
: "Not configured"}
</Text>
</View>
</View>
<Animated.View style={chevronStyle}>
<IconChevronDown size={18} color={mutedFgColor} />
</Animated.View>
</Pressable>
{/* Expanded content */}
{expanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
className="border-white/[0.06] border-t px-4 pt-3 pb-4"
>
{/* Requirement note */}
{config.requirementNote && (
<View className="mb-3 flex-row items-start gap-2 rounded-lg bg-primary/[0.06] px-3 py-2.5">
<IconInfoCircle
size={14}
color={primaryColor}
className="mt-px"
/>
<Text className="flex-1 text-[12px] text-foreground/80">
{config.requirementNote}
</Text>
</View>
)}
{!connection ? (
/* Connect button */
<Pressable
onPress={() =>
connectMutation.mutate({ provider: providerInput })
}
disabled={connectMutation.isPending}
className="items-center rounded-lg bg-primary py-2.5 active:opacity-80"
>
<Text className="font-sans-medium text-primary-foreground">
{connectMutation.isPending ? "Connecting…" : `Connect ${label}`}
</Text>
</Pressable>
) : (
<View className="gap-3">
{/* URL display */}
<View>
<Text className="mb-1.5 text-muted-foreground text-xs">
{config.urlLabel}
</Text>
<View className="flex-row items-center rounded-lg bg-secondary/50 px-3 py-2.5">
<Text
className="flex-1 font-mono text-[11px] text-muted-foreground"
numberOfLines={1}
>
{url}
</Text>
<Pressable
onPress={handleCopy}
className="ml-2 active:opacity-60"
hitSlop={8}
>
{copied ? (
<IconCheck size={16} color="#4ade80" />
) : (
<IconCopy size={16} color={mutedFgColor} />
)}
</Pressable>
</View>
</View>
{/* Action buttons */}
<View className="flex-row gap-2">
<Pressable
onPress={handleRegenerate}
disabled={regenerateMutation.isPending}
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-secondary py-2.5 active:opacity-80"
>
<IconRefresh size={14} color={mutedFgColor} />
<Text className="font-sans-medium text-[13px] text-foreground">
Regenerate
</Text>
</Pressable>
<Pressable
onPress={handleDisconnect}
className="flex-1 flex-row items-center justify-center gap-1.5 rounded-lg bg-destructive/10 py-2.5 active:opacity-80"
>
<IconTrash size={14} color="#ef4444" />
<Text className="font-sans-medium text-[13px] text-destructive">
Disconnect
</Text>
</Pressable>
</View>
</View>
)}
{/* Setup instructions (nested accordion) */}
<View className="mt-3">
<Pressable
onPress={toggleSetup}
className="flex-row items-center gap-1.5"
>
<Animated.View style={setupChevronStyle}>
<IconChevronDown size={12} color={mutedFgColor} />
</Animated.View>
<Text className="text-muted-foreground text-xs">
Setup instructions
</Text>
</Pressable>
{setupOpen && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
className="mt-2 rounded-lg border border-white/[0.04] bg-secondary/30 px-3 py-2.5"
>
{config.setupSteps.map((step, i) => (
<View key={step} className="flex-row py-1">
<Text className="w-5 text-muted-foreground text-xs">
{i + 1}.
</Text>
<Text className="flex-1 text-muted-foreground text-xs leading-[18px]">
{step}
</Text>
</View>
))}
</Animated.View>
)}
</View>
</Animated.View>
)}
</View>
);
}
@@ -0,0 +1,117 @@
import type { SvgProps } from "react-native-svg";
import {
EmbyIcon,
JellyfinIcon,
PlexIcon,
RadarrIcon,
SonarrIcon,
} from "@/components/settings/icons";
import { getServerUrl } from "@/lib/server-url";
import { timeAgo } from "@/utils/time-ago";
export interface IntegrationConfig {
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
label: string;
icon: React.ComponentType<SvgProps>;
type: "webhook" | "list";
buildUrl: (token: string) => string;
urlLabel: string;
connectedStatus: (lastEventAt: string | null) => string;
requirementNote?: string;
setupSteps: string[];
}
function webhookStatus(lastEventAt: string | null): string {
return lastEventAt
? `Last event ${timeAgo(lastEventAt)}`
: "Ready — nothing received yet";
}
function listStatus(lastEventAt: string | null): string {
return lastEventAt
? `Last polled ${timeAgo(lastEventAt)}`
: "Ready — not polled yet";
}
export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
{
provider: "plex",
label: "Plex",
icon: PlexIcon,
type: "webhook",
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
urlLabel: "Webhook URL",
connectedStatus: webhookStatus,
requirementNote: "Requires an active Plex Pass subscription.",
setupSteps: [
"Open Plex, go to Settings > Webhooks",
'Click "Add Webhook" and paste the URL above',
"Sofa will automatically log movies and episodes when you finish watching them",
],
},
{
provider: "jellyfin",
label: "Jellyfin",
icon: JellyfinIcon,
type: "webhook",
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
urlLabel: "Webhook URL",
connectedStatus: webhookStatus,
setupSteps: [
"Install the Webhook plugin from Jellyfin's plugin catalog",
"Go to Dashboard > Plugins > Webhook",
'Add a "Generic Destination" and paste the URL above',
'Enable the "Playback Stop" notification type',
"Sofa will automatically log movies and episodes when you finish watching them",
],
},
{
provider: "emby",
label: "Emby",
icon: EmbyIcon,
type: "webhook",
buildUrl: (token) => `${getServerUrl()}/api/webhooks/${token}`,
urlLabel: "Webhook URL",
connectedStatus: webhookStatus,
requirementNote:
"Requires Emby Server 4.7.9+ and an active Emby Premiere license.",
setupSteps: [
"Open Emby, go to Settings > Webhooks",
"Add a new webhook and paste the URL above",
'Enable the "Playback" event category',
"Sofa will automatically log movies and episodes when you finish watching them",
],
},
{
provider: "sonarr",
label: "Sonarr",
icon: SonarrIcon,
type: "list",
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
urlLabel: "Sonarr List URL",
connectedStatus: listStatus,
setupSteps: [
"Open Sonarr, go to Settings > Import Lists",
'Click "+" and select "Custom Lists"',
"Paste the Sonarr URL above into the List URL field",
"Set your preferred quality profile and root folder",
"Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)",
],
},
{
provider: "radarr",
label: "Radarr",
icon: RadarrIcon,
type: "list",
buildUrl: (token) => `${getServerUrl()}/api/lists/${token}`,
urlLabel: "Radarr List URL",
connectedStatus: listStatus,
setupSteps: [
"Open Radarr, go to Settings > Import Lists",
'Click "+" and select "Custom Lists"',
"Paste the Radarr URL above into the List URL field",
"Set your preferred quality profile and root folder",
"Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)",
],
},
];
@@ -0,0 +1,55 @@
import { IconRefresh, IconWebhook } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { Pressable, View } from "react-native";
import { IntegrationCard } from "@/components/settings/integration-card";
import { INTEGRATION_CONFIGS } from "@/components/settings/integration-configs";
import { SectionHeader } from "@/components/ui/section-header";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
export function IntegrationsSection() {
const integrations = useQuery(orpc.integrations.list.queryOptions());
return (
<View className="mb-6">
<SectionHeader title="Integrations" icon={IconWebhook} />
{integrations.isPending ? (
<View className="items-center py-4">
<Spinner colorClassName="accent-primary" />
</View>
) : integrations.isError ? (
<View className="items-center gap-2 py-4">
<Text className="text-[13px] text-muted-foreground">
Could not load integrations
</Text>
<Pressable
onPress={() => integrations.refetch()}
className="flex-row items-center gap-1.5 rounded-lg bg-secondary px-3 py-1.5"
style={{ borderCurve: "continuous" }}
>
<IconRefresh size={14} className="accent-primary" />
<Text className="font-sans-medium text-[13px] text-primary">
Retry
</Text>
</Pressable>
</View>
) : (
INTEGRATION_CONFIGS.map((config) => {
const connection =
integrations.data?.integrations?.find(
(i) => i.provider === config.provider,
) ?? null;
return (
<IntegrationCard
key={config.provider}
config={config}
connection={connection}
/>
);
})
)}
</View>
);
}
@@ -0,0 +1,59 @@
import type { Icon } from "@tabler/icons-react-native";
import { IconChevronRight } from "@tabler/icons-react-native";
import type { ReactNode } from "react";
import { Pressable } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
export function SettingsRow({
label,
value,
onPress,
icon: IconComponent,
destructive,
disabled,
right,
}: {
label: string;
value?: string;
onPress?: () => void;
icon?: Icon;
destructive?: boolean;
disabled?: boolean;
right?: ReactNode;
}) {
const destructiveColor = useCSSVariable("--color-destructive") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return (
<Pressable
onPress={onPress}
disabled={disabled || !onPress}
className="flex-row items-center py-3.5"
style={disabled ? { opacity: 0.5 } : undefined}
>
{IconComponent && (
<IconComponent
size={18}
color={destructive ? destructiveColor : mutedFgColor}
/>
)}
<Text
className={`flex-1 text-[15px] ${destructive ? "text-destructive" : "text-foreground"} ${IconComponent ? "ml-2" : ""}`}
>
{label}
</Text>
{right}
{!right && value ? (
<Text
selectable
className="mr-1 max-w-[160px] text-[14px] text-muted-foreground"
numberOfLines={1}
>
{value}
</Text>
) : null}
{!right && onPress && <IconChevronRight size={16} color={mutedFgColor} />}
</Pressable>
);
}
@@ -0,0 +1,71 @@
import type { Icon } from "@tabler/icons-react-native";
import { Children, Fragment, isValidElement, type ReactNode } from "react";
import { View } from "react-native";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
function flattenChildren(node: ReactNode): ReactNode[] {
const result: ReactNode[] = [];
Children.forEach(node, (child) => {
if (isValidElement(child) && child.type === Fragment) {
result.push(
...flattenChildren((child.props as { children?: ReactNode }).children),
);
} else if (child != null && child !== false) {
result.push(child);
}
});
return result;
}
export function SettingsSection({
title,
icon,
badge,
children,
}: {
title: string;
icon?: Icon;
badge?: string;
children: ReactNode;
}) {
const items = flattenChildren(children);
return (
<View className="mb-6">
<View className="mb-2 flex-row items-center gap-2">
<SectionHeader title={title} icon={icon} />
{badge ? (
<View className="mb-2.5 rounded-full bg-primary/10 px-2 py-0.5">
<Text className="font-sans-medium text-[10px] text-primary">
{badge}
</Text>
</View>
) : null}
</View>
<View
className="rounded-xl border border-white/[0.06] bg-card px-3"
style={{ borderCurve: "continuous" }}
>
{items.map((child, i) => {
const key =
isValidElement(child) && child.key != null
? String(child.key)
: `settings-item-${i}`;
return (
<Fragment key={key}>
{i > 0 && (
<View
className="border-border border-t"
style={{ borderTopWidth: 0.5 }}
/>
)}
{child}
</Fragment>
);
})}
</View>
</View>
);
}
@@ -0,0 +1,71 @@
import { Link } from "expo-router";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
export function CastCard({
person,
}: {
person: {
id: string;
personId: string;
name: string;
character: string | null;
profilePath: string | null;
};
}) {
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
}));
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
return (
<Link href={`/person/${person.personId}` as `/person/${string}`}>
<Link.Trigger>
<GestureDetector gesture={tapGesture}>
<Animated.View className="w-20 items-center" style={animatedStyle}>
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
className="h-full w-full"
contentFit="cover"
/>
)}
</View>
<Text
numberOfLines={1}
className="text-center font-sans-medium text-[11px] text-foreground"
>
{person.name}
</Text>
{person.character ? (
<Text
numberOfLines={1}
className="text-center text-[10px] text-muted-foreground"
>
{person.character}
</Text>
) : null}
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
);
}
@@ -0,0 +1,91 @@
import type { Season } from "@sofa/api/schemas";
import { getNextEpisode } from "@sofa/api/utils";
import { IconPlayerPlay } from "@tabler/icons-react-native";
import { useMemo } from "react";
import { View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { Image } from "@/components/ui/image";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
export function ContinueWatchingBanner({
seasons,
watchedEpisodeIds,
userStatus,
backdropPath,
}: {
seasons: Season[];
watchedEpisodeIds: Set<string>;
userStatus: string | null;
backdropPath: string | null;
}) {
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
() => getNextEpisode(seasons, watchedEpisodeIds),
[seasons, watchedEpisodeIds],
);
if (userStatus !== "in_progress" || !nextEpisode) return null;
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
const progress =
totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
return (
<Animated.View
entering={FadeInDown.duration(300).delay(350)}
className="mt-6 px-4"
>
<SectionHeader title="Next Episode" icon={IconPlayerPlay} />
<View
className="overflow-hidden rounded-[12px] border bg-card"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
}}
>
<View className="h-28">
{stillUrl && (
<Image
source={{ uri: stillUrl }}
className="h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{
backgroundColor: "rgba(0,0,0,0.45)",
}}
/>
<View className="absolute right-3 bottom-2.5 left-3">
<View className="flex-row items-center gap-1.5">
<View className="h-1.5 w-1.5 rounded-full bg-primary" />
<Text className="font-sans-medium text-[10px] text-primary uppercase tracking-wider">
Up next
</Text>
</View>
<Text
numberOfLines={1}
className="mt-0.5 font-sans-medium text-[13px] text-white"
>
<Text className="font-sans-medium text-[11px] text-white/60">
S{nextEpisode.seasonNumber} E{nextEpisode.episodeNumber}
</Text>
{" "}
{nextEpisode.name}
</Text>
</View>
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
style={{ width: `${progress}%` }}
/>
</View>
</View>
</View>
</Animated.View>
);
}
@@ -0,0 +1,53 @@
import {
IconCircleCheckFilled,
IconCircleDashed,
} from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
export function EpisodeRow({
episode,
isWatched,
onToggle,
}: {
episode: {
id: string;
episodeNumber: number;
name: string | null;
airDate: string | null;
};
isWatched: boolean;
onToggle: () => void;
}) {
const completedColor = useCSSVariable("--color-status-completed") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return (
<Pressable
onPress={onToggle}
className="flex-row items-center border-border border-b px-4 py-3"
style={{ borderBottomWidth: 0.5 }}
>
{isWatched ? (
<IconCircleCheckFilled size={22} color={completedColor} />
) : (
<IconCircleDashed size={22} color={mutedFgColor} />
)}
<View className="ml-3 flex-1">
<Text
className={`font-sans-medium text-sm ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
numberOfLines={1}
>
{episode.episodeNumber}.{" "}
{episode.name ?? `Episode ${episode.episodeNumber}`}
</Text>
{episode.airDate ? (
<Text className="mt-0.5 text-[11px] text-muted-foreground">
{episode.airDate}
</Text>
) : null}
</View>
</Pressable>
);
}
@@ -0,0 +1,192 @@
import { IconChevronDown } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { InteractionManager, Pressable, View } from "react-native";
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { EpisodeRow } from "@/components/titles/episode-row";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
export function SeasonAccordion({
season,
episodes,
watchedEpisodeIds,
}: {
season: {
id: string;
seasonNumber: number;
name: string | null;
};
episodes: Array<{
id: string;
episodeNumber: number;
name: string | null;
airDate: string | null;
}>;
watchedEpisodeIds: Set<string>;
}) {
const completedColor = useCSSVariable("--color-status-completed") as string;
const watchingColor = useCSSVariable("--color-status-watching") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const [expanded, setExpanded] = useState(false);
const chevronRotation = useSharedValue(0);
const watchedCount = episodes.filter((e) =>
watchedEpisodeIds.has(e.id),
).length;
const progress = episodes.length > 0 ? watchedCount / episodes.length : 0;
// Progressive rendering: show first batch immediately, defer rest
const INITIAL_BATCH = 10;
const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH);
useEffect(() => {
if (expanded) {
setVisibleCount(INITIAL_BATCH);
if (episodes.length > INITIAL_BATCH) {
const task = InteractionManager.runAfterInteractions(() => {
setVisibleCount(episodes.length);
});
return () => task.cancel();
}
}
}, [expanded, episodes.length]);
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
useEffect(() => {
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 }));
}, [expanded, chevronRotation]);
const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${chevronRotation.get()}deg` }],
}));
const watchEpisode = useMutation(
orpc.episodes.watch.mutationOptions({
onSuccess: (_data, { id: epId }) => {
const ep = episodes.find((e) => e.id === epId);
toast.success(
ep
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode watched",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to mark episode"),
}),
);
const unwatchEpisode = useMutation(
orpc.episodes.unwatch.mutationOptions({
onSuccess: (_data, { id: epId }) => {
const ep = episodes.find((e) => e.id === epId);
toast.success(
ep
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode unwatched",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to unmark episode"),
}),
);
const watchSeason = useMutation(
orpc.seasons.watch.mutationOptions({
onSuccess: () => {
toast.success(
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to mark some episodes"),
}),
);
return (
<View
className="mb-2 overflow-hidden rounded-xl border bg-card"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
}}
>
<Pressable
onPress={toggleExpanded}
className="flex-row items-center justify-between p-4"
>
<View className="flex-1">
<Text className="font-sans-medium text-[15px] text-foreground">
{season.name ?? `Season ${season.seasonNumber}`}
</Text>
<Text className="mt-0.5 text-muted-foreground text-xs">
{watchedCount}/{episodes.length} episodes
</Text>
</View>
<View
className="mx-3 h-1 w-[60px] overflow-hidden rounded-full"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
style={{
height: "100%",
width: `${progress * 100}%`,
backgroundColor: progress === 1 ? completedColor : watchingColor,
}}
/>
</View>
<Animated.View style={chevronStyle}>
<IconChevronDown size={18} color={mutedFgColor} />
</Animated.View>
</Pressable>
{expanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
>
{watchedCount < episodes.length && (
<Pressable
onPress={() => watchSeason.mutate({ id: season.id })}
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
>
<Text className="font-sans-medium text-primary text-xs">
Mark All Watched
</Text>
</Pressable>
)}
{episodes.slice(0, visibleCount).map((episode) => (
<EpisodeRow
key={episode.id}
episode={episode}
isWatched={watchedEpisodeIds.has(episode.id)}
onToggle={() => {
if (watchedEpisodeIds.has(episode.id)) {
unwatchEpisode.mutate({ id: episode.id });
} else {
watchEpisode.mutate({ id: episode.id });
}
}}
/>
))}
</Animated.View>
)}
</View>
);
}
@@ -0,0 +1,64 @@
import {
IconBookmark,
IconCircleCheck,
IconPlayerPlay,
} from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
type TitleStatus = "watchlist" | "in_progress" | "completed";
export function StatusActionButton({
currentStatus,
onStatusChange,
isPending,
}: {
currentStatus: TitleStatus | null;
onStatusChange: (status: TitleStatus | null) => void;
isPending: boolean;
}) {
const statuses: Array<{
status: TitleStatus;
label: string;
Icon: typeof IconBookmark;
}> = [
{ status: "watchlist", label: "Watchlist", Icon: IconBookmark },
{ status: "in_progress", label: "Watching", Icon: IconPlayerPlay },
{ status: "completed", label: "Completed", Icon: IconCircleCheck },
];
const primaryColor = useCSSVariable("--color-primary") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return (
<View className="flex-row gap-2">
{statuses.map(({ status, label, Icon }) => {
const isActive = currentStatus === status;
return (
<Pressable
key={status}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onStatusChange(isActive ? null : status);
}}
disabled={isPending}
className={`flex-row items-center gap-1.5 rounded-full border px-3 py-2 ${
isActive
? "border-primary bg-primary/10"
: "border-border bg-card"
}`}
>
<Icon size={14} color={isActive ? primaryColor : mutedFgColor} />
<Text
className={`font-sans-medium text-xs ${isActive ? "text-primary" : "text-foreground"}`}
>
{label}
</Text>
</Pressable>
);
})}
</View>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
export function TmdbLogo({ height = 12 }: { height?: number }) {
const aspectRatio = 190.24 / 81.52;
const width = height * aspectRatio;
return (
<Svg
viewBox="0 0 190.24 81.52"
width={width}
height={height}
accessibilityLabel="TMDB"
>
<Defs>
<LinearGradient
id="tmdb-grad"
x1="0"
x2="190.24"
y1="40.76"
y2="40.76"
gradientUnits="userSpaceOnUse"
>
<Stop offset="0" stopColor="#90cea1" />
<Stop offset="0.56" stopColor="#3cbec9" />
<Stop offset="1" stopColor="#00b3e5" />
</LinearGradient>
</Defs>
<Path
fill="url(#tmdb-grad)"
d="M105.67 36.06h66.9a17.67 17.67 0 0 0 17.67-17.66A17.67 17.67 0 0 0 172.57.73h-66.9A17.67 17.67 0 0 0 88 18.4a17.67 17.67 0 0 0 17.67 17.66m-88 45h76.9a17.67 17.67 0 0 0 17.67-17.66 17.67 17.67 0 0 0-17.67-17.67h-76.9A17.67 17.67 0 0 0 0 63.4a17.67 17.67 0 0 0 17.67 17.66m-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1Zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8Zm113.92 20.25a15.1 15.1 0 0 0-4.52-5.52 18.6 18.6 0 0 0-6.68-3.08 33.5 33.5 0 0 0-8.07-1h-11.7v35.4h12.75a24.6 24.6 0 0 0 7.55-1.15 19.3 19.3 0 0 0 6.35-3.32 16.3 16.3 0 0 0 4.37-5.5 16.9 16.9 0 0 0 1.63-7.58 18.5 18.5 0 0 0-1.68-8.25M145 68.6a8.8 8.8 0 0 1-2.64 3.4 10.7 10.7 0 0 1-4 1.82 21.6 21.6 0 0 1-5 .55h-4.05v-21h4.6a17 17 0 0 1 4.67.63 11.7 11.7 0 0 1 3.88 1.87A9.14 9.14 0 0 1 145 59a9.9 9.9 0 0 1 1 4.52 11.9 11.9 0 0 1-1 5.08m44.63-.13a8 8 0 0 0-1.58-2.62 8.4 8.4 0 0 0-2.42-1.85 10.3 10.3 0 0 0-3.17-1v-.1a9.2 9.2 0 0 0 4.42-2.82 7.43 7.43 0 0 0 1.68-5 8.4 8.4 0 0 0-1.15-4.65 8.1 8.1 0 0 0-3-2.72 12.6 12.6 0 0 0-4.18-1.3 33 33 0 0 0-4.62-.33h-13.2v35.4h14.5a22.4 22.4 0 0 0 4.72-.5 13.5 13.5 0 0 0 4.28-1.65 9.4 9.4 0 0 0 3.1-3 8.5 8.5 0 0 0 1.2-4.68 9.4 9.4 0 0 0-.55-3.18Zm-19.42-15.75h5.3a10 10 0 0 1 1.85.18 6.2 6.2 0 0 1 1.7.57 3.4 3.4 0 0 1 1.22 1.13 3.2 3.2 0 0 1 .48 1.82 3.63 3.63 0 0 1-.43 1.8 3.4 3.4 0 0 1-1.12 1.2 4.9 4.9 0 0 1-1.58.65 7.5 7.5 0 0 1-1.77.2h-5.65Zm11.72 20a3.9 3.9 0 0 1-1.22 1.3 4.6 4.6 0 0 1-1.68.7 8.2 8.2 0 0 1-1.82.2h-7v-8h5.9a15 15 0 0 1 2 .15 8.5 8.5 0 0 1 2.05.55 4 4 0 0 1 1.57 1.18 3.1 3.1 0 0 1 .63 2 3.7 3.7 0 0 1-.43 1.92"
/>
</Svg>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { createContext, forwardRef, useContext } from "react";
import { Pressable, type PressableProps, type TextProps } from "react-native";
import { Text } from "@/components/ui/text";
import { cn } from "@/utils/cn";
type ButtonVariant = "default" | "secondary";
const ButtonVariantContext = createContext<ButtonVariant>("default");
interface ButtonProps extends PressableProps {
size?: "default" | "sm";
variant?: ButtonVariant;
className?: string;
}
export const Button = forwardRef<
React.ComponentRef<typeof Pressable>,
ButtonProps
>(
(
{
size = "default",
variant = "default",
className,
style,
disabled,
...props
},
ref,
) => {
return (
<ButtonVariantContext.Provider value={variant}>
<Pressable
ref={ref}
disabled={disabled}
className={cn(
"items-center justify-center rounded-xl",
size === "sm" ? "h-10 px-4" : "h-12 px-5",
variant === "secondary" ? "bg-secondary" : "bg-primary",
className,
)}
style={(state) => [
{
opacity: state.pressed ? 0.8 : disabled ? 0.5 : 1,
},
typeof style === "function" ? style(state) : style,
]}
{...props}
/>
</ButtonVariantContext.Provider>
);
},
);
Button.displayName = "Button";
interface ButtonLabelProps extends TextProps {
className?: string;
}
export function ButtonLabel({ style, className, ...props }: ButtonLabelProps) {
const variant = useContext(ButtonVariantContext);
return (
<Text
className={cn(
"text-center font-sans-medium text-[15px]",
variant === "secondary"
? "text-secondary-foreground"
: "text-primary-foreground",
className,
)}
style={style}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import type { Icon } from "@tabler/icons-react-native";
import { IconMovie } from "@tabler/icons-react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
interface EmptyStateProps {
icon?: Icon;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
}
export function EmptyState({
icon: IconComponent = IconMovie,
title,
description,
actionLabel,
onAction,
}: EmptyStateProps) {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
return (
<Animated.View
entering={FadeIn.duration(400)}
className="items-center justify-center px-6 py-12"
>
<IconComponent size={48} color={mutedForeground} />
<Text className="mt-3 text-center font-sans-medium text-[16px] text-foreground">
{title}
</Text>
{description && (
<Text className="mt-1 text-center text-[13px] text-muted-foreground">
{description}
</Text>
)}
{actionLabel && onAction && (
<Button onPress={onAction} size="sm" className="mt-4 bg-primary">
<ButtonLabel className="text-primary-foreground">
{actionLabel}
</ButtonLabel>
</Button>
)}
</Animated.View>
);
}
@@ -0,0 +1,56 @@
import { useCallback, useRef, useState } from "react";
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
import { Pressable, View } from "react-native";
import { Text } from "@/components/ui/text";
export function ExpandableText({
text,
maxLines = 3,
}: {
text: string;
maxLines?: number;
}) {
const prevTextRef = useRef(text);
let expanded: boolean;
let needsTruncation: boolean;
const [expandedState, setExpanded] = useState(false);
const [needsTruncationState, setNeedsTruncation] = useState(false);
if (prevTextRef.current !== text) {
prevTextRef.current = text;
setExpanded(false);
setNeedsTruncation(false);
expanded = false;
needsTruncation = false;
} else {
expanded = expandedState;
needsTruncation = needsTruncationState;
}
const onTextLayout = useCallback(
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
setNeedsTruncation(e.nativeEvent.lines.length > maxLines);
},
[maxLines],
);
return (
<View>
<Text
selectable
numberOfLines={expanded ? undefined : maxLines}
onTextLayout={onTextLayout}
className="text-[14px] text-foreground leading-[22px]"
>
{text}
</Text>
{needsTruncation && (
<Pressable onPress={() => setExpanded(!expanded)} className="mt-1">
<Text className="font-sans-medium text-[13px] text-primary">
{expanded ? "Show less" : "Show more"}
</Text>
</Pressable>
)}
</View>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Image as ExpoImage, type ImageProps } from "expo-image";
import { useResolveClassNames } from "uniwind";
import { resolveUrl } from "@/lib/server-url";
export function Image({
source,
className,
...props
}: ImageProps & { className?: string }) {
const resolved =
source && typeof source === "object" && "uri" in source && source.uri
? { ...source, uri: resolveUrl(source.uri) ?? undefined }
: source;
const style = useResolveClassNames(className ?? "");
return (
<ExpoImage source={resolved} style={[style, props.style]} {...props} />
);
}
@@ -0,0 +1,62 @@
import { IconWifiOff } from "@tabler/icons-react-native";
import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
export function OfflineBanner() {
const [isOffline, setIsOffline] = useState(false);
const insets = useSafeAreaInsets();
const wasOnline = useRef(true);
useEffect(() => {
let mounted = true;
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
const offline = !state.isConnected || !state.isInternetReachable;
setIsOffline(offline);
if (offline && wasOnline.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasOnline.current = !offline;
};
// Check initial state
Network.getNetworkStateAsync().then(handleState);
// Listen for changes
const subscription = Network.addNetworkStateListener(handleState);
return () => {
mounted = false;
subscription.remove();
};
}, []);
if (!isOffline) return null;
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
top: insets.top,
left: 0,
right: 0,
zIndex: 100,
}}
>
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
<IconWifiOff size={16} color="white" />
<Text className="font-sans-medium text-[13px] text-white">
No internet connection
</Text>
</View>
</Animated.View>
);
}
@@ -0,0 +1,368 @@
import {
IconBookmarkFilled,
IconCheckbox,
IconDeviceTv,
IconLoader,
IconMovie,
IconPlayerPlayFilled,
IconPlus,
IconStarFilled,
} from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import * as ContextMenu from "zeego/context-menu";
import { Image } from "@/components/ui/image";
import { Skeleton } from "@/components/ui/skeleton";
import { Text } from "@/components/ui/text";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface PosterCardProps {
id?: string;
tmdbId: number;
title: string;
type: "movie" | "tv";
posterPath: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null;
width?: number;
}
export function PosterCard({
id,
tmdbId,
title,
type,
posterPath,
releaseDate,
voteAverage,
userStatus,
episodeProgress,
width = 140,
}: PosterCardProps) {
const { navigate } = useRouter();
const primaryColor = useCSSVariable("--color-primary") as string;
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
const watchingColor = useCSSVariable("--color-status-watching") as string;
const completedColor = useCSSVariable("--color-status-completed") as string;
const statusColors: Record<TitleStatus, string> = {
watchlist: watchlistColor,
in_progress: watchingColor,
completed: completedColor,
};
const pressed = useSharedValue(0);
const [localStatus, setLocalStatus] = useState<TitleStatus | null>(
userStatus ?? null,
);
useEffect(() => {
setLocalStatus(userStatus ?? null);
}, [userStatus]);
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id: resolvedId }) => {
if (resolvedId) {
navigate(`/title/${resolvedId}`);
}
},
onError: () => toast.error("Failed to load title"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
setLocalStatus("watchlist");
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to add to watchlist"),
}),
);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(pressed.get(), [0, 1], [1, 0.97]),
},
],
}));
const handleResolve = useCallback(() => {
resolveMutation.mutate({ tmdbId, type });
}, [tmdbId, type, resolveMutation]);
const handleQuickAdd = useCallback(() => {
if (localStatus || quickAddMutation.isPending) return;
quickAddMutation.mutate({ tmdbId, type });
}, [localStatus, quickAddMutation, tmdbId, type]);
const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5;
const cardContent = (
<View
className={`overflow-hidden rounded-xl border bg-card ${
localStatus ? "border-primary/25" : "border-white/[0.06]"
}`}
style={{ borderCurve: "continuous" }}
>
{/* Poster image */}
<View style={{ width, height: imageHeight }}>
{posterPath ? (
<Image
source={{ uri: posterPath }}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
recyclingKey={`poster-${tmdbId}`}
transition={200}
/>
) : (
<View className="flex-1 items-center justify-center bg-secondary p-3">
<Text className="text-center font-display text-[13px] text-foreground/[0.44]">
{title}
</Text>
</View>
)}
{/* Quick-add button */}
{!localStatus && (
<Pressable
onPress={handleQuickAdd}
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{quickAddMutation.isPending ? (
<IconLoader size={16} color="white" />
) : (
<IconPlus size={16} color="white" />
)}
</Pressable>
)}
{/* Status indicator */}
{localStatus && (
<View
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{localStatus === "completed" ? (
<IconCheckbox size={16} color="white" />
) : localStatus === "in_progress" ? (
<IconPlayerPlayFilled size={16} color="white" />
) : (
<IconBookmarkFilled size={16} color="white" />
)}
</View>
)}
{/* Episode progress bar */}
{episodeProgress &&
episodeProgress.total > 0 &&
episodeProgress.watched > 0 && (
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
style={{
width: `${episodeProgress.total > 0 ? (episodeProgress.watched / episodeProgress.total) * 100 : 0}%`,
}}
/>
</View>
)}
</View>
{/* Metadata */}
<View className="px-2.5 pt-2 pb-2.5">
<View className="flex-row items-center gap-1.5">
{localStatus && (
<View
className="size-1.5 rounded-full"
style={{ backgroundColor: statusColors[localStatus] }}
/>
)}
<Text
className="flex-1 font-sans-medium text-[13px] text-foreground"
numberOfLines={1}
>
{title}
</Text>
</View>
<View className="mt-1 flex-row items-center gap-2">
{type === "movie" ? (
<IconMovie size={12} color={primaryColor} opacity={0.6} />
) : (
<IconDeviceTv size={12} color={primaryColor} opacity={0.6} />
)}
{year ? (
<Text className="text-[11px] text-muted-foreground">{year}</Text>
) : null}
{voteAverage != null && voteAverage > 0 && (
<View className="ml-auto flex-row items-center gap-0.5">
<IconStarFilled size={10} color={primaryColor} opacity={0.8} />
<Text className="text-[11px] text-primary/80">
{voteAverage.toFixed(1)}
</Text>
</View>
)}
</View>
</View>
</View>
);
// Gesture for UI-thread press animation (used by both paths)
const pressGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
// Cards with id: use context menu with navigation
if (id) {
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<Pressable onPress={() => navigate(`/title/${id}`)}>
{cardContent}
</Pressable>
</Animated.View>
</GestureDetector>
</ContextMenu.Trigger>
<ContextMenu.Content>
{!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAdd}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus !== "in_progress" && (
<ContextMenu.Item
key="watching"
onSelect={async () => {
await client.titles.updateStatus({
id,
status: "in_progress",
});
toast.success("Marked as watching");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{type === "movie" && (
<ContextMenu.Item
key="watched"
onSelect={async () => {
await client.titles.watchMovie({ id });
toast.success(
title ? `Marked "${title}" as watched` : "Marked as watched",
);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus && (
<ContextMenu.Item
key="remove"
destructive
onSelect={async () => {
await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
);
}
// Cards without id: use GestureDetector for resolve-then-navigate
const resolveTapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
runOnJS(handleResolve)();
});
return (
<GestureDetector gesture={resolveTapGesture}>
<Animated.View style={[animatedStyle, { width }]}>
{cardContent}
</Animated.View>
</GestureDetector>
);
}
export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
const imageHeight = width * 1.5;
return (
<View
className="overflow-hidden rounded-xl border border-white/[0.06] bg-card"
style={{
width,
borderCurve: "continuous",
}}
>
<Skeleton width={width} height={imageHeight} borderRadius={0} />
<View className="px-2.5 pt-2 pb-2.5">
<Skeleton width="75%" height={14} />
<Skeleton width="50%" height={10} style={{ marginTop: 6 }} />
</View>
</View>
);
}
@@ -0,0 +1,28 @@
import type { Icon } from "@tabler/icons-react-native";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
interface SectionHeaderProps {
title: string;
icon?: Icon;
iconColor?: string;
}
export function SectionHeader({
title,
icon: IconComponent,
iconColor,
}: SectionHeaderProps) {
const primaryColor = useCSSVariable("--color-primary") as string;
const resolvedColor = iconColor ?? primaryColor;
return (
<View className="mb-3 flex-row items-center gap-2">
{IconComponent && <IconComponent size={20} color={resolvedColor} />}
<Text className="font-display text-[20px] text-foreground tracking-tight">
{title}
</Text>
</View>
);
}
@@ -0,0 +1,50 @@
import { useEffect } from "react";
import type { ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
interface SkeletonProps {
width?: number | `${number}%`;
height?: number;
borderRadius?: number;
style?: ViewStyle;
}
export function Skeleton({
width,
height = 14,
borderRadius = 4,
style,
}: SkeletonProps) {
const secondaryColor = useCSSVariable("--color-secondary") as string;
const opacity = useSharedValue(0.4);
useEffect(() => {
opacity.set(withRepeat(withTiming(1, { duration: 800 }), -1, true));
}, [opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.get(),
}));
return (
<Animated.View
style={[
{
width,
height,
borderRadius,
backgroundColor: secondaryColor,
},
animatedStyle,
style,
]}
/>
);
}
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import { useCSSVariable } from "uniwind";
interface SofaLogoProps {
size?: number;
color?: string;
}
export function SofaLogo({ size = 24, color }: SofaLogoProps) {
const primaryColor = useCSSVariable("--color-primary") as string;
const fill = color ?? primaryColor;
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
fill={fill}
d="M7 12v1h10v-1a3 3 0 0 1 2.993-3a4.6 4.6 0 0 0-.07-.78a4 4 0 0 0-3.143-3.143C16.394 5 15.93 5 15 5H9c-.93 0-1.394 0-1.78.077A4 4 0 0 0 4.077 8.22a4.6 4.6 0 0 0-.07.78A3 3 0 0 1 7 12"
/>
<Path
fill={fill}
d="M18.444 18H5.556a3.6 3.6 0 0 1-.806-.092V19a.75.75 0 0 1-1.5 0v-1.849A3.55 3.55 0 0 1 2 14.444V12a2 2 0 1 1 4 0v1.2a.8.8 0 0 0 .8.8h10.4a.8.8 0 0 0 .8-.8V12a2 2 0 1 1 4 0v2.444a3.55 3.55 0 0 1-1.25 2.707V19a.75.75 0 0 1-1.5 0v-1.092a3.6 3.6 0 0 1-.806.092"
/>
</Svg>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
const SIZES = { sm: "small", default: "large" } as const;
interface SpinnerProps extends Omit<ActivityIndicatorProps, "size" | "color"> {
size?: "sm" | "default";
}
export function Spinner({ size = "default", ...props }: SpinnerProps) {
return (
<ActivityIndicator
size={SIZES[size]}
colorClassName="accent-primary-foreground"
{...props}
/>
);
}
@@ -0,0 +1,46 @@
import { IconStar, IconStarFilled } from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import * as Haptics from "@/utils/haptics";
interface StarRatingProps {
rating: number;
onRate?: (rating: number) => void;
size?: number;
interactive?: boolean;
}
export function StarRating({
rating,
onRate,
size = 22,
interactive = true,
}: StarRatingProps) {
const [primary, mutedForeground] = useCSSVariable([
"--color-primary",
"--color-muted-foreground",
]) as [string, string];
return (
<View className="flex-row items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Pressable
key={star}
disabled={!interactive}
onPress={() => {
if (!onRate) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onRate(star === rating ? 0 : star);
}}
hitSlop={6}
>
{star <= rating ? (
<IconStarFilled size={size} color={primary} />
) : (
<IconStar size={size} color={mutedForeground} />
)}
</Pressable>
))}
</View>
);
}
@@ -0,0 +1,53 @@
import type { Icon } from "@tabler/icons-react-native";
import {
IconBookmarkFilled,
IconCircleCheckFilled,
IconPlayerPlayFilled,
} from "@tabler/icons-react-native";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
type TitleStatus = "watchlist" | "in_progress" | "completed";
const bgClasses: Record<TitleStatus, string> = {
watchlist: "bg-status-watchlist/10",
in_progress: "bg-status-watching/10",
completed: "bg-status-completed/10",
};
const textClasses: Record<TitleStatus, string> = {
watchlist: "text-status-watchlist",
in_progress: "text-status-watching",
completed: "text-status-completed",
};
const icons: Record<TitleStatus, { label: string; Icon: Icon }> = {
watchlist: { label: "Watchlist", Icon: IconBookmarkFilled },
in_progress: { label: "Watching", Icon: IconPlayerPlayFilled },
completed: { label: "Completed", Icon: IconCircleCheckFilled },
};
export function StatusBadge({ status }: { status: TitleStatus }) {
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
const watchingColor = useCSSVariable("--color-status-watching") as string;
const completedColor = useCSSVariable("--color-status-completed") as string;
const colorMap: Record<TitleStatus, string> = {
watchlist: watchlistColor,
in_progress: watchingColor,
completed: completedColor,
};
const { label, Icon: StatusIcon } = icons[status];
return (
<View
className={`flex-row items-center gap-1.5 rounded-full px-2.5 py-1 ${bgClasses[status]}`}
>
<StatusIcon size={12} color={colorMap[status]} />
<Text className={`font-sans-medium text-[11px] ${textClasses[status]}`}>
{label}
</Text>
</View>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { Pressable } from "react-native";
import Animated, {
interpolateColor,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
const TRACK_WIDTH = 40;
const TRACK_HEIGHT = 20;
const THUMB_SIZE = 14;
const THUMB_OFFSET = 3;
export function Switch({
value,
onValueChange,
disabled,
}: {
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}) {
const primaryColor = useCSSVariable("--color-primary") as string;
const secondaryColor = useCSSVariable("--color-secondary") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const progress = useDerivedValue(() =>
withTiming(value ? 1 : 0, { duration: 200 }),
);
const trackStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[secondaryColor, primaryColor],
),
}));
const thumbStyle = useAnimatedStyle(() => ({
transform: [
{
translateX:
THUMB_OFFSET +
progress.value * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
},
],
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[mutedFgColor, "#fff"],
),
}));
return (
<Pressable
onPress={() => onValueChange(!value)}
disabled={disabled}
hitSlop={8}
style={disabled ? { opacity: 0.5 } : undefined}
>
<Animated.View
style={[
{
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
borderRadius: TRACK_HEIGHT / 2,
},
trackStyle,
]}
>
<Animated.View
style={[
{
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: THUMB_SIZE / 2,
position: "absolute",
top: (TRACK_HEIGHT - THUMB_SIZE) / 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 2,
elevation: 3,
},
thumbStyle,
]}
/>
</Animated.View>
</Pressable>
);
}
@@ -0,0 +1,60 @@
import { forwardRef, type PropsWithChildren } from "react";
import {
TextInput,
type TextInputProps,
type TextProps,
View,
} from "react-native";
import { Text } from "@/components/ui/text";
import { cn } from "@/utils/cn";
export function TextField({ children }: PropsWithChildren) {
return <View className="gap-1.5">{children}</View>;
}
export function Label({
className,
...props
}: TextProps & { className?: string }) {
return (
<Text
className={cn("font-sans-medium text-foreground text-sm", className)}
{...props}
/>
);
}
export const Input = forwardRef<
TextInput,
TextInputProps & { className?: string }
>(({ className, style, ...props }, ref) => {
return (
<TextInput
ref={ref}
placeholderTextColorClassName="accent-muted-foreground/50"
className={cn(
"h-12 rounded-[12px] border border-border bg-input px-3.5 font-sans text-[15px] text-foreground",
className,
)}
style={[{ borderCurve: "continuous" }, style]}
{...props}
/>
);
});
Input.displayName = "Input";
export function FieldError({
isInvalid,
children,
className,
...props
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
if (!isInvalid) return null;
return (
<Text className={cn("text-[13px] text-destructive", className)} {...props}>
{children}
</Text>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Text as RNText, type TextProps } from "react-native";
import { cn } from "@/utils/cn";
export function Text({
className,
...props
}: TextProps & { className?: string }) {
return <RNText className={cn("font-sans", className)} {...props} />;
}
@@ -0,0 +1,42 @@
import { useEffect, useSyncExternalStore } from "react";
import { ToastView } from "@/components/ui/toast";
import { dismiss, getSnapshot, subscribe } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
const hapticMap = {
success: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
error: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
warning: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
info: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
} as const;
export function ToastProvider() {
const queue = useSyncExternalStore(subscribe, getSnapshot);
const activeToast = queue[0];
const activeId = activeToast?.id;
const activeType = activeToast?.type;
const activeDuration = activeToast?.duration;
useEffect(() => {
if (!activeId || !activeType || activeDuration == null) return;
hapticMap[activeType]();
const timer = setTimeout(() => {
dismiss(activeId);
}, activeDuration);
return () => clearTimeout(timer);
}, [activeId, activeType, activeDuration]);
if (!activeToast) return null;
return (
<ToastView key={activeToast.id} toast={activeToast} onDismiss={dismiss} />
);
}
+127
View File
@@ -0,0 +1,127 @@
import {
IconAlertOctagon,
IconAlertTriangle,
IconCircleCheck,
IconInfoCircle,
} from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
import type { ToastItem } from "@/lib/toast";
const DISMISS_THRESHOLD = 50;
const iconMap = {
success: IconCircleCheck,
error: IconAlertOctagon,
warning: IconAlertTriangle,
info: IconInfoCircle,
} as const;
const colorVarMap = {
success: "--color-status-completed",
error: "--color-destructive",
warning: "--color-primary",
info: "--color-muted-foreground",
} as const;
interface ToastViewProps {
toast: ToastItem;
onDismiss: (id: string) => void;
}
export function ToastView({ toast, onDismiss }: ToastViewProps) {
const insets = useSafeAreaInsets();
const translateY = useSharedValue(0);
const Icon = iconMap[toast.type];
const iconColor = useCSSVariable(colorVarMap[toast.type]);
const handleDismiss = () => {
onDismiss(toast.id);
};
const pan = Gesture.Pan()
.onUpdate((e) => {
translateY.value = e.translationY;
})
.onEnd((e) => {
if (Math.abs(e.translationY) > DISMISS_THRESHOLD) {
const target = e.translationY > 0 ? 200 : -200;
translateY.value = withTiming(target, { duration: 200 }, () => {
scheduleOnRN(handleDismiss);
});
} else {
translateY.value = withSpring(0, { damping: 15, stiffness: 300 });
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: interpolate(Math.abs(translateY.value), [0, 100], [1, 0]),
}));
return (
<Animated.View
entering={SlideInDown.duration(350)}
exiting={SlideOutDown.duration(200)}
style={{
position: "absolute",
bottom: insets.bottom + 4,
left: 0,
right: 0,
zIndex: 200,
}}
>
<GestureDetector gesture={pan}>
<Animated.View style={animatedStyle}>
<View
className="mx-4 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
style={{ borderCurve: "continuous" }}
>
<Icon size={18} color={iconColor as string} />
<View className="flex-1 gap-0.5">
<Text className="font-sans-medium text-[14px] text-foreground">
{toast.message}
</Text>
{toast.description ? (
<Text className="text-[13px] text-muted-foreground">
{toast.description}
</Text>
) : null}
</View>
{toast.action ? (
<Pressable
onPress={() => {
toast.action?.onPress();
handleDismiss();
}}
className="rounded-lg bg-secondary px-3 py-1.5"
style={{ borderCurve: "continuous" }}
>
<Text className="font-sans-medium text-[13px] text-primary">
{toast.action.label}
</Text>
</Pressable>
) : null}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
);
}
+37
View File
@@ -0,0 +1,37 @@
@import "tailwindcss";
@import "uniwind";
@theme {
/* Fonts */
--font-sans: "DMSans-Regular";
--font-sans-medium: "DMSans-Medium";
--font-sans-semibold: "DMSans-SemiBold";
--font-sans-bold: "DMSans-Bold";
--font-display: "DMSerifDisplay-Regular";
--font-mono: "GeistMono-Regular";
/* Sofa — dark cinema theme with warm amber accents */
--color-background: oklch(0.13 0.006 55);
--color-foreground: oklch(0.93 0.015 80);
--color-card: oklch(0.19 0.008 55);
--color-card-foreground: oklch(0.93 0.015 80);
--color-popover: oklch(0.21 0.008 55);
--color-popover-foreground: oklch(0.93 0.015 80);
--color-primary: oklch(0.8 0.14 65);
--color-primary-foreground: oklch(0.13 0.006 55);
--color-secondary: oklch(0.22 0.008 55);
--color-secondary-foreground: oklch(0.85 0.02 80);
--color-muted: oklch(0.22 0.008 55);
--color-muted-foreground: oklch(0.63 0.02 80);
--color-accent: oklch(0.25 0.01 55);
--color-accent-foreground: oklch(0.93 0.015 80);
--color-destructive: oklch(0.65 0.2 25);
--color-border: oklch(1 0.015 65 / 10%);
--color-input: oklch(1 0 0 / 12%);
--color-ring: oklch(0.8 0.14 65);
--color-status-watchlist: oklch(0.72 0.12 220);
--color-status-watching: oklch(0.78 0.14 65);
--color-status-completed: oklch(0.75 0.14 155);
--radius: 0.625rem;
}
+10
View File
@@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,45 @@
import type { Stack } from "expo-router";
import type { ComponentProps } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
type ScreenOptions = NonNullable<
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
>;
export function useTabScreenOptions() {
const headerLargeTitleStyle = useResolveClassNames(
"font-display text-foreground",
);
const headerTitleStyle = useResolveClassNames(
"font-display text-lg text-foreground",
);
const contentStyle = useResolveClassNames("bg-background");
return {
headerLargeTitle: true,
headerTransparent: true,
headerBlurEffect: "dark" as const,
headerLargeStyle: { backgroundColor: "transparent" },
headerLargeTitleStyle: headerLargeTitleStyle as Record<string, unknown>,
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
headerTintColor: useCSSVariable("--color-primary") as string,
headerShadowVisible: false,
headerLargeTitleShadowVisible: false,
headerBackButtonDisplayMode: "minimal" as const,
scrollEdgeEffects: {
top: "hidden" as const,
bottom: "hidden" as const,
left: "hidden" as const,
right: "hidden" as const,
},
unstable_headerRightItems: () => [
{
type: "custom" as const,
element: <HeaderAvatar />,
hidesSharedBackground: true,
},
],
contentStyle,
} satisfies ScreenOptions;
}
+40
View File
@@ -0,0 +1,40 @@
import { expoClient } from "@better-auth/expo/client";
import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
import { QUERY_CACHE_KEY, storage } from "@/lib/mmkv";
import { queryClient } from "@/lib/query-client";
import { getServerUrl, onServerUrlChange } from "@/lib/server-url";
function buildAuthClient() {
return createAuthClient({
baseURL: getServerUrl(),
plugins: [
adminClient(),
genericOAuthClient(),
expoClient({
scheme: "sofa",
storagePrefix: "sofa",
storage: SecureStore,
}),
],
});
}
export let authClient = buildAuthClient();
onServerUrlChange(() => {
// Fire-and-forget SecureStore cleanup — must not block the
// synchronous rebuild so that subsequent listeners (e.g. the root
// layout re-render) see the new authClient immediately.
Promise.allSettled([
SecureStore.deleteItemAsync("sofa_cookie"),
SecureStore.deleteItemAsync("sofa_session_token"),
SecureStore.deleteItemAsync("sofa_session_data"),
]);
authClient = buildAuthClient();
storage.remove(QUERY_CACHE_KEY);
queryClient.clear();
});
+17
View File
@@ -0,0 +1,17 @@
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { createMMKV } from "react-native-mmkv";
export const storage = createMMKV();
const mmkvStorage = {
getItem: (key: string) => storage.getString(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => void storage.remove(key),
};
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
export const queryPersister = createAsyncStoragePersister({
storage: mmkvStorage,
key: QUERY_CACHE_KEY,
});
+34
View File
@@ -0,0 +1,34 @@
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { ContractRouterClient } from "@orpc/contract";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { contract } from "@sofa/api/contract";
import { authClient } from "@/lib/auth-client";
import { getServerUrl } from "@/lib/server-url";
export const link = new RPCLink({
url: () => `${getServerUrl()}/rpc`,
fetch: (url, options) => {
return fetch(url, {
...options,
credentials: process.env.EXPO_OS === "web" ? "include" : "omit",
});
},
headers() {
if (process.env.EXPO_OS === "web") {
return {};
}
const headers = new Map<string, string>();
const cookies = authClient.getCookie();
if (cookies) {
headers.set("Cookie", cookies);
}
return Object.fromEntries(headers);
},
});
export const client: ContractRouterClient<typeof contract> =
createORPCClient(link);
export const orpc = createTanstackQueryUtils(client);
+44
View File
@@ -0,0 +1,44 @@
import type { PostHogCustomStorage } from "posthog-react-native";
import { PostHog } from "posthog-react-native";
import { storage } from "@/lib/mmkv";
const posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
const ANALYTICS_ENABLED_KEY = "sofa_analytics_enabled";
const posthogStorage: PostHogCustomStorage = {
getItem: (key: string) => storage.getString(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
};
// PostHog throws if apiKey is empty, so only construct when configured.
export const posthog: PostHog | null = posthogApiKey
? new PostHog(posthogApiKey, {
host,
defaultOptIn: storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true,
customStorage: posthogStorage,
captureAppLifecycleEvents: true,
personProfiles: "never",
errorTracking: {
autocapture: {
uncaughtExceptions: true,
unhandledRejections: true,
},
},
})
: null;
export function isAnalyticsEnabled(): boolean {
return storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
}
export function setAnalyticsEnabled(enabled: boolean): void {
storage.set(ANALYTICS_ENABLED_KEY, enabled);
if (enabled) {
posthog?.optIn();
} else {
posthog?.optOut();
}
}
+22
View File
@@ -0,0 +1,22 @@
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog";
import { toast } from "@/lib/toast";
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: ONE_DAY_MS,
},
},
queryCache: new QueryCache({
onError: (error) => {
toast.error("Something went wrong\u2026", {
description: error.message,
});
posthog?.captureException(error, { source: "react-query" });
},
}),
});
+121
View File
@@ -0,0 +1,121 @@
import { storage } from "@/lib/mmkv";
const SERVER_URL_KEY = "sofa_server_url";
const DEFAULT_URL =
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
const serverUrlListeners: Array<() => void> = [];
// --- Types ---
export type ValidationResult =
| { status: "success" }
| { status: "error"; error: ValidationError };
export type ValidationError =
| "network_unreachable"
| "timeout"
| "not_sofa_server"
| "server_unhealthy"
| "invalid_url";
// --- URL helpers ---
export function normalizeUrl(input: string): string {
let url = input.trim().replace(/\/+$/, "");
if (url && !url.includes("://")) {
url = `http://${url}`;
}
return url;
}
export function splitUrl(input: string): { protocol: string; host: string } {
const match = input.match(/^(https?:\/\/)(.*)/);
if (match) {
return { protocol: match[1], host: match[2] };
}
return { protocol: "http://", host: input };
}
export function resolveUrl(path: string | null): string | null {
if (!path) return null;
if (!path.startsWith("/")) return path;
return `${getServerUrl()}${path}`;
}
// --- Server URL storage ---
export function getServerUrl(): string {
return storage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
}
export function setServerUrl(url: string): void {
const normalized = url.replace(/\/+$/, "");
storage.set(SERVER_URL_KEY, normalized);
for (const listener of serverUrlListeners) listener();
}
export function onServerUrlChange(callback: () => void): () => void {
serverUrlListeners.push(callback);
return () => {
const idx = serverUrlListeners.indexOf(callback);
if (idx !== -1) serverUrlListeners.splice(idx, 1);
};
}
export function hasStoredServerUrl(): boolean {
return storage.contains(SERVER_URL_KEY);
}
// --- Validation ---
export async function validateServerUrl(
url: string,
): Promise<ValidationResult> {
const normalized = normalizeUrl(url);
if (!normalized || !normalized.includes("://")) {
return { status: "error", error: "invalid_url" };
}
try {
new URL(normalized);
} catch {
return { status: "error", error: "invalid_url" };
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${normalized}/api/health`, {
method: "GET",
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
return { status: "error", error: "server_unhealthy" };
}
try {
const data = await response.json();
if (!data || typeof data !== "object" || !("status" in data)) {
return { status: "error", error: "not_sofa_server" };
}
} catch {
return { status: "error", error: "not_sofa_server" };
}
return { status: "success" };
} catch (err) {
if (
err instanceof Error &&
(err.name === "TimeoutError" || err.name === "AbortError")
) {
return { status: "error", error: "timeout" };
}
return { status: "error", error: "network_unreachable" };
}
}
+68
View File
@@ -0,0 +1,68 @@
type ToastType = "success" | "error" | "info" | "warning";
interface ToastAction {
label: string;
onPress: () => void;
}
interface ToastOptions {
description?: string;
duration?: number;
action?: ToastAction;
}
export interface ToastItem {
id: string;
type: ToastType;
message: string;
description?: string;
duration: number;
action?: ToastAction;
}
let queue: ToastItem[] = [];
const listeners = new Set<() => void>();
function emit() {
for (const listener of listeners) listener();
}
export function getSnapshot(): ToastItem[] {
return queue;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function addToast(type: ToastType, message: string, options?: ToastOptions) {
const item: ToastItem = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
type,
message,
description: options?.description,
duration: options?.duration ?? 4000,
action: options?.action,
};
queue = [...queue, item];
emit();
}
export function dismiss(id: string) {
queue = queue.filter((t) => t.id !== id);
emit();
}
export const toast = {
success: (message: string, options?: ToastOptions) =>
addToast("success", message, options),
error: (message: string, options?: ToastOptions) =>
addToast("error", message, options),
info: (message: string, options?: ToastOptions) =>
addToast("info", message, options),
warning: (message: string, options?: ToastOptions) =>
addToast("warning", message, options),
};
+5
View File
@@ -0,0 +1,5 @@
import { twMerge } from "tailwind-merge";
export function cn(...inputs: (string | undefined | null | false)[]) {
return twMerge(inputs.filter(Boolean).join(" "));
}
+50
View File
@@ -0,0 +1,50 @@
import {
impactAsync as _impactAsync,
notificationAsync as _notificationAsync,
AndroidHaptics,
ImpactFeedbackStyle,
NotificationFeedbackType,
performAndroidHapticsAsync,
} from "expo-haptics";
export { ImpactFeedbackStyle, NotificationFeedbackType };
// See https://docs.expo.dev/versions/latest/sdk/haptics/#androidhaptics
const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
[ImpactFeedbackStyle.Light]: AndroidHaptics.Clock_Tick,
[ImpactFeedbackStyle.Medium]: AndroidHaptics.Context_Click,
[ImpactFeedbackStyle.Heavy]: AndroidHaptics.Long_Press,
[ImpactFeedbackStyle.Soft]: AndroidHaptics.Keyboard_Tap,
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
};
const notificationTypeToAndroid: Record<
NotificationFeedbackType,
AndroidHaptics
> = {
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
};
export async function impactAsync(
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
) {
if (process.env.EXPO_OS === "ios") {
return _impactAsync(style);
}
if (process.env.EXPO_OS === "android") {
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
}
}
export async function notificationAsync(
type: NotificationFeedbackType = NotificationFeedbackType.Success,
) {
if (process.env.EXPO_OS === "ios") {
return _notificationAsync(type);
}
if (process.env.EXPO_OS === "android") {
return performAndroidHapticsAsync(notificationTypeToAndroid[type]);
}
}
+36
View File
@@ -0,0 +1,36 @@
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
const WEEK = 604800;
const MONTH = 2592000;
const YEAR = 31536000;
export function timeAgo(dateString: string): string {
const seconds = Math.floor(
(Date.now() - new Date(dateString).getTime()) / 1000,
);
if (seconds < MINUTE) return "just now";
if (seconds < HOUR) {
const m = Math.floor(seconds / MINUTE);
return `${m} ${m === 1 ? "minute" : "minutes"} ago`;
}
if (seconds < DAY) {
const h = Math.floor(seconds / HOUR);
return `${h} ${h === 1 ? "hour" : "hours"} ago`;
}
if (seconds < WEEK) {
const d = Math.floor(seconds / DAY);
return `${d} ${d === 1 ? "day" : "days"} ago`;
}
if (seconds < MONTH) {
const w = Math.floor(seconds / WEEK);
return `${w} ${w === 1 ? "week" : "weeks"} ago`;
}
if (seconds < YEAR) {
const mo = Math.floor(seconds / MONTH);
return `${mo} ${mo === 1 ? "month" : "months"} ago`;
}
const y = Math.floor(seconds / YEAR);
return `${y} ${y === 1 ? "year" : "years"} ago`;
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"uniwind-types.d.ts"
]
}
+10
View File
@@ -0,0 +1,10 @@
// NOTE: This file is generated by uniwind and it should not be edited manually.
/// <reference types="uniwind/types" />
declare module 'uniwind' {
export interface UniwindConfig {
themes: readonly ['light', 'dark']
}
}
export {}
+21 -6
View File
@@ -13,7 +13,7 @@ import {
getCachedUpdateCheck,
isUpdateCheckEnabled,
} from "@sofa/core/update-check";
import { rescheduleBackup, triggerJob } from "../../cron";
import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron";
import { os } from "../context";
import { admin } from "../middleware";
@@ -35,7 +35,16 @@ export const backupsCreate = os.admin.backups.create
export const backupsDelete = os.admin.backups.delete
.use(admin)
.handler(async ({ input }) => {
await deleteBackup(input.filename);
try {
await deleteBackup(input.filename);
} catch (err) {
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) {
throw new ORPCError("NOT_FOUND", { message: msg });
}
throw new ORPCError("BAD_REQUEST", { message: msg });
}
});
export const backupsRestore = os.admin.backups.restore
@@ -54,7 +63,9 @@ export const backupsRestore = os.admin.backups.restore
// Clean up the upload file if restoreFromBackup didn't consume it
const f = Bun.file(tmpPath);
if (await f.exists()) await f.delete();
throw err;
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new ORPCError("BAD_REQUEST", { message: msg });
}
});
@@ -67,7 +78,11 @@ export const backupsSchedule = os.admin.backups.schedule
getSetting("maxBackupRetention") ?? "7",
10,
),
frequency: getSetting("backupScheduleFrequency") ?? "1d",
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as
| "6h"
| "12h"
| "1d"
| "7d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
};
@@ -119,10 +134,10 @@ export const toggleUpdateCheck = os.admin.toggleUpdateCheck
// ─── Jobs ──────────────────────────────────────────────────────
export const triggerJobProcedure = os.admin.triggerJob
export const triggerJob = os.admin.triggerJob
.use(admin)
.handler(async ({ input }) => {
const triggered = await triggerJob(input.name);
const triggered = await triggerCronJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
}
+17 -2
View File
@@ -3,11 +3,15 @@ import {
getNewAvailableFeed,
getRecommendationsFeed,
getUserStats,
getWatchCount,
getWatchHistory,
} from "@sofa/core/discovery";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
return getUserStats(context.user.id);
});
@@ -46,7 +50,8 @@ export const library = os.dashboard.library
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
userStatus: t.userStatus,
}));
@@ -66,8 +71,18 @@ export const recommendations = os.dashboard.recommendations
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
}));
return { items };
});
export const watchHistory = os.dashboard.watchHistory
.use(authed)
.handler(({ input, context }) => {
const coreType = watchHistoryTypeMap[input.type];
const count = getWatchCount(context.user.id, coreType, input.period);
const history = getWatchHistory(context.user.id, coreType, input.period);
return { count, history };
});
+6 -5
View File
@@ -14,11 +14,11 @@ export const discover = os.discover
.handler(async ({ input, context }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
message: "TMDB API key is not configured",
});
}
const results = await discoverTmdb(input.mediaType, {
const results = await discoverTmdb(input.type, {
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
@@ -35,11 +35,12 @@ export const discover = os.discover
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: input.mediaType,
type: input.type,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
+7 -5
View File
@@ -12,7 +12,7 @@ import { authed } from "../middleware";
function requireTmdb() {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
message: "TMDB API key is not configured",
});
}
}
@@ -40,8 +40,9 @@ export const trending = os.explore.trending
(r.poster_path as string) ?? null,
"posters",
),
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
voteAverage: r.vote_average as number,
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
};
});
@@ -89,8 +90,9 @@ export const popular = os.explore.popular
type: input.type,
title: ((r.title ?? r.name) as string) || "",
posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"),
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
voteAverage: r.vote_average as number,
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
@@ -116,7 +116,7 @@ export const create = os.integrations.create
return serializeIntegration(row);
});
export const deleteProcedure = os.integrations.delete
export const deleteIntegration = os.integrations.delete
.use(authed)
.handler(({ input, context }) => {
db.delete(integrations)
+21 -15
View File
@@ -13,7 +13,7 @@ import { authed } from "../middleware";
export const search = os.search.use(authed).handler(async ({ input }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
message: "TMDB API key is not configured",
});
}
@@ -32,15 +32,16 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
title: r.name ?? "",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
overview: null,
releaseDate: null,
popularity: r.popularity,
voteAverage: 0,
knownForDepartment: r.known_for_department,
knownFor: r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter(Boolean) as string[] | undefined,
popularity: r.popularity ?? null,
voteAverage: null,
knownForDepartment: r.known_for_department ?? null,
knownFor:
(r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter((s): s is string => !!s) as string[]) ?? null,
})),
};
}
@@ -75,10 +76,12 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
title: r.name ?? "Unknown",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
overview: null,
releaseDate: null,
popularity: r.popularity ?? 0,
voteAverage: 0,
popularity: r.popularity ?? null,
voteAverage: null,
knownForDepartment: null,
knownFor: null,
};
}
@@ -90,11 +93,14 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
tmdbId: r.id,
type: mediaType,
title: r.title ?? r.name ?? "",
overview: r.overview ?? "",
overview: r.overview ?? null,
releaseDate: r.release_date ?? r.first_air_date ?? null,
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
popularity: r.popularity ?? 0,
voteAverage: r.vote_average ?? 0,
profilePath: null,
popularity: r.popularity ?? null,
voteAverage: r.vote_average ?? null,
knownForDepartment: null,
knownFor: null,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
-9
View File
@@ -1,9 +0,0 @@
import { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
import { os } from "../context";
import { authed } from "../middleware";
export const stats = os.stats.use(authed).handler(({ input, context }) => {
const count = getWatchCount(context.user.id, input.type, input.period);
const history = getWatchHistory(context.user.id, input.type, input.period);
return { count, history };
});
+7 -12
View File
@@ -1,17 +1,12 @@
import { getSystemHealth } from "@sofa/core/system-health";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { os } from "../context";
import { authed } from "../middleware";
import { admin, authed } from "../middleware";
export const systemStatus = os.systemStatus
.use(authed)
.handler(async ({ context }) => {
const tmdbConfigured = isTmdbConfigured();
export const status = os.system.status.use(authed).handler(() => {
return { tmdbConfigured: isTmdbConfigured() };
});
if (context.user.role === "admin") {
const health = await getSystemHealth();
return { tmdbConfigured, health };
}
return { tmdbConfigured };
});
export const health = os.system.health.use(admin).handler(async () => {
return await getSystemHealth();
});
+31
View File
@@ -14,6 +14,9 @@ import {
removeTitleStatus,
setTitleStatus,
} from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
@@ -86,3 +89,31 @@ export const hydrateSeasons = os.titles.hydrateSeasons
const seasons = await ensureTvHydrated(input.id, input.tmdbId);
return { seasons };
});
export const quickAdd = os.titles.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to import title",
});
}
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, context.user.id),
eq(userTitleStatus.titleId, title.id),
),
)
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
});
@@ -1,36 +0,0 @@
import { ORPCError } from "@orpc/server";
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import { setTitleStatus } from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
export const quickAdd = os.watchlist.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title) {
throw new ORPCError("BAD_GATEWAY", {
message: "Failed to import title",
});
}
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, context.user.id),
eq(userTitleStatus.titleId, title.id),
),
)
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
});
+7 -10
View File
@@ -9,11 +9,9 @@ import * as integrations from "./procedures/integrations";
import * as people from "./procedures/people";
import { search } from "./procedures/search";
import * as seasons from "./procedures/seasons";
import { stats } from "./procedures/stats";
import { systemStatus } from "./procedures/status";
import * as status from "./procedures/status";
import * as system from "./procedures/system";
import * as titles from "./procedures/titles";
import * as watchlist from "./procedures/watchlist";
export const router = os.router({
titles: {
@@ -26,6 +24,7 @@ export const router = os.router({
userInfo: titles.userInfo,
recommendations: titles.recommendations,
hydrateSeasons: titles.hydrateSeasons,
quickAdd: titles.quickAdd,
},
episodes: {
watch: episodes.watch,
@@ -45,6 +44,7 @@ export const router = os.router({
continueWatching: dashboard.continueWatching,
library: dashboard.library,
recommendations: dashboard.recommendations,
watchHistory: dashboard.watchHistory,
},
explore: {
trending: explore.trending,
@@ -53,16 +53,16 @@ export const router = os.router({
},
search,
discover,
stats,
systemStatus,
system: {
publicInfo: system.publicInfo,
authConfig: system.authConfig,
status: status.status,
health: status.health,
},
integrations: {
list: integrations.list,
create: integrations.create,
delete: integrations.deleteProcedure,
delete: integrations.deleteIntegration,
regenerateToken: integrations.regenerateToken,
},
admin: {
@@ -78,16 +78,13 @@ export const router = os.router({
toggleRegistration: admin.toggleRegistration,
updateCheck: admin.updateCheck,
toggleUpdateCheck: admin.toggleUpdateCheck,
triggerJob: admin.triggerJobProcedure,
triggerJob: admin.triggerJob,
},
account: {
updateName: account.updateName,
uploadAvatar: account.uploadAvatar,
removeAvatar: account.removeAvatar,
},
watchlist: {
quickAdd: watchlist.quickAdd,
},
});
export type Router = typeof router;
+2 -2
View File
@@ -29,14 +29,14 @@ app.get("/:filename", async (c) => {
}
const file = Bun.file(backupPath);
const buffer = await file.arrayBuffer();
return new Response(new Uint8Array(buffer), {
return new Response(file.stream(), {
status: 200,
headers: {
"Content-Type": "application/x-sqlite3",
"Content-Disposition": `attachment; filename="${safe}"`,
"Content-Length": String(file.size),
"Cache-Control": "no-store",
},
});
});
+15 -15
View File
@@ -18,13 +18,13 @@
"@fontsource/dm-serif-display": "5.2.8",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/tanstack-query": "1.13.6",
"@orpc/tanstack-query": "catalog:",
"@player.style/sutro": "0.2.1",
"@sofa/api": "workspace:*",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.1",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-router": "1.166.6",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.166.7",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -32,14 +32,14 @@
"date-fns": "catalog:",
"jotai": "2.18.1",
"media-chrome": "4.18.0",
"motion": "12.35.2",
"react": "19.2.4",
"motion": "12.36.0",
"react": "catalog:",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-dom": "catalog:",
"recharts": "3.8.0",
"shadcn": "4.0.3",
"shadcn": "4.0.5",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"tailwind-merge": "catalog:",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
@@ -47,18 +47,18 @@
},
"devDependencies": {
"@tailwindcss/vite": "4.2.1",
"@tanstack/devtools-vite": "0.5.3",
"@tanstack/react-devtools": "0.9.10",
"@tanstack/devtools-vite": "0.5.5",
"@tanstack/react-devtools": "0.9.13",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-router-devtools": "1.166.6",
"@tanstack/router-plugin": "1.166.6",
"@tanstack/react-router-devtools": "1.166.7",
"@tanstack/router-plugin": "1.166.7",
"@types/bun": "catalog:",
"@types/node": "25.4.0",
"@types/react": "19.2.14",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.4",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "4.2.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"

Some files were not shown because too many files have changed in this diff Show More