mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -102,6 +102,7 @@
|
||||
}
|
||||
],
|
||||
["expo-system-ui"],
|
||||
["expo-web-browser"],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
|
||||
+19
-18
@@ -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",
|
||||
|
||||
@@ -16,6 +16,8 @@ export default function AuthLayout() {
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle,
|
||||
headerShown: false,
|
||||
navigationBarHidden: true,
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user