├── hooks ├── useColorScheme.ts ├── useColorScheme.web.ts ├── useHeaderSearch.ts └── useTabToTop.ts ├── assets └── images │ ├── icon.png │ ├── texture.png │ ├── adaptive-icon.png │ └── splash-icon.png ├── components ├── img.tsx ├── layout │ ├── modalNavigator.tsx │ ├── modal.module.css │ └── modalNavigator.web.tsx ├── ui │ ├── TouchableBounce.tsx │ ├── IconSymbol.tsx │ ├── TouchableBounce.native.tsx │ ├── TabBarBackground.tsx │ ├── FadeIn.tsx │ ├── ThemeProvider.tsx │ ├── TabBarBackground.ios.tsx │ ├── IconSymbol.ios.tsx │ ├── BodyScrollView.tsx │ ├── Header.tsx │ ├── Stack.tsx │ ├── Tabs.tsx │ ├── Form.tsx │ └── IconSymbolFallback.tsx ├── HapticTab.tsx ├── ShowMore.tsx ├── SearchPlaceholder.tsx ├── usable-component.tsx └── show-header-background.tsx ├── app ├── (redirects) │ ├── github+api.ts │ └── testflight+api.ts ├── (index) │ ├── _layout.tsx │ ├── index.tsx │ ├── movie │ │ └── [id].tsx │ ├── show │ │ └── [id].tsx │ └── person │ │ └── [id].tsx ├── (settings) │ ├── _layout.tsx │ └── index.tsx ├── _layout.tsx └── _layout.web.tsx ├── .env ├── eslint.config.js ├── tsconfig.json ├── eas.json ├── .vscode └── settings.json ├── .gitignore ├── public └── .well-known │ └── apple-app-site-association ├── README.md ├── app.json ├── package.json ├── patches └── react-server-dom-webpack+19.1.2.patch └── functions ├── render-person-details.tsx ├── render-search.tsx ├── fixtures └── search-fixtures.ts └── render-movie-details.tsx /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-rsc-movies/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /components/img.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Image } from "expo-image"; 4 | 5 | export { Image }; 6 | -------------------------------------------------------------------------------- /components/layout/modalNavigator.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export default Stack; 4 | -------------------------------------------------------------------------------- /assets/images/texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-rsc-movies/HEAD/assets/images/texture.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-rsc-movies/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-rsc-movies/HEAD/assets/images/splash-icon.png -------------------------------------------------------------------------------- /app/(redirects)/github+api.ts: -------------------------------------------------------------------------------- 1 | export function GET() { 2 | return Response.redirect("https://github.com/EvanBacon/expo-rsc-movies"); 3 | } 4 | -------------------------------------------------------------------------------- /app/(redirects)/testflight+api.ts: -------------------------------------------------------------------------------- 1 | export function GET() { 2 | return Response.redirect("https://testflight.apple.com/join/2dukdeQW"); 3 | } 4 | -------------------------------------------------------------------------------- /components/ui/TouchableBounce.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TouchableOpacity } from "react-native"; 4 | 5 | export default TouchableOpacity; 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Add your TMDB API key here 2 | # Get yours here https://www.themoviedb.org/settings/api 3 | TMDB_READ_ACCESS_TOKEN=xxx 4 | EXPO_UNSTABLE_DEPLOY_SERVER=1 -------------------------------------------------------------------------------- /components/ui/IconSymbol.tsx: -------------------------------------------------------------------------------- 1 | import { IconSymbolMaterial, IconSymbolName } from "./IconSymbolFallback"; 2 | 3 | export const IconSymbol = IconSymbolMaterial; 4 | 5 | export { IconSymbolName }; 6 | -------------------------------------------------------------------------------- /components/ui/TouchableBounce.native.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import TouchableBounce from "react-native/Libraries/Components/Touchable/TouchableBounce"; 3 | 4 | export default TouchableBounce; 5 | -------------------------------------------------------------------------------- /components/ui/TabBarBackground.tsx: -------------------------------------------------------------------------------- 1 | // This is a shim for web and Android where the tab bar is generally opaque. 2 | export default undefined; 3 | 4 | export function useBottomTabOverflow() { 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require('eslint/config'); 3 | const expoConfig = require("eslint-config-expo/flat"); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ["dist/*"], 9 | } 10 | ]); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /components/ui/FadeIn.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Animated, { FadeIn as EnterFadeIn } from "react-native-reanimated"; 4 | 5 | export function FadeIn({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(index)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import React from "react"; 3 | 4 | export const unstable_settings = { 5 | anchor: "index", 6 | }; 7 | 8 | export { ErrorBoundary } from "expo-router"; 9 | 10 | export default function TabLayout() { 11 | return ( 12 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/(settings)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import React from "react"; 3 | 4 | export const unstable_settings = { 5 | anchor: "index", 6 | }; 7 | 8 | export { ErrorBoundary } from "expo-router"; 9 | 10 | export default function TabLayout() { 11 | return ( 12 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 16.4.1", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.fixAll": "explicit", 5 | "source.organizeImports": "explicit", 6 | "source.sortMembers": "explicit" 7 | }, 8 | "typescript.preferences.autoImportSpecifierExcludeRegexes": [ 9 | "^react-native-reanimated/lib/*", 10 | "^expo-router/build/*" 11 | ], 12 | "typescript.tsdk": "node_modules/typescript/lib" 13 | } 14 | -------------------------------------------------------------------------------- /components/ui/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from "@/hooks/useColorScheme"; 2 | import { 3 | DarkTheme, 4 | DefaultTheme, 5 | ThemeProvider as RNTheme, 6 | } from "@react-navigation/native"; 7 | 8 | export default function ThemeProvider(props: { children: React.ReactNode }) { 9 | const colorScheme = useColorScheme(); 10 | return ( 11 | 12 | {props.children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | -------------------------------------------------------------------------------- /public/.well-known/apple-app-site-association: -------------------------------------------------------------------------------- 1 | { 2 | "applinks": { 3 | "details": [ 4 | { 5 | "appIDs": [ 6 | "QQ57RJ5UTD.app.expo.flix" 7 | ], 8 | "components": [ 9 | { 10 | "/": "*", 11 | "comment": "Matches all routes" 12 | } 13 | ] 14 | } 15 | ] 16 | }, 17 | "activitycontinuation": { 18 | "apps": [ 19 | "QQ57RJ5UTD.app.expo.flix" 20 | ] 21 | }, 22 | "webcredentials": { 23 | "apps": [ 24 | "QQ57RJ5UTD.app.expo.flix" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /components/HapticTab.tsx: -------------------------------------------------------------------------------- 1 | import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; 2 | import { PlatformPressable } from '@react-navigation/elements'; 3 | import * as Haptics from 'expo-haptics'; 4 | 5 | export function HapticTab(props: BottomTabBarButtonProps) { 6 | return ( 7 | { 10 | if (process.env.EXPO_OS === 'ios') { 11 | // Add a soft haptic feedback when pressing down on the tabs. 12 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 13 | } 14 | props.onPressIn?.(ev); 15 | }} 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo Router RSC Movies 2 | 3 | Movies app built with with Expo Router. Running on iOS, Android, and web. 4 | 5 | ![image](https://github.com/user-attachments/assets/5690e374-e85e-401d-bd56-8e1549bd8372) 6 | 7 | https://github.com/user-attachments/assets/1873c9cf-06a0-4b79-b1c1-f7dd0fc55724 8 | 9 | ## Configuration 10 | 11 | Add environment variables to your `.env.local` file: 12 | 13 | - `TMDB_READ_ACCESS_TOKEN` -- Get this here https://www.themoviedb.org/settings/api 14 | 15 | This secret will only be available in the server as it's not prefixed with `EXPO_PUBLIC_`. 16 | 17 | 18 | ## Responsive layout 19 | 20 | https://github.com/user-attachments/assets/9cb8f8bd-452d-49fd-9a17-05f39094fd18 21 | 22 | -------------------------------------------------------------------------------- /components/ui/TabBarBackground.ios.tsx: -------------------------------------------------------------------------------- 1 | import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; 2 | import { BlurView } from "expo-blur"; 3 | import { StyleSheet } from "react-native"; 4 | 5 | export default function BlurTabBarBackground() { 6 | return ( 7 | 14 | ); 15 | } 16 | 17 | export function useBottomTabOverflow() { 18 | let tabHeight = 0; 19 | try { 20 | // eslint-disable-next-line react-hooks/rules-of-hooks 21 | tabHeight = useBottomTabBarHeight(); 22 | } catch {} 23 | return tabHeight; 24 | } 25 | -------------------------------------------------------------------------------- /components/ui/IconSymbol.ios.tsx: -------------------------------------------------------------------------------- 1 | import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"; 2 | import { StyleProp, ViewStyle } from "react-native"; 3 | 4 | export function IconSymbol({ 5 | name, 6 | size = 24, 7 | color, 8 | style, 9 | weight = "regular", 10 | animationSpec, 11 | }: { 12 | name: SymbolViewProps["name"]; 13 | size?: number; 14 | color: string; 15 | style?: StyleProp; 16 | weight?: SymbolWeight; 17 | animationSpec?: SymbolViewProps["animationSpec"]; 18 | }) { 19 | return ( 20 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | // NOTE: The default React Native styling doesn't support server rendering. 2 | // Server rendered styles should not change between the first render of the HTML 3 | // and the first render on the client. Typically, web developers will use CSS media queries 4 | // to render different styles on the client and server, these aren't directly supported in React Native 5 | 6 | import { useEffect, useState } from "react"; 7 | import { Appearance, ColorSchemeName } from "react-native"; 8 | 9 | // but can be achieved using a styling library like Nativewind. 10 | export function useColorScheme() { 11 | const [colorScheme, setColorScheme] = useState("light"); 12 | 13 | useEffect(() => { 14 | setColorScheme(Appearance.getColorScheme()); 15 | const subscription = Appearance.addChangeListener(({ colorScheme }) => { 16 | setColorScheme(colorScheme); 17 | }); 18 | return () => subscription.remove(); 19 | }, []); 20 | return colorScheme; 21 | } 22 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; 2 | import { StatusBar } from "expo-status-bar"; 3 | import "react-native-reanimated"; 4 | 5 | import ThemeProvider from "@/components/ui/ThemeProvider"; 6 | import { ReanimatedScreenProvider } from "react-native-screens/reanimated"; 7 | 8 | export { ErrorBoundary } from "expo-router"; 9 | 10 | export default function RootLayout() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {process.env.EXPO_OS === "android" && } 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/ShowMore.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Text, TouchableOpacity, LayoutAnimation } from "react-native"; 4 | import * as AC from "@bacons/apple-colors"; 5 | import { useState } from "react"; 6 | 7 | LayoutAnimation.easeInEaseOut(); 8 | export default function ShowMore({ text }: { text: string }) { 9 | const [showMore, setShowMore] = useState(false); 10 | return ( 11 | <> 12 | 20 | {text} 21 | 22 | {text?.length > 250 && ( 23 | setShowMore(!showMore)}> 24 | 31 | {showMore ? "Show Less" : "Show More"} 32 | 33 | 34 | )} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /hooks/useHeaderSearch.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useNavigation } from "expo-router"; 5 | import { SearchBarProps } from "react-native-screens"; 6 | 7 | export function useHeaderSearch(options: Omit = {}) { 8 | const [search, setSearch] = useState(""); 9 | const navigation = useNavigation(); 10 | 11 | useEffect(() => { 12 | const interceptedOptions: SearchBarProps = { 13 | ...options, 14 | onChangeText(event) { 15 | setSearch(event.nativeEvent.text); 16 | options.onChangeText?.(event); 17 | }, 18 | onSearchButtonPress(e) { 19 | setSearch(e.nativeEvent.text); 20 | options.onSearchButtonPress?.(e); 21 | }, 22 | onCancelButtonPress(e) { 23 | setSearch(""); 24 | options.onCancelButtonPress?.(e); 25 | }, 26 | }; 27 | 28 | navigation.setOptions({ 29 | headerShown: true, 30 | headerSearchBarOptions: interceptedOptions, 31 | }); 32 | }, [options]); 33 | 34 | return search; 35 | } 36 | -------------------------------------------------------------------------------- /app/_layout.web.tsx: -------------------------------------------------------------------------------- 1 | import Tabs from "@/components/ui/Tabs"; 2 | import { StatusBar } from "expo-status-bar"; 3 | import "react-native-reanimated"; 4 | 5 | import ThemeProvider from "@/components/ui/ThemeProvider"; 6 | import { ReanimatedScreenProvider } from "react-native-screens/reanimated"; 7 | 8 | export { ErrorBoundary } from "expo-router"; 9 | 10 | export default function RootLayout() { 11 | return ( 12 | 13 | 14 | {process.env.EXPO_OS === "web" && ( 15 | 16 | )} 17 | 18 | 23 | 28 | 29 | 30 | {process.env.EXPO_OS === "android" && } 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/layout/modal.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | display: flex; 3 | flex: 1; 4 | pointer-events: auto; 5 | background-color: var(--apple-systemGroupedBackground); 6 | border: 1px solid var(--apple-separator); /* Replace with your separator variable */ 7 | } 8 | 9 | @media (min-width: 768px) { 10 | .modal { 11 | position: fixed; 12 | left: 50%; 13 | top: 50%; 14 | z-index: 50; 15 | width: 100%; 16 | max-width: 55rem; 17 | min-height: 50rem; 18 | transform: translate(-50%, -50%); 19 | box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 20 | 0 4px 6px -4px rgb(0 0 0 / 0.1); /* Replace with your shadow variable */ 21 | max-height: 80%; 22 | overflow: scroll; 23 | border-radius: 0.5rem; /* Equivalent to sm:rounded-lg */ 24 | outline: none; 25 | } 26 | } 27 | 28 | .drawerContent { 29 | position: fixed; 30 | display: flex; 31 | flex-direction: column; 32 | bottom: 0; 33 | left: 0; 34 | right: 0; 35 | border-radius: 8px 8px 0 0; 36 | overflow: hidden; 37 | height: 100%; 38 | max-height: 97%; 39 | outline: none; 40 | 41 | } 42 | 43 | @media (min-width: 768px) { 44 | .drawerContent { 45 | max-height: 100%; 46 | /* pointer-events: box-none; */ 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /components/ui/BodyScrollView.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useScrollToTop } from "@/hooks/useTabToTop"; 4 | import * as AC from "@bacons/apple-colors"; 5 | import { ScrollViewProps } from "react-native"; 6 | import Animated from "react-native-reanimated"; 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 8 | import { useBottomTabOverflow } from "./TabBarBackground"; 9 | 10 | export function BodyScrollView( 11 | props: ScrollViewProps & { ref?: React.Ref } 12 | ) { 13 | const paddingBottom = useBottomTabOverflow(); 14 | const { bottom } = useSafeAreaInsets(); 15 | 16 | const statusBarInset = useSafeAreaInsets().top; // inset of the status bar 17 | 18 | const largeHeaderInset = statusBarInset + 92; // inset to use for a large header since it's frame is equal to 96 + the frame of status bar 19 | 20 | useScrollToTop(props.ref!, -largeHeaderInset); 21 | 22 | return ( 23 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Expo Movies", 4 | "slug": "rsc-movies", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "x.movie", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "web": { 12 | "output": "server", 13 | "favicon": "./assets/images/icon.png" 14 | }, 15 | "ios": { 16 | "supportsTablet": true, 17 | "infoPlist": { 18 | "UIViewControllerBasedStatusBarAppearance": true, 19 | "ITSAppUsesNonExemptEncryption": false 20 | }, 21 | "bundleIdentifier": "app.expo.flix" 22 | }, 23 | "android": { 24 | "adaptiveIcon": { 25 | "foregroundImage": "./assets/images/icon.png", 26 | "backgroundColor": "#000" 27 | } 28 | }, 29 | "plugins": [ 30 | "expo-router", 31 | [ 32 | "expo-splash-screen", 33 | { 34 | "image": "./assets/images/splash-icon.png", 35 | "imageWidth": 200, 36 | "resizeMode": "contain", 37 | "backgroundColor": "#000" 38 | } 39 | ], 40 | "expo-font", 41 | "expo-web-browser" 42 | ], 43 | "experiments": { 44 | "reactServerFunctions": true, 45 | "typedRoutes": true, 46 | "reactCompiler": true 47 | }, 48 | "extra": { 49 | "eas": { 50 | "projectId": "07793961-ad0c-43ca-bce1-301475ecd214" 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | // Fork of upstream but with forward ref for Link asChild 2 | // https://github.com/react-navigation/react-navigation/blob/bddcc44ab0e0ad5630f7ee0feb69496412a00217/packages/elements/src/Header/HeaderButton.tsx#L1 3 | import { 4 | PlatformPressable, 5 | type HeaderButtonProps, 6 | } from "@react-navigation/elements"; 7 | import React from "react"; 8 | import { Platform, StyleSheet } from "react-native"; 9 | 10 | export function HeaderButton({ 11 | disabled, 12 | onPress, 13 | pressColor, 14 | pressOpacity, 15 | accessibilityLabel, 16 | testID, 17 | style, 18 | children, 19 | ...props 20 | }: HeaderButtonProps) { 21 | return ( 22 | 37 | {children} 38 | 39 | ); 40 | } 41 | 42 | const androidRipple = { 43 | borderless: true, 44 | foreground: Platform.OS === "android" && Platform.Version >= 23, 45 | radius: 20, 46 | }; 47 | 48 | const styles = StyleSheet.create({ 49 | container: { 50 | flexDirection: "row", 51 | alignItems: "center", 52 | paddingHorizontal: 8, 53 | // Roundness for iPad hover effect 54 | borderRadius: 10, 55 | }, 56 | disabled: { 57 | opacity: 0.5, 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rsc-movies", 3 | "license": "0BSD", 4 | "main": "expo-router/entry", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "start": "expo start", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "lint": "expo lint", 12 | "prepare": "npx patch-package", 13 | "deploy:ios": "npx testflight", 14 | "deploy:web": "expo export -p web && npx eas-cli@latest deploy" 15 | }, 16 | "dependencies": { 17 | "@bacons/apple-colors": "^0.0.8", 18 | "@expo/vector-icons": "^15.0.3", 19 | "expo": "^54.0.27", 20 | "expo-application": "~7.0.8", 21 | "expo-blur": "~15.0.8", 22 | "expo-constants": "~18.0.11", 23 | "expo-font": "~14.0.10", 24 | "expo-glass-effect": "~0.1.8", 25 | "expo-haptics": "~15.0.8", 26 | "expo-image": "~3.0.11", 27 | "expo-linking": "~8.0.10", 28 | "expo-router": "~6.0.17", 29 | "expo-splash-screen": "~31.0.12", 30 | "expo-status-bar": "~3.0.9", 31 | "expo-symbols": "~1.0.8", 32 | "expo-system-ui": "~6.0.9", 33 | "expo-web-browser": "~15.0.10", 34 | "react": "19.1.0", 35 | "react-dom": "19.1.0", 36 | "react-native": "0.81.5", 37 | "react-native-gesture-handler": "~2.28.0", 38 | "react-native-reanimated": "4.1.3", 39 | "react-native-safe-area-context": "~5.6.0", 40 | "react-native-screens": "~4.16.0", 41 | "react-native-web": "^0.21.0", 42 | "react-native-webview": "13.15.0", 43 | "react-native-worklets": "0.5.1", 44 | "react-server-dom-webpack": "~19.1.2", 45 | "vaul": "^1.1.2" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.25.2", 49 | "@types/react": "~19.1.10", 50 | "eslint": "^9.0.0", 51 | "eslint-config-expo": "~10.0.0", 52 | "typescript": "~5.9.2" 53 | }, 54 | "private": true 55 | } 56 | -------------------------------------------------------------------------------- /patches/react-server-dom-webpack+19.1.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 2 | index c95b085..3a32a71 100644 3 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 4 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 5 | @@ -816,9 +816,9 @@ 6 | return bound 7 | ? "fulfilled" === bound.status 8 | ? callServer(id, bound.value.concat(args)) 9 | - : Promise.resolve(bound).then(function (boundArgs) { 10 | - return callServer(id, boundArgs.concat(args)); 11 | - }) 12 | + // HACK: This is required to make native server actions return a non-undefined value. 13 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 14 | + : (async () => callServer(id, (await bound).concat(args)))() 15 | : callServer(id, args); 16 | } 17 | var id = metaData.id, 18 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 19 | index 70b77aa..4ead3d7 100644 20 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 21 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 22 | @@ -519,9 +519,9 @@ function createBoundServerReference(metaData, callServer) { 23 | return bound 24 | ? "fulfilled" === bound.status 25 | ? callServer(id, bound.value.concat(args)) 26 | - : Promise.resolve(bound).then(function (boundArgs) { 27 | - return callServer(id, boundArgs.concat(args)); 28 | - }) 29 | + // HACK: This is required to make native server actions return a non-undefined value. 30 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 31 | + : (async () => callServer(id, (await bound).concat(args)))() 32 | : callServer(id, args); 33 | } 34 | var id = metaData.id, 35 | -------------------------------------------------------------------------------- /components/SearchPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { BodyScrollView } from "@/components/ui/BodyScrollView"; 3 | import { renderTrendingMedia } from "@/functions/render-search"; 4 | import React from "react"; 5 | import { ScrollView, useWindowDimensions, View } from "react-native"; 6 | 7 | export function SearchPlaceholder() { 8 | const { width } = useWindowDimensions(); 9 | const cardWidth = 140; 10 | const cardHeight = 210; 11 | const gap = 8; 12 | const numCards = Math.floor((width * 2) / (cardWidth + gap)); 13 | 14 | function SkeletonRow() { 15 | return ( 16 | 17 | 26 | 27 | {[...Array(numCards)].map((_, i) => ( 28 | 35 | 44 | 53 | 61 | 62 | ))} 63 | 64 | 65 | ); 66 | } 67 | 68 | const numItems = process.env.EXPO_OS === "web" ? 9 : 6; 69 | 70 | return ( 71 | 72 | }> 73 | {renderTrendingMedia({ 74 | type: "movie", 75 | timeWindow: "day", 76 | size: numItems, 77 | })} 78 | 79 | }> 80 | {renderTrendingMedia({ 81 | type: "tv", 82 | timeWindow: "day", 83 | size: numItems, 84 | })} 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/(index)/index.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { ScrollView, View } from "react-native"; 3 | 4 | import { SearchPlaceholder } from "@/components/SearchPlaceholder"; 5 | import { BodyScrollView } from "@/components/ui/BodyScrollView"; 6 | import { renderSearchContents } from "@/functions/render-search"; 7 | import { useHeaderSearch } from "@/hooks/useHeaderSearch"; 8 | import * as AC from "@bacons/apple-colors"; 9 | import React from "react"; 10 | 11 | const POSTER_WIDTH = 140; 12 | const POSTER_HEIGHT = 210; 13 | 14 | export default function HomeScreen() { 15 | const text = useHeaderSearch({ 16 | placeholder: "Shows, Movies, and More", 17 | }); 18 | 19 | if (!text || text.length < 2) { 20 | return ; 21 | } 22 | 23 | return ; 24 | } 25 | 26 | function SearchPage({ text }: { text: string }) { 27 | return ( 28 | 34 | }> 35 | {renderSearchContents(text)} 36 | 37 | 38 | ); 39 | } 40 | 41 | function Loading() { 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | 51 | const SkeletonItem = () => ( 52 | 53 | 61 | 69 | 70 | 78 | 86 | 87 | 88 | 89 | ); 90 | 91 | const SkeletonSection = () => ( 92 | 93 | 103 | 108 | {[...Array(4)].map((_, i) => ( 109 | 110 | ))} 111 | 112 | 113 | ); 114 | -------------------------------------------------------------------------------- /app/(index)/movie/[id].tsx: -------------------------------------------------------------------------------- 1 | import { renderMedia } from "@/functions/render-movie-details"; 2 | import { Stack, useLocalSearchParams } from "expo-router"; 3 | import React from "react"; 4 | import { View } from "react-native"; 5 | 6 | import { ShowPageBody } from "@/components/show-header-background"; 7 | 8 | export { ErrorBoundary } from "expo-router"; 9 | 10 | export default function Movie() { 11 | const { id } = useLocalSearchParams<{ id: string }>(); 12 | return ( 13 | 14 | 19 | }> 20 | {renderMedia(id, "movie")} 21 | 22 | 23 | ); 24 | } 25 | 26 | function MovieSkeleton() { 27 | return ( 28 | 29 | {/* Hero Section */} 30 | 33 | 34 | {/* Overview Section */} 35 | 36 | 44 | 52 | 60 | 61 | 62 | {/* About Section */} 63 | 64 | 73 | 80 | {[...Array(8)].map((_, i) => ( 81 | 90 | 98 | 106 | 107 | ))} 108 | 109 | 110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /app/(index)/show/[id].tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import { renderMedia } from "@/functions/render-movie-details"; 3 | import { useLocalSearchParams } from "expo-router"; 4 | import React from "react"; 5 | import { View } from "react-native"; 6 | 7 | import { ShowPageBody } from "@/components/show-header-background"; 8 | 9 | export { ErrorBoundary } from "expo-router"; 10 | 11 | export default function ShowDetails() { 12 | const { id } = useLocalSearchParams<{ id: string }>(); 13 | 14 | return ( 15 | 16 | 21 | 22 | }> 23 | {renderMedia(id, "tv")} 24 | 25 | 26 | ); 27 | } 28 | 29 | function MovieSkeleton() { 30 | return ( 31 | 32 | {/* Hero Section */} 33 | 36 | 37 | {/* Overview Section */} 38 | 39 | 47 | 55 | 63 | 64 | 65 | {/* About Section */} 66 | 67 | 76 | 83 | {[...Array(8)].map((_, i) => ( 84 | 93 | 101 | 109 | 110 | ))} 111 | 112 | 113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /components/ui/Stack.tsx: -------------------------------------------------------------------------------- 1 | // import { Stack as NativeStack } from "expo-router"; 2 | import { NativeStackNavigationOptions } from "@react-navigation/native-stack"; 3 | import React from "react"; 4 | 5 | // Better transitions on web, no changes on native. 6 | import NativeStack from "@/components/layout/modalNavigator"; 7 | import { isLiquidGlassAvailable } from "expo-glass-effect"; 8 | 9 | import * as AC from "@bacons/apple-colors"; 10 | 11 | // These are the default stack options for iOS, they disable on other platforms. 12 | const DEFAULT_STACK_HEADER: NativeStackNavigationOptions = 13 | process.env.EXPO_OS !== "ios" 14 | ? {} 15 | : isLiquidGlassAvailable() 16 | ? { 17 | // iOS 26 + liquid glass 18 | headerTransparent: true, 19 | headerShadowVisible: false, 20 | headerLargeTitleShadowVisible: false, 21 | headerLargeStyle: { 22 | backgroundColor: "transparent", 23 | }, 24 | headerTitleStyle: { 25 | color: AC.label as any, 26 | }, 27 | headerLargeTitle: true, 28 | headerBlurEffect: "none", 29 | headerBackButtonDisplayMode: "minimal", 30 | } 31 | : { 32 | headerTransparent: true, 33 | headerBlurEffect: "systemChromeMaterial", 34 | headerShadowVisible: true, 35 | headerLargeTitleShadowVisible: false, 36 | headerLargeStyle: { 37 | backgroundColor: "transparent", 38 | }, 39 | headerLargeTitle: true, 40 | }; 41 | 42 | /** Create a bottom sheet on iOS with extra snap points (`sheetAllowedDetents`) */ 43 | export const BOTTOM_SHEET: NativeStackNavigationOptions = { 44 | // https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md#sheetalloweddetents 45 | presentation: "formSheet", 46 | gestureDirection: "vertical", 47 | animation: "slide_from_bottom", 48 | sheetGrabberVisible: true, 49 | sheetInitialDetentIndex: 0, 50 | sheetAllowedDetents: [0.5, 1.0], 51 | }; 52 | 53 | export default function Stack({ 54 | screenOptions, 55 | children, 56 | ...props 57 | }: React.ComponentProps) { 58 | const processedChildren = React.Children.map(children, (child) => { 59 | if (React.isValidElement(child)) { 60 | const { sheet, modal, ...props } = child.props; 61 | if (sheet) { 62 | return React.cloneElement(child, { 63 | ...props, 64 | options: { 65 | ...BOTTOM_SHEET, 66 | ...props.options, 67 | }, 68 | }); 69 | } else if (modal) { 70 | return React.cloneElement(child, { 71 | ...props, 72 | options: { 73 | presentation: "modal", 74 | ...props.options, 75 | }, 76 | }); 77 | } 78 | } 79 | return child; 80 | }); 81 | 82 | return ( 83 | 91 | ); 92 | } 93 | 94 | Stack.Screen = NativeStack.Screen as React.FC< 95 | React.ComponentProps & { 96 | /** Make the sheet open as a bottom sheet with default options on iOS. */ 97 | sheet?: boolean; 98 | /** Make the screen open as a modal. */ 99 | modal?: boolean; 100 | } 101 | >; 102 | -------------------------------------------------------------------------------- /app/(index)/person/[id].tsx: -------------------------------------------------------------------------------- 1 | import { ShowPageBody } from "@/components/show-header-background"; 2 | import { renderPersonDetails } from "@/functions/render-person-details"; 3 | import * as AC from "@bacons/apple-colors"; 4 | import { Stack, useLocalSearchParams } from "expo-router"; 5 | import React from "react"; 6 | import { View } from "react-native"; 7 | 8 | export { ErrorBoundary } from "expo-router"; 9 | 10 | export default function PersonDetails() { 11 | const { id } = useLocalSearchParams<{ id: string }>(); 12 | 13 | return ( 14 | 15 | 20 | }> 21 | {renderPersonDetails(id)} 22 | 23 | 24 | ); 25 | } 26 | 27 | function PersonDetailsSkeleton() { 28 | return ( 29 | 30 | 38 | 47 | 55 | 56 | 57 | 58 | 67 | 68 | 76 | 77 | 84 | 85 | 86 | 87 | 88 | {[120, 100, 110].map((width, i) => ( 89 | 98 | ))} 99 | 100 | 101 | 110 | {[1, 2, 3, 4].map((i) => ( 111 | 120 | ))} 121 | 122 | 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /components/ui/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol"; 2 | import { 3 | BottomTabBarButtonProps, 4 | BottomTabNavigationOptions, 5 | } from "@react-navigation/bottom-tabs"; 6 | import * as Haptics from "expo-haptics"; 7 | import React from "react"; 8 | // Better transitions on web, no changes on native. 9 | import { PlatformPressable } from "@react-navigation/elements"; 10 | import { Tabs as NativeTabs } from "expo-router"; 11 | import { Platform, useWindowDimensions } from "react-native"; 12 | import BlurTabBarBackground from "./TabBarBackground"; 13 | 14 | // These are the default tab options for iOS, they disable on other platforms. 15 | const DEFAULT_TABS: BottomTabNavigationOptions = 16 | process.env.EXPO_OS !== "ios" 17 | ? { 18 | headerShown: false, 19 | } 20 | : { 21 | headerShown: false, 22 | tabBarButton: HapticTab, 23 | tabBarBackground: BlurTabBarBackground, 24 | tabBarStyle: { 25 | // Use a transparent background on iOS to show the blur effect 26 | position: "absolute", 27 | }, 28 | }; 29 | 30 | export default function Tabs({ 31 | screenOptions, 32 | children, 33 | ...props 34 | }: React.ComponentProps) { 35 | const processedChildren = React.Children.map(children, (child) => { 36 | if (React.isValidElement(child)) { 37 | const { systemImage, title, ...props } = child.props; 38 | if (systemImage || title != null) { 39 | return React.cloneElement(child, { 40 | ...props, 41 | options: { 42 | tabBarIcon: !systemImage 43 | ? undefined 44 | : (props: any) => , 45 | title, 46 | ...props.options, 47 | }, 48 | }); 49 | } 50 | } 51 | return child; 52 | }); 53 | 54 | const { width } = useWindowDimensions(); 55 | 56 | const isMd = width >= 768; 57 | const isLg = width >= 1024; 58 | 59 | return ( 60 | 93 | {processedChildren} 94 | 95 | ); 96 | } 97 | 98 | Tabs.Screen = NativeTabs.Screen as React.FC< 99 | React.ComponentProps & { 100 | /** Add a system image for the tab icon. */ 101 | systemImage?: IconSymbolName; 102 | /** Set the title of the icon. */ 103 | title?: string; 104 | } 105 | >; 106 | 107 | function HapticTab(props: BottomTabBarButtonProps) { 108 | return ( 109 | { 112 | if (process.env.EXPO_OS === "ios") { 113 | // Add a soft haptic feedback when pressing down on the tabs. 114 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 115 | } 116 | props.onPressIn?.(ev); 117 | }} 118 | /> 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /components/usable-component.tsx: -------------------------------------------------------------------------------- 1 | import * as Form from "@/components/ui/Form"; 2 | import React, { use } from "react"; 3 | 4 | export function withCachedServerActionResults< 5 | T extends (...args: any[]) => Promise 6 | >(action: T, funcName: string, maxDuration: number) { 7 | const cacheKeyPart = encodeURIComponent(`cache_${funcName}_`); 8 | 9 | const func = ( 10 | ...args: Parameters 11 | ): Awaited> | Promise>> => { 12 | // const start = Date.now(); 13 | const cacheKey = cacheKeyPart + encodeURIComponent(JSON.stringify(args)); 14 | 15 | const existing = localStorage.getItem(cacheKey); 16 | if (typeof existing === "string") { 17 | try { 18 | const cacheEntry = JSON.parse(existing); 19 | if (cacheEntry && Date.now() - cacheEntry.timestamp < maxDuration) { 20 | // console.log("cache hit", cacheKey, Date.now() - start); 21 | return cacheEntry.result; 22 | } 23 | } catch {} 24 | } 25 | 26 | // console.log("cache miss", cacheKey, Date.now() - start); 27 | 28 | return new Promise(async (resolve, reject) => { 29 | action(...args) 30 | .then((result) => { 31 | localStorage.setItem( 32 | cacheKey, 33 | JSON.stringify({ result, timestamp: Date.now() }) 34 | ); 35 | 36 | // console.log("cache set", cacheKey, Date.now() - start); 37 | resolve(result); 38 | }) 39 | .catch(reject); 40 | }); 41 | }; 42 | 43 | func.purge = () => { 44 | const keys = 45 | process.env.EXPO_OS === "web" 46 | ? Object.keys(localStorage) 47 | : localStorage.keys(); 48 | // console.log("purging cache for", funcName, keys); 49 | 50 | for (const key of keys) { 51 | if (key.startsWith(`cache_${funcName}_`)) { 52 | localStorage.removeItem(key); 53 | } 54 | } 55 | }; 56 | 57 | func.force = ( 58 | ...args: Parameters 59 | ): RevalidatingPromise>> => { 60 | const results = func(...args); 61 | 62 | // Only returns a promise if no cache hit. 63 | if (results instanceof Promise) { 64 | return results; 65 | } 66 | 67 | const cacheKey = encodeURIComponent( 68 | `cache_${funcName}_${JSON.stringify(args)}` 69 | ); 70 | localStorage.removeItem(cacheKey); 71 | 72 | const nextData = func(...args); 73 | return storePreviousData(nextData, results); 74 | }; 75 | 76 | return func; 77 | } 78 | 79 | type RevalidatingPromise = Promise & { 80 | previous?: T; 81 | }; 82 | 83 | function storePreviousData( 84 | promise: Promise, 85 | data: T 86 | ): RevalidatingPromise { 87 | (promise as any).previous = data; 88 | return promise as RevalidatingPromise; 89 | } 90 | 91 | type AwaitedRecord = { 92 | [K in keyof T]: T[K] extends Promise ? U : T[K]; 93 | }; 94 | 95 | type UseableComponentProps> = { 96 | UI: React.FC>; 97 | } & T; 98 | 99 | export function UseableComponent>({ 100 | UI, 101 | ...props 102 | }: UseableComponentProps) { 103 | const resolved = Object.entries(props).reduce((acc, [key, value]) => { 104 | // Use "as keyof T" to ensure key is properly typed 105 | if (value instanceof Promise) { 106 | acc[key as keyof T] = use(value); 107 | } else { 108 | acc[key as keyof T] = value; 109 | } 110 | 111 | return acc; 112 | }, {} as AwaitedRecord); 113 | 114 | return ; 115 | } 116 | /** 117 | * Alternative to `const value = useMemo(() => callAction(), []);` where the callback is invoked and a promise is returned. 118 | * Here the callback is invoked immediately and the result is memoized. 119 | * A pull to refresh is registered to re-fetch the data with a force flag. 120 | * Unlike the initial value, the pull to refresh will wait for the promise to resolve before updating the state. 121 | * 122 | * ```ts 123 | * const [externalGroup] = usePullRefreshAction((force) => 124 | * (force ? ensureExternalGroupAsync.force : ensureExternalGroupAsync)(app.id, build?.build?.id) 125 | * ); 126 | * ``` 127 | * @param cb function that returns a value. Accepts a force boolean to re-fetch the data. 128 | * @returns 129 | */ 130 | export function usePullRefreshAction( 131 | cb: (force: boolean) => T | Promise 132 | ) { 133 | const [data, setData] = React.useState>(() => cb(false)); 134 | const update = async (force: boolean = true) => { 135 | const promise = cb(force); 136 | if (promise instanceof Promise) { 137 | const results = await promise; 138 | setData(results); 139 | } else { 140 | setData(promise); 141 | } 142 | return promise; 143 | }; 144 | 145 | Form.usePullRefresh(async () => { 146 | await update(true); 147 | }); 148 | return [data, setData, update] as const; 149 | } 150 | 151 | export function usePullRefreshCachedAction< 152 | T extends (...args: any[]) => Promise 153 | >( 154 | callback: (callback: T) => ReturnType, 155 | 156 | props: { action: T; name: string; timeout: number } 157 | ) { 158 | const cachedAction = withCachedServerActionResults( 159 | props.action, 160 | props.name, 161 | props.timeout 162 | ); 163 | 164 | return usePullRefreshAction((force) => { 165 | return callback(force ? cachedAction.force : cachedAction); 166 | }); 167 | } 168 | -------------------------------------------------------------------------------- /hooks/useTabToTop.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventArg, 3 | NavigationProp, 4 | useNavigation, 5 | useRoute, 6 | } from "@react-navigation/core"; 7 | import * as React from "react"; 8 | import type { ScrollView } from "react-native"; 9 | import type { WebView } from "react-native-webview"; 10 | 11 | type ScrollOptions = { x?: number; y?: number; animated?: boolean }; 12 | 13 | type ScrollableView = 14 | | { scrollToTop(): void } 15 | | { scrollTo(options: ScrollOptions): void } 16 | | { scrollToOffset(options: { offset?: number; animated?: boolean }): void } 17 | | { scrollResponderScrollTo(options: ScrollOptions): void }; 18 | 19 | type ScrollableWrapper = 20 | | { getScrollResponder(): React.ReactNode | ScrollView } 21 | | { getNode(): ScrollableView } 22 | | ScrollableView; 23 | 24 | function getScrollableNode( 25 | ref: React.RefObject | React.RefObject 26 | ) { 27 | if (ref?.current == null) { 28 | return null; 29 | } 30 | 31 | if ( 32 | "scrollToTop" in ref.current || 33 | "scrollTo" in ref.current || 34 | "scrollToOffset" in ref.current || 35 | "scrollResponderScrollTo" in ref.current 36 | ) { 37 | // This is already a scrollable node. 38 | return ref.current; 39 | } else if ("getScrollResponder" in ref.current) { 40 | // If the view is a wrapper like FlatList, SectionList etc. 41 | // We need to use `getScrollResponder` to get access to the scroll responder 42 | return ref.current.getScrollResponder(); 43 | } else if ("getNode" in ref.current) { 44 | // When a `ScrollView` is wraped in `Animated.createAnimatedComponent` 45 | // we need to use `getNode` to get the ref to the actual scrollview. 46 | // Note that `getNode` is deprecated in newer versions of react-native 47 | // this is why we check if we already have a scrollable node above. 48 | return ref.current.getNode(); 49 | } else { 50 | return ref.current; 51 | } 52 | } 53 | 54 | export function useScrollToTop( 55 | ref: 56 | | React.RefObject 57 | | React.RefObject 58 | | React.Ref, 59 | offset: number = 0 60 | ) { 61 | const navigation = useNavigation(); 62 | const route = useRoute(); 63 | 64 | React.useEffect(() => { 65 | let tabNavigations: NavigationProp[] = []; 66 | let currentNavigation = navigation; 67 | 68 | // If the screen is nested inside multiple tab navigators, we should scroll to top for any of them 69 | // So we need to find all the parent tab navigators and add the listeners there 70 | while (currentNavigation) { 71 | if (currentNavigation.getState()?.type === "tab") { 72 | tabNavigations.push(currentNavigation); 73 | } 74 | 75 | currentNavigation = currentNavigation.getParent(); 76 | } 77 | 78 | if (tabNavigations.length === 0) { 79 | return; 80 | } 81 | 82 | const unsubscribers = tabNavigations.map((tab) => { 83 | return tab.addListener( 84 | // We don't wanna import tab types here to avoid extra deps 85 | // in addition, there are multiple tab implementations 86 | // @ts-expect-error 87 | "tabPress", 88 | (e: EventArg<"tabPress", true>) => { 89 | // We should scroll to top only when the screen is focused 90 | const isFocused = navigation.isFocused(); 91 | 92 | // In a nested stack navigator, tab press resets the stack to first screen 93 | // So we should scroll to top only when we are on first screen 94 | const isFirst = 95 | tabNavigations.includes(navigation) || 96 | navigation.getState()?.routes[0].key === route.key; 97 | 98 | // Run the operation in the next frame so we're sure all listeners have been run 99 | // This is necessary to know if preventDefault() has been called 100 | requestAnimationFrame(() => { 101 | const scrollable = getScrollableNode(ref) as 102 | | ScrollableWrapper 103 | | WebView; 104 | 105 | if (isFocused && isFirst && scrollable && !e.defaultPrevented) { 106 | if ("scrollToTop" in scrollable) { 107 | scrollable.scrollToTop(); 108 | } else if ("scrollTo" in scrollable) { 109 | scrollable.scrollTo({ y: offset, animated: true }); 110 | } else if ("scrollToOffset" in scrollable) { 111 | scrollable.scrollToOffset({ offset: offset, animated: true }); 112 | } else if ("scrollResponderScrollTo" in scrollable) { 113 | scrollable.scrollResponderScrollTo({ 114 | y: offset, 115 | animated: true, 116 | }); 117 | } else if ("injectJavaScript" in scrollable) { 118 | scrollable.injectJavaScript( 119 | `;window.scrollTo({ top: ${offset}, behavior: 'smooth' }); true;` 120 | ); 121 | } 122 | } 123 | }); 124 | } 125 | ); 126 | }); 127 | 128 | return () => { 129 | unsubscribers.forEach((unsubscribe) => unsubscribe()); 130 | }; 131 | }, [navigation, ref, offset, route.key]); 132 | } 133 | 134 | export const useScrollRef = 135 | process.env.EXPO_OS === "web" 136 | ? () => undefined 137 | : () => { 138 | const ref = React.useRef(null); 139 | 140 | useScrollToTop(ref); 141 | 142 | return ref; 143 | }; 144 | -------------------------------------------------------------------------------- /app/(settings)/index.tsx: -------------------------------------------------------------------------------- 1 | import { Image, Platform, Text, TouchableOpacity, View } from "react-native"; 2 | 3 | import * as Form from "@/components/ui/Form"; 4 | import * as AC from "@bacons/apple-colors"; 5 | import React from "react"; 6 | 7 | import Constants from "expo-constants"; 8 | 9 | import { IconSymbol } from "@/components/ui/IconSymbol"; 10 | import * as App from "expo-application"; 11 | import { BlurView } from "expo-blur"; 12 | import { router } from "expo-router"; 13 | 14 | const backgroundImage = 15 | process.env.EXPO_OS === "web" 16 | ? `backgroundImage` 17 | : `experimental_backgroundImage`; 18 | 19 | export default function SettingsScreen() { 20 | return ( 21 | 22 | 23 | 35 | 53 | { 55 | router.push("https://github.com/EvanBacon/expo-rsc-movies"); 56 | }} 57 | activeOpacity={0.6} 58 | style={{ 59 | flex: 1, 60 | gap: 8, 61 | padding: 16, 62 | flexDirection: "row", 63 | }} 64 | > 65 | 74 | 75 | 82 | Expo Movies 83 | 84 | 85 | Beautiful movies app with modern data fetching and security. 86 | 87 | 88 | 89 | 107 | 108 | 114 | Clone 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | Expo Server Components 125 | 126 | 131 | Evan Bacon 132 | 133 | 134 | 135 | 136 | 141 | Clone on GitHub 142 | 143 | 144 | 148 | Download on TestFlight 149 | 150 | 151 | {process.env.EXPO_OS !== "web" && ( 152 | 153 | Open in the browser 154 | 155 | )} 156 | 157 | 158 | 159 | 160 | Expo{" "} 161 | {Constants.expoConfig?.sdkVersion?.split(".").shift() ?? "(Latest)"} 162 | 163 | 164 | 165 | 168 | 175 | Expo Movies for{" "} 176 | {Platform.select({ 177 | web: "Web", 178 | ios: `iOS v${App.nativeApplicationVersion} (${App.nativeBuildVersion})`, 179 | android: `Android v${App.nativeApplicationVersion} (${App.nativeBuildVersion})`, 180 | })} 181 | 182 | 183 | 184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /components/show-header-background.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | "use no memo"; 3 | 4 | import React, { useEffect } from "react"; 5 | import { StyleSheet, useColorScheme, ViewStyle } from "react-native"; 6 | 7 | import { BlurView } from "expo-blur"; 8 | import { Stack } from "expo-router"; 9 | import Animated, { 10 | AnimatedRef, 11 | interpolate, 12 | interpolateColor, 13 | useAnimatedRef, 14 | useAnimatedStyle, 15 | useScrollViewOffset, 16 | } from "react-native-reanimated"; 17 | import { BodyScrollView } from "./ui/BodyScrollView"; 18 | 19 | import * as AC from "@bacons/apple-colors"; 20 | import { useReanimatedHeaderHeight } from "react-native-screens/reanimated"; 21 | 22 | export const ScrollContext = 23 | React.createContext | null>(null); 24 | 25 | const ABlurView = Animated.createAnimatedComponent(BlurView); 26 | const HEADER_HEIGHT = 300; 27 | const ANIM_START = HEADER_HEIGHT * 0.66; 28 | 29 | export function ShowPageBody({ children }: { children: React.ReactNode }) { 30 | "use no memo"; 31 | const ref = useAnimatedRef(); 32 | 33 | const scroll = useScrollViewOffset(ref); 34 | const style = 35 | process.env.EXPO_OS === "ios" 36 | ? useAnimatedStyle(() => { 37 | const inputRange = [ANIM_START, ANIM_START + 30]; 38 | return { 39 | opacity: interpolate(scroll.get(), inputRange, [0, 1], "clamp"), 40 | borderBottomColor: interpolateColor(scroll.get(), inputRange, [ 41 | `rgba(84.15, 84.15, 89.25,0)`, 42 | `rgba(84.15, 84.15, 89.25,0.5)`, 43 | ]), 44 | }; 45 | }) 46 | : useAnimatedStyle(() => { 47 | return { 48 | opacity: interpolate(scroll.get(), [100, 150], [0, 1], "clamp"), 49 | 50 | borderBottomColor: `rgba(84.15, 84.15, 89.25,${interpolate( 51 | scroll.get(), 52 | [100, 150], 53 | [0, 0.2], 54 | "clamp" 55 | )})`, 56 | }; 57 | }); 58 | const titleStyle = useAnimatedStyle(() => { 59 | const inputRange = [ANIM_START, ANIM_START + 30]; 60 | return { 61 | opacity: interpolate(scroll.get(), inputRange, [0, 1], "clamp"), 62 | transform: [ 63 | { translateY: interpolate(scroll.get(), inputRange, [5, 0], "clamp") }, 64 | ], 65 | }; 66 | }); 67 | 68 | useEffect(() => { 69 | if (ref.current) { 70 | ref.current.scrollTo(); 71 | } 72 | }, [ref]); 73 | 74 | return ( 75 | <> 76 | {/* 95 | 105 | */} 106 | 111 | ; 124 | } 125 | return ; 126 | }, 127 | headerTitle(props) { 128 | return ( 129 | 140 | {props.children} 141 | 142 | ); 143 | }, 144 | }} 145 | /> 146 | 147 | {children} 148 | 149 | 150 | ); 151 | } 152 | 153 | function AnimatedShowHeaderBackgroundIos({ style }: { style: ViewStyle }) { 154 | const headerHeight = useReanimatedHeaderHeight(); 155 | 156 | return ( 157 | 176 | 186 | 187 | ); 188 | } 189 | 190 | export function ParallaxImageWrapper({ 191 | children, 192 | }: { 193 | children: React.ReactNode; 194 | }) { 195 | "use no memo"; 196 | const ref = React.use(ScrollContext); 197 | const scrollOffset = useScrollViewOffset(ref); 198 | 199 | const headerAnimatedStyle = useAnimatedStyle(() => { 200 | return { 201 | transform: [ 202 | { 203 | translateY: interpolate( 204 | scrollOffset.value, 205 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 206 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75], 207 | "clamp" 208 | ), 209 | }, 210 | { 211 | scale: interpolate( 212 | scrollOffset.value, 213 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 214 | [2, 1, 1] 215 | ), 216 | }, 217 | ], 218 | }; 219 | }); 220 | 221 | return ( 222 | 230 | {children} 231 | 232 | ); 233 | } 234 | 235 | export function AnimatedShowHeaderBackground({ style }: { style: ViewStyle }) { 236 | const theme = useColorScheme(); 237 | 238 | return ( 239 | 252 | ); 253 | } 254 | -------------------------------------------------------------------------------- /components/layout/modalNavigator.web.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createNavigatorFactory, 3 | DefaultRouterOptions, 4 | ParamListBase, 5 | StackNavigationState, 6 | StackRouter, 7 | useNavigationBuilder, 8 | } from "@react-navigation/native"; 9 | import { 10 | NativeStackNavigationOptions, 11 | NativeStackView, 12 | } from "@react-navigation/native-stack"; 13 | import { withLayoutContext } from "expo-router"; 14 | import React from "react"; 15 | import { Platform } from "react-native"; 16 | import { Drawer } from "vaul"; 17 | 18 | import modalStyles from "./modal.module.css"; 19 | 20 | import * as AC from "@bacons/apple-colors"; 21 | 22 | /** Extend NativeStackNavigationOptions with extra sheet/detent props */ 23 | type MyModalStackNavigationOptions = NativeStackNavigationOptions & { 24 | presentation?: 25 | | "modal" 26 | | "formSheet" 27 | | "containedModal" 28 | | "card" 29 | | "fullScreenModal"; 30 | /** 31 | * If you want to mimic iOS sheet detents on native (iOS 16+ w/ react-native-screens), 32 | * you might do something like: 33 | * 34 | * supportedOrientations?: string[]; 35 | * sheetAllowedDetents?: Array; 36 | * sheetInitialDetentIndex?: number; 37 | * 38 | * But here we specifically pass them for the web side via vaul: 39 | */ 40 | sheetAllowedDetents?: (number | string)[]; // e.g. [0.5, 1.0] or ['148px', '355px', 1] 41 | sheetInitialDetentIndex?: number; // which index in `sheetAllowedDetents` is the default 42 | sheetGrabberVisible?: boolean; 43 | }; 44 | 45 | type MyModalStackRouterOptions = DefaultRouterOptions & { 46 | // Extend if you need custom router logic 47 | }; 48 | 49 | type Props = { 50 | initialRouteName?: string; 51 | screenOptions?: MyModalStackNavigationOptions; 52 | children: React.ReactNode; 53 | }; 54 | 55 | function MyModalStackNavigator({ 56 | initialRouteName, 57 | children, 58 | screenOptions, 59 | }: Props) { 60 | const { state, navigation, descriptors, NavigationContent } = 61 | useNavigationBuilder< 62 | StackNavigationState, 63 | MyModalStackRouterOptions, 64 | MyModalStackNavigationOptions 65 | >(StackRouter, { 66 | children, 67 | screenOptions, 68 | initialRouteName, 69 | }); 70 | 71 | return ( 72 | 73 | 78 | 79 | ); 80 | } 81 | /** 82 | * Filters out "modal"/"formSheet" routes from the normal on web, 83 | * rendering them in a vaul with snap points. On native, we just let 84 | * React Navigation handle the sheet or modal transitions. 85 | */ 86 | function MyModalStackView({ 87 | state, 88 | navigation, 89 | descriptors, 90 | }: { 91 | state: StackNavigationState; 92 | navigation: any; 93 | descriptors: Record< 94 | string, 95 | { 96 | options: MyModalStackNavigationOptions; 97 | render: () => React.ReactNode; 98 | } 99 | >; 100 | }) { 101 | const isWeb = Platform.OS === "web"; 102 | 103 | // Filter out any route that wants to be shown as a modal on web 104 | const nonModalRoutes = state.routes.filter((route) => { 105 | const descriptor = descriptors[route.key]; 106 | const { presentation } = descriptor.options || {}; 107 | const isModalType = 108 | presentation === "modal" || 109 | presentation === "formSheet" || 110 | presentation === "fullScreenModal" || 111 | presentation === "containedModal"; 112 | return !(isWeb && isModalType); 113 | }); 114 | 115 | // Recalculate index so we don't point to a missing route on web 116 | let nonModalIndex = nonModalRoutes.findIndex( 117 | (r) => r.key === state.routes[state.index]?.key 118 | ); 119 | if (nonModalIndex < 0) { 120 | nonModalIndex = nonModalRoutes.length - 1; 121 | } 122 | 123 | const newStackState: StackNavigationState = { 124 | ...state, 125 | routes: nonModalRoutes, 126 | index: nonModalIndex, 127 | }; 128 | 129 | return ( 130 |
134 | {/* Normal stack rendering for native & non-modal routes on web */} 135 | 140 | 141 | {/* Render vaul Drawer for active "modal" route on web, with snap points */} 142 | {isWeb && 143 | state.routes.map((route, i) => { 144 | const descriptor = descriptors[route.key]; 145 | const { presentation, sheetAllowedDetents, sheetGrabberVisible } = 146 | descriptor.options || {}; 147 | 148 | const isModalType = 149 | presentation === "modal" || 150 | presentation === "formSheet" || 151 | presentation === "fullScreenModal" || 152 | presentation === "containedModal"; 153 | const isActive = i === state.index && isModalType; 154 | if (!isActive) return null; 155 | 156 | // Convert numeric detents (e.g. 0.5 => "50%") to a string 157 | // If user passes pixel or percentage strings, we'll keep them as is. 158 | const rawDetents = sheetAllowedDetents || [1]; 159 | 160 | return ( 161 | { 170 | if (!open) { 171 | navigation.goBack(); 172 | } 173 | }} 174 | > 175 | 176 | 183 | 187 |
188 | {/* Optional "grabber" */} 189 | {sheetGrabberVisible && ( 190 |
201 |
209 |
210 | )} 211 | 212 | {/* Render the actual screen */} 213 | {descriptor.render()} 214 |
215 | 216 | 217 | 218 | ); 219 | })} 220 |
221 | ); 222 | } 223 | 224 | const createMyModalStack = createNavigatorFactory(MyModalStackNavigator); 225 | 226 | /** 227 | * If you're using Expo Router, wrap with `withLayoutContext`. 228 | * Otherwise, just export the createMyModalStack().Navigator as usual. 229 | */ 230 | const RouterModal = withLayoutContext(createMyModalStack().Navigator); 231 | 232 | export default RouterModal; 233 | -------------------------------------------------------------------------------- /functions/render-person-details.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import ShowMore from "@/components/ShowMore"; 4 | import { Image } from "@/components/img"; 5 | import { ParallaxImageWrapper } from "@/components/show-header-background"; 6 | import TouchableBounce from "@/components/ui/TouchableBounce"; 7 | import * as AC from "@bacons/apple-colors"; 8 | import { Link, Stack } from "expo-router"; 9 | import { ScrollView, Text, View } from "react-native"; 10 | 11 | export async function renderPersonDetails(id: string) { 12 | // Fetch person details 13 | const [person, credits] = await Promise.all([ 14 | fetch(`https://api.themoviedb.org/3/person/${id}`, { 15 | headers: { 16 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 17 | }, 18 | }).then((res) => res.json()), 19 | fetch(`https://api.themoviedb.org/3/person/${id}/combined_credits`, { 20 | headers: { 21 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 22 | }, 23 | }).then((res) => res.json()), 24 | ]); 25 | 26 | // Process credits into categories 27 | const allCredits = credits.cast.concat(credits.crew); 28 | const actingCredits = credits.cast; 29 | const crewCredits = credits.crew; 30 | const directingCredits = crewCredits.filter( 31 | (credit: any) => credit.job === "Director" 32 | ); 33 | 34 | return ( 35 | 36 | 37 | {/* Hero Section */} 38 | 46 | {person.profile_path && ( 47 | 56 | 57 | 70 | 71 | 85 | 86 | )} 87 | 94 | {person.name} 95 | 96 | 102 | {person.known_for_department} 103 | 104 | 105 | 106 | {/* Overview Section */} 107 | 108 | 116 | Overview 117 | 118 | 119 | 126 | {[ 127 | person.birthday && { 128 | label: "Born", 129 | value: `${new Date(person.birthday).toLocaleDateString()}${ 130 | person.place_of_birth ? ` in ${person.place_of_birth}` : "" 131 | }`, 132 | }, 133 | person.deathday && { 134 | label: "Died", 135 | value: new Date(person.deathday).toLocaleDateString(), 136 | }, 137 | ] 138 | .filter(Boolean) 139 | .map((item, index, array) => ( 140 | 150 | 153 | {item.label} 154 | 155 | 159 | {item.value} 160 | 161 | 162 | ))} 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | {/* Credits Section */} 171 | 172 | 177 | 178 | 179 | All ({allCredits.length}) 180 | 181 | 182 | 183 | 184 | Acting ({actingCredits.length}) 185 | 186 | 187 | 188 | 189 | Directing ({directingCredits.length}) 190 | 191 | 192 | 193 | 194 | 195 | 202 | {allCredits.map((credit: any, index: number) => ( 203 | 211 | 217 | 224 | 238 | 239 | 247 | {credit.title || credit.name} 248 | 249 | 255 | {credit.character || credit.job} 256 | 257 | 258 | 259 | 260 | 261 | ))} 262 | 263 | 264 | 265 | 266 | ); 267 | } 268 | -------------------------------------------------------------------------------- /functions/render-search.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { Image } from "@/components/img"; 4 | import { FadeIn } from "@/components/ui/FadeIn"; 5 | import TouchableBounce from "@/components/ui/TouchableBounce"; 6 | import * as AC from "@bacons/apple-colors"; 7 | import { Link } from "expo-router"; 8 | import React from "react"; 9 | import { ScrollView, Text, View } from "react-native"; 10 | import { TRENDING_MEDIA_FIXTURE } from "./fixtures/search-fixtures"; 11 | 12 | const POSTER_WIDTH = 140; 13 | const POSTER_HEIGHT = 210; 14 | const USE_FIXTURES = false; 15 | 16 | export async function renderSearchContents(query: string) { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | const MediaCard = ({ 29 | id, 30 | title, 31 | rating, 32 | posterPath, 33 | type, 34 | }: { 35 | id: number; 36 | title: string; 37 | rating: number; 38 | posterPath: string | null; 39 | type: "movie" | "show" | "person"; 40 | }) => ( 41 | 42 | 43 | 51 | 59 | {posterPath && ( 60 | 65 | )} 66 | 67 | 68 | 77 | {title} 78 | 79 | 85 | ★ {rating.toFixed(1)} 86 | 87 | 88 | 89 | 90 | 91 | ); 92 | 93 | const PersonCard = ({ 94 | id, 95 | name, 96 | department, 97 | profilePath, 98 | }: { 99 | id: number; 100 | name: string; 101 | department: string; 102 | profilePath: string | null; 103 | }) => ( 104 | 105 | 106 | 113 | 121 | {profilePath && ( 122 | 127 | )} 128 | 129 | 130 | 139 | {name} 140 | 141 | 147 | {department} 148 | 149 | 150 | 151 | 152 | 153 | ); 154 | 155 | async function MoviesSection({ query }: { query: string }) { 156 | const movies = await getMovies(query); 157 | if (!movies.length) return null; 158 | 159 | return ( 160 | 161 | 170 | Movies 171 | 172 | 177 | {movies.map((movie: any) => ( 178 | 186 | ))} 187 | 188 | 189 | ); 190 | } 191 | 192 | const ShowsSection = async ({ query }: { query: string }) => { 193 | const shows = await getShows(query); 194 | if (!shows.length) return null; 195 | 196 | return ( 197 | 198 | 207 | TV Shows 208 | 209 | 214 | {shows.map((show: any) => ( 215 | 223 | ))} 224 | 225 | 226 | ); 227 | }; 228 | 229 | const PeopleSection = async ({ query }: { query: string }) => { 230 | const people = await getPeople(query); 231 | if (!people.length) return null; 232 | 233 | return ( 234 | 235 | 244 | People 245 | 246 | 251 | {people.map((person: any) => ( 252 | 259 | ))} 260 | 261 | 262 | ); 263 | }; 264 | 265 | async function getMovies(query = "") { 266 | try { 267 | const response = await fetch( 268 | `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent( 269 | query 270 | )}`, 271 | { 272 | headers: { 273 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 274 | }, 275 | } 276 | ); 277 | 278 | if (!response.ok) { 279 | throw new Error("Failed to fetch movies"); 280 | } 281 | 282 | const data = await response.json(); 283 | 284 | return data.results; 285 | } catch (error) { 286 | console.error("Error fetching movies:", error); 287 | return []; 288 | } 289 | } 290 | 291 | async function getShows(query = "") { 292 | try { 293 | const response = await fetch( 294 | `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent( 295 | query 296 | )}`, 297 | { 298 | headers: { 299 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 300 | }, 301 | } 302 | ); 303 | 304 | if (!response.ok) { 305 | throw new Error("Failed to fetch shows"); 306 | } 307 | 308 | const data = await response.json(); 309 | return data.results; 310 | } catch (error) { 311 | console.error("Error fetching shows:", error); 312 | return []; 313 | } 314 | } 315 | 316 | async function getPeople(query = "") { 317 | try { 318 | const response = await fetch( 319 | `https://api.themoviedb.org/3/search/person?query=${encodeURIComponent( 320 | query 321 | )}`, 322 | { 323 | headers: { 324 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 325 | }, 326 | } 327 | ); 328 | 329 | if (!response.ok) { 330 | throw new Error("Failed to fetch people"); 331 | } 332 | 333 | const data = await response.json(); 334 | return data.results; 335 | } catch (error) { 336 | console.error("Error fetching people:", error); 337 | return []; 338 | } 339 | } 340 | 341 | export async function renderTrendingMedia({ 342 | type, 343 | timeWindow, 344 | size, 345 | }: { 346 | type: "movie" | "tv"; 347 | timeWindow: "day" | "week"; 348 | size: number; 349 | }) { 350 | const data = USE_FIXTURES 351 | ? TRENDING_MEDIA_FIXTURE 352 | : await fetch( 353 | `https://api.themoviedb.org/3/trending/${type}/${timeWindow}`, 354 | { 355 | headers: { 356 | accept: "application/json", 357 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 358 | }, 359 | } 360 | ).then((res) => res.json()); 361 | 362 | // const data = await response.json(); 363 | const shows = data.results.slice(0, size); 364 | return ( 365 | 369 | ); 370 | } 371 | 372 | function TrendingSection({ title, items }: { title: string; items: any[] }) { 373 | return ( 374 | 375 | <> 376 | 385 | 392 | Trending {title} 393 | 394 | {/* 395 | 396 | 400 | See All 401 | 402 | 403 | */} 404 | 405 | 406 | 411 | {items.map((item) => ( 412 | 420 | ))} 421 | 422 | 423 | 424 | ); 425 | } 426 | -------------------------------------------------------------------------------- /functions/fixtures/search-fixtures.ts: -------------------------------------------------------------------------------- 1 | export const TRENDING_MEDIA_FIXTURE = { 2 | page: 1, 3 | results: [ 4 | { 5 | backdrop_path: "/kCGwvjpqM1owt9kI4pkYPJWJLvc.jpg", 6 | id: 83867, 7 | name: "Andor", 8 | original_name: "Andor", 9 | overview: 10 | "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him into a rebel hero.", 11 | poster_path: "/khZqmwHQicTYoS7Flreb9EddFZC.jpg", 12 | media_type: "tv", 13 | adult: false, 14 | original_language: "en", 15 | genre_ids: [10765, 10759, 18], 16 | popularity: 134.3376, 17 | first_air_date: "2022-09-21", 18 | vote_average: 8.222, 19 | vote_count: 1448, 20 | origin_country: ["US"], 21 | }, 22 | { 23 | backdrop_path: "/lY2DhbA7Hy44fAKddr06UrXWWaQ.jpg", 24 | id: 100088, 25 | name: "The Last of Us", 26 | original_name: "The Last of Us", 27 | overview: 28 | "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a brutal, heartbreaking journey, as they both must traverse the United States and depend on each other for survival.", 29 | poster_path: "/dmo6TYuuJgaYinXBPjrgG9mB5od.jpg", 30 | media_type: "tv", 31 | adult: false, 32 | original_language: "en", 33 | genre_ids: [18], 34 | popularity: 321.661, 35 | first_air_date: "2023-01-15", 36 | vote_average: 8.573, 37 | vote_count: 5885, 38 | origin_country: ["US"], 39 | }, 40 | { 41 | backdrop_path: "/yMjGzK7L4gwzpQNNtFKDeG79upo.jpg", 42 | id: 226362, 43 | name: "The Eternaut", 44 | original_name: "El Eternauta", 45 | overview: 46 | "After a devastating toxic snowfall kills millions, Juan Salvo and a group of survivors in Buenos Aires must resist an invisible threat from another world.", 47 | poster_path: "/ucI5KroZLP0KyJqQnAdOpzhVvBs.jpg", 48 | media_type: "tv", 49 | adult: false, 50 | original_language: "es", 51 | genre_ids: [18, 10759, 10765], 52 | popularity: 277.5585, 53 | first_air_date: "2025-04-30", 54 | vote_average: 7.8, 55 | vote_count: 179, 56 | origin_country: ["AR"], 57 | }, 58 | { 59 | backdrop_path: "/gDtZQmfzvErZpeXOVeCBQE9WkSF.jpg", 60 | id: 239770, 61 | name: "Doctor Who", 62 | original_name: "Doctor Who", 63 | overview: 64 | "The Doctor and his companion travel across time and space encountering incredible friends and foes.", 65 | poster_path: "/2JP6NSmBwxg75uTcIHiv5R8PpPi.jpg", 66 | media_type: "tv", 67 | adult: false, 68 | original_language: "en", 69 | genre_ids: [10759, 18, 10765], 70 | popularity: 32.4036, 71 | first_air_date: "2024-05-11", 72 | vote_average: 6.5, 73 | vote_count: 200, 74 | origin_country: ["GB"], 75 | }, 76 | { 77 | backdrop_path: "/ccOzCmrglAjRGtqv5ClTaBEsWt8.jpg", 78 | id: 120998, 79 | name: "Poker Face", 80 | original_name: "Poker Face", 81 | overview: 82 | "Follow Charlie Cale, a woman with an extraordinary ability to tell when someone is lying, as she hits the road and, at every stop, encounters a new cast of characters and crimes she can't help but solve.", 83 | poster_path: "/8Xm5dyMQC9whMJKnGdFugUAW73C.jpg", 84 | media_type: "tv", 85 | adult: false, 86 | original_language: "en", 87 | genre_ids: [9648, 80], 88 | popularity: 44.4506, 89 | first_air_date: "2023-01-26", 90 | vote_average: 7.7, 91 | vote_count: 351, 92 | origin_country: ["US"], 93 | }, 94 | { 95 | backdrop_path: "/5Oj9YrVNadPGMyMOCt9SDfeF3Je.jpg", 96 | id: 111111, 97 | name: "Blood of Zeus", 98 | original_name: "Blood of Zeus", 99 | overview: 100 | "In a brewing war between the gods of Olympus and the titans, Heron, a commoner living on the outskirts of ancient Greece, becomes mankind's best hope of surviving an evil demon army, when he discovers the secrets of his past.", 101 | poster_path: "/zXRR5tgGLtKrRmuN4ko9SLAdCiZ.jpg", 102 | media_type: "tv", 103 | adult: false, 104 | original_language: "en", 105 | genre_ids: [16, 10759, 10765], 106 | popularity: 33.7299, 107 | first_air_date: "2020-10-27", 108 | vote_average: 7.784, 109 | vote_count: 528, 110 | origin_country: ["US"], 111 | }, 112 | { 113 | backdrop_path: "/dg3OindVAGZBjlT3xYKqIAdukPL.jpg", 114 | id: 42009, 115 | name: "Black Mirror", 116 | original_name: "Black Mirror", 117 | overview: 118 | "Twisted tales run wild in this mind-bending anthology series that reveals humanity's worst traits, greatest innovations and more.", 119 | poster_path: "/seN6rRfN0I6n8iDXjlSMk1QjNcq.jpg", 120 | media_type: "tv", 121 | adult: false, 122 | original_language: "en", 123 | genre_ids: [10765, 18, 9648], 124 | popularity: 96.581, 125 | first_air_date: "2011-12-04", 126 | vote_average: 8.29, 127 | vote_count: 5471, 128 | origin_country: ["GB"], 129 | }, 130 | { 131 | backdrop_path: "/2lj4e5psR9jJA0LSn557fwxcqLv.jpg", 132 | id: 261545, 133 | name: "The Royals", 134 | original_name: "द रॉयल्स", 135 | overview: 136 | "When charming Prince Aviraaj meets Sophia, a self-made girl boss, the worlds of royalty and startups collide in a whirlwind of romance and ambition.", 137 | poster_path: "/nnD4364ToXZwDpFWEnY94xzykIs.jpg", 138 | media_type: "tv", 139 | adult: false, 140 | original_language: "hi", 141 | genre_ids: [18, 35], 142 | popularity: 8.2675, 143 | first_air_date: "2025-05-09", 144 | vote_average: 5.5, 145 | vote_count: 2, 146 | origin_country: ["IN"], 147 | }, 148 | { 149 | backdrop_path: "/hMMWtinDnIjvUeiWKmvpkmnxGnK.jpg", 150 | id: 288055, 151 | name: "Star Wars: Tales of the Underworld", 152 | original_name: "Star Wars: Tales of the Underworld", 153 | overview: 154 | "Two outlaws must navigate the galaxy's dangerous underworld as they chart their destinies.", 155 | poster_path: "/vmLXaBruRgjtC8CpzSTeQH2bLbJ.jpg", 156 | media_type: "tv", 157 | adult: false, 158 | original_language: "en", 159 | genre_ids: [16, 10765, 80, 10759, 18], 160 | popularity: 48.2295, 161 | first_air_date: "2025-05-04", 162 | vote_average: 7.692, 163 | vote_count: 39, 164 | origin_country: ["US"], 165 | }, 166 | { 167 | backdrop_path: "/lhbT6Kmv8yi4QJzyshTRCdoNhfV.jpg", 168 | id: 248394, 169 | name: "FOREVER", 170 | original_name: "FOREVER", 171 | overview: 172 | "Reunited as teens, two childhood friends fall deeply in love, experiencing the joy and heartache of a first romance that will change their lives forever.", 173 | poster_path: "/98UiKiaQV5paB1EpZa3XDUxmGki.jpg", 174 | media_type: "tv", 175 | adult: false, 176 | original_language: "en", 177 | genre_ids: [18], 178 | popularity: 17.124, 179 | first_air_date: "2025-05-08", 180 | vote_average: 5.2, 181 | vote_count: 5, 182 | origin_country: ["US"], 183 | }, 184 | { 185 | backdrop_path: "/zZqpAXxVSBtxV9qPBcscfXBcL2w.jpg", 186 | id: 1399, 187 | name: "Game of Thrones", 188 | original_name: "Game of Thrones", 189 | overview: 190 | "Seven noble families fight for control of the mythical land of Westeros. Friction between the houses leads to full-scale war. All while a very ancient evil awakens in the farthest north. Amidst the war, a neglected military order of misfits, the Night's Watch, is all that stands between the realms of men and icy horrors beyond.", 191 | poster_path: "/1XS1oqL89opfnbLl8WnZY1O1uJx.jpg", 192 | media_type: "tv", 193 | adult: false, 194 | original_language: "en", 195 | genre_ids: [10765, 18, 10759], 196 | popularity: 202.8327, 197 | first_air_date: "2011-04-17", 198 | vote_average: 8.457, 199 | vote_count: 24962, 200 | origin_country: ["US"], 201 | }, 202 | { 203 | backdrop_path: "/2rmK7mnchw9Xr3XdiTFSxTTLXqv.jpg", 204 | id: 37854, 205 | name: "One Piece", 206 | original_name: "ワンピース", 207 | overview: 208 | 'Years ago, the fearsome Pirate King, Gol D. Roger was executed leaving a huge pile of treasure and the famous "One Piece" behind. Whoever claims the "One Piece" will be named the new King of the Pirates.\n\nMonkey D. Luffy, a boy who consumed a "Devil Fruit," decides to follow in the footsteps of his idol, the pirate Shanks, and find the One Piece. It helps, of course, that his body has the properties of rubber and that he\'s surrounded by a bevy of skilled fighters and thieves to help him along the way.\n\nLuffy will do anything to get the One Piece and become King of the Pirates!', 209 | poster_path: "/cMD9Ygz11zjJzAovURpO75Qg7rT.jpg", 210 | media_type: "tv", 211 | adult: false, 212 | original_language: "ja", 213 | genre_ids: [10759, 35, 16], 214 | popularity: 62.5851, 215 | first_air_date: "1999-10-20", 216 | vote_average: 8.724, 217 | vote_count: 4867, 218 | origin_country: ["JP"], 219 | }, 220 | { 221 | backdrop_path: "/odVlTMqPPiMksmxpN9cCbPCjUPP.jpg", 222 | id: 127532, 223 | name: "Solo Leveling", 224 | original_name: "俺だけレベルアップな件", 225 | overview: 226 | "They say whatever doesn’t kill you makes you stronger, but that’s not the case for the world’s weakest hunter Sung Jinwoo. After being brutally slaughtered by monsters in a high-ranking dungeon, Jinwoo came back with the System, a program only he could see, that’s leveling him up in every way. Now, he’s inspired to discover the secrets behind his powers and the dungeon that spawned them.", 227 | poster_path: "/geCRueV3ElhRTr0xtJuEWJt6dJ1.jpg", 228 | media_type: "tv", 229 | adult: false, 230 | original_language: "ja", 231 | genre_ids: [16, 10759, 10765], 232 | popularity: 82.7742, 233 | first_air_date: "2024-01-07", 234 | vote_average: 8.637, 235 | vote_count: 1129, 236 | origin_country: ["JP"], 237 | }, 238 | { 239 | backdrop_path: "/vcFW09U4834DyFOeRZpsx9x1D3S.jpg", 240 | id: 57243, 241 | name: "Doctor Who", 242 | original_name: "Doctor Who", 243 | overview: 244 | "The Doctor is a Time Lord: a 900 year old alien with 2 hearts, part of a gifted civilization who mastered time travel. The Doctor saves planets for a living—more of a hobby actually, and the Doctor's very, very good at it.", 245 | poster_path: "/4edFyasCrkH4MKs6H4mHqlrxA6b.jpg", 246 | media_type: "tv", 247 | adult: false, 248 | original_language: "en", 249 | genre_ids: [10759, 18, 10765], 250 | popularity: 180.0137, 251 | first_air_date: "2005-03-26", 252 | vote_average: 7.5, 253 | vote_count: 3113, 254 | origin_country: ["GB"], 255 | }, 256 | { 257 | backdrop_path: "/9faGSFi5jam6pDWGNd0p8JcJgXQ.jpg", 258 | id: 1396, 259 | name: "Breaking Bad", 260 | original_name: "Breaking Bad", 261 | overview: 262 | "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to secure his family's financial future at any cost as he enters the dangerous world of drugs and crime.", 263 | poster_path: "/ineLOBPG8AZsluYwnkMpHRyu7L.jpg", 264 | media_type: "tv", 265 | adult: false, 266 | original_language: "en", 267 | genre_ids: [18, 80], 268 | popularity: 116.064, 269 | first_air_date: "2008-01-20", 270 | vote_average: 8.925, 271 | vote_count: 15512, 272 | origin_country: ["US"], 273 | }, 274 | { 275 | backdrop_path: "/2m1Mu0xPj4SikiqkaolTRUcNtWH.jpg", 276 | id: 79744, 277 | name: "The Rookie", 278 | original_name: "The Rookie", 279 | overview: 280 | "Starting over isn't easy, especially for small-town guy John Nolan who, after a life-altering incident, is pursuing his dream of being an LAPD officer. As the force's oldest rookie, he’s met with skepticism from some higher-ups who see him as just a walking midlife crisis.", 281 | poster_path: "/bL1mwXDnH5fCxqc4S2n40hoVyoe.jpg", 282 | media_type: "tv", 283 | adult: false, 284 | original_language: "en", 285 | genre_ids: [80, 18, 35], 286 | popularity: 225.5888, 287 | first_air_date: "2018-10-16", 288 | vote_average: 8.525, 289 | vote_count: 2420, 290 | origin_country: ["US"], 291 | }, 292 | { 293 | backdrop_path: "/ctxm191q5o3axFzQsvNPlbKoSYv.jpg", 294 | id: 110492, 295 | name: "Peacemaker", 296 | original_name: "Peacemaker", 297 | overview: 298 | "The continuing story of Peacemaker – a compellingly vainglorious man who believes in peace at any cost, no matter how many people he has to kill to get it – in the aftermath of the events of “The Suicide Squad.”", 299 | poster_path: "/hE3LRZAY84fG19a18pzpkZERjTE.jpg", 300 | media_type: "tv", 301 | adult: false, 302 | original_language: "en", 303 | genre_ids: [10759, 10765, 18], 304 | popularity: 21, 305 | first_air_date: "2022-01-13", 306 | vote_average: 8.3, 307 | vote_count: 2755, 308 | origin_country: ["US"], 309 | }, 310 | { 311 | backdrop_path: "/jXB3OoWPkojsOP2O2OoLCeAIDRS.jpg", 312 | id: 69478, 313 | name: "The Handmaid's Tale", 314 | original_name: "The Handmaid's Tale", 315 | overview: 316 | "Set in a dystopian future, a woman is forced to live as a concubine under a fundamentalist theocratic dictatorship. A TV adaptation of Margaret Atwood's novel.", 317 | poster_path: "/qdWEaWkIQIjANGFeskheXpP0mm1.jpg", 318 | media_type: "tv", 319 | adult: false, 320 | original_language: "en", 321 | genre_ids: [18, 10765], 322 | popularity: 136.8672, 323 | first_air_date: "2017-04-26", 324 | vote_average: 8.178, 325 | vote_count: 2875, 326 | origin_country: ["US"], 327 | }, 328 | { 329 | backdrop_path: "/nyE3Xw5f775yukAUT6Z6AZx1z4U.jpg", 330 | id: 243316, 331 | name: "The Four Seasons", 332 | original_name: "The Four Seasons", 333 | overview: 334 | "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", 335 | poster_path: "/w09XeYl096pwES8riRMZwEA9rnh.jpg", 336 | media_type: "tv", 337 | adult: false, 338 | original_language: "en", 339 | genre_ids: [35], 340 | popularity: 18.8631, 341 | first_air_date: "2025-05-01", 342 | vote_average: 7, 343 | vote_count: 31, 344 | origin_country: ["US"], 345 | }, 346 | { 347 | backdrop_path: "/tQqbbxBAdW2ql8vbOqMOJbtSQ7O.jpg", 348 | id: 247718, 349 | name: "MobLand", 350 | original_name: "MobLand", 351 | overview: 352 | "Two mob families clash in a war that threatens to topple empires and lives.", 353 | poster_path: "/abeH7n5pcuQcwYcTxG6DTZvXLP1.jpg", 354 | media_type: "tv", 355 | adult: false, 356 | original_language: "en", 357 | genre_ids: [80, 18], 358 | popularity: 44.8381, 359 | first_air_date: "2025-03-30", 360 | vote_average: 8.449, 361 | vote_count: 89, 362 | origin_country: ["GB", "US"], 363 | }, 364 | ], 365 | total_pages: 500, 366 | total_results: 10000, 367 | }; 368 | -------------------------------------------------------------------------------- /functions/render-movie-details.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { Image } from "@/components/img"; 4 | import { FadeIn } from "@/components/ui/FadeIn"; 5 | import TouchableBounce from "@/components/ui/TouchableBounce"; 6 | import { label } from "@bacons/apple-colors"; 7 | import { Link, Stack } from "expo-router"; 8 | import React from "react"; 9 | import { ScrollView, Text, View } from "react-native"; 10 | 11 | import { ParallaxImageWrapper } from "@/components/show-header-background"; 12 | import * as AC from "@bacons/apple-colors"; 13 | import { BlurView } from "expo-blur"; 14 | import { 15 | CREDITS_FIXTURE, 16 | MEDIA_COMPANIES_FIXTURE, 17 | MEDIA_FIXTURE, 18 | SIMILAR_MEDIA_FIXTURE, 19 | VIDEOS_FIXTURE, 20 | } from "./fixtures/movie-detail-fixtures"; 21 | 22 | const USE_FIXTURES = false; 23 | 24 | type MediaType = "movie" | "tv"; 25 | 26 | export async function renderMedia(id: string, type: MediaType = "movie") { 27 | return ( 28 | <> 29 | 34 | 35 | 36 | 37 | }> 38 | 39 | 40 | 41 | }> 42 | 43 | 44 | 45 | }> 46 | 47 | 48 | 49 | }> 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | function HorizontalList({ 57 | title, 58 | children, 59 | }: { 60 | title: string; 61 | children: React.ReactNode; 62 | }) { 63 | return ( 64 | 65 | 74 | {title} 75 | 76 | 81 | {children} 82 | 83 | 84 | ); 85 | } 86 | 87 | function MediaHero({ media, type }: { media: any; type: MediaType }) { 88 | return ( 89 | 90 | 91 | 92 | 103 | 104 | 105 | 112 | 123 | 135 | 136 | 142 | 147 | 155 | {type === "movie" ? media.title : media.name} 156 | 157 | 158 | {media.tagline} 159 | 160 | 161 | 162 | 163 | 164 | 170 | 171 | ); 172 | } 173 | 174 | function VideoCard({ video }: { video: any }) { 175 | return ( 176 | 177 | 182 | 186 | {video.name} 187 | 188 | 189 | ); 190 | } 191 | 192 | function CastCard({ person }: { person: any }) { 193 | return ( 194 | 195 | 196 | 205 | 209 | {person.name} 210 | 211 | 215 | {person.character} 216 | 217 | 218 | 219 | ); 220 | } 221 | 222 | function CompanyCard({ company }: { company: any }) { 223 | return ( 224 | 227 | 239 | {company.logo_path && ( 240 | 247 | )} 248 | 249 | 258 | {company.name} 259 | 260 | 261 | ); 262 | } 263 | 264 | function MediaCard({ media, type }: { media: any; type: MediaType }) { 265 | return ( 266 | // @ts-expect-error 267 | 268 | 269 | 270 | 277 | 281 | {type === "movie" ? media.title : media.name} 282 | 283 | 284 | ★ {media.vote_average.toFixed(1)} 285 | 286 | 287 | 288 | 289 | ); 290 | } 291 | 292 | async function MediaDetails({ id, type }: { id: string; type: MediaType }) { 293 | const media = USE_FIXTURES 294 | ? MEDIA_FIXTURE 295 | : await fetch(`https://api.themoviedb.org/3/${type}/${id}`, { 296 | headers: { 297 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 298 | }, 299 | }).then((res) => res.json()); 300 | 301 | // if (!response.ok) { 302 | // throw new Error(`Failed to fetch ${type}`); 303 | // } 304 | 305 | return ( 306 | <> 307 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 325 | 326 | {media.overview} 327 | 328 | 329 | 330 | 331 | 332 | 339 | 347 | About 348 | 349 | 355 | {[ 356 | { 357 | label: type === "movie" ? "Release Date" : "First Air Date", 358 | value: new Date( 359 | type === "movie" ? media.release_date : media.first_air_date 360 | ).toLocaleDateString(), 361 | }, 362 | { 363 | label: "Age Rating", 364 | value: media.adult ? "Adult" : "All Ages", 365 | }, 366 | { 367 | label: type === "movie" ? "Runtime" : "Episode Runtime", 368 | value: 369 | type === "movie" 370 | ? `${media.runtime} minutes` 371 | : `${media.episode_run_time?.[0] || "N/A"} minutes`, 372 | }, 373 | { 374 | label: "Budget", 375 | value: media.budget 376 | ? `$${(media.budget / 1000000).toFixed(1)}M` 377 | : "N/A", 378 | }, 379 | { 380 | label: "Revenue", 381 | value: media.revenue 382 | ? `$${(media.revenue / 1000000).toFixed(1)}M` 383 | : "N/A", 384 | }, 385 | { 386 | label: "Countries", 387 | value: media.production_countries 388 | .map((c: { name: string }) => c.name) 389 | .join(", "), 390 | }, 391 | { 392 | label: "Languages", 393 | value: media.spoken_languages 394 | .map((l: { name: string }) => l.name) 395 | .join(", "), 396 | }, 397 | { 398 | label: "Genres", 399 | value: media.genres 400 | .map((g: { name: string }) => g.name) 401 | .join(", "), 402 | }, 403 | ].map((item, index, array) => ( 404 | 414 | 417 | {item.label} 418 | 419 | 420 | {item.value} 421 | 422 | 423 | ))} 424 | 425 | 426 | 427 | 428 | ); 429 | } 430 | 431 | async function MediaVideos({ id, type }: { id: string; type: MediaType }) { 432 | const videos = USE_FIXTURES 433 | ? VIDEOS_FIXTURE 434 | : await fetch(`https://api.themoviedb.org/3/${type}/${id}/videos`, { 435 | headers: { 436 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 437 | }, 438 | }).then((res) => res.json()); 439 | 440 | if (!videos.results.length) return null; 441 | 442 | return ( 443 | 444 | {videos.results.map((video: any) => ( 445 | 446 | ))} 447 | 448 | ); 449 | } 450 | 451 | async function MediaCast({ id, type }: { id: string; type: MediaType }) { 452 | const credits = USE_FIXTURES 453 | ? CREDITS_FIXTURE 454 | : await fetch(`https://api.themoviedb.org/3/${type}/${id}/credits`, { 455 | headers: { 456 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 457 | }, 458 | }).then((res) => res.json()); 459 | // console.log(JSON.stringify(credits)); 460 | 461 | return ( 462 | 463 | {credits.cast.slice(0, 10).map((person: any) => ( 464 | 465 | ))} 466 | 467 | ); 468 | } 469 | 470 | async function MediaCompanies({ id, type }: { id: string; type: MediaType }) { 471 | const media = USE_FIXTURES 472 | ? MEDIA_COMPANIES_FIXTURE 473 | : await fetch(`https://api.themoviedb.org/3/${type}/${id}`, { 474 | headers: { 475 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 476 | }, 477 | }).then((res) => res.json()); 478 | 479 | return ( 480 | 481 | {media.production_companies.map((company: any) => ( 482 | 483 | ))} 484 | 485 | ); 486 | } 487 | 488 | async function SimilarMedia({ id, type }: { id: string; type: MediaType }) { 489 | const similar = USE_FIXTURES 490 | ? SIMILAR_MEDIA_FIXTURE 491 | : await fetch(`https://api.themoviedb.org/3/${type}/${id}/similar`, { 492 | headers: { 493 | Authorization: `Bearer ${process.env.TMDB_READ_ACCESS_TOKEN}`, 494 | }, 495 | }).then((res) => res.json()); 496 | 497 | return ( 498 | 499 | {similar.results.slice(0, 10).map((media: any) => ( 500 | 501 | ))} 502 | 503 | ); 504 | } 505 | 506 | function MovieSkeleton() { 507 | return ( 508 | 509 | 512 | 519 | 520 | ); 521 | } 522 | 523 | function ListSkeleton() { 524 | return ( 525 | 526 | 535 | 540 | {[1, 2, 3, 4].map((i) => ( 541 | 551 | ))} 552 | 553 | 554 | ); 555 | } 556 | -------------------------------------------------------------------------------- /components/ui/Form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol"; 4 | import * as AppleColors from "@bacons/apple-colors"; 5 | import { Href, LinkProps, Link as RouterLink, Stack } from "expo-router"; 6 | import * as WebBrowser from "expo-web-browser"; 7 | import React from "react"; 8 | import { 9 | ActivityIndicator, 10 | Button, 11 | OpaqueColorValue, 12 | RefreshControl, 13 | Text as RNText, 14 | ScrollViewProps, 15 | Share, 16 | StyleProp, 17 | StyleSheet, 18 | TextProps, 19 | TextStyle, 20 | TouchableHighlight, 21 | View, 22 | ViewProps, 23 | ViewStyle, 24 | } from "react-native"; 25 | import { BodyScrollView } from "./BodyScrollView"; 26 | import { HeaderButton } from "./Header"; 27 | 28 | import Animated from "react-native-reanimated"; 29 | 30 | type ListStyle = "grouped" | "auto"; 31 | 32 | const ListStyleContext = React.createContext("auto"); 33 | 34 | type RefreshCallback = () => Promise; 35 | 36 | const RefreshContext = React.createContext<{ 37 | subscribe: (cb: RefreshCallback) => () => void; 38 | hasSubscribers: boolean; 39 | refresh: () => Promise; 40 | refreshing: boolean; 41 | }>({ 42 | subscribe: () => () => {}, 43 | hasSubscribers: false, 44 | refresh: async () => {}, 45 | refreshing: false, 46 | }); 47 | 48 | export const RefreshContextProvider: React.FC<{ 49 | children: React.ReactNode; 50 | }> = ({ children }) => { 51 | const subscribersRef = React.useRef>(new Set()); 52 | const [subscriberCount, setSubscriberCount] = React.useState(0); 53 | const [refreshing, setRefreshing] = React.useState(false); 54 | 55 | const subscribe = (cb: RefreshCallback) => { 56 | subscribersRef.current.add(cb); 57 | setSubscriberCount((count) => count + 1); 58 | 59 | return () => { 60 | subscribersRef.current.delete(cb); 61 | setSubscriberCount((count) => count - 1); 62 | }; 63 | }; 64 | 65 | const refresh = async () => { 66 | const subscribers = Array.from(subscribersRef.current); 67 | if (subscribers.length === 0) return; 68 | 69 | setRefreshing(true); 70 | try { 71 | await Promise.all(subscribers.map((cb) => cb())); 72 | } finally { 73 | setRefreshing(false); 74 | } 75 | }; 76 | 77 | return ( 78 | 0, 84 | }} 85 | > 86 | {children} 87 | 88 | ); 89 | }; 90 | 91 | export function usePullRefresh(callback?: () => Promise) { 92 | const { subscribe, refresh } = React.use(RefreshContext); 93 | 94 | React.useEffect(() => { 95 | if (callback) { 96 | const unsubscribe = subscribe(callback); 97 | return unsubscribe; 98 | } 99 | }, [callback, subscribe]); 100 | 101 | return refresh; 102 | } 103 | 104 | type ListProps = ScrollViewProps & { 105 | /** Set the Expo Router `` title when mounted. */ 106 | navigationTitle?: string; 107 | listStyle?: ListStyle; 108 | }; 109 | export function List(props: ListProps) { 110 | return ( 111 | 112 | 113 | 114 | ); 115 | } 116 | 117 | function InnerList({ contentContainerStyle, ...props }: ListProps) { 118 | const { hasSubscribers, refreshing, refresh } = React.use(RefreshContext); 119 | 120 | return ( 121 | <> 122 | {props.navigationTitle && ( 123 | 124 | )} 125 | 126 | 143 | ) : undefined 144 | } 145 | {...props} 146 | /> 147 | 148 | 149 | ); 150 | } 151 | 152 | if (__DEV__) List.displayName = "FormList"; 153 | 154 | export function HStack(props: ViewProps) { 155 | return ( 156 | 170 | ); 171 | } 172 | 173 | const minItemHeight = 20; 174 | 175 | const styles = StyleSheet.create({ 176 | itemPadding: { 177 | paddingVertical: 11, 178 | paddingHorizontal: 20, 179 | }, 180 | }); 181 | 182 | export function FormItem({ 183 | children, 184 | href, 185 | onPress, 186 | onLongPress, 187 | style, 188 | hStyle, 189 | ref, 190 | }: Pick & { 191 | href?: Href; 192 | onPress?: () => void; 193 | onLongPress?: () => void; 194 | style?: ViewStyle; 195 | hStyle?: ViewStyle; 196 | ref?: React.Ref; 197 | }) { 198 | if (href == null) { 199 | if (onPress == null && onLongPress == null) { 200 | return ( 201 | 202 | 203 | {children} 204 | 205 | 206 | ); 207 | } 208 | return ( 209 | 215 | 216 | 217 | {children} 218 | 219 | 220 | 221 | ); 222 | } 223 | 224 | return ( 225 | 226 | 227 | 228 | 229 | {children} 230 | 231 | 232 | 233 | 234 | ); 235 | } 236 | 237 | const Colors = { 238 | systemGray4: AppleColors.systemGray4, // "rgba(209, 209, 214, 1)", 239 | secondarySystemGroupedBackground: 240 | AppleColors.secondarySystemGroupedBackground, // "rgba(255, 255, 255, 1)", 241 | separator: AppleColors.separator, // "rgba(61.2, 61.2, 66, 0.29)", 242 | }; 243 | 244 | type SystemImageProps = 245 | | IconSymbolName 246 | | { 247 | name: IconSymbolName; 248 | color?: OpaqueColorValue; 249 | size?: number; 250 | }; 251 | 252 | /** Text but with iOS default color and sizes. */ 253 | export function Text({ 254 | bold, 255 | ...props 256 | }: TextProps & { 257 | /** Value displayed on the right side of the form item. */ 258 | hint?: React.ReactNode; 259 | /** Adds a prefix SF Symbol image to the left of the text */ 260 | systemImage?: SystemImageProps; 261 | 262 | bold?: boolean; 263 | }) { 264 | const font: TextStyle = { 265 | ...FormFont.default, 266 | flexShrink: 0, 267 | fontWeight: bold ? "600" : "normal", 268 | }; 269 | 270 | return ( 271 | 276 | ); 277 | } 278 | 279 | if (__DEV__) Text.displayName = "FormText"; 280 | 281 | export function Link({ 282 | bold, 283 | children, 284 | headerRight, 285 | hintImage, 286 | ...props 287 | }: LinkProps & { 288 | /** Value displayed on the right side of the form item. */ 289 | hint?: React.ReactNode; 290 | /** Adds a prefix SF Symbol image to the left of the text. */ 291 | systemImage?: SystemImageProps | React.ReactNode; 292 | 293 | /** Changes the right icon. */ 294 | hintImage?: SystemImageProps | React.ReactNode; 295 | 296 | // TODO: Automatically detect this somehow. 297 | /** Is the link inside a header. */ 298 | headerRight?: boolean; 299 | 300 | bold?: boolean; 301 | }) { 302 | const font: TextStyle = { 303 | ...FormFont.default, 304 | fontWeight: bold ? "600" : "normal", 305 | }; 306 | 307 | const resolvedChildren = (() => { 308 | if (headerRight) { 309 | if (process.env.EXPO_OS === "web") { 310 | return
{children}
; 311 | } 312 | const wrappedTextChildren = React.Children.map(children, (child) => { 313 | // Filter out empty children 314 | if (!child) { 315 | return null; 316 | } 317 | if (typeof child === "string") { 318 | return ( 319 | ( 321 | { ...font, color: AppleColors.link }, 322 | props.style 323 | )} 324 | > 325 | {child} 326 | 327 | ); 328 | } 329 | return child; 330 | }); 331 | 332 | return ( 333 | 341 | {wrappedTextChildren} 342 | 343 | ); 344 | } 345 | return children; 346 | })(); 347 | 348 | return ( 349 | (font, props.style)} 356 | onPress={ 357 | process.env.EXPO_OS === "web" 358 | ? props.onPress 359 | : (e) => { 360 | if ( 361 | props.target === "_blank" && 362 | // Ensure the resolved href is an external URL. 363 | /^([\w\d_+.-]+:)?\/\//.test(RouterLink.resolveHref(props.href)) 364 | ) { 365 | // Prevent the default behavior of linking to the default browser on native. 366 | e.preventDefault(); 367 | // Open the link in an in-app browser. 368 | WebBrowser.openBrowserAsync(props.href as string, { 369 | presentationStyle: 370 | WebBrowser.WebBrowserPresentationStyle.AUTOMATIC, 371 | }); 372 | } else if ( 373 | props.target === "share" && 374 | // Ensure the resolved href is an external URL. 375 | /^([\w\d_+.-]+:)?\/\//.test(RouterLink.resolveHref(props.href)) 376 | ) { 377 | // Prevent the default behavior of linking to the default browser on native. 378 | e.preventDefault(); 379 | // Open the link in an in-app browser. 380 | Share.share({ 381 | url: props.href as string, 382 | }); 383 | } else { 384 | props.onPress?.(e); 385 | } 386 | } 387 | } 388 | children={resolvedChildren} 389 | /> 390 | ); 391 | } 392 | 393 | if (__DEV__) Link.displayName = "FormLink"; 394 | 395 | export const FormFont = { 396 | // From inspecting SwiftUI `List { Text("Foo") }` in Xcode. 397 | default: { 398 | color: AppleColors.label, 399 | // 17.00pt is the default font size for a Text in a List. 400 | fontSize: 17, 401 | // UICTFontTextStyleBody is the default fontFamily. 402 | }, 403 | secondary: { 404 | color: AppleColors.secondaryLabel, 405 | fontSize: 17, 406 | }, 407 | caption: { 408 | color: AppleColors.secondaryLabel, 409 | fontSize: 12, 410 | }, 411 | title: { 412 | color: AppleColors.label, 413 | fontSize: 17, 414 | fontWeight: "600", 415 | }, 416 | }; 417 | 418 | export function Loading({ title }: { title?: string }) { 419 | return ( 420 |
421 | 422 | 423 | 424 |
425 | ); 426 | } 427 | 428 | function flattenChildren(children: React.ReactNode): React.ReactElement[] { 429 | const result: React.ReactElement[] = []; 430 | 431 | function recurse(nodes: React.ReactNode) { 432 | React.Children.forEach(nodes, (child) => { 433 | if (!React.isValidElement(child)) return; 434 | 435 | if (child.type === React.Fragment && child.key == null) { 436 | recurse(child.props.children); // 🌟 recurse here 437 | } else { 438 | result.push(child); 439 | } 440 | }); 441 | } 442 | 443 | recurse(children); 444 | return result; 445 | } 446 | 447 | export function Section({ 448 | children, 449 | title, 450 | titleView, 451 | footer, 452 | ...props 453 | }: ViewProps & { 454 | title?: string | React.ReactNode; 455 | titleView?: boolean; 456 | footer?: string | React.ReactNode; 457 | }) { 458 | const listStyle = React.use(ListStyleContext) ?? "auto"; 459 | 460 | const allChildren: React.ReactNode[] = flattenChildren(children); 461 | 462 | const childrenWithSeparator = allChildren.map((child, index) => { 463 | if (!React.isValidElement(child)) return child; 464 | 465 | const isLastChild = index === allChildren.length - 1; 466 | 467 | const resolvedProps = { 468 | ...child.props, 469 | }; 470 | // Extract onPress from child 471 | const originalOnPress = resolvedProps.onPress; 472 | const originalOnLongPress = resolvedProps.onLongPress; 473 | let wrapsFormItem = false; 474 | if (child.type === Button) { 475 | const { title, color } = resolvedProps; 476 | 477 | delete resolvedProps.title; 478 | resolvedProps.style = mergedStyleProp( 479 | { color: color ?? AppleColors.link }, 480 | resolvedProps.style 481 | ); 482 | child = ( 483 | 484 | {title} 485 | 486 | ); 487 | } 488 | 489 | if ( 490 | // If child is type of Text, add default props 491 | child.type === RNText || 492 | child.type === Text 493 | ) { 494 | child = React.cloneElement(child, { 495 | key: child.key, 496 | dynamicTypeRamp: "body", 497 | numberOfLines: 1, 498 | adjustsFontSizeToFit: true, 499 | ...resolvedProps, 500 | onPress: undefined, 501 | onLongPress: undefined, 502 | style: mergedStyleProp(FormFont.default, resolvedProps.style), 503 | }); 504 | 505 | const hintView = (() => { 506 | if (!resolvedProps.hint) { 507 | return null; 508 | } 509 | 510 | return React.Children.map(resolvedProps.hint, (child, index) => { 511 | // Filter out empty children 512 | if (!child) { 513 | return null; 514 | } 515 | if (typeof child === "string") { 516 | return ( 517 | 526 | {child} 527 | 528 | ); 529 | } 530 | return child; 531 | }); 532 | })(); 533 | 534 | const symbolView = (() => { 535 | if (!resolvedProps.systemImage) { 536 | return null; 537 | } 538 | 539 | if ( 540 | typeof resolvedProps.systemImage !== "string" && 541 | React.isValidElement(resolvedProps.systemImage) 542 | ) { 543 | return resolvedProps.systemImage; 544 | } 545 | 546 | const symbolProps = 547 | typeof resolvedProps.systemImage === "string" 548 | ? { name: resolvedProps.systemImage } 549 | : resolvedProps.systemImage; 550 | 551 | return ( 552 | 563 | ); 564 | })(); 565 | 566 | if (hintView || symbolView) { 567 | child = ( 568 | 569 | {symbolView} 570 | {child} 571 | {hintView && } 572 | {hintView} 573 | 574 | ); 575 | } 576 | } else if (child.type === RouterLink || child.type === Link) { 577 | wrapsFormItem = true; 578 | 579 | const wrappedTextChildren = React.Children.map( 580 | resolvedProps.children, 581 | (linkChild) => { 582 | // Filter out empty children 583 | if (!linkChild) { 584 | return null; 585 | } 586 | if (typeof linkChild === "string") { 587 | return ( 588 | 593 | {linkChild} 594 | 595 | ); 596 | } 597 | return linkChild; 598 | } 599 | ); 600 | 601 | const hintView = (() => { 602 | if (!resolvedProps.hint) { 603 | return null; 604 | } 605 | 606 | return React.Children.map(resolvedProps.hint, (child) => { 607 | // Filter out empty children 608 | if (!child) { 609 | return null; 610 | } 611 | if (typeof child === "string") { 612 | return {child}; 613 | } 614 | return child; 615 | }); 616 | })(); 617 | 618 | const symbolView = (() => { 619 | if (!resolvedProps.systemImage) { 620 | return null; 621 | } 622 | 623 | if ( 624 | typeof resolvedProps.systemImage !== "string" && 625 | React.isValidElement(resolvedProps.systemImage) 626 | ) { 627 | return resolvedProps.systemImage; 628 | } 629 | 630 | const symbolProps = 631 | typeof resolvedProps.systemImage === "string" 632 | ? { name: resolvedProps.systemImage } 633 | : resolvedProps.systemImage; 634 | 635 | return ( 636 | 647 | ); 648 | })(); 649 | 650 | child = React.cloneElement(child, { 651 | key: child.key, 652 | style: [ 653 | FormFont.default, 654 | process.env.EXPO_OS === "web" && { 655 | alignItems: "stretch", 656 | flexDirection: "column", 657 | display: "flex", 658 | }, 659 | resolvedProps.style, 660 | ], 661 | dynamicTypeRamp: "body", 662 | numberOfLines: 1, 663 | adjustsFontSizeToFit: true, 664 | // TODO: This causes issues with ref in React 19. 665 | asChild: process.env.EXPO_OS !== "web", 666 | children: ( 667 | 668 | 669 | {symbolView} 670 | {wrappedTextChildren} 671 | 672 | {hintView} 673 | 674 | 678 | 679 | 680 | 681 | ), 682 | }); 683 | } 684 | 685 | // Ensure child is a FormItem otherwise wrap it in a FormItem 686 | if (!wrapsFormItem && !child.props.custom && child.type !== FormItem) { 687 | child = ( 688 | 693 | {child} 694 | 695 | ); 696 | } 697 | 698 | return ( 699 | 700 | {child} 701 | {!isLastChild && } 702 | 703 | ); 704 | }); 705 | 706 | const contents = ( 707 | 726 | {childrenWithSeparator} 727 | 728 | ); 729 | 730 | const padding = listStyle === "grouped" ? 0 : 16; 731 | 732 | if (!title && !footer) { 733 | return ( 734 | 739 | {contents} 740 | 741 | ); 742 | } 743 | 744 | return ( 745 | 750 | {title && 751 | (titleView ? ( 752 | title 753 | ) : ( 754 | 766 | {title} 767 | 768 | ))} 769 | {contents} 770 | {footer && ( 771 | 781 | {footer} 782 | 783 | )} 784 | 785 | ); 786 | } 787 | 788 | function LinkChevronIcon({ 789 | href, 790 | systemImage, 791 | }: { 792 | href?: any; 793 | systemImage?: SystemImageProps | React.ReactNode; 794 | }) { 795 | const isHrefExternal = 796 | typeof href === "string" && /^([\w\d_+.-]+:)?\/\//.test(href); 797 | 798 | const size = process.env.EXPO_OS === "ios" ? 14 : 24; 799 | 800 | if (systemImage) { 801 | if (typeof systemImage !== "string") { 802 | if (React.isValidElement(systemImage)) { 803 | return systemImage; 804 | } 805 | return ( 806 | 811 | ); 812 | } 813 | } 814 | 815 | const resolvedName = 816 | typeof systemImage === "string" 817 | ? systemImage 818 | : isHrefExternal 819 | ? "arrow.up.right" 820 | : "chevron.right"; 821 | 822 | return ( 823 | 832 | ); 833 | } 834 | 835 | function Separator() { 836 | return ( 837 | 845 | ); 846 | } 847 | 848 | function mergedStyles(style: ViewStyle | TextStyle, props: any) { 849 | return mergedStyleProp(style, props.style); 850 | } 851 | 852 | export function mergedStyleProp( 853 | style: TStyle, 854 | styleProps?: StyleProp | null 855 | ): StyleProp { 856 | if (styleProps == null) { 857 | return style; 858 | } else if (Array.isArray(styleProps)) { 859 | return [style, ...styleProps]; 860 | } 861 | return [style, styleProps]; 862 | } 863 | 864 | function extractStyle(styleProp: any, key: string) { 865 | if (styleProp == null) { 866 | return undefined; 867 | } else if (Array.isArray(styleProp)) { 868 | return styleProp.find((style) => { 869 | return style[key] != null; 870 | })?.[key]; 871 | } else if (typeof styleProp === "object") { 872 | return styleProp?.[key]; 873 | } 874 | return null; 875 | } 876 | -------------------------------------------------------------------------------- /components/ui/IconSymbolFallback.tsx: -------------------------------------------------------------------------------- 1 | // This file is a fallback for using MaterialCommunityIcons on Android and web. 2 | 3 | import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; 4 | import MaterialIcons from "@expo/vector-icons/MaterialIcons"; 5 | import { SymbolWeight } from "expo-symbols"; 6 | import React from "react"; 7 | import { OpaqueColorValue, StyleProp, TextStyle } from "react-native"; 8 | 9 | // Add your SFSymbol to MaterialCommunityIcons mappings here. 10 | const MAPPING = { 11 | // See MaterialCommunityIcons here: https://icons.expo.app 12 | // See SF Symbols in the SF Symbols app on Mac. 13 | 14 | "chevron.left.forwardslash.chevron.right": "code-tags", 15 | car: "car-outline", 16 | "car.fill": "car", 17 | "light.beacon.min": "alarm-light-outline", 18 | "airpodspro.chargingcase.wireless.fill": "headset", 19 | "cursorarrow.rays": "cursor-default-click-outline", 20 | "person.text.rectangle": "account-box-outline", 21 | "hand.raised.fill": "hand-front-left", 22 | "0.square": "numeric-0-box-outline", 23 | "0.square.fill": "numeric-0-box", 24 | "1.square": "numeric-1-box-outline", 25 | "1.square.fill": "numeric-1-box", 26 | "2.square": "numeric-2-box-outline", 27 | "2.square.fill": "numeric-2-box", 28 | "3.square": "numeric-3-box-outline", 29 | "3.square.fill": "numeric-3-box", 30 | "4.square": "numeric-4-box-outline", 31 | "4.square.fill": "numeric-4-box", 32 | "5.square": "numeric-5-box-outline", 33 | "5.square.fill": "numeric-5-box", 34 | "6.square": "numeric-6-box-outline", 35 | "6.square.fill": "numeric-6-box", 36 | "7.square": "numeric-7-box-outline", 37 | "7.square.fill": "numeric-7-box", 38 | "8.square": "numeric-8-box-outline", 39 | "8.square.fill": "numeric-8-box", 40 | "9.square": "numeric-9-box-outline", 41 | "9.square.fill": "numeric-9-box", 42 | "10.square": "numeric-10-box-outline", 43 | "10.square.fill": "numeric-10-box", 44 | 45 | "app.gift": "gift-outline", 46 | "app.gift.fill": "gift", 47 | person: "account-outline", 48 | 49 | // From: https://github.com/roninoss/icons/blob/05c6ec9eda6c1be50f29577946d7cf778df1501c/packages/icons/src/icon-mapping.ts#L1 50 | "square.and.arrow.up": "tray-arrow-up", 51 | "square.and.arrow.down": "tray-arrow-down", 52 | "square.and.arrow.down.on.square": "arrow-down-bold-box-outline", 53 | "square.and.arrow.down.on.square.fill": "arrow-down-bold-box", 54 | "rectangle.portrait.and.arrow.right": "arrow-right-bold-box-outline", 55 | "rectangle.portrait.and.arrow.right.fill": "arrow-right-bold-box", 56 | pencil: "pencil", 57 | "pencil.circle": "pencil-circle-outline", 58 | "pencil.circle.fill": "pencil-circle", 59 | "pencil.slash": "pencil-off", 60 | "pencil.line": "progress-pencil", 61 | eraser: "eraser", 62 | "eraser.fill": "eraser-variant", 63 | "square.and.pencil": "pencil-box-outline", 64 | "pencil.and.scribble": "draw", 65 | highlighter: "marker", 66 | "pencil.tip": "fountain-pen-tip", 67 | "pencil.tip.crop.circle.badge.plus": "pencil-plus-outline", 68 | "pencil.tip.crop.circle.badge.plus.fill": "pen-plus", 69 | "pencil.tip.crop.circle.badge.minus": "pencil-minus-outline", 70 | "pencil.tip.crop.circle.badge.minus.fill": "pen-minus", 71 | lasso: "lasso", 72 | trash: "trash-can-outline", 73 | "trash.fill": "trash-can", 74 | "trash.circle": "delete-circle-outline", 75 | "trash.circle.fill": "delete-circle", 76 | "trash.slash": "delete-off-outline", 77 | "trash.slash.fill": "delete-off", 78 | "folder.fill": "folder", 79 | "folder.badge.plus": "folder-plus-outline", 80 | "folder.fill.badge.plus": "folder-plus", 81 | "folder.badge.minus": "folder-remove-outline", 82 | "folder.fill.badge.minus": "folder-remove", 83 | "folder.badge.person.crop": "folder-account-outline", 84 | "folder.fill.badge.person.crop": "folder-account", 85 | "folder.badge.gearshape": "folder-cog-outline", 86 | "folder.fill.badge.gearshape": "folder-cog", 87 | paperplane: "send-outline", 88 | "paperplane.fill": "send", 89 | "paperplane.circle": "send-circle-outline", 90 | "paperplane.circle.fill": "send-circle", 91 | tray: "tray", 92 | "tray.fill": "inbox", 93 | "tray.full": "inbox-full-outline", 94 | "tray.full.fill": "inbox-full", 95 | "tray.and.arrow.up": "inbox-arrow-up-outline", 96 | "tray.and.arrow.up.fill": "inbox-arrow-up", 97 | "tray.and.arrow.down": "inbox-arrow-down-outline", 98 | "tray.and.arrow.down.fill": "inbox-arrow-down", 99 | "tray.2": "inbox-multiple-outline", 100 | "tray.2.fill": "inbox-multiple", 101 | externaldrive: "database-outline", 102 | "externaldrive.fill": "database", 103 | "externaldrive.badge.plus": "database-plus-outline", 104 | "externaldrive.fill.badge.plus": "database-plus", 105 | "externaldrive.badge.minus": "database-minus-outline", 106 | "externaldrive.fill.badge.minus": "database-minus", 107 | "externaldrive.badge.checkmark": "database-check-outline", 108 | "externaldrive.fill.badge.checkmark": "database-check", 109 | "externaldrive.badge.xmark": "database-remove-outline", 110 | "externaldrive.fill.badge.xmark": "database-remove", 111 | "externaldrive.badge.exclamationmark": "database-alert-outline", 112 | "externaldrive.fill.badge.exclamationmark": "database-alert", 113 | "externaldrive.badge.timemachine": "database-clock-outline", 114 | "externaldrive.fill.badge.timemachine": "database-clock", 115 | archivebox: "archive-outline", 116 | "archivebox.fill": "archive", 117 | "xmark.bin": "archive-remove-outline", 118 | "xmark.bin.fill": "archive-remove", 119 | "arrow.up.bin": "archive-arrow-up-outline", 120 | "arrow.up.bin.fill": "archive-arrow-up", 121 | doc: "file-document-outline", 122 | "doc.fill": "file-document", 123 | "doc.badge.plus": "file-plus-outline", 124 | "doc.fill.badge.plus": "file-plus", 125 | "arrow.up.doc": "file-upload-outline", 126 | "arrow.up.doc.fill": "file-upload", 127 | "doc.badge.clock": "file-clock-outline", 128 | "doc.badge.clock.fill": "file-clock", 129 | "doc.badge.gearshape": "file-cog-outline", 130 | "doc.badge.gearshape.fill": "file-cog", 131 | "lock.doc": "file-lock-outline", 132 | "lock.doc.fill": "file-lock", 133 | "arrow.down.doc": "file-download-outline", 134 | "arrow.down.doc.fill": "file-download", 135 | "doc.on.doc": "file-document-multiple-outline", 136 | "doc.on.doc.fill": "file-document-multiple", 137 | clipboard: "clipboard-outline", 138 | "clipboard.fill": "clipboard", 139 | "list.clipboard": "clipboard-list-outline", 140 | "list.clipboard.fill": "clipboard-list", 141 | "pencil.and.list.clipboard": "clipboard-edit-outline", 142 | "doc.richtext": "image-text", 143 | "doc.questionmark": "file-question-outline", 144 | "doc.questionmark.fill": "file-question", 145 | "list.bullet.rectangle": "card-bulleted-outline", 146 | "list.bullet.rectangle.fill": "card-bulleted", 147 | "doc.text.magnifyingglass": "file-search-outline", 148 | note: "note-outline", 149 | "note.text": "note-text-outline", 150 | calendar: "calendar-month", 151 | "calendar.badge.plus": "calendar-plus", 152 | "calendar.badge.minus": "calendar-minus", 153 | "calendar.badge.clock": "calendar-clock", 154 | "calendar.badge.exclamationmark": "calendar-alert", 155 | "calendar.badge.checkmark": "calendar-check", 156 | "arrowshape.left": "arrow-left-bold-outline", 157 | "arrowshape.left.fill": "arrow-left-bold", 158 | "arrowshape.left.circle": "arrow-left-bold-circle-outline", 159 | "arrowshape.left.circle.fill": "arrow-left-bold-circle", 160 | "arrowshape.right": "arrow-right-bold-outline", 161 | "arrowshape.right.fill": "arrow-right-bold", 162 | "arrowshape.right.circle": "arrow-right-bold-circle-outline", 163 | "arrowshape.right.circle.fill": "arrow-right-bold-circle", 164 | "arrowshape.up": "arrow-up-bold-outline", 165 | "arrowshape.up.fill": "arrow-up-bold", 166 | "arrowshape.up.circle": "arrow-up-bold-circle-outline", 167 | "arrowshape.up.circle.fill": "arrow-up-bold-circle", 168 | "arrowshape.down": "arrow-down-bold-outline", 169 | "arrowshape.down.fill": "arrow-down-bold", 170 | "arrowshape.down.circle": "arrow-down-bold-circle-outline", 171 | "arrowshape.down.circle.fill": "arrow-down-bold-circle", 172 | "arrowshape.left.arrowshape.right": "arrow-left-right-bold-outline", 173 | "arrowshape.left.arrowshape.right.fill": "arrow-left-right-bold", 174 | "arrowshape.turn.up.left": "arrow-left-top", 175 | "arrowshape.turn.up.left.fill": "arrow-left-top-bold", 176 | "arrowshape.turn.up.right": "arrow-right-top", 177 | "arrowshape.turn.up.right.fill": "arrow-right-top-bold", 178 | book: "book-open-outline", 179 | "book.fill": "book-open", 180 | "books.vertical.fill": "bookshelf", 181 | newspaper: "newspaper", 182 | "newspaper.fill": "newspaper-variant", 183 | bookmark: "bookmark-outline", 184 | "bookmark.fill": "bookmark", 185 | "bookmark.slash": "bookmark-off-outline", 186 | "bookmark.slash.fill": "bookmark-off", 187 | "pencil.and.ruler.fill": "pencil-ruler", 188 | "ruler.fill": "ruler", 189 | paperclip: "paperclip", 190 | link: "link", 191 | "link.badge.plus": "link-plus", 192 | personalhotspot: "vector-link", 193 | "person.circle": "account-circle-outline", 194 | "person.circle.fill": "account-circle", 195 | "person.slash": "account-off-outline", 196 | "person.slash.fill": "account-off", 197 | "person.fill.checkmark": "account-check", 198 | "person.fill.xmark": "account-remove", 199 | "person.fill.questionmark": "account-question", 200 | "person.badge.plus": "account-plus-outline", 201 | "person.badge.minus": "account-minus-outline", 202 | "person.badge.clock": "account-clock-outline", 203 | "person.badge.clock.fill": "account-clock", 204 | "person.badge.key": "account-key-outline", 205 | "person.badge.key.fill": "account-key", 206 | "person.2": "account-multiple-outline", 207 | "person.2.fill": "account-multiple", 208 | "person.wave.2.fill": "account-voice", 209 | "photo.artframe": "image-frame", 210 | "rectangle.checkered": "checkerboard", 211 | "dumbbell.fill": "dumbbell", 212 | sportscourt: "soccer-field", 213 | soccerball: "soccer", 214 | "baseball.fill": "baseball", 215 | "basketball.fill": "basketball", 216 | "football.fill": "football", 217 | "tennis.racket": "tennis", 218 | "hockey.puck.fill": "hockey-puck", 219 | "cricket.ball.fill": "cricket", 220 | "tennisball.fill": "tennis-ball", 221 | "volleyball.fill": "volleyball", 222 | skateboard: "skateboard", 223 | skis: "ski", 224 | surfboard: "surfing", 225 | trophy: "trophy-outline", 226 | "trophy.fill": "trophy", 227 | medal: "medal-outline", 228 | "medal.fill": "medal", 229 | command: "apple-keyboard-command", 230 | space: "keyboard-space", 231 | option: "apple-keyboard-option", 232 | restart: "restart", 233 | zzz: "sleep", 234 | power: "power", 235 | togglepower: "power-cycle", 236 | poweron: "power-on", 237 | poweroff: "power-off", 238 | powersleep: "power-sleep", 239 | clear: "alpha-x-box-outline", 240 | "clear.fill": "alpha-x-box", 241 | "delete.left": "keyboard-backspace", 242 | shift: "apple-keyboard-shift", 243 | capslock: "apple-keyboard-caps", 244 | keyboard: "keyboard-outline", 245 | "keyboard.fill": "keyboard", 246 | "keyboard.badge.ellipsis": "keyboard-settings-outline", 247 | "keyboard.badge.ellipsis.fill": "keyboard-settings", 248 | globe: "web", 249 | network: "access-point-network", 250 | "network.slash": "access-point-network-off", 251 | "sun.min": "weather-sunny", 252 | "sun.max.fill": "white-balance-sunny", 253 | "sun.max.trianglebadge.exclamationmark": "weather-sunny-alert", 254 | "moon.stars": "weather-night", 255 | sparkle: "star-four-points", 256 | cloud: "cloud-outline", 257 | "cloud.fill": "cloud", 258 | "cloud.rain": "weather-rainy", 259 | "cloud.bolt": "weather-lightning", 260 | tornado: "weather-tornado", 261 | "thermometer.sun": "sun-thermometer-outline", 262 | "thermometer.sun.fill": "sun-thermometer", 263 | drop: "water-outline", 264 | "drop.fill": "water", 265 | "drop.circle.fill": "water-circle", 266 | flame: "fire", 267 | "flame.circle": "fire-circle", 268 | umbrella: "umbrella-outline", 269 | "umbrella.fill": "umbrella", 270 | play: "play-outline", 271 | "play.fill": "play", 272 | "play.circle": "play-circle-outline", 273 | "play.circle.fill": "play-circle", 274 | "play.square": "play-box-outline", 275 | "play.square.fill": "play-box", 276 | "play.square.stack": "play-box-multiple-outline", 277 | "play.square.stack.fill": "play-box-multiple", 278 | pause: "pause", 279 | "pause.circle": "pause-circle-outline", 280 | "pause.circle.fill": "pause-circle", 281 | "stop.fill": "stop", 282 | "stop.circle": "stop-circle-outline", 283 | "stop.circle.fill": "stop-circle", 284 | "record.circle": "record-circle-outline", 285 | "record.circle.fill": "record-circle", 286 | "playpause.fill": "play-pause", 287 | backward: "rewind-outline", 288 | "backward.fill": "rewind", 289 | forward: "fast-forward-outline", 290 | "forward.fill": "fast-forward", 291 | "backward.end": "skip-backward-outline", 292 | "backward.end.fill": "skip-backward", 293 | "forward.end": "skip-forward-outline", 294 | "forward.end.fill": "skip-forward", 295 | "backward.frame.fill": "step-backward", 296 | "forward.frame.fill": "step-forward", 297 | shuffle: "shuffle", 298 | repeat: "repeat", 299 | "repeat.1": "repeat-once", 300 | infinity: "infinity", 301 | megaphone: "bullhorn-outline", 302 | "megaphone.fill": "bullhorn", 303 | "speaker.fill": "volume-low", 304 | "speaker.plus.fill": "volume-plus", 305 | "speaker.minus.fill": "volume-minus", 306 | "speaker.slash.fill": "volume-variant-off", 307 | "speaker.wave.1.fill": "volume-medium", 308 | "speaker.wave.3.fill": "volume-high", 309 | "music.note": "music-note", 310 | "music.note.list": "playlist-music", 311 | "music.mic": "microphone-variant", 312 | magnifyingglass: "magnify", 313 | "minus.magnifyingglass": "magnify-minus-outline", 314 | "plus.magnifyingglass": "magnify-plus-outline", 315 | mic: "microphone-outline", 316 | "mic.fill": "microphone", 317 | "mic.slash.fill": "microphone-off", 318 | "mic.fill.badge.plus": "microphone-plus", 319 | circle: "circle-outline", 320 | "circle.fill": "circle", 321 | "circle.slash": "cancel", 322 | target: "target", 323 | square: "square-outline", 324 | "square.fill": "square", 325 | "star.square.on.square": "star-box-multiple-outline", 326 | "star.square.on.square.fill": "star-box-multiple", 327 | "plus.app": "plus-box-outline", 328 | "plus.app.fill": "plus-box", 329 | "app.badge": "checkbox-blank-badge-outline", 330 | "app.badge.fill": "checkbox-blank-badge", 331 | "checkmark.seal": "check-decagram-outline", 332 | "checkmark.seal.fill": "check-decagram", 333 | heart: "heart-outline", 334 | "heart.fill": "heart", 335 | "bolt.heart.fill": "heart-flash", 336 | star: "star-outline", 337 | "star.fill": "star", 338 | shield: "shield-outline", 339 | "shield.fill": "shield", 340 | flag: "flag-outline", 341 | "flag.fill": "flag", 342 | bell: "bell-outline", 343 | "bell.fill": "bell", 344 | tag: "tag-outline", 345 | "tag.fill": "tag", 346 | bolt: "lightning-bolt-outline", 347 | "bolt.fill": "lightning-bolt", 348 | "x.squareroot": "square-root", 349 | "flashlight.on.fill": "flashlight", 350 | "flashlight.off.fill": "flashlight-off", 351 | camera: "camera-outline", 352 | "camera.fill": "camera", 353 | message: "message-outline", 354 | "message.fill": "message", 355 | "plus.message": "message-plus-outline", 356 | "plus.message.fill": "message-plus", 357 | "ellipsis.message": "message-processing-outline", 358 | "ellipsis.message.fill": "message-processing", 359 | "quote.opening": "format-quote-open", 360 | "quote.closing": "format-quote-close", 361 | "quote.bubble": "comment-quote-outline", 362 | "quote.bubble.fill": "comment-quote", 363 | "star.bubble": "message-star-outline", 364 | "star.bubble.fill": "message-star", 365 | "questionmark.bubble": "message-question-outline", 366 | "questionmark.bubble.fill": "message-question", 367 | phone: "phone-outline", 368 | "phone.fill": "phone", 369 | "phone.badge.plus": "phone-plus-outline", 370 | "phone.fill.badge.plus": "phone-plus", 371 | "phone.badge.checkmark": "phone-check-outline", 372 | "phone.fill.badge.checkmark": "phone-check", 373 | video: "video-outline", 374 | "video.fill": "video", 375 | envelope: "email-outline", 376 | "envelope.fill": "email", 377 | "envelope.open.fill": "email-open", 378 | gearshape: "cog-outline", 379 | "gearshape.fill": "cog", 380 | signature: "signature-freehand", 381 | ellipsis: "dots-horizontal", 382 | "ellipsis.circle": "dots-horizontal-circle-outline", 383 | "ellipsis.circle.fill": "dots-horizontal-circle", 384 | bag: "shopping-outline", 385 | "bag.fill": "shopping", 386 | cart: "cart-outline", 387 | "cart.fill": "cart", 388 | basket: "basket-outline", 389 | "basket.fill": "basket", 390 | creditcard: "credit-card-outline", 391 | "creditcard.fill": "credit-card", 392 | crop: "crop", 393 | "crop.rotate": "crop-rotate", 394 | paintbrush: "brush-variant", 395 | level: "spirit-level", 396 | "wrench.adjustable": "wrench-outline", 397 | "wrench.adjustable.fill": "wrench", 398 | "hammer.fill": "hammer", 399 | screwdriver: "screwdriver", 400 | eyedropper: "eyedropper", 401 | "wrench.and.screwdriver.fill": "hammer-screwdriver", 402 | scroll: "script-outline", 403 | "scroll.fill": "script", 404 | printer: "printer-outline", 405 | "printer.fill": "printer", 406 | scanner: "scanner", 407 | theatermasks: "drama-masks", 408 | puzzlepiece: "puzzle-outline", 409 | "puzzlepiece.fill": "puzzle", 410 | house: "home-outline", 411 | "house.fill": "home", 412 | "house.circle": "home-circle-outline", 413 | "house.circle.fill": "home-circle", 414 | storefront: "storefront-outline", 415 | "storefront.fill": "storefront", 416 | lightbulb: "lightbulb-outline", 417 | "lightbulb.fill": "lightbulb", 418 | "lightbulb.slash": "lightbulb-off-outline", 419 | "lightbulb.slash.fill": "lightbulb-off", 420 | "poweroutlet.type.b": "power-socket-us", 421 | powerplug: "power-plug-outline", 422 | "powerplug.fill": "power-plug", 423 | "web.camera.fill": "webcam", 424 | "spigot.fill": "water-pump", 425 | "party.popper.fill": "party-popper", 426 | "balloon.fill": "balloon", 427 | fireworks: "firework", 428 | building: "office-building-outline", 429 | "building.fill": "office-building", 430 | "building.2": "city-variant-outline", 431 | "building.2.fill": "city-variant", 432 | lock: "lock-outline", 433 | "lock.fill": "lock", 434 | "lock.shield": "shield-lock-outline", 435 | "lock.shield.fill": "shield-lock", 436 | "lock.slash": "lock-off-outline", 437 | "lock.slash.fill": "lock-off", 438 | "exclamationmark.lock": "lock-alert-outline", 439 | "exclamationmark.lock.fill": "lock-alert", 440 | "lock.badge.clock.fill": "lock-clock", 441 | "lock.open": "lock-open", 442 | "lock.open.fill": "lock-open-outline", 443 | "lock.open.trianglebadge.exclamationmark": "lock-open-alert", 444 | "lock.open.trianglebadge.exclamationmark.fill": "lock-open-alert-outline", 445 | "lock.rotation": "lock-reset", 446 | key: "key-outline", 447 | "key.fill": "key", 448 | wifi: "wifi", 449 | "wifi.slash": "wifi-off", 450 | "wifi.exclamationmark": "wifi-alert", 451 | pin: "pin-outline", 452 | "pin.fill": "pin", 453 | "pin.slash": "pin-off-outline", 454 | "pin.slash.fill": "pin-off", 455 | mappin: "map-marker-outline", 456 | "mappin.circle": "map-marker-radius-outline", 457 | "mappin.circle.fill": "map-marker-radius", 458 | "mappin.slash": "map-marker-off-outline", 459 | map: "map-outline", 460 | "map.fill": "map", 461 | display: "monitor", 462 | "lock.display": "monitor-lock", 463 | "display.2": "monitor-multiple", 464 | "server.rack": "server", 465 | laptopcomputer: "laptop", 466 | "laptopcomputer.slash": "laptop-off", 467 | smartphone: "cellphone", 468 | headphones: "headphones", 469 | "play.tv.fill": "youtube-tv", 470 | horn: "bullhorn-variant-outline", 471 | "horn.fill": "bullhorn-variant", 472 | "bandage.fill": "bandage", 473 | crown: "crown-outline", 474 | "crown.fill": "crown", 475 | "film.fill": "filmstrip", 476 | "film.stack.fill": "filmstrip-box-multiple", 477 | movieclapper: "movie-open-outline", 478 | "movieclapper.fill": "movie-open", 479 | ticket: "ticket-confirmation-outline", 480 | "ticket.fill": "ticket-confirmation", 481 | eye: "eye-outline", 482 | "eye.fill": "eye", 483 | "eye.slash": "eye-off-outline", 484 | "eye.slash.fill": "eye-off", 485 | brain: "brain", 486 | qrcode: "qrcode", 487 | barcode: "barcode", 488 | photo: "image-outline", 489 | "photo.fill": "image", 490 | "photo.badge.plus.fill": "image-plus", 491 | "photo.stack": "image-multiple-outline", 492 | "photo.stack.fill": "image-multiple", 493 | clock: "clock-outline", 494 | "clock.fill": "clock", 495 | "clock.badge.checkmark": "clock-check-outline", 496 | "clock.badge.checkmark.fill": "clock-check", 497 | "clock.badge.xmark": "clock-remove-outline", 498 | "clock.badge.xmark.fill": "clock-remove", 499 | "clock.badge.exclamationmark": "clock-alert-outline", 500 | "clock.badge.exclamationmark.fill": "clock-alert", 501 | alarm: "alarm", 502 | stopwatch: "timer-outline", 503 | "stopwatch.fill": "timer", 504 | "chart.xyaxis.line": "chart-timeline-variant", 505 | timer: "camera-timer", 506 | gamecontroller: "controller-classic-outline", 507 | "gamecontroller.fill": "controller-classic", 508 | "playstation.logo": "sony-playstation", 509 | "xbox.logo": "microsoft-xbox", 510 | paintpalette: "palette-outline", 511 | "paintpalette.fill": "palette", 512 | swatchpalette: "palette-swatch-outline", 513 | "swatchpalette.fill": "palette-swatch", 514 | "fork.knife": "silverware-fork-knife", 515 | "chart.bar": "chart-box-outline", 516 | "chart.bar.fill": "chart-box", 517 | cellularbars: "signal-cellular-3", 518 | "chart.pie.fill": "chart-pie", 519 | "sdcard.fill": "sd", 520 | atom: "atom", 521 | angle: "angle-acute", 522 | "compass.drawing": "math-compass", 523 | "globe.desk": "globe-model", 524 | gift: "gift-outline", 525 | "gift.fill": "gift", 526 | banknote: "cash", 527 | grid: "grid", 528 | checklist: "format-list-checks", 529 | "list.bullet": "format-list-bulleted", 530 | "line.3.horizontal": "reorder-horizontal", 531 | bold: "format-bold", 532 | italic: "format-italic", 533 | underline: "format-underline", 534 | strikethrough: "format-strikethrough", 535 | percent: "percent", 536 | "info.circle": "information-outline", 537 | "info.circle.fill": "information", 538 | questionmark: "help", 539 | exclamationmark: "exclamation", 540 | plus: "plus", 541 | minus: "minus", 542 | plusminus: "plus-minus", 543 | multiply: "close", 544 | divide: "division", 545 | equal: "equal", 546 | lessthan: "less-than", 547 | greaterthan: "greater-than", 548 | parentheses: "code-parentheses", 549 | curlybraces: "code-braces", 550 | "ellipsis.curlybraces": "code-json", 551 | xmark: "close-thick", 552 | "xmark.circle": "close-circle-outline", 553 | "xmark.circle.fill": "close-circle", 554 | "xmark.square": "close-box-outline", 555 | "xmark.square.fill": "close-box", 556 | "xmark.shield": "shield-remove-outline", 557 | "xmark.shield.fill": "shield-remove", 558 | "xmark.octagon": "close-octagon-outline", 559 | "xmark.octagon.fill": "close-octagon", 560 | checkmark: "check", 561 | "checkmark.circle": "check-circle-outline", 562 | "checkmark.circle.fill": "check-circle", 563 | "checkmark.shield": "shield-check-outline", 564 | "checkmark.shield.fill": "shield-check", 565 | "chevron.left": "chevron-left", 566 | "chevron.left.circle": "chevron-left-circle-outline", 567 | "chevron.left.circle.fill": "chevron-left-circle", 568 | "chevron.left.square": "chevron-left-box-outline", 569 | "chevron.left.square.fill": "chevron-left-box", 570 | "chevron.right": "chevron-right", 571 | "chevron.right.circle": "chevron-right-circle-outline", 572 | "chevron.right.circle.fill": "chevron-right-circle", 573 | "chevron.right.square": "chevron-right-box-outline", 574 | "chevron.right.square.fill": "chevron-right-box", 575 | "chevron.left.2": "chevron-double-left", 576 | "chevron.right.2": "chevron-double-right", 577 | "chevron.up": "chevron-up", 578 | "chevron.up.circle": "chevron-up-circle-outline", 579 | "chevron.up.circle.fill": "chevron-up-circle", 580 | "chevron.up.square": "chevron-up-box-outline", 581 | "chevron.up.square.fill": "chevron-up-box", 582 | "chevron.down": "chevron-down", 583 | "chevron.down.circle": "chevron-down-circle-outline", 584 | "chevron.down.circle.fill": "chevron-down-circle", 585 | "chevron.down.square": "chevron-down-box-outline", 586 | "chevron.down.square.fill": "chevron-down-box", 587 | "chevron.up.chevron.down": "unfold-more-horizontal", 588 | "arrow.left": "arrow-left", 589 | "arrow.left.circle": "arrow-left-circle-outline", 590 | "arrow.left.circle.fill": "arrow-left-circle", 591 | "arrow.left.square.fill": "arrow-left-box", 592 | "arrow.right": "arrow-right", 593 | "arrow.right.circle": "arrow-right-circle-outline", 594 | "arrow.right.circle.fill": "arrow-right-circle", 595 | "arrow.right.square.fill": "arrow-right-box", 596 | "arrow.up": "arrow-up", 597 | "arrow.up.circle": "arrow-up-circle-outline", 598 | "arrow.up.circle.fill": "arrow-up-circle", 599 | "arrow.up.left": "arrow-top-left", 600 | "arrow.up.right": "arrow-top-right", 601 | "arrow.up.square.fill": "arrow-up-box", 602 | "arrow.down": "arrow-down", 603 | "arrow.down.circle": "arrow-down-circle-outline", 604 | "arrow.down.circle.fill": "arrow-down-circle", 605 | "arrow.down.square.fill": "arrow-down-box", 606 | "arrow.down.left": "arrow-bottom-left", 607 | "arrow.down.right": "arrow-bottom-right", 608 | asterisk: "asterisk", 609 | "apple.logo": "apple", 610 | "arrow.triangle.branch": "source-branch", 611 | } as Partial< 612 | Record< 613 | import("expo-symbols").SymbolViewProps["name"], 614 | React.ComponentProps["name"] 615 | > 616 | >; 617 | 618 | const MATERIAL_MAPPING = { 619 | "photo.on.rectangle": "photo-library", 620 | "person.fill.badge.plus": "person-add", 621 | iphone: "smartphone", 622 | } as Partial< 623 | Record< 624 | import("expo-symbols").SymbolViewProps["name"], 625 | React.ComponentProps["name"] 626 | > 627 | >; 628 | 629 | export type IconSymbolName = keyof typeof MAPPING; 630 | 631 | /** 632 | * An icon component that uses native SFSymbols on iOS, and MaterialCommunityIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage. 633 | * 634 | * Icon `name`s are based on SFSymbols and require manual mapping to MaterialCommunityIcons. 635 | */ 636 | export function IconSymbolMaterial({ 637 | name, 638 | size = 24, 639 | color, 640 | style, 641 | }: { 642 | name: IconSymbolName; 643 | size?: number; 644 | color: string | OpaqueColorValue; 645 | style?: StyleProp; 646 | weight?: SymbolWeight; 647 | 648 | /** iOS-only */ 649 | animationSpec?: import("expo-symbols").SymbolViewProps["animationSpec"]; 650 | }) { 651 | const materialCommunityIcon = MAPPING[name]; 652 | if (materialCommunityIcon) { 653 | return ( 654 | 660 | ); 661 | } 662 | return ( 663 | 669 | ); 670 | } 671 | --------------------------------------------------------------------------------