refactor(native): overhaul auth screens, add ModalStackHeader, and bump Expo patch releases

- Replace `Alert` with `toast` for sign-in/register errors and add a `getFormErrors` utility to extract the first Zod validation message and field set
- Display the configured server host inline on the login screen and hide the navigation header in the auth layout (`headerShown: false`, `navigationBarHidden: true`)
- Delete `AuthStackHeader` and `DetailStackHeader`; introduce `ModalStackHeader` for screens that are pushed as modals
- Remove unused `StatusBadge` component and delete unused shadcn web components (`calendar`, `drawer`)
- Bump all Expo 55 SDK packages to their latest patch versions and add `expo-web-browser` plugin to `app.json` + `@react-navigation/native` as an explicit dependency
This commit is contained in:
2026-03-17 18:33:11 -04:00
parent daf90a4f39
commit c6ae58f41d
31 changed files with 941 additions and 1347 deletions
+1
View File
@@ -102,6 +102,7 @@
}
],
["expo-system-ui"],
["expo-web-browser"],
[
"expo-image-picker",
{
+19 -18
View File
@@ -16,12 +16,13 @@
"dependencies": {
"@better-auth/expo": "catalog:",
"@expo/metro-runtime": "55.0.6",
"@expo/ui": "55.0.2",
"@expo/ui": "55.0.3",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:",
"@react-native-menu/menu": "2.0.0",
"@react-navigation/elements": "2.9.10",
"@react-navigation/native": "7.1.33",
"@shopify/flash-list": "2.0.2",
"@sofa/api": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
@@ -32,28 +33,28 @@
"better-auth": "catalog:",
"burnt": "0.13.0",
"date-fns": "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": "55.0.7",
"expo-application": "55.0.10",
"expo-clipboard": "55.0.9",
"expo-constants": "55.0.8",
"expo-dev-client": "55.0.17",
"expo-device": "55.0.10",
"expo-font": "55.0.4",
"expo-glass-effect": "55.0.8",
"expo-haptics": "55.0.8",
"expo-haptics": "55.0.9",
"expo-image": "55.0.6",
"expo-image-picker": "55.0.12",
"expo-linear-gradient": "55.0.8",
"expo-image-picker": "55.0.13",
"expo-linear-gradient": "55.0.9",
"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-localization": "55.0.9",
"expo-network": "55.0.9",
"expo-router": "55.0.6",
"expo-secure-store": "55.0.9",
"expo-splash-screen": "55.0.11",
"expo-status-bar": "55.0.4",
"expo-system-ui": "55.0.9",
"expo-tracking-transparency": "55.0.8",
"expo-web-browser": "55.0.9",
"expo-system-ui": "55.0.10",
"expo-tracking-transparency": "55.0.9",
"expo-web-browser": "55.0.10",
"posthog-react-native": "4.37.3",
"react": "19.2.0",
"react-dom": "19.2.0",
+2
View File
@@ -16,6 +16,8 @@ export default function AuthLayout() {
<Stack
screenOptions={{
contentStyle,
headerShown: false,
navigationBarHidden: true,
animation: "fade",
}}
/>
+147 -123
View File
@@ -1,24 +1,24 @@
import { useForm } from "@tanstack/react-form";
import { IconServer2 } from "@tabler/icons-react-native";
import { useForm, useStore } 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 { useRef, useState } from "react";
import { Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
import { Button, ButtonLabel } from "@/components/ui/button";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import {
FieldError,
Input,
Label,
TextField,
} from "@/components/ui/text-field";
import { 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 { getServerUrl, splitUrl } from "@/lib/server-url";
import { toast } from "@/lib/toast";
import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics";
const signInSchema = z.object({
@@ -30,37 +30,33 @@ const signInSchema = z.object({
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 [errorFields, setErrorFields] = useState<Set<string>>(new Set());
const [isSignedIn, setIsSignedIn] = useState(false);
const authConfig = useQuery(orpc.system.authConfig.queryOptions());
const form = useForm({
defaultValues: { email: "", password: "" },
validators: { onSubmit: signInSchema },
onSubmit: async ({ value, formApi }) => {
onSubmit: async ({ value }) => {
const result = signInSchema.safeParse(value);
if (!result.success) {
const { message, fields } = getFormErrors(result.error);
setErrorFields(fields);
toast.error(message);
return;
}
await authClient.signIn.email(
{ email: value.email.trim(), password: value.password },
{ email: result.data.email, password: result.data.password },
{
onError(error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", error.error?.message || "Failed to sign in");
toast.error(error.error?.message || "Failed to sign in");
},
onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
formApi.reset();
setIsSignedIn(true);
queryClient.invalidateQueries();
},
},
@@ -68,13 +64,30 @@ export default function LoginScreen() {
},
});
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);
const busy = isSubmitting || isSignedIn;
const statusCompletedColor = useCSSVariable(
"--color-status-completed",
) as string;
const serverHost = splitUrl(getServerUrl()).host;
const showPasswordLogin = !authConfig.data?.passwordLoginDisabled;
const showOidc = authConfig.data?.oidcEnabled;
const showRegister = authConfig.data?.registrationOpen;
const clearFieldError = (name: string) => {
if (errorFields.has(name)) {
setErrorFields((prev) => {
const next = new Set(prev);
next.delete(name);
return next;
});
}
};
return (
<AuthScreen title="Sofa" subtitle="Sign in to continue">
<AuthStackHeader title="Sign In" />
{showOidc && (
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
@@ -106,108 +119,119 @@ export default function LoginScreen() {
)}
{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>
<View className="gap-3">
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
value={field.state.value}
accessibilityLabel="Email"
onBlur={field.handleBlur}
onChangeText={(text) => {
field.handleChange(text);
clearFieldError("email");
}}
placeholder="wwhite@graymatter.biz"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
className={
errorFields.has("email")
? "border-destructive"
: undefined
}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
value={field.state.value}
accessibilityLabel="Email"
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}
accessibilityLabel="Password"
onBlur={field.handleBlur}
onChangeText={(text) => {
field.handleChange(text);
clearFieldError("password");
}}
placeholder="••••••••"
secureTextEntry
autoComplete="password"
textContentType="password"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
className={
errorFields.has("password")
? "border-destructive"
: undefined
}
/>
</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}
accessibilityLabel="Password"
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={busy}
className="mt-2"
>
{busy ? (
<Spinner size="sm" />
) : (
<ButtonLabel>Sign In</ButtonLabel>
)}
</Button>
</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>
)}
{showRegister && (
<Animated.View entering={FadeIn.duration(300).delay(500)}>
<Link href="/(auth)/register" asChild>
<Button disabled={busy} variant="secondary">
<ButtonLabel>Create an account</ButtonLabel>
</Button>
</Animated.View>
</View>
</Link>
</Animated.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-8 items-center"
>
<Link href="/(auth)/server-url" replace asChild>
<Pressable
disabled={busy}
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
className="flex-row items-center gap-1.5"
>
<ScaledIcon
icon={IconServer2}
size={14}
color={statusCompletedColor}
/>
<Text className="font-sans text-muted-foreground text-xs">
Connected to <Text className="font-medium">{serverHost}</Text>
. Tap to change.
</Text>
</Pressable>
</Link>
</Animated.View>
</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>
);
}
+144 -133
View File
@@ -1,24 +1,21 @@
import { useForm } from "@tanstack/react-form";
import { useForm, useStore } 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 { useRef, useState } from "react";
import { 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 { AuthStackHeader } from "@/components/navigation/auth-stack-header";
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 { 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 { getServerUrl, splitUrl } from "@/lib/server-url";
import { toast } from "@/lib/toast";
import { getFormErrors } from "@/utils/form-errors";
import * as Haptics from "@/utils/haptics";
const signUpSchema = z.object({
@@ -38,46 +35,39 @@ const signUpSchema = z.object({
.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 [errorFields, setErrorFields] = useState<Set<string>>(new Set());
const [isSignedUp, setIsSignedUp] = useState(false);
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 }) => {
onSubmit: async ({ value }) => {
const result = signUpSchema.safeParse(value);
if (!result.success) {
const { message, fields } = getFormErrors(result.error);
setErrorFields(fields);
toast.error(message);
return;
}
await authClient.signUp.email(
{
name: value.name.trim(),
email: value.email.trim(),
password: value.password,
name: result.data.name,
email: result.data.email,
password: result.data.password,
},
{
onError(error) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert(
"Error",
error.error?.message || "Failed to create account",
);
toast.error(error.error?.message || "Failed to create account");
},
onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
formApi.reset();
setIsSignedUp(true);
queryClient.invalidateQueries();
},
},
@@ -85,13 +75,27 @@ export default function RegisterScreen() {
},
});
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);
const busy = isSubmitting || isSignedUp;
const serverHost = splitUrl(getServerUrl()).host;
const clearFieldError = (name: string) => {
if (errorFields.has(name)) {
setErrorFields((prev) => {
const next = new Set(prev);
next.delete(name);
return next;
});
}
};
if (!registrationOpen && !publicInfo.isPending) {
return (
<AuthScreen
title="Registration Closed"
subtitle="New account creation is currently disabled."
>
<AuthStackHeader title="Registration Closed" />
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/(auth)/login" asChild>
<Button className="mt-6 bg-primary">
@@ -106,115 +110,122 @@ export default function RegisterScreen() {
}
return (
<AuthScreen title="Create Account">
<AuthStackHeader 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>
<AuthScreen
title="Create Account"
subtitle={`Registering on ${serverHost}`}
>
<View className="gap-3">
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="name">
{(field) => (
<TextField>
<Label>Name</Label>
<Input
value={field.state.value}
accessibilityLabel="Name"
onBlur={field.handleBlur}
onChangeText={(text) => {
field.handleChange(text);
clearFieldError("name");
}}
placeholder="Your name"
autoComplete="name"
textContentType="name"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => emailRef.current?.focus()}
className={
errorFields.has("name") ? "border-destructive" : undefined
}
/>
</TextField>
)}
</form.Field>
</Animated.View>
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<form.Field name="name">
{(field) => (
<TextField>
<Label>Name</Label>
<Input
value={field.state.value}
accessibilityLabel="Name"
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}
accessibilityLabel="Email"
onBlur={field.handleBlur}
onChangeText={(text) => {
field.handleChange(text);
clearFieldError("email");
}}
placeholder="email@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
className={
errorFields.has("email") ? "border-destructive" : undefined
}
/>
</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}
accessibilityLabel="Email"
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}
accessibilityLabel="Password"
onBlur={field.handleBlur}
onChangeText={(text) => {
field.handleChange(text);
clearFieldError("password");
}}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
className={
errorFields.has("password")
? "border-destructive"
: undefined
}
/>
</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}
accessibilityLabel="Password"
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={FadeInDown.duration(300).delay(400)}>
<Button
onPress={form.handleSubmit}
disabled={busy}
className="mt-1 bg-primary"
>
{busy ? (
<Spinner size="sm" />
) : (
<ButtonLabel>Create Account</ButtonLabel>
)}
</Button>
</Animated.View>
</View>
<Animated.View
entering={FadeIn.duration(300).delay(500)}
className="mt-6 items-center"
>
<Link href="/(auth)/login" asChild>
<Pressable>
<Pressable disabled={busy}>
<Text className="text-primary text-sm">
Already have an account? Sign in
</Text>
+19 -25
View File
@@ -5,7 +5,7 @@ import {
} 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 { Linking, Pressable, type TextInput, View } from "react-native";
import Animated, {
FadeIn,
FadeInDown,
@@ -16,10 +16,10 @@ import Animated, {
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { AuthScreen } from "@/components/auth-screen";
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
import { Button, ButtonLabel } from "@/components/ui/button";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/text-field";
import {
getServerUrl,
hasStoredServerUrl,
@@ -153,30 +153,24 @@ export default function ServerUrlScreen() {
subtitle="Enter your Sofa server URL to get started"
logoStyle={iconAnimatedStyle}
>
<AuthStackHeader title="Server" />
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View
className="min-h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
style={{ borderCurve: "continuous" }}
>
<TextInput
ref={inputRef}
value={url}
accessibilityLabel="Server URL"
accessibilityHint="Enter the full URL for your Sofa server"
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-3 font-mono text-base text-foreground"
/>
</View>
<Input
ref={inputRef}
value={url}
accessibilityLabel="Server URL"
accessibilityHint="Enter the full URL for your Sofa server"
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="font-mono"
/>
</Animated.View>
{/* Connect Button / Status */}
@@ -74,12 +74,9 @@ export default function ExploreScreen() {
className="bg-background"
contentContainerStyle={exploreContentContainerStyle}
contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
}
>
<View className="gap-8">
+2 -5
View File
@@ -83,12 +83,9 @@ export default function DashboardScreen() {
className="bg-background"
contentContainerStyle={dashboardContentContainerStyle}
contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
}
>
<View className="gap-8">
@@ -210,12 +210,9 @@ export default function SettingsScreen() {
className="bg-background"
contentContainerStyle={settingsContentContainerStyle}
contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
/>
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Account */}
@@ -286,7 +283,7 @@ export default function SettingsScreen() {
</View>
) : (
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
<Text className="font-medium font-sans text-lg text-primary">
<Text className="font-display font-medium text-lg text-primary">
{session?.user?.name?.charAt(0)?.toUpperCase() ?? "?"}
</Text>
</View>
+15 -1
View File
@@ -1,5 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
export default function TabLayout() {
return <NativeTabBar />;
const { data: session } = authClient.useSession();
const isAdmin = session?.user?.role === "admin";
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
staleTime: 10 * 60 * 1000,
});
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
return <NativeTabBar showSettingsBadge={showSettingsBadge} />;
}
+34 -12
View File
@@ -5,7 +5,12 @@ import {
persistQueryClientRestore,
persistQueryClientSubscribe,
} from "@tanstack/react-query-persist-client";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import {
Stack,
useGlobalSearchParams,
usePathname,
useRouter,
} from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import {
@@ -17,6 +22,7 @@ import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
import { useEffect, useRef, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { enableFreeze } from "react-native-screens";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
@@ -45,6 +51,7 @@ import { sofaTheme } from "@/lib/theme";
import { toast } from "@/lib/toast";
SplashScreen.preventAutoHideAsync();
enableFreeze(true);
// Seed the session atom with cached data from SecureStore before React renders.
// This allows the app to show cached data immediately when the server is
@@ -75,7 +82,7 @@ const changePasswordOptions =
sheetAllowedDetents: "fitToContents" as const,
sheetGrabberVisible: true,
headerLargeTitle: false,
headerTransparent: false,
headerTransparent: true,
headerBlurEffect: "none" as const,
}
: {
@@ -188,9 +195,18 @@ function AppContent() {
}, [hasServerUrl]);
// --- Session reconciliation: re-validate when server comes back ---
// Tracks whether the current session was seeded from cache and has NOT
// yet been confirmed by the server. Once confirmed (isRefetching becomes
// false while session still exists), this flips to false so that an
// explicit sign-out doesn't show a misleading "session expired" toast.
const hadOptimisticSession = useRef(!!cachedSession);
const prevSession = useRef(session);
const { isRefetching } = authClient.useSession();
if (hadOptimisticSession.current && session && !isRefetching) {
hadOptimisticSession.current = false;
}
useEffect(() => {
return onServerReachabilityChange((reachable) => {
if (reachable) {
@@ -201,17 +217,24 @@ function AppContent() {
});
}, []);
// If session was seeded from cache and then invalidated by the server,
// show a toast so the user knows why they were signed out.
const { replace } = useRouter();
// When session is lost (sign-out or server invalidation), explicitly
// navigate to auth. Stack.Protected handles screen availability, but
// enableFreeze can prevent the navigator from transitioning on its own.
useEffect(() => {
if (prevSession.current && !session && hadOptimisticSession.current) {
toast.info("Session expired", {
description: "Please sign in again.",
});
hadOptimisticSession.current = false;
if (prevSession.current && !session) {
replace("/(auth)/login");
if (hadOptimisticSession.current) {
toast.info("Session expired", {
description: "Please sign in again.",
});
hadOptimisticSession.current = false;
}
}
prevSession.current = session;
}, [session]);
}, [session, replace]);
return (
<ThemeProvider value={sofaTheme}>
@@ -239,8 +262,7 @@ function AppContent() {
name="title/[id]"
dangerouslySingular
options={{
headerShown: true,
animation: "slide_from_right",
presentation: "modal",
}}
/>
<Stack.Screen
+2 -2
View File
@@ -42,7 +42,7 @@ function formatFormErrors(errors: unknown): string | null {
}
export default function ChangePasswordScreen() {
const router = useRouter();
const { back } = useRouter();
const newPasswordRef = useRef<TextInput>(null);
const confirmPasswordRef = useRef<TextInput>(null);
const [revokeOtherSessions, setRevokeOtherSessions] = useState(false);
@@ -73,7 +73,7 @@ export default function ChangePasswordScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
toast.success("Password updated");
formApi.reset();
router.back();
back();
} catch {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Something went wrong");
+32 -33
View File
@@ -21,7 +21,7 @@ import {
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { DetailStackHeader } from "@/components/navigation/detail-stack-header";
import { DetailStackHeader } from "@/components/navigation/modal-stack-header";
import { ExpandableText } from "@/components/ui/expandable-text";
import { Image } from "@/components/ui/image";
import { PosterCard } from "@/components/ui/poster-card";
@@ -249,6 +249,8 @@ export default function PersonDetailScreen() {
const listHeader = (
<>
<DetailStackHeader />
{/* Profile hero */}
<Animated.View
entering={FadeIn.duration(400)}
@@ -336,38 +338,35 @@ export default function PersonDetailScreen() {
);
return (
<>
<DetailStackHeader title={person.name} />
<View className="flex-1 bg-background">
<FlashList
data={filmography}
keyExtractor={(item) => item.titleId}
renderItem={renderFilmographyItem}
numColumns={filmographyColumns}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
<View className="flex-1 bg-background" collapsable={false}>
<FlashList
data={filmography}
keyExtractor={(item) => item.titleId}
renderItem={renderFilmographyItem}
numColumns={filmographyColumns}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentContainerStyle={{
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
paddingHorizontal: FILMOGRAPHY_PADDING - FILMOGRAPHY_GUTTER,
}}
ListHeaderComponent={listHeader}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
contentContainerStyle={{
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
paddingHorizontal: FILMOGRAPHY_PADDING - FILMOGRAPHY_GUTTER,
}}
ListHeaderComponent={listHeader}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? (
<View className="items-center py-4">
<ActivityIndicator />
</View>
) : null
}
/>
</View>
</>
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? (
<View className="items-center py-4">
<ActivityIndicator />
</View>
) : null
}
/>
</View>
);
}
+307 -308
View File
@@ -25,7 +25,7 @@ import {
HorizontalPosterRow,
type PosterRowItem,
} from "@/components/dashboard/horizontal-poster-row";
import { DetailStackHeader } from "@/components/navigation/detail-stack-header";
import { DetailStackHeader } from "@/components/navigation/modal-stack-header";
import { CastCard } from "@/components/titles/cast-card";
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
import { SeasonAccordion } from "@/components/titles/season-accordion";
@@ -306,334 +306,333 @@ export default function TitleDetailScreen() {
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
return (
<>
<DetailStackHeader title={title.title} />
<ScrollView
className="bg-background"
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentContainerStyle={titleScrollContentStyle}
<ScrollView
className="bg-background"
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentContainerStyle={titleScrollContentStyle}
>
<DetailStackHeader />
{/* Hero */}
<View className="h-[300px]" style={heroMarginStyle}>
{title.backdropPath && (
<Image
source={{ uri: title.backdropPath }}
thumbHash={title.backdropThumbHash}
style={titleDetailStyles.heroFill}
contentFit="cover"
/>
)}
{/* Base darkening overlay */}
<View style={titleDetailStyles.heroBaseOverlay} />
{/* Colored tint from palette */}
{palette?.darkMuted && (
<View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />
)}
{palette?.vibrant && (
<View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />
)}
{/* Bottom fade to background */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={titleDetailStyles.heroGradient}
/>
{title.trailerVideoKey && (
<Pressable
onPress={() =>
WebBrowser.openBrowserAsync(
`https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
)
}
accessibilityRole="button"
accessibilityLabel={`Play trailer for ${title.title}`}
accessibilityHint="Opens the trailer in YouTube"
className="absolute inset-0 items-center justify-center"
>
{isLiquidGlassAvailable() ? (
<GlassView
glassEffectStyle="clear"
colorScheme="dark"
isInteractive={true}
style={titleDetailStyles.trailerGlass}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</GlassView>
) : (
<View
className="h-14 w-14 items-center justify-center rounded-full"
style={titleDetailStyles.trailerFallback}
>
<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 && (
<Link.AppleZoomTarget>
<View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={[{ borderCurve: "continuous" }, posterShadowStyle]}
>
<Image
source={{ uri: title.posterPath }}
thumbHash={title.posterThumbHash}
style={titleDetailStyles.posterImage}
contentFit="cover"
/>
</View>
</Link.AppleZoomTarget>
)}
<View className="flex-1 pb-1">
<Text
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-title-accent px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-title-accent-foreground text-xs"
>
{title.type === "movie" ? "Movie" : "TV"}
</Text>
</View>
{year ? (
<Text className="text-sm 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">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={titleAccent}
/>
<Text className="text-title-accent 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={titleGenresContentStyle}
>
{title.genres.map((genre: string) => (
<View
key={genre}
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
>
<Text className="text-muted-foreground text-xs">{genre}</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Actions */}
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="mt-4 px-4"
>
{/* Hero */}
<View className="h-[300px]" style={heroMarginStyle}>
{title.backdropPath && (
<Image
source={{ uri: title.backdropPath }}
thumbHash={title.backdropThumbHash}
style={titleDetailStyles.heroFill}
contentFit="cover"
/>
)}
{/* Base darkening overlay */}
<View style={titleDetailStyles.heroBaseOverlay} />
{/* Colored tint from palette */}
{palette?.darkMuted && (
<View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />
)}
{palette?.vibrant && (
<View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />
)}
{/* Bottom fade to background */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={titleDetailStyles.heroGradient}
<View className="flex-row flex-wrap items-center gap-3">
<StatusActionButton
currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => {
if (status === "watchlist") {
quickAddMutation.mutate({ id });
} else {
updateStatus.mutate({ id, status: null });
}
}}
isPending={
updateStatus.isPending ||
quickAddMutation.isPending ||
watchMovie.isPending
}
/>
{title.trailerVideoKey && (
{title.type === "movie" && (
<Pressable
onPress={() =>
WebBrowser.openBrowserAsync(
`https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
)
}
accessibilityRole="button"
accessibilityLabel={`Play trailer for ${title.title}`}
accessibilityHint="Opens the trailer in YouTube"
className="absolute inset-0 items-center justify-center"
onPress={() => watchMovie.mutate({ id })}
disabled={watchMovie.isPending}
className="flex-row items-center gap-1.5 rounded-lg bg-title-accent px-4 py-2"
>
{isLiquidGlassAvailable() ? (
<GlassView
glassEffectStyle="clear"
colorScheme="dark"
isInteractive={true}
style={titleDetailStyles.trailerGlass}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</GlassView>
{watchMovie.isPending ? (
<Spinner size="sm" />
) : (
<View
className="h-14 w-14 items-center justify-center rounded-full"
style={titleDetailStyles.trailerFallback}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</View>
<>
<ScaledIcon
icon={IconCheck}
size={16}
color={titleAccentForeground}
/>
<Text className="font-medium font-sans text-sm text-title-accent-foreground">
Mark Watched
</Text>
</>
)}
</Pressable>
)}
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
{title.posterPath && (
<Link.AppleZoomTarget>
<View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={[{ borderCurve: "continuous" }, posterShadowStyle]}
>
<Image
source={{ uri: title.posterPath }}
thumbHash={title.posterThumbHash}
style={titleDetailStyles.posterImage}
contentFit="cover"
/>
</View>
</Link.AppleZoomTarget>
)}
<View className="flex-1 pb-1">
<Text
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-title-accent px-2 py-0.5">
<Text
maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-title-accent-foreground text-xs"
>
{title.type === "movie" ? "Movie" : "TV"}
</Text>
</View>
{year ? (
<Text className="text-sm 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">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={titleAccent}
/>
<Text className="text-title-accent text-xs">
{title.voteAverage.toFixed(1)}
</Text>
</View>
)}
</View>
</View>
</View>
<View className="h-6 w-px bg-border/50" />
<StarRating
rating={userInfo.data?.rating ?? 0}
onRate={(stars) => updateRating.mutate({ id, stars })}
accentColor={titleAccent}
/>
</View>
</Animated.View>
{/* Genres */}
{title.genres && title.genres.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-3"
contentContainerStyle={titleGenresContentStyle}
>
{title.genres.map((genre: string) => (
<View
key={genre}
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
>
<Text className="text-muted-foreground text-xs">{genre}</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Actions */}
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="mt-4 px-4"
>
<View className="flex-row flex-wrap items-center gap-3">
<StatusActionButton
currentStatus={userInfo.data?.status ?? null}
onStatusChange={(status) => {
if (status === "watchlist") {
quickAddMutation.mutate({ id });
} else {
updateStatus.mutate({ id, status: null });
}
}}
isPending={
updateStatus.isPending ||
quickAddMutation.isPending ||
watchMovie.isPending
}
/>
{title.type === "movie" && (
<Pressable
onPress={() => watchMovie.mutate({ id })}
disabled={watchMovie.isPending}
className="flex-row items-center gap-1.5 rounded-lg bg-title-accent px-4 py-2"
>
{watchMovie.isPending ? (
<Spinner size="sm" />
) : (
<>
<ScaledIcon
icon={IconCheck}
size={16}
color={titleAccentForeground}
/>
<Text className="font-medium font-sans text-sm text-title-accent-foreground">
Mark Watched
</Text>
</>
)}
</Pressable>
)}
<View className="h-6 w-px bg-border/50" />
<StarRating
rating={userInfo.data?.rating ?? 0}
onRate={(stars) => updateRating.mutate({ id, stars })}
accentColor={titleAccent}
/>
{/* Overview */}
{title.overview ? (
<Animated.View entering={FadeIn.duration(300).delay(300)}>
<View className="mt-5 px-4">
<ExpandableText text={title.overview} actionColor={titleAccent} />
</View>
</Animated.View>
) : null}
{/* Overview */}
{title.overview ? (
<Animated.View entering={FadeIn.duration(300).delay(300)}>
<View className="mt-5 px-4">
<ExpandableText text={title.overview} actionColor={titleAccent} />
</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={providerIcon}
iconColor={titleAccent}
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={titleAvailabilityContentStyle}
>
{availability.map((offer) => (
<View
key={`${offer.providerId}-${offer.offerType}`}
className="items-center"
>
{offer.logoPath && (
<Image
source={{ uri: offer.logoPath }}
style={titleDetailStyles.providerLogo}
contentFit="cover"
/>
)}
<Text
maxFontSizeMultiplier={1.0}
className="mt-1 max-w-[60px] text-center text-muted-foreground text-xs"
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}
backdropThumbHash={title.backdropThumbHash}
/>
)}
{/* Seasons & Episodes */}
{title.type === "tv" && seasons.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6 px-4"
>
{/* Availability */}
{availability.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="Seasons"
icon={IconList}
title="Where to Watch"
icon={providerIcon}
iconColor={titleAccent}
/>
{seasons.map((season) => (
<SeasonAccordion
key={season.id}
season={season}
episodes={season.episodes ?? []}
watchedEpisodeIds={watchedEpisodeIds}
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={titleAvailabilityContentStyle}
>
{availability.map((offer) => (
<View
key={`${offer.providerId}-${offer.offerType}`}
className="items-center"
>
{offer.logoPath && (
<Image
source={{ uri: offer.logoPath }}
style={titleDetailStyles.providerLogo}
contentFit="cover"
/>
)}
<Text
maxFontSizeMultiplier={1.0}
className="mt-1 max-w-[60px] text-center text-muted-foreground text-xs"
numberOfLines={1}
>
{offer.providerName}
</Text>
</View>
))}
</Animated.View>
)}
</ScrollView>
</Animated.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}
iconColor={titleAccent}
/>
</View>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={cast}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={renderCastItem}
ItemSeparatorComponent={HorizontalListSeparator}
contentContainerStyle={horizontalListContentStyle}
style={horizontalListStyle}
{/* Continue Watching */}
{title.type === "tv" && (
<ContinueWatchingBanner
seasons={seasons}
watchedEpisodeIds={watchedEpisodeIds}
userStatus={userInfo.data?.status ?? null}
backdropPath={title.backdropPath}
backdropThumbHash={title.backdropThumbHash}
/>
)}
{/* 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}
iconColor={titleAccent}
/>
{seasons.map((season) => (
<SeasonAccordion
key={season.id}
season={season}
episodes={season.episodes ?? []}
watchedEpisodeIds={watchedEpisodeIds}
/>
</Animated.View>
)}
))}
</Animated.View>
)}
{/* Recommendations */}
{recItems.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(600)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="More Like This"
icon={IconThumbUp}
iconColor={titleAccent}
/>
</View>
<HorizontalPosterRow items={recItems} />
</Animated.View>
)}
</ScrollView>
</>
{/* Cast */}
{cast.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(500)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="Cast"
icon={IconUsers}
iconColor={titleAccent}
/>
</View>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={cast}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={renderCastItem}
ItemSeparatorComponent={HorizontalListSeparator}
contentContainerStyle={horizontalListContentStyle}
style={horizontalListStyle}
/>
</Animated.View>
)}
{/* Recommendations */}
{recItems.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(600)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="More Like This"
icon={IconThumbUp}
iconColor={titleAccent}
/>
</View>
<HorizontalPosterRow items={recItems} />
</Animated.View>
)}
</ScrollView>
);
}
+2 -6
View File
@@ -19,19 +19,15 @@ export function AuthScreen({
logoStyle,
children,
}: AuthScreenProps) {
const useAutomaticInsets = process.env.EXPO_OS === "ios";
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<ScrollView
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentInsetAdjustmentBehavior="never"
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingTop: 24,
paddingTop: 42,
paddingBottom: 24,
}}
keyboardShouldPersistTaps="handled"
+3 -3
View File
@@ -9,7 +9,7 @@ import * as Haptics from "@/utils/haptics";
export function HeaderAvatar() {
const { data: session } = authClient.useSession();
const router = useRouter();
const { navigate } = useRouter();
if (!session?.user) return null;
@@ -39,7 +39,7 @@ export function HeaderAvatar() {
<View className="flex-1 items-center justify-center bg-primary/[0.08]">
<Text
maxFontSizeMultiplier={1.0}
className="font-medium font-sans text-primary text-sm"
className="font-display font-medium text-primary text-sm"
>
{user.name?.charAt(0)?.toUpperCase() ?? "?"}
</Text>
@@ -52,7 +52,7 @@ export function HeaderAvatar() {
<DropdownMenu.Content>
<DropdownMenu.Item
key="settings"
onSelect={() => router.navigate("/(tabs)/(settings)")}
onSelect={() => navigate("/(tabs)/(settings)")}
>
<DropdownMenu.ItemIcon
ios={{ name: "gear" }}
@@ -1,22 +0,0 @@
import { Stack } from "expo-router";
import { useCSSVariable, useResolveClassNames } from "uniwind";
export function AuthStackHeader({ title }: { title: string }) {
const headerTitleStyle = useResolveClassNames(
"font-display text-base text-foreground",
);
const tintColor = useCSSVariable("--color-primary") as string;
return (
<>
<Stack.Header
transparent
style={{ color: tintColor, shadowColor: "transparent" }}
/>
<Stack.Screen.BackButton displayMode="minimal" />
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
{title}
</Stack.Screen.Title>
</>
);
}
@@ -1,14 +0,0 @@
import { Stack } from "expo-router";
export function DetailStackHeader({ title }: { title?: string }) {
return (
<>
<Stack.Header transparent blurEffect="none" style={{ color: "white" }} />
<Stack.Screen.BackButton
displayMode="minimal"
withMenu={process.env.EXPO_OS === "ios"}
/>
{title ? <Stack.Screen.Title>{title}</Stack.Screen.Title> : null}
</>
);
}
@@ -0,0 +1,20 @@
import { Stack, useRouter } from "expo-router";
import { View } from "react-native";
export function DetailStackHeader() {
const { dismissAll } = useRouter();
return (
<>
<Stack.Header transparent blurEffect="none" />
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button onPress={() => dismissAll()}>
<Stack.Toolbar.Icon sf="xmark" />
<Stack.Toolbar.Label>Close</Stack.Toolbar.Label>
</Stack.Toolbar.Button>
</Stack.Toolbar>
<Stack.Screen.Title asChild>
<View />
</Stack.Screen.Title>
</>
);
}
@@ -1,27 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import {
NativeTabs,
type NativeTabsProps,
} from "expo-router/unstable-native-tabs";
import { useMemo } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import * as Haptics from "@/utils/haptics";
export function NativeTabBar() {
export function NativeTabBar({
showSettingsBadge,
}: {
showSettingsBadge: boolean;
}) {
const primaryColor = useCSSVariable("--color-primary") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const surfaceColor = useCSSVariable("--color-card") as string;
const rippleColor = useCSSVariable("--color-secondary") as string;
const { data: session } = authClient.useSession();
const isAdmin = session?.user?.role === "admin";
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
staleTime: 10 * 60 * 1000,
});
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
const labelTextStyle = useResolveClassNames("font-medium font-sans text-xs");
const screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
() => ({
@@ -31,7 +25,6 @@ export function NativeTabBar() {
}),
[],
);
const labelTextStyle = useResolveClassNames("font-medium font-sans text-xs");
return (
<NativeTabs
@@ -43,14 +36,8 @@ export function NativeTabBar() {
}}
indicatorColor="transparent"
labelStyle={{
default: [
labelTextStyle as Record<string, unknown>,
{ color: mutedFgColor },
],
selected: [
labelTextStyle as Record<string, unknown>,
{ color: primaryColor },
],
default: [labelTextStyle, { color: mutedFgColor }],
selected: [labelTextStyle, { color: primaryColor }],
}}
labelVisibilityMode="labeled"
rippleColor={rippleColor}
@@ -1,77 +1,54 @@
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { Stack } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useState } from "react";
import { useCSSVariable } from "uniwind";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
const tabTitles: Record<string, string> = {
"(home)": "Home",
"(explore)": "Explore",
"(search)": "Search",
"(settings)": "Settings",
};
export function NativeTabBar() {
export function NativeTabBar({
showSettingsBadge,
}: {
showSettingsBadge: boolean;
}) {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const [activeTitle, setActiveTitle] = useState("Home");
const { data: session } = authClient.useSession();
const isAdmin = session?.user?.role === "admin";
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
staleTime: 10 * 60 * 1000,
});
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
return (
<>
<Stack.Screen options={{ title: activeTitle }} />
<NativeTabs
iconColor={{
default: mutedFgColor,
selected: primaryColor,
}}
labelStyle={{
default: { color: mutedFgColor },
selected: { color: primaryColor },
}}
screenListeners={({ route }) => ({
tabPress: () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
focus: () => {
setActiveTitle(tabTitles[route.name] ?? "Home");
},
})}
<NativeTabs
iconColor={{
default: mutedFgColor,
selected: primaryColor,
}}
labelStyle={{
default: { color: mutedFgColor },
selected: { color: primaryColor },
}}
screenListeners={() => ({
tabPress: () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
})}
>
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? (
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
) : null}
</NativeTabs.Trigger>
<NativeTabs.Trigger
name="(search)"
role="search"
disableTransparentOnScrollEdge
>
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? (
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
) : null}
</NativeTabs.Trigger>
<NativeTabs.Trigger
name="(search)"
role="search"
disableTransparentOnScrollEdge
>
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
</NativeTabs.Trigger>
</NativeTabs>
</>
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
@@ -3,13 +3,6 @@ import type { ReactNode } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
const hiddenScrollEdgeEffects = {
top: "hidden" as const,
bottom: "hidden" as const,
left: "hidden" as const,
right: "hidden" as const,
};
export function TabStack({
title,
children,
@@ -35,7 +28,6 @@ export function TabStack({
<Stack
screenOptions={{
contentStyle,
scrollEdgeEffects: hiddenScrollEdgeEffects,
unstable_headerRightItems: () => [
{
type: "custom" as const,
@@ -49,7 +41,7 @@ export function TabStack({
<Stack.Screen name="index">
<Stack.Header
transparent
blurEffect="dark"
blurEffect="systemChromeMaterialDark"
style={{ color: tintColor, shadowColor: "transparent" }}
largeStyle={{
backgroundColor: "transparent",
@@ -1,57 +0,0 @@
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 { ScaledIcon } from "@/components/ui/scaled-icon";
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]}`}
>
<ScaledIcon icon={StatusIcon} size={12} color={colorMap[status]} />
<Text
maxFontSizeMultiplier={1.0}
className={`font-medium font-sans text-xs ${textClasses[status]}`}
>
{label}
</Text>
</View>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export const Input = forwardRef<
accessibilityLabelledBy={ctxId}
placeholderTextColorClassName="accent-muted-foreground/70"
className={cn(
"min-h-12 rounded-[12px] border border-border bg-input px-3.5 py-3 font-sans text-base text-foreground",
"min-h-12 rounded-[12px] border border-border bg-input px-3.5 py-3 font-sans text-foreground text-sm",
className,
)}
style={[{ borderCurve: "continuous" }, style]}
+28
View File
@@ -0,0 +1,28 @@
import type { ZodError } from "zod";
/**
* Extracts the first error message and the set of field names with errors
* from a Zod validation result.
*/
export function getFormErrors(error: ZodError): {
message: string;
fields: Set<string>;
} {
const fieldErrors = error.flatten().fieldErrors as Record<
string,
string[] | undefined
>;
const fields = new Set<string>();
let message = "";
for (const [key, messages] of Object.entries(fieldErrors)) {
if (messages && messages.length > 0) {
fields.add(key);
if (!message) {
message = messages[0];
}
}
}
return { message, fields };
}
-1
View File
@@ -11,7 +11,6 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/json-schema": "1.13.7",
"@orpc/openapi": "1.13.7",
+7 -10
View File
@@ -25,17 +25,16 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.167.1",
"@tanstack/react-router": "1.167.4",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "catalog:",
"jotai": "2.18.1",
"media-chrome": "4.18.0",
"motion": "12.36.0",
"media-chrome": "4.18.1",
"motion": "12.38.0",
"react": "catalog:",
"react-day-picker": "9.14.0",
"react-dom": "catalog:",
"recharts": "3.8.0",
"shadcn": "4.0.8",
@@ -43,17 +42,15 @@
"tailwind-merge": "catalog:",
"thumbhash": "catalog:",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
"zod": "catalog:"
"youtube-video-element": "1.9.0"
},
"devDependencies": {
"@tailwindcss/vite": "0.0.0-insiders.d24b112",
"@tailwindcss/vite": "0.0.0-insiders.f302fce",
"@tanstack/devtools-vite": "0.6.0",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-router-devtools": "1.166.8",
"@tanstack/router-plugin": "1.166.10",
"@tanstack/react-router-devtools": "1.166.9",
"@tanstack/router-plugin": "1.166.13",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
-225
View File
@@ -1,225 +0,0 @@
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react";
import * as React from "react";
import {
type DayButton,
DayPicker,
getDefaultClassNames,
type Locale,
} from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)]",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months,
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 select-none rounded-(--cell-radius) font-normal text-[0.8rem] text-muted-foreground",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header,
),
week_number: cn(
"select-none text-[0.8rem] text-muted-foreground",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none rounded-(--cell-radius) p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day,
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end,
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<IconChevronLeft className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<IconChevronRight
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<IconChevronDown className={cn("size-4", className)} {...props} />
);
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 font-normal leading-none data-[range-end=true]:rounded-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-end=true]:bg-primary data-[range-middle=true]:bg-muted data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
-129
View File
@@ -1,129 +0,0 @@
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/80 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col overscroll-contain bg-transparent p-2 text-xs/relaxed before:absolute before:inset-2 before:-z-10 before:rounded-xl before:border before:border-border before:bg-background data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-1 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
};