mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
refactor(native): centralize navigation header configuration into dedicated stack components
Replace the `useTabScreenOptions` hook (and its `.android` variant) with a `TabStack` component that encapsulates all tab-level header styling in one place. Introduce `AuthStackHeader` and `DetailStackHeader` components to own header configuration for auth and detail screens respectively, removing scattered `Stack.Screen options` overrides from individual screen files.
- Delete `use-tab-screen-options.tsx` and `use-tab-screen-options.android.tsx`; all four tab layouts now render `<TabStack title="…" />` instead of manually composing `<Stack screenOptions={useTabScreenOptions()}>`.
- Add `AuthStackHeader` and swap every `<Stack.Screen options={{ title: "…" }}>` inside auth screens for it; strip the now-redundant header option overrides from the auth `_layout`.
- Add `DetailStackHeader` and remove the hardcoded `headerTransparent`/`headerTintColor`/`headerBackButtonDisplayMode` options from the title and person detail routes in `_layout.tsx`.
- Replace remaining `Stack.Screen options={{ title }}` patterns in non-tab screens (`change-password`, `+not-found`) with the `Stack.Screen.Title` helper.
- Refactor the search screen to use `Stack.Header`, `Stack.Screen.Title`, and `Stack.SearchBar` instead of an inline `Stack.Screen options` object; add `placement` and `allowToolbarIntegration` for the iOS integrated search bar.
- Add `browseTitleIds` server procedure and extend `explore` / `discover` procedures; add corresponding schema fields in `@sofa/api`.
This commit is contained in:
33 files changed
+367
-241
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
import { useResolveClassNames } from "uniwind";
|
||||||
import { hasStoredServerUrl } from "@/lib/server-url";
|
import { hasStoredServerUrl } from "@/lib/server-url";
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
@@ -10,20 +10,11 @@ export const unstable_settings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
const headerTitleStyle = useResolveClassNames(
|
|
||||||
"font-display text-base text-foreground",
|
|
||||||
);
|
|
||||||
const contentStyle = useResolveClassNames("bg-background");
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
const tintColor = useCSSVariable("--color-primary") as string;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerTransparent: true,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
|
|
||||||
headerTintColor: tintColor,
|
|
||||||
contentStyle,
|
contentStyle,
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, Stack } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Alert, Pressable, type TextInput, View } from "react-native";
|
import { Alert, Pressable, type TextInput, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AuthScreen } from "@/components/auth-screen";
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
|
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -73,7 +74,7 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthScreen title="Sofa" subtitle="Sign in to continue">
|
<AuthScreen title="Sofa" subtitle="Sign in to continue">
|
||||||
<Stack.Screen options={{ title: "Sign In" }} />
|
<AuthStackHeader title="Sign In" />
|
||||||
{showOidc && (
|
{showOidc && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.duration(300).delay(100)}
|
entering={FadeInDown.duration(300).delay(100)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, Stack } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Alert, Pressable, type TextInput, View } from "react-native";
|
import { Alert, Pressable, type TextInput, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AuthScreen } from "@/components/auth-screen";
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
|
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -90,7 +91,7 @@ export default function RegisterScreen() {
|
|||||||
title="Registration Closed"
|
title="Registration Closed"
|
||||||
subtitle="New account creation is currently disabled."
|
subtitle="New account creation is currently disabled."
|
||||||
>
|
>
|
||||||
<Stack.Screen options={{ title: "Registration Closed" }} />
|
<AuthStackHeader title="Registration Closed" />
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<Link href="/(auth)/login" asChild>
|
<Link href="/(auth)/login" asChild>
|
||||||
<Button className="mt-6 bg-primary">
|
<Button className="mt-6 bg-primary">
|
||||||
@@ -106,7 +107,7 @@ export default function RegisterScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthScreen title="Create Account">
|
<AuthScreen title="Create Account">
|
||||||
<Stack.Screen options={{ title: "Create Account" }} />
|
<AuthStackHeader title="Create Account" />
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
isSubmitting: state.isSubmitting,
|
isSubmitting: state.isSubmitting,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Linking, Pressable, TextInput, View } from "react-native";
|
import { Linking, Pressable, TextInput, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -16,6 +16,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
import { AuthScreen } from "@/components/auth-screen";
|
import { AuthScreen } from "@/components/auth-screen";
|
||||||
|
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
|
||||||
import { Button, ButtonLabel } from "@/components/ui/button";
|
import { Button, ButtonLabel } from "@/components/ui/button";
|
||||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
@@ -152,7 +153,7 @@ export default function ServerUrlScreen() {
|
|||||||
subtitle="Enter your Sofa server URL to get started"
|
subtitle="Enter your Sofa server URL to get started"
|
||||||
logoStyle={iconAnimatedStyle}
|
logoStyle={iconAnimatedStyle}
|
||||||
>
|
>
|
||||||
<Stack.Screen options={{ title: "Server" }} />
|
<AuthStackHeader title="Server" />
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||||
<View
|
<View
|
||||||
className="min-h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
|
className="min-h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Stack } from "expo-router";
|
import { TabStack } from "@/components/navigation/tab-stack";
|
||||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
|
||||||
|
|
||||||
export default function ExploreLayout() {
|
export default function ExploreLayout() {
|
||||||
return <Stack screenOptions={useTabScreenOptions()} />;
|
return <TabStack title="Explore" />;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Stack } from "expo-router";
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
@@ -83,7 +82,6 @@ export default function ExploreScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack.Screen options={{ title: "Explore" }} />
|
|
||||||
<View className="gap-8">
|
<View className="gap-8">
|
||||||
{heroItem && (
|
{heroItem && (
|
||||||
<Animated.View entering={FadeIn.duration(400)}>
|
<Animated.View entering={FadeIn.duration(400)}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Stack } from "expo-router";
|
import { TabStack } from "@/components/navigation/tab-stack";
|
||||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
|
||||||
|
|
||||||
export default function HomeLayout() {
|
export default function HomeLayout() {
|
||||||
return <Stack screenOptions={useTabScreenOptions()} />;
|
return <TabStack title="Home" />;
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
IconThumbUp,
|
IconThumbUp,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
@@ -91,7 +91,6 @@ export default function DashboardScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack.Screen options={{ title: "Home" }} />
|
|
||||||
<View className="gap-8">
|
<View className="gap-8">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Stack } from "expo-router";
|
import { TabStack } from "@/components/navigation/tab-stack";
|
||||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
return (
|
return <TabStack />;
|
||||||
<Stack screenOptions={useTabScreenOptions()}>
|
|
||||||
<Stack.Screen name="index" options={{ title: "Search" }} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -142,17 +142,22 @@ export default function SearchScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-background">
|
<View className="flex-1 bg-background">
|
||||||
<Stack.Screen
|
<Stack.Header
|
||||||
options={{
|
transparent={false}
|
||||||
headerTransparent: false,
|
style={{ backgroundColor: "#000" }}
|
||||||
headerStyle: { backgroundColor: "#000" },
|
largeStyle={{ backgroundColor: "#000" }}
|
||||||
headerLargeStyle: { backgroundColor: "#000" },
|
/>
|
||||||
headerSearchBarOptions: {
|
<Stack.Screen.Title>Search</Stack.Screen.Title>
|
||||||
placeholder: "Search movies, shows, people...",
|
<Stack.SearchBar
|
||||||
onChangeText: (e) => setQuery(e.nativeEvent.text),
|
placeholder="Search movies, shows, people..."
|
||||||
hideWhenScrolling: false,
|
onChangeText={(e) => setQuery(e.nativeEvent.text)}
|
||||||
},
|
onCancelButtonPress={() => setQuery("")}
|
||||||
}}
|
onClose={() => setQuery("")}
|
||||||
|
hideWhenScrolling={false}
|
||||||
|
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
|
||||||
|
allowToolbarIntegration={
|
||||||
|
process.env.EXPO_OS === "ios" ? true : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{debouncedQuery.length === 0 ? (
|
{debouncedQuery.length === 0 ? (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Stack } from "expo-router";
|
import { TabStack } from "@/components/navigation/tab-stack";
|
||||||
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
|
|
||||||
|
|
||||||
export default function SettingsLayout() {
|
export default function SettingsLayout() {
|
||||||
return <Stack screenOptions={useTabScreenOptions()} />;
|
return <TabStack title="Settings" />;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import * as Application from "expo-application";
|
import * as Application from "expo-application";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -218,8 +218,6 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack.Screen options={{ title: "Settings" }} />
|
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
<SettingsSection title="Account" icon={IconUser}>
|
<SettingsSection title="Account" icon={IconUser}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function NotFoundScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Not Found" }} />
|
<Stack.Screen.Title>Not Found</Stack.Screen.Title>
|
||||||
<Container>
|
<Container>
|
||||||
<View className="flex-1 items-center justify-center p-4">
|
<View className="flex-1 items-center justify-center p-4">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export const unstable_settings = {
|
|||||||
const changePasswordOptions =
|
const changePasswordOptions =
|
||||||
process.env.EXPO_OS === "ios"
|
process.env.EXPO_OS === "ios"
|
||||||
? {
|
? {
|
||||||
title: "Change Password",
|
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
presentation: "formSheet" as const,
|
presentation: "formSheet" as const,
|
||||||
sheetAllowedDetents: "fitToContents" as const,
|
sheetAllowedDetents: "fitToContents" as const,
|
||||||
@@ -77,7 +76,6 @@ const changePasswordOptions =
|
|||||||
headerBlurEffect: "none" as const,
|
headerBlurEffect: "none" as const,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: "Change Password",
|
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
presentation: "modal" as const,
|
presentation: "modal" as const,
|
||||||
headerLargeTitle: false,
|
headerLargeTitle: false,
|
||||||
@@ -239,10 +237,6 @@ function AppContent() {
|
|||||||
dangerouslySingular
|
dangerouslySingular
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
animation: "slide_from_right",
|
animation: "slide_from_right",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -251,10 +245,6 @@ function AppContent() {
|
|||||||
dangerouslySingular
|
dangerouslySingular
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
animation: "slide_from_right",
|
animation: "slide_from_right",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function ChangePasswordScreen() {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<Stack.Screen options={{ title: "Change Password" }} />
|
<Stack.Screen.Title>Change Password</Stack.Screen.Title>
|
||||||
|
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { format } from "date-fns/format";
|
import { format } from "date-fns/format";
|
||||||
import { parseISO } from "date-fns/parseISO";
|
import { parseISO } from "date-fns/parseISO";
|
||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { DetailStackHeader } from "@/components/navigation/detail-stack-header";
|
||||||
import { ExpandableText } from "@/components/ui/expandable-text";
|
import { ExpandableText } from "@/components/ui/expandable-text";
|
||||||
import { Image } from "@/components/ui/image";
|
import { Image } from "@/components/ui/image";
|
||||||
import { PosterCard } from "@/components/ui/poster-card";
|
import { PosterCard } from "@/components/ui/poster-card";
|
||||||
@@ -164,15 +165,7 @@ export default function PersonDetailScreen() {
|
|||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
className="flex-1 items-center bg-background"
|
className="flex-1 items-center bg-background"
|
||||||
style={{ paddingTop: headerHeight + 24 }}
|
style={{ paddingTop: headerHeight + 24 }}
|
||||||
@@ -207,15 +200,7 @@ export default function PersonDetailScreen() {
|
|||||||
if (isError && !data) {
|
if (isError && !data) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
className="flex-1 items-center justify-center bg-background"
|
className="flex-1 items-center justify-center bg-background"
|
||||||
style={{ paddingTop: insets.top }}
|
style={{ paddingTop: insets.top }}
|
||||||
@@ -243,15 +228,7 @@ export default function PersonDetailScreen() {
|
|||||||
if (!person) {
|
if (!person) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
className="flex-1 items-center justify-center bg-background"
|
className="flex-1 items-center justify-center bg-background"
|
||||||
style={{ paddingTop: insets.top }}
|
style={{ paddingTop: insets.top }}
|
||||||
@@ -363,15 +340,7 @@ export default function PersonDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader title={person.name} />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className="flex-1 bg-background">
|
<View className="flex-1 bg-background">
|
||||||
<FlashList
|
<FlashList
|
||||||
data={filmography}
|
data={filmography}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { Link, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
import * as WebBrowser from "expo-web-browser";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
|
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
HorizontalPosterRow,
|
HorizontalPosterRow,
|
||||||
type PosterRowItem,
|
type PosterRowItem,
|
||||||
} from "@/components/dashboard/horizontal-poster-row";
|
} from "@/components/dashboard/horizontal-poster-row";
|
||||||
|
import { DetailStackHeader } from "@/components/navigation/detail-stack-header";
|
||||||
import { CastCard } from "@/components/titles/cast-card";
|
import { CastCard } from "@/components/titles/cast-card";
|
||||||
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
|
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
|
||||||
import { SeasonAccordion } from "@/components/titles/season-accordion";
|
import { SeasonAccordion } from "@/components/titles/season-accordion";
|
||||||
@@ -284,15 +285,7 @@ export default function TitleDetailScreen() {
|
|||||||
if (detail.isPending) {
|
if (detail.isPending) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className="flex-1 bg-background">
|
<View className="flex-1 bg-background">
|
||||||
{/* Hero skeleton */}
|
{/* Hero skeleton */}
|
||||||
<Skeleton width="100%" height={300} borderRadius={0} />
|
<Skeleton width="100%" height={300} borderRadius={0} />
|
||||||
@@ -322,15 +315,7 @@ export default function TitleDetailScreen() {
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className="flex-1 items-center justify-center bg-background px-6">
|
<View className="flex-1 items-center justify-center bg-background px-6">
|
||||||
<IconMovie size={48} color={mutedForeground} />
|
<IconMovie size={48} color={mutedForeground} />
|
||||||
<Text className="mt-3 font-display text-foreground text-xl">
|
<Text className="mt-3 font-display text-foreground text-xl">
|
||||||
@@ -348,15 +333,7 @@ export default function TitleDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<DetailStackHeader title={title.title} />
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTintColor: "white",
|
|
||||||
headerBackButtonDisplayMode: "minimal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
contentInsetAdjustmentBehavior={
|
contentInsetAdjustmentBehavior={
|
||||||
@@ -424,17 +401,19 @@ export default function TitleDetailScreen() {
|
|||||||
|
|
||||||
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
|
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
|
||||||
{title.posterPath && (
|
{title.posterPath && (
|
||||||
<View
|
<Link.AppleZoomTarget>
|
||||||
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
|
<View
|
||||||
style={[{ borderCurve: "continuous" }, posterShadowStyle]}
|
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
|
||||||
>
|
style={[{ borderCurve: "continuous" }, posterShadowStyle]}
|
||||||
<Image
|
>
|
||||||
source={{ uri: title.posterPath }}
|
<Image
|
||||||
thumbHash={title.posterThumbHash}
|
source={{ uri: title.posterPath }}
|
||||||
style={titleDetailStyles.posterImage}
|
thumbHash={title.posterThumbHash}
|
||||||
contentFit="cover"
|
style={titleDetailStyles.posterImage}
|
||||||
/>
|
contentFit="cover"
|
||||||
</View>
|
/>
|
||||||
|
</View>
|
||||||
|
</Link.AppleZoomTarget>
|
||||||
)}
|
)}
|
||||||
<View className="flex-1 pb-1">
|
<View className="flex-1 pb-1">
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
|
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
|
||||||
<Link.Trigger>
|
<Link.Trigger withAppleZoom>
|
||||||
<GestureDetector gesture={tapGesture}>
|
<GestureDetector gesture={tapGesture}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
accessible
|
accessible
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ export function FilterableTitleRow({
|
|||||||
icon: Icon;
|
icon: Icon;
|
||||||
mediaType: "movie" | "tv";
|
mediaType: "movie" | "tv";
|
||||||
defaultItems: Array<{
|
defaultItems: Array<{
|
||||||
|
id?: string;
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate?: string | null;
|
releaseDate?: string | null;
|
||||||
firstAirDate?: string | null;
|
firstAirDate?: string | null;
|
||||||
voteAverage?: number | null;
|
voteAverage?: number | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IconStarFilled } from "@tabler/icons-react-native";
|
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
import { useRouter } from "expo-router";
|
import { Link, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
@@ -19,6 +19,7 @@ import { orpc } from "@/lib/orpc";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export interface HeroBannerItem {
|
export interface HeroBannerItem {
|
||||||
|
id?: string;
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -73,8 +74,11 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
const titleHref = item.id
|
||||||
|
? (`/title/${item.id}` as `/title/${string}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
const bannerContent = (
|
||||||
<GestureDetector gesture={tapGesture}>
|
<GestureDetector gesture={tapGesture}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
className="mx-4 overflow-hidden rounded-2xl"
|
className="mx-4 overflow-hidden rounded-2xl"
|
||||||
@@ -88,7 +92,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={titleHref ? undefined : handlePress}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
accessibilityHint="Opens title details"
|
accessibilityHint="Opens title details"
|
||||||
@@ -179,4 +183,15 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (titleHref) {
|
||||||
|
return (
|
||||||
|
<Link href={titleHref}>
|
||||||
|
<Link.Trigger>{bannerContent}</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerContent;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IconStarFilled } from "@tabler/icons-react-native";
|
import { IconStarFilled } from "@tabler/icons-react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { Link, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
@@ -19,6 +19,7 @@ import { orpc } from "@/lib/orpc";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export interface HeroBannerItem {
|
export interface HeroBannerItem {
|
||||||
|
id?: string;
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -72,8 +73,11 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
const titleHref = item.id
|
||||||
|
? (`/title/${item.id}` as `/title/${string}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
const bannerContent = (
|
||||||
<GestureDetector gesture={tapGesture}>
|
<GestureDetector gesture={tapGesture}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
className="mx-4 overflow-hidden rounded-2xl"
|
className="mx-4 overflow-hidden rounded-2xl"
|
||||||
@@ -87,7 +91,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={titleHref ? undefined : handlePress}
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
@@ -135,4 +139,15 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (titleHref) {
|
||||||
|
return (
|
||||||
|
<Link href={titleHref}>
|
||||||
|
<Link.Trigger>{bannerContent}</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerContent;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
NativeTabs,
|
NativeTabs,
|
||||||
type NativeTabsProps,
|
type NativeTabsProps,
|
||||||
} from "expo-router/unstable-native-tabs";
|
} from "expo-router/unstable-native-tabs";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function NativeTabBar() {
|
export function NativeTabBar() {
|
||||||
@@ -11,6 +14,14 @@ export function NativeTabBar() {
|
|||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const surfaceColor = useCSSVariable("--color-card") as string;
|
const surfaceColor = useCSSVariable("--color-card") as string;
|
||||||
const rippleColor = useCSSVariable("--color-secondary") 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 screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
|
const screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -45,21 +56,24 @@ export function NativeTabBar() {
|
|||||||
rippleColor={rippleColor}
|
rippleColor={rippleColor}
|
||||||
screenListeners={screenListeners}
|
screenListeners={screenListeners}
|
||||||
>
|
>
|
||||||
<NativeTabs.Trigger name="(home)">
|
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon md="home" />
|
<NativeTabs.Trigger.Icon md="home" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(explore)">
|
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon md="explore" />
|
<NativeTabs.Trigger.Icon md="explore" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(search)">
|
<NativeTabs.Trigger name="(search)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon md="search" />
|
<NativeTabs.Trigger.Icon md="search" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(settings)">
|
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon md="settings" />
|
<NativeTabs.Trigger.Icon md="settings" />
|
||||||
|
{showSettingsBadge ? (
|
||||||
|
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
|
||||||
|
) : null}
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/lib/orpc";
|
||||||
|
|
||||||
const tabTitles: Record<string, string> = {
|
const tabTitles: Record<string, string> = {
|
||||||
"(home)": "Home",
|
"(home)": "Home",
|
||||||
@@ -15,6 +18,14 @@ export function NativeTabBar() {
|
|||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||||
const [activeTitle, setActiveTitle] = useState("Home");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -37,19 +48,26 @@ export function NativeTabBar() {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<NativeTabs.Trigger name="(home)">
|
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(explore)">
|
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
|
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(settings)">
|
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
|
{showSettingsBadge ? (
|
||||||
|
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
|
||||||
|
) : null}
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="(search)" role="search" />
|
<NativeTabs.Trigger
|
||||||
|
name="(search)"
|
||||||
|
role="search"
|
||||||
|
disableTransparentOnScrollEdge
|
||||||
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const contentStyle = useResolveClassNames("bg-background");
|
||||||
|
const tintColor = useCSSVariable("--color-primary") as string;
|
||||||
|
const backgroundColor = useCSSVariable("--color-background") as string;
|
||||||
|
const iosHeaderLargeTitleStyle = useResolveClassNames(
|
||||||
|
"font-display text-foreground",
|
||||||
|
);
|
||||||
|
const iosHeaderTitleStyle = useResolveClassNames(
|
||||||
|
"font-display text-foreground text-lg",
|
||||||
|
);
|
||||||
|
const androidHeaderTitleStyle = useResolveClassNames(
|
||||||
|
"font-sans font-semibold text-foreground text-lg",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (process.env.EXPO_OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle,
|
||||||
|
scrollEdgeEffects: hiddenScrollEdgeEffects,
|
||||||
|
unstable_headerRightItems: () => [
|
||||||
|
{
|
||||||
|
type: "custom" as const,
|
||||||
|
element: <HeaderAvatar />,
|
||||||
|
hidesSharedBackground: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<Stack.Screen name="index">
|
||||||
|
<Stack.Header
|
||||||
|
transparent
|
||||||
|
blurEffect="dark"
|
||||||
|
style={{ color: tintColor, shadowColor: "transparent" }}
|
||||||
|
largeStyle={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
shadowColor: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen.Title
|
||||||
|
large
|
||||||
|
style={iosHeaderTitleStyle as Record<string, unknown>}
|
||||||
|
largeStyle={iosHeaderLargeTitleStyle as Record<string, unknown>}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Stack.Screen.Title>
|
||||||
|
</Stack.Screen>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle,
|
||||||
|
headerTitleAlign: "left",
|
||||||
|
headerRight: () => <HeaderAvatar />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<Stack.Screen name="index">
|
||||||
|
<Stack.Header
|
||||||
|
transparent={false}
|
||||||
|
style={{
|
||||||
|
backgroundColor,
|
||||||
|
color: tintColor,
|
||||||
|
shadowColor: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen.Title
|
||||||
|
style={androidHeaderTitleStyle as Record<string, unknown>}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Stack.Screen.Title>
|
||||||
|
</Stack.Screen>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
|
import { Link } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
@@ -272,19 +273,25 @@ export function PosterCard({
|
|||||||
|
|
||||||
// Cards with id: use context menu with navigation
|
// Cards with id: use context menu with navigation
|
||||||
if (id) {
|
if (id) {
|
||||||
|
const titleHref = `/title/${id}` as `/title/${string}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger>
|
<ContextMenu.Trigger>
|
||||||
<GestureDetector gesture={pressGesture}>
|
<GestureDetector gesture={pressGesture}>
|
||||||
<Animated.View style={[animatedStyle, { width }]}>
|
<Animated.View style={[animatedStyle, { width }]}>
|
||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Link href={titleHref}>
|
||||||
onPress={handlePressAction}
|
<Link.Trigger withAppleZoom>
|
||||||
accessibilityRole="button"
|
<Pressable
|
||||||
accessibilityLabel={cardAccessibilityLabel}
|
accessibilityRole="button"
|
||||||
>
|
accessibilityLabel={cardAccessibilityLabel}
|
||||||
{cardContent}
|
>
|
||||||
</Pressable>
|
{cardContent}
|
||||||
|
</Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Preview />
|
||||||
|
</Link>
|
||||||
{quickAddButton}
|
{quickAddButton}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { Stack } from "expo-router";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
|
||||||
import { HeaderAvatar } from "@/components/header-avatar";
|
|
||||||
|
|
||||||
type ScreenOptions = NonNullable<
|
|
||||||
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function useTabScreenOptions() {
|
|
||||||
const backgroundColor = useCSSVariable("--color-background") as string;
|
|
||||||
const headerTitleStyle = useResolveClassNames(
|
|
||||||
"font-sans font-semibold text-foreground text-lg",
|
|
||||||
);
|
|
||||||
const contentStyle = useResolveClassNames("bg-background");
|
|
||||||
|
|
||||||
return {
|
|
||||||
headerLargeTitle: false,
|
|
||||||
headerTransparent: false,
|
|
||||||
headerStyle: { backgroundColor },
|
|
||||||
headerTitleAlign: "left" as const,
|
|
||||||
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
|
|
||||||
headerTintColor: useCSSVariable("--color-primary") as string,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerBackButtonDisplayMode: "minimal" as const,
|
|
||||||
headerRight: () => <HeaderAvatar />,
|
|
||||||
contentStyle,
|
|
||||||
} satisfies ScreenOptions;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { Stack } from "expo-router";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
|
||||||
import { HeaderAvatar } from "@/components/header-avatar";
|
|
||||||
|
|
||||||
type ScreenOptions = NonNullable<
|
|
||||||
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function useTabScreenOptions() {
|
|
||||||
const headerLargeTitleStyle = useResolveClassNames(
|
|
||||||
"font-display text-foreground",
|
|
||||||
);
|
|
||||||
const headerTitleStyle = useResolveClassNames(
|
|
||||||
"font-display text-foreground text-lg",
|
|
||||||
);
|
|
||||||
const contentStyle = useResolveClassNames("bg-background");
|
|
||||||
|
|
||||||
return {
|
|
||||||
headerLargeTitle: true,
|
|
||||||
headerTransparent: true,
|
|
||||||
headerBlurEffect: "dark" as const,
|
|
||||||
headerLargeStyle: { backgroundColor: "transparent" },
|
|
||||||
headerLargeTitleStyle: headerLargeTitleStyle as Record<string, unknown>,
|
|
||||||
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
|
|
||||||
headerTintColor: useCSSVariable("--color-primary") as string,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLargeTitleShadowVisible: false,
|
|
||||||
headerBackButtonDisplayMode: "minimal" as const,
|
|
||||||
scrollEdgeEffects: {
|
|
||||||
top: "hidden" as const,
|
|
||||||
bottom: "hidden" as const,
|
|
||||||
left: "hidden" as const,
|
|
||||||
right: "hidden" as const,
|
|
||||||
},
|
|
||||||
unstable_headerRightItems: () => [
|
|
||||||
{
|
|
||||||
type: "custom" as const,
|
|
||||||
element: <HeaderAvatar />,
|
|
||||||
hidesSharedBackground: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
contentStyle,
|
|
||||||
} satisfies ScreenOptions;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { db } from "@sofa/db/client";
|
||||||
|
import { inArray } from "@sofa/db/helpers";
|
||||||
|
import { titles } from "@sofa/db/schema";
|
||||||
|
|
||||||
|
interface BrowseTitleLookup {
|
||||||
|
tmdbId: number;
|
||||||
|
type: "movie" | "tv";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrowseTitleIds(
|
||||||
|
lookups: BrowseTitleLookup[],
|
||||||
|
): Record<string, string> {
|
||||||
|
if (lookups.length === 0) return {};
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: titles.id,
|
||||||
|
tmdbId: titles.tmdbId,
|
||||||
|
type: titles.type,
|
||||||
|
})
|
||||||
|
.from(titles)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
titles.tmdbId,
|
||||||
|
lookups.map((lookup) => lookup.tmdbId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const idsByLookup: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
idsByLookup[`${row.tmdbId}-${row.type}`] = row.id;
|
||||||
|
}
|
||||||
|
return idsByLookup;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
browseLookupKey,
|
browseLookupKey,
|
||||||
getBrowsePosterThumbHashes,
|
getBrowsePosterThumbHashes,
|
||||||
} from "./browse-thumbhashes";
|
} from "./browse-thumbhashes";
|
||||||
|
import { getBrowseTitleIds } from "./browse-title-ids";
|
||||||
|
|
||||||
export const discover = os.discover
|
export const discover = os.discover
|
||||||
.use(authed)
|
.use(authed)
|
||||||
@@ -50,9 +51,13 @@ export const discover = os.discover
|
|||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||||
voteAverage: r.vote_average ?? null,
|
voteAverage: r.vote_average ?? null,
|
||||||
}));
|
}));
|
||||||
|
const titleIdsByLookup = getBrowseTitleIds(
|
||||||
|
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
|
||||||
|
);
|
||||||
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
|
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
|
||||||
const items = baseItems.map((item) => ({
|
const items = baseItems.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
id: titleIdsByLookup[browseLookupKey(item)],
|
||||||
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
|
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
browseLookupKey,
|
browseLookupKey,
|
||||||
getBrowsePosterThumbHashes,
|
getBrowsePosterThumbHashes,
|
||||||
} from "./browse-thumbhashes";
|
} from "./browse-thumbhashes";
|
||||||
|
import { getBrowseTitleIds } from "./browse-title-ids";
|
||||||
|
|
||||||
function requireTmdb() {
|
function requireTmdb() {
|
||||||
if (!isTmdbConfigured()) {
|
if (!isTmdbConfigured()) {
|
||||||
@@ -49,18 +50,33 @@ export const trending = os.explore.trending
|
|||||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
voteAverage: (r.vote_average as number | undefined) ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
|
|
||||||
const items = baseItems.map((item) => ({
|
|
||||||
...item,
|
|
||||||
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const heroResult = results.find(
|
const heroResult = results.find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
||||||
);
|
);
|
||||||
|
const titleIdsByLookup = getBrowseTitleIds([
|
||||||
|
...baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
|
||||||
|
...(heroResult
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
tmdbId: heroResult.id as number,
|
||||||
|
type: heroResult.media_type as "movie" | "tv",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
|
||||||
|
const items = baseItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: titleIdsByLookup[browseLookupKey(item)],
|
||||||
|
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
const hero = heroResult
|
const hero = heroResult
|
||||||
? {
|
? {
|
||||||
|
id: titleIdsByLookup[
|
||||||
|
`${heroResult.id as number}-${heroResult.media_type as "movie" | "tv"}`
|
||||||
|
],
|
||||||
tmdbId: heroResult.id as number,
|
tmdbId: heroResult.id as number,
|
||||||
type: heroResult.media_type as "movie" | "tv",
|
type: heroResult.media_type as "movie" | "tv",
|
||||||
title:
|
title:
|
||||||
@@ -111,9 +127,13 @@ export const popular = os.explore.popular
|
|||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||||
voteAverage: (r.vote_average as number | undefined) ?? null,
|
voteAverage: (r.vote_average as number | undefined) ?? null,
|
||||||
}));
|
}));
|
||||||
|
const titleIdsByLookup = getBrowseTitleIds(
|
||||||
|
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
|
||||||
|
);
|
||||||
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
|
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
|
||||||
const items = baseItems.map((item) => ({
|
const items = baseItems.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
id: titleIdsByLookup[browseLookupKey(item)],
|
||||||
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
|
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,10 @@ export const PersonCreditSchema = z
|
|||||||
/** Reusable TMDB browse result (trending / popular / discover items) */
|
/** Reusable TMDB browse result (trending / popular / discover items) */
|
||||||
export const TmdbBrowseItem = z
|
export const TmdbBrowseItem = z
|
||||||
.object({
|
.object({
|
||||||
|
id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Internal title ID when the title already exists locally"),
|
||||||
tmdbId: z.number().describe("TMDB numeric ID"),
|
tmdbId: z.number().describe("TMDB numeric ID"),
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: z.string().describe("Display title"),
|
title: z.string().describe("Display title"),
|
||||||
@@ -690,6 +694,10 @@ export const TrendingOutput = z
|
|||||||
items: z.array(TmdbBrowseItem).describe("Trending titles"),
|
items: z.array(TmdbBrowseItem).describe("Trending titles"),
|
||||||
hero: z
|
hero: z
|
||||||
.object({
|
.object({
|
||||||
|
id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Internal title ID when the title already exists locally"),
|
||||||
tmdbId: z.number().describe("TMDB numeric ID"),
|
tmdbId: z.number().describe("TMDB numeric ID"),
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: z.string().describe("Display title"),
|
title: z.string().describe("Display title"),
|
||||||
|
|||||||
Reference in new issue
Block a user