A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: mobile onboarding

+3773 -1290
+318 -35
apps/mobile/app/(tabs)/index.tsx
··· 8 8 import { router } from "expo-router"; 9 9 import { 10 10 CalendarRange, 11 + Clock3, 12 + Database, 11 13 Film, 12 14 LayoutDashboard, 13 15 ListChecks, 14 16 ListPlus, 17 + LogIn, 15 18 Search, 16 - Share2, 17 - Shield, 19 + ShieldCheck, 20 + Tv, 18 21 } from "lucide-react-native"; 19 22 import { useCallback, useMemo, useState } from "react"; 20 23 import { ··· 36 39 import { useFormattedDate } from "@/hooks/useFormattedDate"; 37 40 import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 38 41 39 - const features = [ 42 + const featureCards = [ 43 + { 44 + icon: Tv, 45 + title: "Movie, show, season, episode", 46 + description: 47 + "Track at exactly the level you want, from full-series completion down to single episodes.", 48 + }, 49 + { 50 + icon: Clock3, 51 + title: "Full watch history", 52 + description: 53 + "Log rewatches, keep each watch date, and build a complete timeline of your viewing activity.", 54 + }, 55 + { 56 + icon: ListChecks, 57 + title: "Powerful list workflows", 58 + description: 59 + "Use default lists and custom lists to organize favorites, queues, themes, and deep cuts.", 60 + }, 61 + { 62 + icon: Database, 63 + title: "Import your history", 64 + description: 65 + "Import history from a public Trakt username or CSV to start with real data instead of a blank slate.", 66 + }, 67 + { 68 + icon: CalendarRange, 69 + title: "Timezone-aware activity", 70 + description: 71 + "Keep your watch dates accurate with timezone and 12h/24h preferences built into your profile.", 72 + }, 73 + { 74 + icon: ShieldCheck, 75 + title: "AT Protocol identity", 76 + description: 77 + "Sign in with your Atmosphere account and keep your identity and data model portable across apps.", 78 + }, 79 + ]; 80 + 81 + const valuePoints = [ 40 82 { 41 - icon: Film, 42 - title: "Track Your Media", 83 + title: "Granular tracking", 43 84 description: 44 - "Keep track of movies, shows, and games you've watched and played", 85 + "Track movies, shows, seasons, and episodes as separate items.", 45 86 }, 46 87 { 47 - icon: Shield, 48 - title: "Own Your Data", 49 - description: "Built on AT Protocol - your data belongs to you", 88 + title: "Real watch history", 89 + description: "Keep every watch date and rewatch, not just a binary status.", 50 90 }, 51 91 { 52 - icon: Share2, 53 - title: "Discover & Share", 54 - description: "See what others are watching and share your favorites", 92 + title: "Lists that stay useful", 93 + description: "Combine default lists with your own lists for any workflow.", 55 94 }, 56 95 ]; 57 96 ··· 606 645 style={styles.logo} 607 646 /> 608 647 </View> 648 + <View 649 + style={[ 650 + styles.heroBadge, 651 + { backgroundColor: colors.secondaryContainer }, 652 + ]} 653 + > 654 + <Text 655 + style={[ 656 + styles.heroBadgeText, 657 + { color: colors.onSecondaryContainer }, 658 + ]} 659 + > 660 + Built for serious tracking 661 + </Text> 662 + </View> 609 663 <Text style={[styles.title, { color: colors.onBackground }]}> 610 - OpnShelf 664 + Track every watch. Organize every obsession. 611 665 </Text> 612 666 <Text style={[styles.subtitle, { color: colors.onSurfaceVariant }]}> 613 - Your personal media tracker powered by AT Protocol 667 + OpnShelf gives you movie and show tracking down to season and 668 + episode level, complete watch history, list organization, and a 669 + portable AT Protocol account. 670 + </Text> 671 + <View style={styles.heroActions}> 672 + <Button 673 + size="lg" 674 + onPress={() => router.push("/login")} 675 + style={styles.heroActionButton} 676 + > 677 + <LogIn 678 + size={20} 679 + color={colors.onPrimary} 680 + style={styles.buttonIcon} 681 + /> 682 + <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 683 + Sign in to start tracking 684 + </Text> 685 + </Button> 686 + <Button 687 + size="lg" 688 + variant="outlined" 689 + onPress={() => router.push("/(tabs)/search")} 690 + style={styles.heroActionButton} 691 + > 692 + <Search 693 + size={20} 694 + color={colors.primary} 695 + style={styles.buttonIcon} 696 + /> 697 + <Text style={[styles.buttonText, { color: colors.primary }]}> 698 + Browse catalog 699 + </Text> 700 + </Button> 701 + </View> 702 + </View> 703 + 704 + <Card 705 + style={{ 706 + ...styles.valueCard, 707 + backgroundColor: colors.surfaceContainerLow, 708 + borderColor: colors.outlineVariant, 709 + }} 710 + > 711 + <CardHeader> 712 + <Text style={[styles.valueCardTitle, { color: colors.onSurface }]}> 713 + Why people use OpnShelf 714 + </Text> 715 + <Text 716 + style={[ 717 + styles.valueCardDescription, 718 + { color: colors.onSurfaceVariant }, 719 + ]} 720 + > 721 + Built for people who want more than a single watched toggle. 722 + </Text> 723 + </CardHeader> 724 + <CardContent style={styles.valuePoints}> 725 + {valuePoints.map((point, index) => ( 726 + <View key={point.title} style={styles.valuePointRow}> 727 + <View 728 + style={[ 729 + styles.valuePointNumber, 730 + { backgroundColor: colors.primaryContainer }, 731 + ]} 732 + > 733 + <Text 734 + style={[ 735 + styles.valuePointNumberText, 736 + { color: colors.onPrimaryContainer }, 737 + ]} 738 + > 739 + {index + 1} 740 + </Text> 741 + </View> 742 + <View style={styles.valuePointCopy}> 743 + <Text 744 + style={[ 745 + styles.valuePointTitle, 746 + { color: colors.onSurface }, 747 + ]} 748 + > 749 + {point.title} 750 + </Text> 751 + <Text 752 + style={[ 753 + styles.valuePointDescription, 754 + { color: colors.onSurfaceVariant }, 755 + ]} 756 + > 757 + {point.description} 758 + </Text> 759 + </View> 760 + </View> 761 + ))} 762 + </CardContent> 763 + </Card> 764 + 765 + <View style={styles.featuresIntro}> 766 + <Text style={[styles.sectionTitle, { color: colors.onBackground }]}> 767 + Features 614 768 </Text> 615 - <Button 616 - size="lg" 617 - onPress={() => router.push("/(tabs)/search")} 618 - style={styles.searchButton} 769 + <Text 770 + style={[styles.sectionSubtitle, { color: colors.onSurfaceVariant }]} 619 771 > 620 - <Search 621 - size={20} 622 - color={colors.onPrimary} 623 - style={styles.buttonIcon} 624 - /> 625 - <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 626 - Search 627 - </Text> 628 - </Button> 772 + Everything you need to track and organize what you watch. 773 + </Text> 629 774 </View> 630 775 631 776 <View style={styles.features}> 632 - {features.map((feature, index) => ( 633 - <Card key={index} style={styles.featureCard}> 777 + {featureCards.map((feature) => ( 778 + <Card key={feature.title} style={styles.featureCard}> 634 779 <CardHeader> 635 780 <feature.icon 636 - size={32} 781 + size={28} 637 782 color={colors.primary} 638 783 style={styles.featureIcon} 639 784 /> ··· 656 801 </Card> 657 802 ))} 658 803 </View> 804 + 805 + <Card style={styles.exploreCard}> 806 + <CardHeader> 807 + <Text style={[styles.featureTitle, { color: colors.onSurface }]}> 808 + Explore without signing in 809 + </Text> 810 + </CardHeader> 811 + <CardContent> 812 + <Text 813 + style={[ 814 + styles.featureDescription, 815 + { color: colors.onSurfaceVariant }, 816 + ]} 817 + > 818 + Explore movies and shows right away, then sign in when you are 819 + ready to track. 820 + </Text> 821 + <View style={styles.exploreActions}> 822 + <Button 823 + variant="filled-tonal" 824 + onPress={() => router.push("/(tabs)/search")} 825 + style={styles.exploreActionButton} 826 + > 827 + <Search 828 + size={18} 829 + color={colors.onSecondaryContainer} 830 + style={styles.buttonIcon} 831 + /> 832 + <Text 833 + style={[ 834 + styles.buttonText, 835 + { color: colors.onSecondaryContainer }, 836 + ]} 837 + > 838 + Start searching 839 + </Text> 840 + </Button> 841 + <Button 842 + onPress={() => router.push("/login")} 843 + style={styles.exploreActionButton} 844 + > 845 + <LogIn 846 + size={18} 847 + color={colors.onPrimary} 848 + style={styles.buttonIcon} 849 + /> 850 + <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 851 + Unlock full tracking 852 + </Text> 853 + </Button> 854 + </View> 855 + </CardContent> 856 + </Card> 659 857 </ScrollView> 660 858 </SafeAreaView> 661 859 ); ··· 683 881 }, 684 882 hero: { 685 883 alignItems: "center", 686 - paddingVertical: spacing.xxl, 884 + paddingTop: spacing.xl, 885 + paddingBottom: spacing.lg, 687 886 }, 688 887 logoContainer: { 689 - marginBottom: spacing.lg, 888 + marginBottom: spacing.md, 889 + }, 890 + heroBadge: { 891 + borderRadius: borderRadius.full, 892 + paddingHorizontal: spacing.md, 893 + paddingVertical: spacing.xs, 894 + marginBottom: spacing.md, 895 + }, 896 + heroBadgeText: { 897 + fontSize: 12, 898 + fontWeight: "600", 899 + letterSpacing: 0.2, 690 900 }, 691 901 logo: { 692 902 width: 100, ··· 694 904 borderRadius: 20, 695 905 }, 696 906 title: { 697 - fontSize: 40, 698 - fontWeight: "bold", 907 + fontSize: 34, 908 + fontWeight: "700", 699 909 marginBottom: spacing.sm, 910 + textAlign: "center", 700 911 }, 701 912 subtitle: { 702 913 fontSize: 16, 703 914 textAlign: "center", 704 915 marginBottom: spacing.xl, 705 - paddingHorizontal: spacing.lg, 916 + paddingHorizontal: spacing.md, 917 + lineHeight: 24, 918 + }, 919 + heroActions: { 920 + width: "100%", 921 + gap: spacing.sm, 922 + }, 923 + heroActionButton: { 924 + width: "100%", 706 925 }, 707 926 searchButton: { 708 927 minWidth: 200, ··· 716 935 }, 717 936 features: { 718 937 gap: spacing.md, 938 + }, 939 + featuresIntro: { 940 + marginTop: spacing.xl, 941 + marginBottom: spacing.sm, 942 + gap: spacing.xs, 943 + }, 944 + sectionSubtitle: { 945 + fontSize: 14, 946 + lineHeight: 20, 947 + }, 948 + valueCard: { 949 + borderWidth: 1, 950 + marginTop: spacing.md, 951 + marginBottom: spacing.xl, 952 + }, 953 + valueCardTitle: { 954 + fontSize: 20, 955 + fontWeight: "700", 956 + marginBottom: spacing.xs, 957 + }, 958 + valueCardDescription: { 959 + fontSize: 14, 960 + lineHeight: 20, 961 + }, 962 + valuePoints: { 963 + gap: spacing.md, 964 + }, 965 + valuePointRow: { 966 + flexDirection: "row", 967 + gap: spacing.sm, 968 + }, 969 + valuePointNumber: { 970 + width: 28, 971 + height: 28, 972 + borderRadius: borderRadius.full, 973 + alignItems: "center", 974 + justifyContent: "center", 975 + marginTop: 2, 976 + }, 977 + valuePointNumberText: { 978 + fontSize: 12, 979 + fontWeight: "700", 980 + }, 981 + valuePointCopy: { 982 + flex: 1, 983 + gap: spacing.xs, 984 + }, 985 + valuePointTitle: { 986 + fontSize: 16, 987 + fontWeight: "600", 988 + }, 989 + valuePointDescription: { 990 + fontSize: 13, 991 + lineHeight: 19, 992 + }, 993 + exploreCard: { 994 + marginTop: spacing.lg, 995 + }, 996 + exploreActions: { 997 + marginTop: spacing.md, 998 + gap: spacing.sm, 999 + }, 1000 + exploreActionButton: { 1001 + width: "100%", 719 1002 }, 720 1003 dashboardHeader: { 721 1004 marginBottom: spacing.lg,
+34 -1
apps/mobile/app/_layout.tsx
··· 1 1 import { QueryClientProvider } from "@tanstack/react-query"; 2 - import { Stack, useGlobalSearchParams, usePathname } from "expo-router"; 2 + import { 3 + Stack, 4 + useGlobalSearchParams, 5 + usePathname, 6 + useRouter, 7 + } from "expo-router"; 3 8 import { StatusBar } from "expo-status-bar"; 4 9 import { PostHogProvider } from "posthog-react-native"; 5 10 import { useEffect, useRef, useState } from "react"; ··· 55 60 56 61 return ( 57 62 <M3SnackbarProvider> 63 + <OnboardingGate /> 58 64 <Stack 59 65 screenOptions={{ 60 66 headerShown: false, ··· 79 85 <Stack.Screen name="settings" /> 80 86 <Stack.Screen name="list/[slug]" /> 81 87 <Stack.Screen name="login" /> 88 + <Stack.Screen name="onboarding" /> 82 89 </Stack> 83 90 <StatusBar style="light" /> 84 91 </M3SnackbarProvider> 85 92 ); 93 + } 94 + 95 + function OnboardingGate() { 96 + const { user, isLoading, isAuthenticated } = useAuth(); 97 + const pathname = usePathname(); 98 + const router = useRouter(); 99 + 100 + useEffect(() => { 101 + if (isLoading || !user || !isAuthenticated) { 102 + return; 103 + } 104 + 105 + const isAuthRoute = pathname === "/login" || pathname.startsWith("/auth/"); 106 + const isOnboardingRoute = pathname === "/onboarding"; 107 + 108 + if (user.needsOnboarding && !isOnboardingRoute && !isAuthRoute) { 109 + router.replace("/onboarding"); 110 + return; 111 + } 112 + 113 + if (!user.needsOnboarding && isOnboardingRoute) { 114 + router.replace("/(tabs)"); 115 + } 116 + }, [isLoading, user, isAuthenticated, pathname, router]); 117 + 118 + return null; 86 119 } 87 120 88 121 function ScreenTracker() {
+5
apps/mobile/app/auth/complete.tsx
··· 45 45 46 46 await new Promise((resolve) => setTimeout(resolve, 100)); 47 47 48 + if (user?.needsOnboarding) { 49 + router.replace("/onboarding"); 50 + return; 51 + } 52 + 48 53 router.replace("/(tabs)"); 49 54 } catch (error) { 50 55 console.error("Auth complete failed:", error);
+5
apps/mobile/app/login.tsx
··· 38 38 39 39 useEffect(() => { 40 40 if (user && !isAuthLoading) { 41 + if (user.needsOnboarding) { 42 + router.replace("/onboarding"); 43 + return; 44 + } 45 + 41 46 if (redirect === "shelf") { 42 47 router.replace("/(tabs)"); 43 48 } else if (redirect === "search") {
+460
apps/mobile/app/onboarding.tsx
··· 1 + import { 2 + authControllerMeOptions, 3 + listsControllerGetUserListsOptions, 4 + shelfControllerGetUserShelfOptions, 5 + usersControllerCompleteOnboardingMutation, 6 + usersControllerFetchMyTraktPublicHistoryMutation, 7 + usersControllerGetMySettingsOptions, 8 + usersControllerImportMyHistoryMutation, 9 + usersControllerUpdateMyProfileMutation, 10 + usersControllerUpdateMySettingsMutation, 11 + } from "@opnshelf/api"; 12 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 13 + import * as DocumentPicker from "expo-document-picker"; 14 + import { File } from "expo-file-system"; 15 + import { router } from "expo-router"; 16 + import { useEffect, useMemo, useState } from "react"; 17 + import { ActivityIndicator, View } from "react-native"; 18 + import { ONBOARDING_STEPS } from "@/components/onboarding/constants"; 19 + import { OnboardingContent } from "@/components/onboarding/OnboardingContent"; 20 + import type { 21 + ImportProgressState, 22 + OnboardingImportResult, 23 + TabValue, 24 + } from "@/components/onboarding/types"; 25 + import { useAuth } from "@/contexts/auth"; 26 + import { useTheme } from "@/contexts/theme"; 27 + import { useToast } from "@/contexts/toast"; 28 + import { 29 + type ImportProgressUpdate, 30 + parseCsvText, 31 + runImportInChunks, 32 + } from "@/lib/onboarding-import"; 33 + 34 + export default function OnboardingScreen() { 35 + const { user, isLoading: isAuthLoading, isAuthenticated } = useAuth(); 36 + const { colors } = useTheme(); 37 + const { showToast } = useToast(); 38 + const queryClient = useQueryClient(); 39 + 40 + const [step, setStep] = useState(1); 41 + const [activeTab, setActiveTab] = useState<TabValue>("trakt"); 42 + const [traktUsername, setTraktUsername] = useState(""); 43 + const [displayName, setDisplayName] = useState(""); 44 + const [timezone, setTimezone] = useState("UTC"); 45 + const [timeFormat, setTimeFormat] = useState<"12h" | "24h">("24h"); 46 + const [csvFileName, setCsvFileName] = useState<string | null>(null); 47 + 48 + const [importResult, setImportResult] = useState<OnboardingImportResult>({ 49 + imported: 0, 50 + skipped: 0, 51 + failed: 0, 52 + errors: [], 53 + }); 54 + 55 + const [importProgress, setImportProgress] = useState<ImportProgressState>({ 56 + phase: "idle", 57 + totalItems: 0, 58 + processedItems: 0, 59 + currentBatch: 0, 60 + totalBatches: 0, 61 + imported: 0, 62 + skipped: 0, 63 + failed: 0, 64 + startedAt: null, 65 + message: "", 66 + }); 67 + 68 + const { data: settings } = useQuery({ 69 + ...usersControllerGetMySettingsOptions(), 70 + enabled: !!user, 71 + staleTime: 60_000, 72 + }); 73 + 74 + const completeOnboardingMutation = useMutation({ 75 + ...usersControllerCompleteOnboardingMutation(), 76 + onError: () => { 77 + showToast("Could not complete onboarding", "error"); 78 + }, 79 + }); 80 + 81 + const fetchTraktMutation = useMutation({ 82 + ...usersControllerFetchMyTraktPublicHistoryMutation(), 83 + }); 84 + 85 + const updateProfileMutation = useMutation({ 86 + ...usersControllerUpdateMyProfileMutation(), 87 + onError: () => { 88 + showToast("Could not save profile details", "error"); 89 + }, 90 + }); 91 + 92 + const updateSettingsMutation = useMutation({ 93 + ...usersControllerUpdateMySettingsMutation(), 94 + onError: () => { 95 + showToast("Could not save time settings", "error"); 96 + }, 97 + }); 98 + 99 + const importHistoryMutation = useMutation({ 100 + ...usersControllerImportMyHistoryMutation(), 101 + }); 102 + 103 + const needsAuthRedirect = !isAuthLoading && (!isAuthenticated || !user); 104 + const needsDashboardRedirect = 105 + !isAuthLoading && !!user && !user.needsOnboarding; 106 + 107 + useEffect(() => { 108 + if (needsAuthRedirect) { 109 + router.replace("/login"); 110 + return; 111 + } 112 + 113 + if (needsDashboardRedirect) { 114 + router.replace("/(tabs)"); 115 + } 116 + }, [needsAuthRedirect, needsDashboardRedirect]); 117 + 118 + useEffect(() => { 119 + if (!user) { 120 + return; 121 + } 122 + 123 + const rawDisplayName = (user as unknown as { displayName?: unknown }) 124 + .displayName; 125 + setDisplayName( 126 + typeof rawDisplayName === "string" && rawDisplayName.trim().length > 0 127 + ? rawDisplayName 128 + : user.handle, 129 + ); 130 + }, [user]); 131 + 132 + useEffect(() => { 133 + if (!settings) { 134 + return; 135 + } 136 + 137 + setTimezone(settings.timezone); 138 + setTimeFormat(settings.timeFormat === "12h" ? "12h" : "24h"); 139 + }, [settings]); 140 + 141 + const progressPercent = useMemo( 142 + () => Math.round((step / ONBOARDING_STEPS.length) * 100), 143 + [step], 144 + ); 145 + 146 + const importPercent = 147 + importProgress.totalItems > 0 148 + ? Math.round( 149 + (importProgress.processedItems / importProgress.totalItems) * 100, 150 + ) 151 + : 0; 152 + 153 + const isImporting = 154 + fetchTraktMutation.isPending || importHistoryMutation.isPending; 155 + const isImportBusy = isImporting || importProgress.phase === "parsing_csv"; 156 + const isSavingProfile = 157 + updateProfileMutation.isPending || updateSettingsMutation.isPending; 158 + const isCompleting = completeOnboardingMutation.isPending; 159 + 160 + const updateImportProgress = (update: ImportProgressUpdate) => { 161 + setImportProgress((previous) => ({ 162 + ...previous, 163 + phase: "importing", 164 + message: "Importing history...", 165 + ...update, 166 + })); 167 + }; 168 + 169 + const handleSaveProfileAndContinue = async () => { 170 + try { 171 + await updateProfileMutation.mutateAsync({ 172 + body: { 173 + displayName: displayName.trim() || undefined, 174 + }, 175 + }); 176 + 177 + await updateSettingsMutation.mutateAsync({ 178 + body: { 179 + timezone, 180 + timeFormat, 181 + }, 182 + }); 183 + 184 + showToast("Profile and time preferences saved", "success"); 185 + setStep(3); 186 + } catch { 187 + // surfaced by mutation handlers 188 + } 189 + }; 190 + 191 + const completeOnboardingAndRedirect = async () => { 192 + try { 193 + await completeOnboardingMutation.mutateAsync({}); 194 + 195 + queryClient.setQueryData( 196 + authControllerMeOptions().queryKey, 197 + (previousUser) => { 198 + if (!previousUser) { 199 + return previousUser; 200 + } 201 + 202 + return { 203 + ...previousUser, 204 + needsOnboarding: false, 205 + }; 206 + }, 207 + ); 208 + 209 + await Promise.all([ 210 + queryClient.invalidateQueries({ 211 + predicate: (query) => { 212 + const key = query.queryKey[0] as { _id?: string } | undefined; 213 + return ( 214 + key?._id === "shelfControllerGetUserShelf" || 215 + key?._id === "listsControllerGetUserLists" 216 + ); 217 + }, 218 + }), 219 + ]); 220 + 221 + if (user?.did) { 222 + await Promise.all([ 223 + queryClient.prefetchQuery( 224 + shelfControllerGetUserShelfOptions({ 225 + path: { userDid: user.did }, 226 + query: { limit: 20 }, 227 + }), 228 + ), 229 + queryClient.prefetchQuery(listsControllerGetUserListsOptions()), 230 + ]); 231 + } 232 + 233 + router.replace("/(tabs)"); 234 + void queryClient.invalidateQueries({ 235 + queryKey: authControllerMeOptions().queryKey, 236 + }); 237 + } catch { 238 + // surfaced by mutation handlers 239 + } 240 + }; 241 + 242 + const handleTraktImport = async () => { 243 + const username = traktUsername.trim(); 244 + if (!username) { 245 + showToast("Enter your Trakt username", "error"); 246 + return; 247 + } 248 + 249 + try { 250 + setImportProgress({ 251 + phase: "fetching_trakt", 252 + totalItems: 0, 253 + processedItems: 0, 254 + currentBatch: 0, 255 + totalBatches: 0, 256 + imported: 0, 257 + skipped: 0, 258 + failed: 0, 259 + startedAt: Date.now(), 260 + message: "Fetching public history from Trakt...", 261 + }); 262 + 263 + const fetched = await fetchTraktMutation.mutateAsync({ 264 + body: { username }, 265 + }); 266 + 267 + if (!fetched.items.length) { 268 + setImportResult({ 269 + imported: 0, 270 + skipped: fetched.skipped.length, 271 + failed: 0, 272 + errors: [], 273 + }); 274 + setImportProgress((previous) => ({ 275 + ...previous, 276 + phase: "done", 277 + message: "No importable items found.", 278 + })); 279 + showToast("No supported watch history items found", "info"); 280 + return; 281 + } 282 + 283 + const result = await runImportInChunks( 284 + fetched.items, 285 + importHistoryMutation.mutateAsync, 286 + updateImportProgress, 287 + ); 288 + 289 + setImportResult(result); 290 + setImportProgress((previous) => ({ 291 + ...previous, 292 + phase: "done", 293 + message: "Import complete.", 294 + })); 295 + setStep(4); 296 + } catch (error) { 297 + const message = 298 + error instanceof Error 299 + ? error.message 300 + : "Unable to fetch Trakt history right now"; 301 + setImportProgress((previous) => ({ 302 + ...previous, 303 + phase: "error", 304 + message, 305 + })); 306 + showToast(message, "error"); 307 + } 308 + }; 309 + 310 + const handleCsvImport = async () => { 311 + try { 312 + const picked = await DocumentPicker.getDocumentAsync({ 313 + type: [ 314 + "text/csv", 315 + "text/comma-separated-values", 316 + "application/vnd.ms-excel", 317 + ], 318 + copyToCacheDirectory: true, 319 + multiple: false, 320 + }); 321 + 322 + if (picked.canceled) { 323 + return; 324 + } 325 + 326 + const file = picked.assets[0]; 327 + if (!file?.uri) { 328 + showToast("Could not read selected file", "error"); 329 + return; 330 + } 331 + 332 + setCsvFileName(file.name ?? "Selected CSV"); 333 + setImportProgress({ 334 + phase: "parsing_csv", 335 + totalItems: 0, 336 + processedItems: 0, 337 + currentBatch: 0, 338 + totalBatches: 0, 339 + imported: 0, 340 + skipped: 0, 341 + failed: 0, 342 + startedAt: Date.now(), 343 + message: "Parsing CSV file...", 344 + }); 345 + 346 + const csvText = await new File(file.uri).text(); 347 + const parsed = await parseCsvText(csvText); 348 + 349 + if (!parsed.items.length) { 350 + setImportResult({ 351 + imported: 0, 352 + skipped: 0, 353 + failed: parsed.errors.length, 354 + errors: parsed.errors.map((entry) => entry.message), 355 + }); 356 + setImportProgress((previous) => ({ 357 + ...previous, 358 + phase: "error", 359 + failed: parsed.errors.length, 360 + message: "No valid rows found in CSV.", 361 + })); 362 + showToast("No valid rows found in CSV", "error"); 363 + return; 364 + } 365 + 366 + const imported = await runImportInChunks( 367 + parsed.items, 368 + importHistoryMutation.mutateAsync, 369 + updateImportProgress, 370 + ); 371 + 372 + setImportResult({ 373 + imported: imported.imported, 374 + skipped: imported.skipped, 375 + failed: imported.failed + parsed.errors.length, 376 + errors: [ 377 + ...parsed.errors.map((entry) => entry.message), 378 + ...imported.errors, 379 + ], 380 + }); 381 + 382 + setImportProgress((previous) => ({ 383 + ...previous, 384 + phase: "done", 385 + failed: imported.failed + parsed.errors.length, 386 + message: "Import complete.", 387 + })); 388 + 389 + setStep(4); 390 + } catch (error) { 391 + const message = 392 + error instanceof Error ? error.message : "Unable to parse CSV file"; 393 + setImportProgress((previous) => ({ 394 + ...previous, 395 + phase: "error", 396 + message, 397 + })); 398 + showToast(message, "error"); 399 + } 400 + }; 401 + 402 + if (isAuthLoading) { 403 + return ( 404 + <View 405 + style={{ 406 + flex: 1, 407 + alignItems: "center", 408 + justifyContent: "center", 409 + backgroundColor: colors.background, 410 + }} 411 + > 412 + <ActivityIndicator size="large" color={colors.primary} /> 413 + </View> 414 + ); 415 + } 416 + 417 + if (needsAuthRedirect || needsDashboardRedirect || !user) { 418 + return null; 419 + } 420 + 421 + return ( 422 + <OnboardingContent 423 + step={step} 424 + progressPercent={progressPercent} 425 + activeTab={activeTab} 426 + traktUsername={traktUsername} 427 + displayName={displayName} 428 + timezone={timezone} 429 + timeFormat={timeFormat} 430 + csvFileName={csvFileName} 431 + importProgress={importProgress} 432 + importPercent={importPercent} 433 + importResult={importResult} 434 + isCompleting={isCompleting} 435 + isSavingProfile={isSavingProfile} 436 + isImportBusy={isImportBusy} 437 + onStepChange={setStep} 438 + onActiveTabChange={setActiveTab} 439 + onTraktUsernameChange={setTraktUsername} 440 + onDisplayNameChange={setDisplayName} 441 + onTimezoneChange={setTimezone} 442 + onTimeFormatChange={setTimeFormat} 443 + onSkip={() => { 444 + void completeOnboardingAndRedirect(); 445 + }} 446 + onSaveProfileAndContinue={() => { 447 + void handleSaveProfileAndContinue(); 448 + }} 449 + onTraktImport={() => { 450 + void handleTraktImport(); 451 + }} 452 + onCsvImport={() => { 453 + void handleCsvImport(); 454 + }} 455 + onComplete={() => { 456 + void completeOnboardingAndRedirect(); 457 + }} 458 + /> 459 + ); 460 + }
+69 -732
apps/mobile/app/settings.tsx
··· 5 5 } from "@opnshelf/api"; 6 6 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 7 7 import { useRouter } from "expo-router"; 8 - import { 9 - ArrowLeft, 10 - ChevronRight, 11 - Clock, 12 - Globe, 13 - Loader2, 14 - Trash2, 15 - User, 16 - } from "lucide-react-native"; 17 8 import { usePostHog } from "posthog-react-native"; 18 - import { useCallback, useEffect, useState } from "react"; 9 + import { useCallback, useEffect, useMemo, useState } from "react"; 10 + import { ScrollView, StyleSheet } from "react-native"; 11 + import { SafeAreaView } from "react-native-safe-area-context"; 19 12 import { 20 - Modal, 21 - Pressable, 22 - ScrollView, 23 - StyleSheet, 24 - Text, 25 - TouchableOpacity, 26 - View, 27 - } from "react-native"; 28 - import { SafeAreaView } from "react-native-safe-area-context"; 29 - import { Button } from "@/components/ui/Button"; 30 - import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 31 - import { M3TextField } from "@/components/ui/m3"; 32 - import { Switch } from "@/components/ui/Switch"; 33 - import { defaultColors as staticColors } from "@/constants/extended-theme"; 34 - import { borderRadius, spacing } from "@/constants/spacing"; 13 + AccountCard, 14 + DeleteAccountModal, 15 + SettingsHeader, 16 + TimeRegionCard, 17 + TimezoneModal, 18 + } from "@/components/settings"; 19 + import type { ExtendedThemeColors } from "@/constants/extended-theme"; 35 20 import { useAuth } from "@/contexts/auth"; 36 21 import { useTheme } from "@/contexts/theme"; 37 22 import { useToast } from "@/contexts/toast"; 38 23 39 - const colors = staticColors; 40 - 41 - // Common timezones grouped by region 42 - const TIMEZONES = [ 43 - { region: "UTC", zones: ["UTC"] }, 44 - { 45 - region: "Americas", 46 - zones: [ 47 - "America/New_York", 48 - "America/Chicago", 49 - "America/Denver", 50 - "America/Los_Angeles", 51 - "America/Toronto", 52 - "America/Vancouver", 53 - "America/Mexico_City", 54 - "America/Sao_Paulo", 55 - "America/Buenos_Aires", 56 - ], 57 - }, 58 - { 59 - region: "Europe", 60 - zones: [ 61 - "Europe/London", 62 - "Europe/Paris", 63 - "Europe/Berlin", 64 - "Europe/Rome", 65 - "Europe/Madrid", 66 - "Europe/Amsterdam", 67 - "Europe/Zurich", 68 - "Europe/Stockholm", 69 - "Europe/Oslo", 70 - "Europe/Copenhagen", 71 - "Europe/Helsinki", 72 - "Europe/Warsaw", 73 - "Europe/Prague", 74 - "Europe/Vienna", 75 - "Europe/Budapest", 76 - "Europe/Moscow", 77 - "Europe/Istanbul", 78 - ], 79 - }, 80 - { 81 - region: "Asia & Pacific", 82 - zones: [ 83 - "Asia/Tokyo", 84 - "Asia/Seoul", 85 - "Asia/Shanghai", 86 - "Asia/Hong_Kong", 87 - "Asia/Singapore", 88 - "Asia/Taipei", 89 - "Asia/Manila", 90 - "Asia/Bangkok", 91 - "Asia/Jakarta", 92 - "Asia/Kuala_Lumpur", 93 - "Asia/Ho_Chi_Minh", 94 - "Asia/Dubai", 95 - "Asia/Mumbai", 96 - "Asia/Kolkata", 97 - "Asia/Dhaka", 98 - "Asia/Karachi", 99 - "Pacific/Auckland", 100 - "Pacific/Sydney", 101 - "Pacific/Melbourne", 102 - "Pacific/Perth", 103 - ], 104 - }, 105 - { 106 - region: "Middle East & Africa", 107 - zones: [ 108 - "Africa/Cairo", 109 - "Africa/Johannesburg", 110 - "Africa/Lagos", 111 - "Africa/Nairobi", 112 - "Asia/Jerusalem", 113 - "Asia/Riyadh", 114 - "Asia/Tehran", 115 - ], 116 - }, 117 - ]; 118 - 119 - // Flatten all zones for search 120 - const ALL_ZONES = TIMEZONES.flatMap((group) => 121 - group.zones.map((zone) => ({ zone, region: group.region })), 122 - ); 123 - 124 24 export default function SettingsScreen() { 125 25 const router = useRouter(); 126 26 const { showToast } = useToast(); 127 27 const { user, logout } = useAuth(); 128 28 const { colors } = useTheme(); 29 + const styles = useMemo(() => createStyles(colors), [colors]); 129 30 const queryClient = useQueryClient(); 130 31 const posthog = usePostHog(); 131 32 132 33 const [showTimezoneModal, setShowTimezoneModal] = useState(false); 133 34 const [showDeleteModal, setShowDeleteModal] = useState(false); 134 - const [showPDSOptionModal, setShowPDSOptionModal] = useState(false); 135 - const [timezoneSearch, setTimezoneSearch] = useState(""); 35 + const [timezone, setTimezone] = useState<string>("UTC"); 36 + const [is24Hour, setIs24Hour] = useState<boolean>(true); 136 37 137 - // Fetch user settings 138 38 const { data: settings, isLoading: isSettingsLoading } = useQuery({ 139 39 ...usersControllerGetMySettingsOptions(), 140 40 }); 141 41 142 - // Local state for form values 143 - const [timezone, setTimezone] = useState<string>("UTC"); 144 - const [is24Hour, setIs24Hour] = useState<boolean>(true); 145 - 146 - // Update local state when settings load 147 42 useEffect(() => { 148 43 if (settings) { 149 44 setTimezone(settings.timezone); ··· 151 46 } 152 47 }, [settings]); 153 48 154 - // Mutation for updating settings 155 49 const updateSettingsMutation = useMutation({ 156 50 mutationKey: ["users", "settings", "update"], 157 51 ...usersControllerUpdateMySettingsMutation(), ··· 166 60 }, 167 61 }); 168 62 169 - // Mutation for deleting account 170 63 const deleteAccountMutation = useMutation({ 171 64 mutationKey: ["users", "account", "delete"], 172 65 ...usersControllerDeleteMyAccountMutation(), 173 - onSuccess: async () => { 66 + onSuccess: async (_, variables) => { 174 67 showToast("Account deleted", "success"); 175 - posthog.capture("account_deleted"); 68 + posthog.capture("account_deleted", { 69 + deleted_pds_data: Boolean(variables?.body?.deletePDSData), 70 + }); 176 71 posthog.reset(); 72 + setShowDeleteModal(false); 177 73 await logout(); 178 74 router.replace("/"); 179 75 }, ··· 182 78 }, 183 79 }); 184 80 185 - // Handle delete account - show confirmation modal 186 - const handleDeleteAccount = useCallback(() => { 187 - setShowDeleteModal(true); 188 - }, []); 189 - 190 - // Handle confirmed deletion - show PDS option modal 191 - const handleConfirmDelete = useCallback(() => { 192 - setShowDeleteModal(false); 193 - setShowPDSOptionModal(true); 194 - }, []); 195 - 196 - // Handle PDS data deletion option 197 - const handlePDSOption = useCallback( 198 - (deletePDS: boolean) => { 199 - setShowPDSOptionModal(false); 200 - deleteAccountMutation.mutate({ 201 - body: { deletePDSData: deletePDS }, 202 - }); 203 - }, 204 - [deleteAccountMutation], 205 - ); 206 - 207 - // Handle timezone change 208 81 const handleTimezoneChange = useCallback( 209 82 (value: string) => { 210 83 setTimezone(value); ··· 216 89 [updateSettingsMutation], 217 90 ); 218 91 219 - // Handle time format toggle 220 92 const handleTimeFormatToggle = useCallback( 221 93 (value: boolean) => { 222 94 setIs24Hour(value); ··· 227 99 [updateSettingsMutation], 228 100 ); 229 101 230 - // Get current time display based on settings 231 - const getCurrentTimeDisplay = useCallback(() => { 102 + const handleDeleteAccount = useCallback(() => { 103 + setShowDeleteModal(true); 104 + }, []); 105 + 106 + const handleConfirmDelete = useCallback( 107 + (deletePDSData: boolean) => { 108 + deleteAccountMutation.mutate({ 109 + body: { deletePDSData }, 110 + }); 111 + }, 112 + [deleteAccountMutation], 113 + ); 114 + 115 + const currentTimeDisplay = useMemo(() => { 232 116 const now = new Date(); 233 117 try { 234 118 return now.toLocaleTimeString("en-US", { ··· 238 122 minute: "2-digit", 239 123 }); 240 124 } catch { 241 - // Fallback if timezone is invalid 242 125 return now.toLocaleTimeString("en-US", { 243 126 hour12: !is24Hour, 244 127 hour: "numeric", ··· 247 130 } 248 131 }, [timezone, is24Hour]); 249 132 250 - // Filter timezones based on search 251 - const filteredZones = timezoneSearch 252 - ? ALL_ZONES.filter( 253 - (z) => 254 - z.zone.toLowerCase().includes(timezoneSearch.toLowerCase()) || 255 - z.region.toLowerCase().includes(timezoneSearch.toLowerCase()), 256 - ) 257 - : ALL_ZONES; 258 - 259 133 return ( 260 134 <SafeAreaView style={styles.container} edges={["top"]}> 261 135 <ScrollView style={styles.scrollView}> 262 - <View style={styles.header}> 263 - <TouchableOpacity 264 - onPress={() => router.back()} 265 - style={styles.backButton} 266 - > 267 - <ArrowLeft size={24} color={colors.onBackground} /> 268 - </TouchableOpacity> 269 - <View style={styles.headerLeft}> 270 - <Globe size={28} color={colors.warning} /> 271 - <Text style={styles.title}>Settings</Text> 272 - </View> 273 - </View> 136 + <SettingsHeader onBack={() => router.back()} /> 274 137 275 - <Card style={styles.card}> 276 - <CardHeader style={styles.cardHeader}> 277 - <View style={styles.cardHeaderContent}> 278 - <View style={styles.iconContainer}> 279 - <Globe size={20} color={colors.warning} /> 280 - </View> 281 - <View style={styles.cardTitleContainer}> 282 - <Text style={styles.cardTitle}>Time & Region</Text> 283 - <Text style={styles.cardDescription}> 284 - Customize how dates and times are displayed 285 - </Text> 286 - </View> 287 - </View> 288 - </CardHeader> 289 - <CardContent style={styles.cardContent}> 290 - <Pressable 291 - onPress={() => setShowTimezoneModal(true)} 292 - style={styles.settingRow} 293 - disabled={isSettingsLoading || updateSettingsMutation.isPending} 294 - > 295 - <View style={styles.settingLabelContainer}> 296 - <Text style={styles.settingLabel}>Timezone</Text> 297 - {isSettingsLoading ? ( 298 - <View style={styles.skeleton} /> 299 - ) : ( 300 - <Text style={styles.settingValue}> 301 - {timezone.replace(/_/g, " ")} 302 - </Text> 303 - )} 304 - </View> 305 - {updateSettingsMutation.isPending && ( 306 - <Loader2 307 - size={16} 308 - color={colors.warning} 309 - style={styles.spinner} 310 - /> 311 - )} 312 - <ChevronRight size={20} color={colors.textMuted} /> 313 - </Pressable> 314 - 315 - <View style={styles.divider} /> 316 - 317 - <View style={styles.settingRow}> 318 - <View style={styles.settingLabelContainer}> 319 - <Text style={styles.settingLabel}>Time Format</Text> 320 - <Text style={styles.settingDescription}> 321 - {is24Hour ? "24-hour (14:00)" : "12-hour (2:00 PM)"} 322 - </Text> 323 - </View> 324 - {isSettingsLoading ? ( 325 - <View style={[styles.skeleton, { width: 52, height: 28 }]} /> 326 - ) : ( 327 - <View style={styles.switchContainer}> 328 - {updateSettingsMutation.isPending && ( 329 - <Loader2 330 - size={14} 331 - color={colors.warning} 332 - style={styles.spinnerSmall} 333 - /> 334 - )} 335 - <Switch 336 - value={is24Hour} 337 - onValueChange={handleTimeFormatToggle} 338 - disabled={updateSettingsMutation.isPending} 339 - /> 340 - </View> 341 - )} 342 - </View> 343 - 344 - <View style={styles.divider} /> 345 - 346 - {!isSettingsLoading && ( 347 - <View style={styles.previewContainer}> 348 - <View style={styles.previewContent}> 349 - <Clock size={20} color={colors.warning} /> 350 - <View> 351 - <Text style={styles.previewLabel}> 352 - Current time preview 353 - </Text> 354 - <Text style={styles.previewValue}> 355 - {getCurrentTimeDisplay()} 356 - </Text> 357 - </View> 358 - </View> 359 - </View> 360 - )} 361 - </CardContent> 362 - </Card> 363 - 364 - {user && ( 365 - <Card style={styles.card}> 366 - <CardHeader style={styles.cardHeader}> 367 - <View style={styles.cardHeaderContent}> 368 - <View 369 - style={[ 370 - styles.iconContainer, 371 - { backgroundColor: "rgba(59, 130, 246, 0.1)" }, 372 - ]} 373 - > 374 - <User size={20} color={colors.primary} /> 375 - </View> 376 - <View style={styles.cardTitleContainer}> 377 - <Text style={styles.cardTitle}>Account</Text> 378 - <Text style={styles.cardDescription}> 379 - Manage your account information 380 - </Text> 381 - </View> 382 - </View> 383 - </CardHeader> 384 - <CardContent style={styles.cardContent}> 385 - <View style={styles.settingRow}> 386 - <View style={styles.settingLabelContainer}> 387 - <Text style={styles.settingLabel}>Handle</Text> 388 - <Text style={styles.settingValue}>@{user.handle}</Text> 389 - </View> 390 - </View> 391 - 392 - {user.displayName && ( 393 - <> 394 - <View style={styles.divider} /> 395 - <View style={styles.settingRow}> 396 - <View style={styles.settingLabelContainer}> 397 - <Text style={styles.settingLabel}>Display Name</Text> 398 - <Text style={styles.settingValue}> 399 - {String(user.displayName)} 400 - </Text> 401 - </View> 402 - </View> 403 - </> 404 - )} 138 + <TimeRegionCard 139 + timezone={timezone} 140 + is24Hour={is24Hour} 141 + isSettingsLoading={isSettingsLoading} 142 + isUpdating={updateSettingsMutation.isPending} 143 + currentTimeDisplay={currentTimeDisplay} 144 + onOpenTimezoneModal={() => setShowTimezoneModal(true)} 145 + onToggleTimeFormat={handleTimeFormatToggle} 146 + /> 405 147 406 - <View style={styles.divider} /> 407 - 408 - <Pressable 409 - onPress={handleDeleteAccount} 410 - disabled={deleteAccountMutation.isPending} 411 - style={[styles.settingRow, styles.deleteButton]} 412 - > 413 - <View style={styles.settingLabelContainer}> 414 - <Text style={[styles.settingLabel, { color: colors.error }]}> 415 - Delete Account 416 - </Text> 417 - <Text style={styles.settingDescription}> 418 - Remove your account and data 419 - </Text> 420 - </View> 421 - {deleteAccountMutation.isPending && ( 422 - <Loader2 423 - size={16} 424 - color={colors.error} 425 - style={styles.spinner} 426 - /> 427 - )} 428 - <Trash2 size={20} color={colors.error} /> 429 - </Pressable> 430 - </CardContent> 431 - </Card> 432 - )} 148 + {user ? ( 149 + <AccountCard 150 + user={user} 151 + isDeletingAccount={deleteAccountMutation.isPending} 152 + onDeleteAccount={handleDeleteAccount} 153 + /> 154 + ) : null} 433 155 </ScrollView> 434 156 435 - <Modal 157 + <TimezoneModal 436 158 visible={showTimezoneModal} 437 - animationType="slide" 438 - presentationStyle="pageSheet" 439 - onRequestClose={() => setShowTimezoneModal(false)} 440 - > 441 - <SafeAreaView style={styles.modalContainer}> 442 - <View style={styles.modalHeader}> 443 - <Text style={styles.modalTitle}>Select Timezone</Text> 444 - <Button 445 - variant="text" 446 - size="sm" 447 - onPress={() => setShowTimezoneModal(false)} 448 - > 449 - <Text style={styles.modalCloseText}>Close</Text> 450 - </Button> 451 - </View> 159 + timezone={timezone} 160 + onClose={() => setShowTimezoneModal(false)} 161 + onSelectTimezone={handleTimezoneChange} 162 + /> 452 163 453 - <M3TextField 454 - label="Timezone" 455 - containerStyle={styles.searchInput} 456 - placeholder="Search timezones..." 457 - value={timezoneSearch} 458 - onChangeText={setTimezoneSearch} 459 - variant="outlined" 460 - /> 461 - 462 - <ScrollView style={styles.modalScroll}> 463 - {filteredZones.map((item) => ( 464 - <Pressable 465 - key={item.zone} 466 - style={[ 467 - styles.zoneItem, 468 - timezone === item.zone && styles.zoneItemActive, 469 - ]} 470 - onPress={() => handleTimezoneChange(item.zone)} 471 - > 472 - <Text 473 - style={[ 474 - styles.zoneText, 475 - timezone === item.zone && styles.zoneTextActive, 476 - ]} 477 - > 478 - {item.zone.replace(/_/g, " ")} 479 - </Text> 480 - <Text style={styles.zoneRegion}>{item.region}</Text> 481 - </Pressable> 482 - ))} 483 - </ScrollView> 484 - </SafeAreaView> 485 - </Modal> 486 - 487 - <Modal 164 + <DeleteAccountModal 488 165 visible={showDeleteModal} 489 - animationType="fade" 490 - transparent 491 - onRequestClose={() => setShowDeleteModal(false)} 492 - > 493 - <View style={styles.modalOverlay}> 494 - <View style={styles.deleteModalContent}> 495 - <View style={styles.deleteModalIcon}> 496 - <Trash2 size={32} color={colors.error} /> 497 - </View> 498 - <Text style={styles.deleteModalTitle}>Delete Account</Text> 499 - <Text style={styles.deleteModalDescription}> 500 - Are you sure you want to delete your account? This action cannot 501 - be undone. 502 - </Text> 503 - <View style={styles.deleteModalButtons}> 504 - <Button 505 - variant="outlined" 506 - onPress={() => setShowDeleteModal(false)} 507 - style={styles.deleteModalButton} 508 - > 509 - <Text style={styles.deleteModalButtonText}>Cancel</Text> 510 - </Button> 511 - <Button 512 - variant="filled" 513 - onPress={handleConfirmDelete} 514 - style={styles.deleteModalButton} 515 - > 516 - <Text style={styles.deleteModalButtonText}>Delete</Text> 517 - </Button> 518 - </View> 519 - </View> 520 - </View> 521 - </Modal> 522 - 523 - <Modal 524 - visible={showPDSOptionModal} 525 - animationType="fade" 526 - transparent 527 - onRequestClose={() => setShowPDSOptionModal(false)} 528 - > 529 - <View style={styles.modalOverlay}> 530 - <View style={styles.deleteModalContent}> 531 - <Text style={styles.deleteModalTitle}>Delete PDS Data?</Text> 532 - <Text style={styles.deleteModalDescription}> 533 - Do you also want to delete your watch history from your PDS? 534 - </Text> 535 - <View style={styles.pdsOptionBox}> 536 - <View style={styles.pdsOptionItem}> 537 - <Text style={styles.pdsOptionCheck}>✓</Text> 538 - <Text style={styles.pdsOptionText}> 539 - Your OpnShelf account and settings will be deleted 540 - </Text> 541 - </View> 542 - <View style={styles.pdsOptionItem}> 543 - <Text style={styles.pdsOptionCheck}>✓</Text> 544 - <Text style={styles.pdsOptionText}> 545 - Your local session will be cleared 546 - </Text> 547 - </View> 548 - </View> 549 - <View style={styles.deleteModalButtons}> 550 - <Button 551 - variant="outlined" 552 - onPress={() => handlePDSOption(false)} 553 - style={styles.deleteModalButton} 554 - > 555 - <Text style={styles.deleteModalOutlineText}>Keep on PDS</Text> 556 - </Button> 557 - <Button 558 - variant="filled" 559 - onPress={() => handlePDSOption(true)} 560 - style={styles.deleteModalButton} 561 - > 562 - <Text style={styles.deleteModalButtonText}> 563 - Delete from PDS 564 - </Text> 565 - </Button> 566 - </View> 567 - </View> 568 - </View> 569 - </Modal> 166 + isDeleting={deleteAccountMutation.isPending} 167 + onClose={() => setShowDeleteModal(false)} 168 + onConfirm={handleConfirmDelete} 169 + /> 570 170 </SafeAreaView> 571 171 ); 572 172 } 573 173 574 - const styles = StyleSheet.create({ 575 - container: { 576 - flex: 1, 577 - backgroundColor: colors.background, 578 - }, 579 - scrollView: { 580 - flex: 1, 581 - }, 582 - header: { 583 - paddingHorizontal: spacing.lg, 584 - paddingVertical: spacing.md, 585 - flexDirection: "row", 586 - alignItems: "center", 587 - gap: spacing.md, 588 - }, 589 - backButton: { 590 - padding: spacing.sm, 591 - }, 592 - headerLeft: { 593 - flexDirection: "row", 594 - alignItems: "center", 595 - gap: spacing.sm, 596 - }, 597 - title: { 598 - fontSize: 28, 599 - fontWeight: "bold", 600 - color: colors.text, 601 - }, 602 - section: { 603 - paddingHorizontal: spacing.lg, 604 - paddingBottom: spacing.lg, 605 - }, 606 - card: { 607 - marginHorizontal: spacing.lg, 608 - marginBottom: spacing.lg, 609 - }, 610 - cardHeader: { 611 - paddingBottom: spacing.sm, 612 - }, 613 - cardHeaderContent: { 614 - flexDirection: "row", 615 - alignItems: "center", 616 - gap: spacing.md, 617 - }, 618 - iconContainer: { 619 - width: 40, 620 - height: 40, 621 - borderRadius: borderRadius.lg, 622 - backgroundColor: "rgba(245, 158, 11, 0.1)", 623 - justifyContent: "center", 624 - alignItems: "center", 625 - }, 626 - cardTitleContainer: { 627 - flex: 1, 628 - }, 629 - cardTitle: { 630 - fontSize: 18, 631 - fontWeight: "600", 632 - color: colors.text, 633 - marginBottom: spacing.xs / 2, 634 - }, 635 - cardDescription: { 636 - fontSize: 14, 637 - color: colors.textMuted, 638 - flexShrink: 1, 639 - }, 640 - cardContent: { 641 - paddingTop: 0, 642 - }, 643 - settingRow: { 644 - flexDirection: "row", 645 - alignItems: "center", 646 - justifyContent: "space-between", 647 - paddingVertical: spacing.md, 648 - }, 649 - settingLabelContainer: { 650 - flex: 1, 651 - gap: spacing.xs / 2, 652 - }, 653 - settingLabel: { 654 - fontSize: 16, 655 - fontWeight: "500", 656 - color: colors.text, 657 - }, 658 - settingValue: { 659 - fontSize: 14, 660 - color: colors.textMuted, 661 - }, 662 - settingDescription: { 663 - fontSize: 14, 664 - color: colors.textMuted, 665 - }, 666 - switchContainer: { 667 - flexDirection: "row", 668 - alignItems: "center", 669 - gap: spacing.sm, 670 - }, 671 - spinner: { 672 - marginRight: spacing.sm, 673 - }, 674 - spinnerSmall: { 675 - marginRight: spacing.xs, 676 - }, 677 - divider: { 678 - height: 1, 679 - backgroundColor: colors.border, 680 - }, 681 - skeleton: { 682 - height: 20, 683 - width: 120, 684 - backgroundColor: colors.cardMuted, 685 - borderRadius: borderRadius.sm, 686 - }, 687 - previewContainer: { 688 - marginTop: spacing.md, 689 - padding: spacing.md, 690 - backgroundColor: colors.background, 691 - borderRadius: borderRadius.lg, 692 - borderWidth: 1, 693 - borderColor: colors.border, 694 - }, 695 - previewContent: { 696 - flexDirection: "row", 697 - alignItems: "center", 698 - gap: spacing.md, 699 - }, 700 - previewLabel: { 701 - fontSize: 14, 702 - color: colors.textMuted, 703 - marginBottom: spacing.xs / 2, 704 - }, 705 - previewValue: { 706 - fontSize: 24, 707 - fontWeight: "600", 708 - color: colors.warning, 709 - }, 710 - deleteButton: { 711 - paddingHorizontal: 0, 712 - }, 713 - // Modal styles 714 - modalContainer: { 715 - flex: 1, 716 - backgroundColor: colors.background, 717 - }, 718 - modalHeader: { 719 - flexDirection: "row", 720 - alignItems: "center", 721 - justifyContent: "space-between", 722 - paddingHorizontal: spacing.lg, 723 - paddingVertical: spacing.md, 724 - borderBottomWidth: 1, 725 - borderBottomColor: colors.border, 726 - }, 727 - modalTitle: { 728 - fontSize: 18, 729 - fontWeight: "600", 730 - color: colors.text, 731 - }, 732 - modalCloseText: { 733 - color: colors.warning, 734 - fontSize: 16, 735 - fontWeight: "500", 736 - }, 737 - searchInput: { 738 - marginHorizontal: spacing.lg, 739 - marginVertical: spacing.md, 740 - }, 741 - modalScroll: { 742 - flex: 1, 743 - paddingHorizontal: spacing.lg, 744 - }, 745 - zoneItem: { 746 - paddingVertical: spacing.md, 747 - paddingHorizontal: spacing.md, 748 - borderRadius: borderRadius.md, 749 - marginBottom: spacing.xs, 750 - }, 751 - zoneItemActive: { 752 - backgroundColor: colors.card, 753 - }, 754 - zoneText: { 755 - fontSize: 16, 756 - color: colors.text, 757 - fontWeight: "500", 758 - }, 759 - zoneTextActive: { 760 - color: colors.warning, 761 - }, 762 - zoneRegion: { 763 - fontSize: 12, 764 - color: colors.textMuted, 765 - marginTop: spacing.xs / 2, 766 - }, 767 - modalOverlay: { 768 - flex: 1, 769 - backgroundColor: "rgba(0, 0, 0, 0.7)", 770 - justifyContent: "center", 771 - alignItems: "center", 772 - padding: spacing.lg, 773 - }, 774 - deleteModalContent: { 775 - backgroundColor: colors.card, 776 - borderRadius: borderRadius.xl, 777 - padding: spacing.xl, 778 - width: "100%", 779 - maxWidth: 340, 780 - alignItems: "center", 781 - }, 782 - deleteModalIcon: { 783 - width: 64, 784 - height: 64, 785 - borderRadius: 32, 786 - backgroundColor: "rgba(239, 68, 68, 0.1)", 787 - justifyContent: "center", 788 - alignItems: "center", 789 - marginBottom: spacing.md, 790 - }, 791 - deleteModalTitle: { 792 - fontSize: 20, 793 - fontWeight: "600", 794 - color: colors.text, 795 - marginBottom: spacing.sm, 796 - textAlign: "center", 797 - }, 798 - deleteModalDescription: { 799 - fontSize: 14, 800 - color: colors.textMuted, 801 - textAlign: "center", 802 - marginBottom: spacing.lg, 803 - lineHeight: 20, 804 - }, 805 - deleteModalButtons: { 806 - flexDirection: "row", 807 - gap: spacing.sm, 808 - width: "100%", 809 - }, 810 - deleteModalButton: { 811 - flex: 1, 812 - }, 813 - deleteModalButtonText: { 814 - color: "#fff", 815 - fontSize: 14, 816 - fontWeight: "600", 817 - }, 818 - deleteModalOutlineText: { 819 - color: colors.text, 820 - fontSize: 14, 821 - fontWeight: "500", 822 - }, 823 - pdsOptionBox: { 824 - backgroundColor: colors.background, 825 - borderRadius: borderRadius.lg, 826 - padding: spacing.md, 827 - width: "100%", 828 - marginBottom: spacing.lg, 829 - }, 830 - pdsOptionItem: { 831 - flexDirection: "row", 832 - alignItems: "flex-start", 833 - gap: spacing.sm, 834 - marginBottom: spacing.xs, 835 - }, 836 - pdsOptionCheck: { 837 - color: "#22c55e", 838 - fontSize: 14, 839 - fontWeight: "600", 840 - }, 841 - pdsOptionText: { 842 - fontSize: 13, 843 - color: colors.textMuted, 844 - flex: 1, 845 - }, 846 - }); 174 + const createStyles = (colors: ExtendedThemeColors) => 175 + StyleSheet.create({ 176 + container: { 177 + flex: 1, 178 + backgroundColor: colors.background, 179 + }, 180 + scrollView: { 181 + flex: 1, 182 + }, 183 + });
+143
apps/mobile/components/onboarding/OnboardingContent.tsx
··· 1 + import { useState } from "react"; 2 + import { ScrollView } from "react-native"; 3 + import { SafeAreaView } from "react-native-safe-area-context"; 4 + import { useTheme } from "@/contexts/theme"; 5 + import { OnboardingProgressCard } from "./OnboardingProgressCard"; 6 + import { 7 + BriefingStepCard, 8 + IdentityStepCard, 9 + ImportStepCard, 10 + LaunchStepCard, 11 + } from "./OnboardingStepCards"; 12 + import { OnboardingTimezoneModal } from "./OnboardingTimezoneModal"; 13 + import type { 14 + ImportProgressState, 15 + OnboardingImportResult, 16 + TabValue, 17 + } from "./types"; 18 + import { styles } from "./styles"; 19 + 20 + type OnboardingContentProps = { 21 + step: number; 22 + progressPercent: number; 23 + activeTab: TabValue; 24 + traktUsername: string; 25 + displayName: string; 26 + timezone: string; 27 + timeFormat: "12h" | "24h"; 28 + csvFileName: string | null; 29 + importProgress: ImportProgressState; 30 + importPercent: number; 31 + importResult: OnboardingImportResult; 32 + isCompleting: boolean; 33 + isSavingProfile: boolean; 34 + isImportBusy: boolean; 35 + onStepChange: (step: number) => void; 36 + onActiveTabChange: (tab: TabValue) => void; 37 + onTraktUsernameChange: (value: string) => void; 38 + onDisplayNameChange: (value: string) => void; 39 + onTimezoneChange: (value: string) => void; 40 + onTimeFormatChange: (value: "12h" | "24h") => void; 41 + onSkip: () => void; 42 + onSaveProfileAndContinue: () => void; 43 + onTraktImport: () => void; 44 + onCsvImport: () => void; 45 + onComplete: () => void; 46 + }; 47 + 48 + export function OnboardingContent({ 49 + step, 50 + progressPercent, 51 + activeTab, 52 + traktUsername, 53 + displayName, 54 + timezone, 55 + timeFormat, 56 + csvFileName, 57 + importProgress, 58 + importPercent, 59 + importResult, 60 + isCompleting, 61 + isSavingProfile, 62 + isImportBusy, 63 + onStepChange, 64 + onActiveTabChange, 65 + onTraktUsernameChange, 66 + onDisplayNameChange, 67 + onTimezoneChange, 68 + onTimeFormatChange, 69 + onSkip, 70 + onSaveProfileAndContinue, 71 + onTraktImport, 72 + onCsvImport, 73 + onComplete, 74 + }: OnboardingContentProps) { 75 + const { colors } = useTheme(); 76 + const [isTimezoneModalOpen, setIsTimezoneModalOpen] = useState(false); 77 + 78 + return ( 79 + <SafeAreaView 80 + style={[styles.container, { backgroundColor: colors.background }]} 81 + edges={["top", "left", "right", "bottom"]} 82 + > 83 + <ScrollView contentContainerStyle={styles.scrollContent}> 84 + <OnboardingProgressCard step={step} progressPercent={progressPercent} /> 85 + 86 + {step === 1 && ( 87 + <BriefingStepCard 88 + onStart={() => onStepChange(2)} 89 + onSkip={onSkip} 90 + isCompleting={isCompleting} 91 + /> 92 + )} 93 + 94 + {step === 2 && ( 95 + <IdentityStepCard 96 + displayName={displayName} 97 + timezone={timezone} 98 + timeFormat={timeFormat} 99 + isSavingProfile={isSavingProfile} 100 + onDisplayNameChange={onDisplayNameChange} 101 + onOpenTimezonePicker={() => setIsTimezoneModalOpen(true)} 102 + onTimeFormatChange={onTimeFormatChange} 103 + onBack={() => onStepChange(1)} 104 + onSave={onSaveProfileAndContinue} 105 + /> 106 + )} 107 + 108 + {step === 3 && ( 109 + <ImportStepCard 110 + activeTab={activeTab} 111 + traktUsername={traktUsername} 112 + csvFileName={csvFileName} 113 + importProgress={importProgress} 114 + importPercent={importPercent} 115 + isImportBusy={isImportBusy} 116 + isCompleting={isCompleting} 117 + onActiveTabChange={onActiveTabChange} 118 + onTraktUsernameChange={onTraktUsernameChange} 119 + onTraktImport={onTraktImport} 120 + onCsvImport={onCsvImport} 121 + onBack={() => onStepChange(2)} 122 + onSkip={onSkip} 123 + /> 124 + )} 125 + 126 + {step === 4 && ( 127 + <LaunchStepCard 128 + importResult={importResult} 129 + isCompleting={isCompleting} 130 + onComplete={onComplete} 131 + /> 132 + )} 133 + </ScrollView> 134 + 135 + <OnboardingTimezoneModal 136 + visible={isTimezoneModalOpen} 137 + timezone={timezone} 138 + onClose={() => setIsTimezoneModalOpen(false)} 139 + onSelect={onTimezoneChange} 140 + /> 141 + </SafeAreaView> 142 + ); 143 + }
+85
apps/mobile/components/onboarding/OnboardingProgressCard.tsx
··· 1 + import { Check } from "lucide-react-native"; 2 + import { Text, View } from "react-native"; 3 + import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 4 + import { useTheme } from "@/contexts/theme"; 5 + import { ONBOARDING_STEPS } from "./constants"; 6 + import { styles } from "./styles"; 7 + 8 + type OnboardingProgressCardProps = { 9 + step: number; 10 + progressPercent: number; 11 + }; 12 + 13 + export function OnboardingProgressCard({ 14 + step, 15 + progressPercent, 16 + }: OnboardingProgressCardProps) { 17 + const { colors } = useTheme(); 18 + 19 + return ( 20 + <Card style={styles.progressCard}> 21 + <CardHeader> 22 + <Text style={[styles.kicker, { color: colors.primary }]}>Onboarding</Text> 23 + <Text style={[styles.title, { color: colors.onBackground }]}>Welcome to OpnShelf</Text> 24 + <Text style={[styles.subtitle, { color: colors.onSurfaceVariant }]}>Step {step} of {ONBOARDING_STEPS.length} • {progressPercent}%</Text> 25 + </CardHeader> 26 + <CardContent> 27 + <View 28 + style={[ 29 + styles.progressTrack, 30 + { backgroundColor: colors.surfaceContainerHigh }, 31 + ]} 32 + > 33 + <View 34 + style={[ 35 + styles.progressFill, 36 + { backgroundColor: colors.primary, width: `${progressPercent}%` }, 37 + ]} 38 + /> 39 + </View> 40 + <View style={styles.stepsList}> 41 + {ONBOARDING_STEPS.map((item, index) => { 42 + const stepNumber = index + 1; 43 + const isComplete = step > stepNumber; 44 + const isActive = step === stepNumber; 45 + 46 + return ( 47 + <View 48 + key={item.title} 49 + style={[ 50 + styles.stepRow, 51 + { 52 + borderColor: isActive ? colors.outline : colors.outlineVariant, 53 + backgroundColor: isActive 54 + ? colors.surfaceContainer 55 + : colors.surface, 56 + }, 57 + ]} 58 + > 59 + <View 60 + style={[ 61 + styles.stepBadge, 62 + { 63 + backgroundColor: isComplete 64 + ? colors.primary 65 + : colors.secondaryContainer, 66 + }, 67 + ]} 68 + > 69 + {isComplete ? ( 70 + <Check size={14} color={colors.onPrimary} /> 71 + ) : ( 72 + <item.Icon size={14} color={colors.onSecondaryContainer} /> 73 + )} 74 + </View> 75 + <View style={styles.stepTextWrap}> 76 + <Text style={[styles.stepTitle, { color: colors.onSurface }]}>{item.title}</Text> 77 + </View> 78 + </View> 79 + ); 80 + })} 81 + </View> 82 + </CardContent> 83 + </Card> 84 + ); 85 + }
+447
apps/mobile/components/onboarding/OnboardingStepCards.tsx
··· 1 + import { FileSpreadsheet } from "lucide-react-native"; 2 + import { Pressable, ScrollView, Text, View } from "react-native"; 3 + import { Button } from "@/components/ui/Button"; 4 + import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 5 + import { M3TextField } from "@/components/ui/m3"; 6 + import { useTheme } from "@/contexts/theme"; 7 + import type { 8 + ImportProgressState, 9 + OnboardingImportResult, 10 + TabValue, 11 + } from "./types"; 12 + import { styles } from "./styles"; 13 + 14 + export function BriefingStepCard({ 15 + onStart, 16 + onSkip, 17 + isCompleting, 18 + }: { 19 + onStart: () => void; 20 + onSkip: () => void; 21 + isCompleting: boolean; 22 + }) { 23 + const { colors } = useTheme(); 24 + 25 + return ( 26 + <Card> 27 + <CardHeader> 28 + <Text style={[styles.sectionTitle, { color: colors.onSurface }]}>Briefing</Text> 29 + <Text style={[styles.sectionBody, { color: colors.onSurfaceVariant }]}>You can finish setup in under two minutes. We will save your display name, timezone, and optionally import your watch history.</Text> 30 + </CardHeader> 31 + <CardContent> 32 + <View style={styles.bulletList}> 33 + <Text style={[styles.bulletItem, { color: colors.onSurfaceVariant }]}>• Profile and timezone come first.</Text> 34 + <Text style={[styles.bulletItem, { color: colors.onSurfaceVariant }]}>• Import from Trakt username or CSV export.</Text> 35 + <Text style={[styles.bulletItem, { color: colors.onSurfaceVariant }]}>• Skip import if you want to start tracking immediately.</Text> 36 + </View> 37 + <View style={styles.actionsRow}> 38 + <Button onPress={onStart}>Begin setup</Button> 39 + <Button variant="text" onPress={onSkip} disabled={isCompleting}> 40 + {isCompleting ? "Finishing..." : "Skip to dashboard"} 41 + </Button> 42 + </View> 43 + </CardContent> 44 + </Card> 45 + ); 46 + } 47 + 48 + type IdentityStepCardProps = { 49 + displayName: string; 50 + timezone: string; 51 + timeFormat: "12h" | "24h"; 52 + isSavingProfile: boolean; 53 + onDisplayNameChange: (value: string) => void; 54 + onOpenTimezonePicker: () => void; 55 + onTimeFormatChange: (value: "12h" | "24h") => void; 56 + onBack: () => void; 57 + onSave: () => void; 58 + }; 59 + 60 + export function IdentityStepCard({ 61 + displayName, 62 + timezone, 63 + timeFormat, 64 + isSavingProfile, 65 + onDisplayNameChange, 66 + onOpenTimezonePicker, 67 + onTimeFormatChange, 68 + onBack, 69 + onSave, 70 + }: IdentityStepCardProps) { 71 + const { colors } = useTheme(); 72 + 73 + return ( 74 + <Card> 75 + <CardHeader> 76 + <Text style={[styles.sectionTitle, { color: colors.onSurface }]}>Identity</Text> 77 + <Text style={[styles.sectionBody, { color: colors.onSurfaceVariant }]}>Tune your profile and time preferences before importing.</Text> 78 + </CardHeader> 79 + <CardContent> 80 + <View style={styles.profileFormStack}> 81 + <M3TextField 82 + label="Display name" 83 + value={displayName} 84 + onChangeText={onDisplayNameChange} 85 + containerStyle={{ width: "100%" }} 86 + variant="outlined" 87 + /> 88 + 89 + <Pressable 90 + onPress={onOpenTimezonePicker} 91 + style={[ 92 + styles.selectionRow, 93 + { 94 + backgroundColor: colors.surfaceContainerLow, 95 + borderColor: colors.outline, 96 + }, 97 + ]} 98 + > 99 + <View> 100 + <Text style={[styles.selectionLabel, { color: colors.onSurface }]}>Timezone</Text> 101 + <Text style={[styles.selectionValue, { color: colors.onSurfaceVariant }]}> 102 + {timezone.replace(/_/g, " ")} 103 + </Text> 104 + </View> 105 + <Text style={[styles.selectionAction, { color: colors.primary }]}>Change</Text> 106 + </Pressable> 107 + 108 + <View style={styles.toggleWrap}> 109 + <Text style={[styles.selectionLabel, { color: colors.onSurface }]}>Clock style</Text> 110 + <View style={styles.timeFormatRow}> 111 + <Pressable 112 + onPress={() => onTimeFormatChange("12h")} 113 + style={[ 114 + styles.timeFormatPill, 115 + { 116 + backgroundColor: 117 + timeFormat === "12h" 118 + ? colors.secondaryContainer 119 + : colors.surfaceContainerHigh, 120 + }, 121 + ]} 122 + > 123 + <Text 124 + style={{ 125 + color: 126 + timeFormat === "12h" 127 + ? colors.onSecondaryContainer 128 + : colors.onSurfaceVariant, 129 + fontWeight: "600", 130 + }} 131 + > 132 + 12-hour 133 + </Text> 134 + </Pressable> 135 + <Pressable 136 + onPress={() => onTimeFormatChange("24h")} 137 + style={[ 138 + styles.timeFormatPill, 139 + { 140 + backgroundColor: 141 + timeFormat === "24h" 142 + ? colors.secondaryContainer 143 + : colors.surfaceContainerHigh, 144 + }, 145 + ]} 146 + > 147 + <Text 148 + style={{ 149 + color: 150 + timeFormat === "24h" 151 + ? colors.onSecondaryContainer 152 + : colors.onSurfaceVariant, 153 + fontWeight: "600", 154 + }} 155 + > 156 + 24-hour 157 + </Text> 158 + </Pressable> 159 + </View> 160 + </View> 161 + 162 + <View style={styles.actionsRow}> 163 + <Button variant="text" onPress={onBack} disabled={isSavingProfile}> 164 + Back 165 + </Button> 166 + <Button onPress={onSave} disabled={isSavingProfile}> 167 + {isSavingProfile ? "Saving..." : "Save and continue"} 168 + </Button> 169 + </View> 170 + </View> 171 + </CardContent> 172 + </Card> 173 + ); 174 + } 175 + 176 + type ImportStepCardProps = { 177 + activeTab: TabValue; 178 + traktUsername: string; 179 + csvFileName: string | null; 180 + importProgress: ImportProgressState; 181 + importPercent: number; 182 + isImportBusy: boolean; 183 + isCompleting: boolean; 184 + onActiveTabChange: (tab: TabValue) => void; 185 + onTraktUsernameChange: (value: string) => void; 186 + onTraktImport: () => void; 187 + onCsvImport: () => void; 188 + onBack: () => void; 189 + onSkip: () => void; 190 + }; 191 + 192 + export function ImportStepCard({ 193 + activeTab, 194 + traktUsername, 195 + csvFileName, 196 + importProgress, 197 + importPercent, 198 + isImportBusy, 199 + isCompleting, 200 + onActiveTabChange, 201 + onTraktUsernameChange, 202 + onTraktImport, 203 + onCsvImport, 204 + onBack, 205 + onSkip, 206 + }: ImportStepCardProps) { 207 + const { colors } = useTheme(); 208 + 209 + return ( 210 + <Card> 211 + <CardHeader> 212 + <Text style={[styles.sectionTitle, { color: colors.onSurface }]}>Import</Text> 213 + <Text style={[styles.sectionBody, { color: colors.onSurfaceVariant }]}>Import watch history from Trakt or a Trakt CSV export.</Text> 214 + </CardHeader> 215 + <CardContent> 216 + <View style={styles.importFormStack}> 217 + {importProgress.phase !== "idle" && ( 218 + <View 219 + style={[ 220 + styles.importStatusBox, 221 + { 222 + backgroundColor: colors.surfaceContainerHigh, 223 + borderColor: 224 + importProgress.phase === "error" 225 + ? colors.error 226 + : colors.outlineVariant, 227 + }, 228 + ]} 229 + > 230 + <Text 231 + style={[ 232 + styles.importStatusText, 233 + { 234 + color: 235 + importProgress.phase === "error" 236 + ? colors.error 237 + : colors.onSurface, 238 + }, 239 + ]} 240 + > 241 + {importProgress.message} 242 + </Text> 243 + {importProgress.phase === "importing" && ( 244 + <> 245 + <View 246 + style={[ 247 + styles.progressTrack, 248 + { backgroundColor: colors.surfaceContainerHighest }, 249 + ]} 250 + > 251 + <View 252 + style={[ 253 + styles.progressFill, 254 + { 255 + backgroundColor: colors.primary, 256 + width: `${importPercent}%`, 257 + }, 258 + ]} 259 + /> 260 + </View> 261 + <Text style={[styles.importStatusMeta, { color: colors.onSurfaceVariant }]}> 262 + {importProgress.processedItems} / {importProgress.totalItems} items ({importPercent}%) 263 + </Text> 264 + <Text style={[styles.importStatusMeta, { color: colors.onSurfaceVariant }]}> 265 + Batch {importProgress.currentBatch} of {importProgress.totalBatches}. Imported {importProgress.imported}, skipped {importProgress.skipped}, failed {importProgress.failed}. 266 + </Text> 267 + </> 268 + )} 269 + </View> 270 + )} 271 + 272 + <View style={styles.tabRow}> 273 + <Pressable 274 + onPress={() => onActiveTabChange("trakt")} 275 + style={[ 276 + styles.tabButton, 277 + { 278 + backgroundColor: 279 + activeTab === "trakt" 280 + ? colors.secondaryContainer 281 + : colors.surfaceContainerHigh, 282 + }, 283 + ]} 284 + > 285 + <Text 286 + style={{ 287 + color: 288 + activeTab === "trakt" 289 + ? colors.onSecondaryContainer 290 + : colors.onSurfaceVariant, 291 + fontWeight: "600", 292 + }} 293 + > 294 + Trakt username 295 + </Text> 296 + </Pressable> 297 + <Pressable 298 + onPress={() => onActiveTabChange("csv")} 299 + style={[ 300 + styles.tabButton, 301 + { 302 + backgroundColor: 303 + activeTab === "csv" 304 + ? colors.secondaryContainer 305 + : colors.surfaceContainerHigh, 306 + }, 307 + ]} 308 + > 309 + <Text 310 + style={{ 311 + color: 312 + activeTab === "csv" 313 + ? colors.onSecondaryContainer 314 + : colors.onSurfaceVariant, 315 + fontWeight: "600", 316 + }} 317 + > 318 + CSV upload 319 + </Text> 320 + </Pressable> 321 + </View> 322 + 323 + {activeTab === "trakt" ? ( 324 + <View style={styles.formStack}> 325 + <M3TextField 326 + label="Trakt username" 327 + value={traktUsername} 328 + onChangeText={onTraktUsernameChange} 329 + placeholder="your-trakt-handle" 330 + containerStyle={{ width: "100%" }} 331 + variant="outlined" 332 + /> 333 + <Button onPress={onTraktImport} disabled={isImportBusy}> 334 + {isImportBusy ? "Working..." : "Fetch and import"} 335 + </Button> 336 + </View> 337 + ) : ( 338 + <View style={styles.formStack}> 339 + <Pressable 340 + onPress={onCsvImport} 341 + disabled={isImportBusy} 342 + style={[ 343 + styles.csvButton, 344 + { 345 + borderColor: colors.outline, 346 + backgroundColor: colors.surface, 347 + opacity: isImportBusy ? 0.6 : 1, 348 + }, 349 + ]} 350 + > 351 + <FileSpreadsheet size={18} color={colors.primary} /> 352 + <Text style={[styles.csvButtonText, { color: colors.onSurface }]}> 353 + {isImportBusy ? "Import in progress" : "Select Trakt CSV file"} 354 + </Text> 355 + </Pressable> 356 + {csvFileName ? ( 357 + <Text style={[styles.csvFileName, { color: colors.onSurfaceVariant }]}>Selected: {csvFileName}</Text> 358 + ) : null} 359 + <Text style={[styles.csvHelp, { color: colors.onSurfaceVariant }]}>Use Trakt export columns: watched_at, action, type, tmdb_id, season_number, episode_number.</Text> 360 + </View> 361 + )} 362 + 363 + <View style={styles.actionsRow}> 364 + <Button variant="text" onPress={onBack} disabled={isImportBusy}> 365 + Back 366 + </Button> 367 + <Button variant="text" onPress={onSkip} disabled={isImportBusy || isCompleting}> 368 + {isCompleting ? "Finishing..." : "Skip import"} 369 + </Button> 370 + </View> 371 + </View> 372 + </CardContent> 373 + </Card> 374 + ); 375 + } 376 + 377 + export function LaunchStepCard({ 378 + importResult, 379 + isCompleting, 380 + onComplete, 381 + }: { 382 + importResult: OnboardingImportResult; 383 + isCompleting: boolean; 384 + onComplete: () => void; 385 + }) { 386 + const { colors } = useTheme(); 387 + 388 + return ( 389 + <Card> 390 + <CardHeader> 391 + <Text style={[styles.sectionTitle, { color: colors.onSurface }]}>Launch</Text> 392 + <Text style={[styles.sectionBody, { color: colors.onSurfaceVariant }]}>You are all set. Your shelf is ready for tracking.</Text> 393 + </CardHeader> 394 + <CardContent> 395 + <View style={styles.metricsRow}> 396 + <MetricCard label="Imported" value={importResult.imported} /> 397 + <MetricCard label="Skipped" value={importResult.skipped} /> 398 + <MetricCard label="Failed" value={importResult.failed} /> 399 + </View> 400 + 401 + {importResult.errors.length > 0 && ( 402 + <View 403 + style={[ 404 + styles.errorBox, 405 + { 406 + backgroundColor: `${colors.error}20`, 407 + borderColor: colors.error, 408 + }, 409 + ]} 410 + > 411 + <Text style={[styles.errorTitle, { color: colors.error }]}>Import errors</Text> 412 + <ScrollView style={styles.errorScroll} nestedScrollEnabled> 413 + {importResult.errors.map((error) => ( 414 + <Text key={error} style={[styles.errorItem, { color: colors.error }]}>• {error}</Text> 415 + ))} 416 + </ScrollView> 417 + </View> 418 + )} 419 + 420 + <View style={styles.actionsRow}> 421 + <Button onPress={onComplete} disabled={isCompleting}> 422 + {isCompleting ? "Finishing..." : "Open dashboard"} 423 + </Button> 424 + </View> 425 + </CardContent> 426 + </Card> 427 + ); 428 + } 429 + 430 + function MetricCard({ label, value }: { label: string; value: number }) { 431 + const { colors } = useTheme(); 432 + 433 + return ( 434 + <View 435 + style={[ 436 + styles.metricCard, 437 + { 438 + backgroundColor: colors.surfaceContainerHigh, 439 + borderColor: colors.outlineVariant, 440 + }, 441 + ]} 442 + > 443 + <Text style={[styles.metricLabel, { color: colors.onSurfaceVariant }]}>{label}</Text> 444 + <Text style={[styles.metricValue, { color: colors.primary }]}>{value}</Text> 445 + </View> 446 + ); 447 + }
+113
apps/mobile/components/onboarding/OnboardingTimezoneModal.tsx
··· 1 + import { useMemo, useState } from "react"; 2 + import { Modal, Pressable, ScrollView, Text, View } from "react-native"; 3 + import { SafeAreaView } from "react-native-safe-area-context"; 4 + import { Button } from "@/components/ui/Button"; 5 + import { M3TextField } from "@/components/ui/m3"; 6 + import { useTheme } from "@/contexts/theme"; 7 + import { ALL_ZONES } from "./constants"; 8 + import { styles } from "./styles"; 9 + 10 + type OnboardingTimezoneModalProps = { 11 + visible: boolean; 12 + timezone: string; 13 + onClose: () => void; 14 + onSelect: (timezone: string) => void; 15 + }; 16 + 17 + export function OnboardingTimezoneModal({ 18 + visible, 19 + timezone, 20 + onClose, 21 + onSelect, 22 + }: OnboardingTimezoneModalProps) { 23 + const { colors } = useTheme(); 24 + const [search, setSearch] = useState(""); 25 + 26 + const filteredZones = useMemo(() => { 27 + if (!search) { 28 + return ALL_ZONES; 29 + } 30 + 31 + const normalized = search.toLowerCase(); 32 + return ALL_ZONES.filter( 33 + (zone) => 34 + zone.zone.toLowerCase().includes(normalized) || 35 + zone.region.toLowerCase().includes(normalized), 36 + ); 37 + }, [search]); 38 + 39 + return ( 40 + <Modal 41 + visible={visible} 42 + animationType="slide" 43 + presentationStyle="pageSheet" 44 + onRequestClose={onClose} 45 + > 46 + <SafeAreaView 47 + style={[styles.modalContainer, { backgroundColor: colors.background }]} 48 + edges={["top", "left", "right", "bottom"]} 49 + > 50 + <View 51 + style={[ 52 + styles.modalHeader, 53 + { borderBottomColor: colors.outlineVariant }, 54 + ]} 55 + > 56 + <Text style={[styles.modalTitle, { color: colors.onSurface }]}>Select Timezone</Text> 57 + <Button variant="text" size="sm" onPress={onClose}> 58 + Close 59 + </Button> 60 + </View> 61 + 62 + <View style={styles.modalSearchWrap}> 63 + <M3TextField 64 + label="Timezone" 65 + value={search} 66 + onChangeText={setSearch} 67 + placeholder="Search timezones..." 68 + containerStyle={{ width: "100%" }} 69 + variant="outlined" 70 + /> 71 + </View> 72 + 73 + <ScrollView style={styles.modalList}> 74 + {filteredZones.map((zone) => { 75 + const isSelected = timezone === zone.zone; 76 + return ( 77 + <Pressable 78 + key={zone.zone} 79 + onPress={() => { 80 + onSelect(zone.zone); 81 + onClose(); 82 + }} 83 + style={[ 84 + styles.zoneItem, 85 + { 86 + backgroundColor: isSelected 87 + ? colors.surfaceContainer 88 + : colors.background, 89 + borderColor: isSelected 90 + ? colors.primary 91 + : colors.outlineVariant, 92 + }, 93 + ]} 94 + > 95 + <Text 96 + style={[ 97 + styles.zoneLabel, 98 + { color: isSelected ? colors.primary : colors.onSurface }, 99 + ]} 100 + > 101 + {zone.zone.replace(/_/g, " ")} 102 + </Text> 103 + <Text style={[styles.zoneRegion, { color: colors.onSurfaceVariant }]}> 104 + {zone.region} 105 + </Text> 106 + </Pressable> 107 + ); 108 + })} 109 + </ScrollView> 110 + </SafeAreaView> 111 + </Modal> 112 + ); 113 + }
+110
apps/mobile/components/onboarding/constants.ts
··· 1 + import { 2 + CloudDownload, 3 + Sparkles, 4 + UserCircle2, 5 + WandSparkles, 6 + } from "lucide-react-native"; 7 + 8 + export const ONBOARDING_STEPS = [ 9 + { 10 + title: "Briefing", 11 + description: "See how your shelf gets calibrated.", 12 + Icon: Sparkles, 13 + }, 14 + { 15 + title: "Identity", 16 + description: "Tune your profile card and local time.", 17 + Icon: UserCircle2, 18 + }, 19 + { 20 + title: "Import", 21 + description: "Bring your watch history from Trakt or CSV.", 22 + Icon: CloudDownload, 23 + }, 24 + { 25 + title: "Launch", 26 + description: "Review import status and open your shelf.", 27 + Icon: WandSparkles, 28 + }, 29 + ] as const; 30 + 31 + export const TIMEZONE_GROUPS = [ 32 + { region: "UTC", zones: ["UTC"] }, 33 + { 34 + region: "Americas", 35 + zones: [ 36 + "America/New_York", 37 + "America/Chicago", 38 + "America/Denver", 39 + "America/Los_Angeles", 40 + "America/Toronto", 41 + "America/Vancouver", 42 + "America/Mexico_City", 43 + "America/Sao_Paulo", 44 + "America/Buenos_Aires", 45 + ], 46 + }, 47 + { 48 + region: "Europe", 49 + zones: [ 50 + "Europe/London", 51 + "Europe/Paris", 52 + "Europe/Berlin", 53 + "Europe/Rome", 54 + "Europe/Madrid", 55 + "Europe/Amsterdam", 56 + "Europe/Zurich", 57 + "Europe/Stockholm", 58 + "Europe/Oslo", 59 + "Europe/Copenhagen", 60 + "Europe/Helsinki", 61 + "Europe/Warsaw", 62 + "Europe/Prague", 63 + "Europe/Vienna", 64 + "Europe/Budapest", 65 + "Europe/Moscow", 66 + "Europe/Istanbul", 67 + ], 68 + }, 69 + { 70 + region: "Asia & Pacific", 71 + zones: [ 72 + "Asia/Tokyo", 73 + "Asia/Seoul", 74 + "Asia/Shanghai", 75 + "Asia/Hong_Kong", 76 + "Asia/Singapore", 77 + "Asia/Taipei", 78 + "Asia/Manila", 79 + "Asia/Bangkok", 80 + "Asia/Jakarta", 81 + "Asia/Kuala_Lumpur", 82 + "Asia/Ho_Chi_Minh", 83 + "Asia/Dubai", 84 + "Asia/Mumbai", 85 + "Asia/Kolkata", 86 + "Asia/Dhaka", 87 + "Asia/Karachi", 88 + "Pacific/Auckland", 89 + "Pacific/Sydney", 90 + "Pacific/Melbourne", 91 + "Pacific/Perth", 92 + ], 93 + }, 94 + { 95 + region: "Middle East & Africa", 96 + zones: [ 97 + "Africa/Cairo", 98 + "Africa/Johannesburg", 99 + "Africa/Lagos", 100 + "Africa/Nairobi", 101 + "Asia/Jerusalem", 102 + "Asia/Riyadh", 103 + "Asia/Tehran", 104 + ], 105 + }, 106 + ] as const; 107 + 108 + export const ALL_ZONES = TIMEZONE_GROUPS.flatMap((group) => 109 + group.zones.map((zone) => ({ zone, region: group.region })), 110 + );
+255
apps/mobile/components/onboarding/styles.ts
··· 1 + import { StyleSheet } from "react-native"; 2 + import { borderRadius, spacing } from "@/constants/spacing"; 3 + 4 + export const styles = StyleSheet.create({ 5 + container: { 6 + flex: 1, 7 + }, 8 + scrollContent: { 9 + padding: spacing.md, 10 + gap: spacing.md, 11 + }, 12 + progressCard: { 13 + marginBottom: spacing.xs, 14 + }, 15 + kicker: { 16 + fontSize: 12, 17 + fontWeight: "700", 18 + textTransform: "uppercase", 19 + letterSpacing: 1, 20 + }, 21 + title: { 22 + fontSize: 24, 23 + fontWeight: "700", 24 + marginTop: 2, 25 + }, 26 + subtitle: { 27 + fontSize: 13, 28 + marginTop: 2, 29 + }, 30 + progressTrack: { 31 + height: 8, 32 + borderRadius: borderRadius.full, 33 + overflow: "hidden", 34 + }, 35 + progressFill: { 36 + height: "100%", 37 + borderRadius: borderRadius.full, 38 + }, 39 + stepsList: { 40 + marginTop: spacing.sm, 41 + gap: spacing.xs, 42 + flexDirection: "row", 43 + flexWrap: "wrap", 44 + }, 45 + stepRow: { 46 + flexDirection: "row", 47 + gap: spacing.xs, 48 + paddingHorizontal: spacing.sm, 49 + paddingVertical: 12, 50 + borderRadius: borderRadius.md, 51 + borderWidth: 1, 52 + width: "49%", 53 + alignItems: "center", 54 + }, 55 + stepBadge: { 56 + width: 20, 57 + height: 20, 58 + borderRadius: 10, 59 + alignItems: "center", 60 + justifyContent: "center", 61 + }, 62 + stepTextWrap: { 63 + flex: 1, 64 + }, 65 + stepTitle: { 66 + fontSize: 13, 67 + fontWeight: "600", 68 + }, 69 + sectionTitle: { 70 + fontSize: 20, 71 + fontWeight: "700", 72 + }, 73 + sectionBody: { 74 + fontSize: 13, 75 + lineHeight: 18, 76 + marginTop: 2, 77 + }, 78 + bulletList: { 79 + gap: 2, 80 + }, 81 + bulletItem: { 82 + fontSize: 13, 83 + lineHeight: 18, 84 + }, 85 + actionsRow: { 86 + flexDirection: "row", 87 + gap: spacing.sm, 88 + marginTop: spacing.sm, 89 + flexWrap: "wrap", 90 + }, 91 + formStack: { 92 + gap: spacing.sm, 93 + }, 94 + profileFormStack: { 95 + gap: spacing.md, 96 + marginTop: 0, 97 + }, 98 + importFormStack: { 99 + gap: spacing.sm, 100 + marginTop: spacing.sm, 101 + }, 102 + selectionRow: { 103 + paddingHorizontal: spacing.md, 104 + paddingVertical: spacing.sm, 105 + borderRadius: borderRadius.md, 106 + borderWidth: 1, 107 + flexDirection: "row", 108 + alignItems: "center", 109 + justifyContent: "space-between", 110 + }, 111 + selectionLabel: { 112 + fontSize: 14, 113 + fontWeight: "600", 114 + }, 115 + selectionValue: { 116 + fontSize: 12, 117 + marginTop: 2, 118 + }, 119 + selectionAction: { 120 + fontSize: 13, 121 + fontWeight: "600", 122 + }, 123 + toggleWrap: { 124 + gap: spacing.sm, 125 + marginVertical: 0, 126 + }, 127 + timeFormatRow: { 128 + flexDirection: "row", 129 + gap: spacing.sm, 130 + }, 131 + timeFormatPill: { 132 + paddingHorizontal: spacing.md, 133 + paddingVertical: 6, 134 + borderRadius: borderRadius.full, 135 + }, 136 + importStatusBox: { 137 + borderRadius: borderRadius.md, 138 + borderWidth: 1, 139 + padding: spacing.sm, 140 + gap: spacing.xs, 141 + }, 142 + importStatusText: { 143 + fontSize: 13, 144 + fontWeight: "600", 145 + }, 146 + importStatusMeta: { 147 + fontSize: 11, 148 + }, 149 + tabRow: { 150 + flexDirection: "row", 151 + gap: spacing.sm, 152 + }, 153 + tabButton: { 154 + paddingHorizontal: spacing.md, 155 + paddingVertical: 6, 156 + borderRadius: borderRadius.full, 157 + }, 158 + csvButton: { 159 + paddingHorizontal: spacing.md, 160 + paddingVertical: spacing.sm, 161 + borderRadius: borderRadius.md, 162 + borderWidth: 1, 163 + flexDirection: "row", 164 + alignItems: "center", 165 + gap: spacing.sm, 166 + }, 167 + csvButtonText: { 168 + fontSize: 13, 169 + fontWeight: "600", 170 + }, 171 + csvFileName: { 172 + fontSize: 12, 173 + }, 174 + csvHelp: { 175 + fontSize: 12, 176 + lineHeight: 18, 177 + }, 178 + metricsRow: { 179 + flexDirection: "row", 180 + gap: spacing.sm, 181 + }, 182 + metricCard: { 183 + flex: 1, 184 + borderRadius: borderRadius.md, 185 + padding: spacing.sm, 186 + borderWidth: 1, 187 + }, 188 + metricLabel: { 189 + fontSize: 11, 190 + fontWeight: "600", 191 + textTransform: "uppercase", 192 + }, 193 + metricValue: { 194 + fontSize: 22, 195 + fontWeight: "700", 196 + marginTop: 2, 197 + }, 198 + errorBox: { 199 + marginTop: spacing.md, 200 + padding: spacing.md, 201 + borderWidth: 1, 202 + borderRadius: borderRadius.md, 203 + }, 204 + errorTitle: { 205 + fontSize: 14, 206 + fontWeight: "700", 207 + marginBottom: spacing.sm, 208 + }, 209 + errorScroll: { 210 + maxHeight: 140, 211 + }, 212 + errorItem: { 213 + fontSize: 12, 214 + lineHeight: 18, 215 + marginBottom: spacing.xs, 216 + }, 217 + modalContainer: { 218 + flex: 1, 219 + }, 220 + modalHeader: { 221 + paddingHorizontal: spacing.md, 222 + paddingVertical: spacing.md, 223 + borderBottomWidth: 1, 224 + flexDirection: "row", 225 + alignItems: "center", 226 + justifyContent: "space-between", 227 + }, 228 + modalTitle: { 229 + fontSize: 18, 230 + fontWeight: "700", 231 + }, 232 + modalSearchWrap: { 233 + paddingHorizontal: spacing.md, 234 + paddingTop: spacing.sm, 235 + }, 236 + modalList: { 237 + paddingHorizontal: spacing.md, 238 + paddingTop: spacing.sm, 239 + }, 240 + zoneItem: { 241 + paddingHorizontal: spacing.md, 242 + paddingVertical: spacing.sm, 243 + borderRadius: borderRadius.md, 244 + borderWidth: 1, 245 + marginBottom: spacing.xs, 246 + }, 247 + zoneLabel: { 248 + fontSize: 15, 249 + fontWeight: "600", 250 + }, 251 + zoneRegion: { 252 + fontSize: 12, 253 + marginTop: 2, 254 + }, 255 + });
+29
apps/mobile/components/onboarding/types.ts
··· 1 + export type TabValue = "trakt" | "csv"; 2 + 3 + export type ImportPhase = 4 + | "idle" 5 + | "fetching_trakt" 6 + | "parsing_csv" 7 + | "importing" 8 + | "done" 9 + | "error"; 10 + 11 + export type ImportProgressState = { 12 + phase: ImportPhase; 13 + totalItems: number; 14 + processedItems: number; 15 + currentBatch: number; 16 + totalBatches: number; 17 + imported: number; 18 + skipped: number; 19 + failed: number; 20 + startedAt: number | null; 21 + message: string; 22 + }; 23 + 24 + export type OnboardingImportResult = { 25 + imported: number; 26 + skipped: number; 27 + failed: number; 28 + errors: string[]; 29 + };
+152
apps/mobile/components/settings/AccountCard.tsx
··· 1 + import type { UserDto } from "@opnshelf/api"; 2 + import { Loader2, Trash2, User } from "lucide-react-native"; 3 + import { useMemo } from "react"; 4 + import { Pressable, StyleSheet, Text, View } from "react-native"; 5 + import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 6 + import type { ExtendedThemeColors } from "@/constants/extended-theme"; 7 + import { borderRadius, spacing } from "@/constants/spacing"; 8 + import { useTheme } from "@/contexts/theme"; 9 + 10 + interface AccountCardProps { 11 + user: UserDto; 12 + isDeletingAccount: boolean; 13 + onDeleteAccount: () => void; 14 + } 15 + 16 + export function AccountCard({ 17 + user, 18 + isDeletingAccount, 19 + onDeleteAccount, 20 + }: AccountCardProps) { 21 + const { colors } = useTheme(); 22 + const styles = useMemo(() => createStyles(colors), [colors]); 23 + 24 + return ( 25 + <Card style={styles.card}> 26 + <CardHeader style={styles.cardHeader}> 27 + <View style={styles.cardHeaderContent}> 28 + <View style={styles.iconContainer}> 29 + <User size={20} color={colors.onPrimaryContainer} /> 30 + </View> 31 + <View style={styles.cardTitleContainer}> 32 + <Text style={styles.cardTitle}>Account</Text> 33 + <Text style={styles.cardDescription}> 34 + Manage your account information 35 + </Text> 36 + </View> 37 + </View> 38 + </CardHeader> 39 + <CardContent style={styles.cardContent}> 40 + <View style={styles.settingRow}> 41 + <View style={styles.settingLabelContainer}> 42 + <Text style={styles.settingLabel}>Handle</Text> 43 + <Text style={styles.settingValue}>@{user.handle}</Text> 44 + </View> 45 + </View> 46 + 47 + {user.displayName && ( 48 + <> 49 + <View style={styles.divider} /> 50 + <View style={styles.settingRow}> 51 + <View style={styles.settingLabelContainer}> 52 + <Text style={styles.settingLabel}>Display Name</Text> 53 + <Text style={styles.settingValue}>{String(user.displayName)}</Text> 54 + </View> 55 + </View> 56 + </> 57 + )} 58 + 59 + <View style={styles.divider} /> 60 + 61 + <Pressable 62 + onPress={onDeleteAccount} 63 + disabled={isDeletingAccount} 64 + style={[styles.settingRow, styles.deleteButton]} 65 + > 66 + <View style={styles.settingLabelContainer}> 67 + <Text style={[styles.settingLabel, { color: colors.error }]}>Delete Account</Text> 68 + <Text style={styles.settingDescription}>Remove your account and data</Text> 69 + </View> 70 + {isDeletingAccount && ( 71 + <Loader2 size={16} color={colors.error} style={styles.spinner} /> 72 + )} 73 + <Trash2 size={20} color={colors.error} /> 74 + </Pressable> 75 + </CardContent> 76 + </Card> 77 + ); 78 + } 79 + 80 + const createStyles = (colors: ExtendedThemeColors) => 81 + StyleSheet.create({ 82 + card: { 83 + marginHorizontal: spacing.lg, 84 + marginBottom: spacing.lg, 85 + }, 86 + cardHeader: { 87 + paddingBottom: spacing.sm, 88 + }, 89 + cardHeaderContent: { 90 + flexDirection: "row", 91 + alignItems: "center", 92 + gap: spacing.md, 93 + }, 94 + iconContainer: { 95 + width: 40, 96 + height: 40, 97 + borderRadius: borderRadius.lg, 98 + backgroundColor: colors.primaryContainer, 99 + justifyContent: "center", 100 + alignItems: "center", 101 + }, 102 + cardTitleContainer: { 103 + flex: 1, 104 + }, 105 + cardTitle: { 106 + fontSize: 18, 107 + fontWeight: "600", 108 + color: colors.text, 109 + marginBottom: spacing.xs / 2, 110 + }, 111 + cardDescription: { 112 + fontSize: 14, 113 + color: colors.textMuted, 114 + flexShrink: 1, 115 + }, 116 + cardContent: { 117 + paddingTop: 0, 118 + }, 119 + settingRow: { 120 + flexDirection: "row", 121 + alignItems: "center", 122 + justifyContent: "space-between", 123 + paddingVertical: spacing.md, 124 + }, 125 + settingLabelContainer: { 126 + flex: 1, 127 + gap: spacing.xs / 2, 128 + }, 129 + settingLabel: { 130 + fontSize: 16, 131 + fontWeight: "500", 132 + color: colors.text, 133 + }, 134 + settingValue: { 135 + fontSize: 14, 136 + color: colors.textMuted, 137 + }, 138 + settingDescription: { 139 + fontSize: 14, 140 + color: colors.textMuted, 141 + }, 142 + divider: { 143 + height: 1, 144 + backgroundColor: colors.border, 145 + }, 146 + deleteButton: { 147 + paddingHorizontal: 0, 148 + }, 149 + spinner: { 150 + marginRight: spacing.sm, 151 + }, 152 + });
+242
apps/mobile/components/settings/DeleteAccountModal.tsx
··· 1 + import { Trash2 } from "lucide-react-native"; 2 + import { useEffect, useMemo, useState } from "react"; 3 + import { Modal, StyleSheet, Text, View } from "react-native"; 4 + import { Button } from "@/components/ui/Button"; 5 + import { Switch } from "@/components/ui/Switch"; 6 + import type { ExtendedThemeColors } from "@/constants/extended-theme"; 7 + import { borderRadius, spacing } from "@/constants/spacing"; 8 + import { useTheme } from "@/contexts/theme"; 9 + 10 + interface DeleteAccountModalProps { 11 + visible: boolean; 12 + isDeleting: boolean; 13 + onClose: () => void; 14 + onConfirm: (deletePDSData: boolean) => void; 15 + } 16 + 17 + export function DeleteAccountModal({ 18 + visible, 19 + isDeleting, 20 + onClose, 21 + onConfirm, 22 + }: DeleteAccountModalProps) { 23 + const { colors } = useTheme(); 24 + const styles = useMemo(() => createStyles(colors), [colors]); 25 + const [deletePDSData, setDeletePDSData] = useState(false); 26 + 27 + useEffect(() => { 28 + if (visible) { 29 + setDeletePDSData(false); 30 + } 31 + }, [visible]); 32 + 33 + return ( 34 + <Modal 35 + visible={visible} 36 + animationType="fade" 37 + transparent 38 + onRequestClose={onClose} 39 + > 40 + <View style={styles.modalOverlay}> 41 + <View style={styles.deleteModalContent}> 42 + <View style={styles.deleteModalIcon}> 43 + <Trash2 size={32} color={colors.error} /> 44 + </View> 45 + <Text style={styles.deleteModalTitle}>Delete Account</Text> 46 + <Text style={styles.deleteModalDescription}> 47 + Are you sure you want to delete your account? This action cannot be 48 + undone. 49 + </Text> 50 + 51 + <View style={styles.deleteDataBox}> 52 + <Text style={styles.deleteDataBoxTitle}>What happens to your data:</Text> 53 + <View style={styles.deleteDataItem}> 54 + <Text style={styles.deleteDataCheck}>✓</Text> 55 + <Text style={styles.deleteDataText}> 56 + Your OpnShelf account and settings will be deleted 57 + </Text> 58 + </View> 59 + <View style={styles.deleteDataItem}> 60 + <Text style={styles.deleteDataCheck}>✓</Text> 61 + <Text style={styles.deleteDataText}>Your local session will be cleared</Text> 62 + </View> 63 + </View> 64 + 65 + <View style={styles.pdsSwitchRow}> 66 + <Text style={styles.pdsSwitchLabel}> 67 + Also delete my watch history from my PDS 68 + </Text> 69 + <Switch 70 + value={deletePDSData} 71 + onValueChange={setDeletePDSData} 72 + disabled={isDeleting} 73 + /> 74 + </View> 75 + 76 + {deletePDSData ? ( 77 + <View style={styles.deleteWarningBox}> 78 + <Text style={styles.deleteWarningText}> 79 + Your watch history will be permanently deleted from your personal 80 + data server. This cannot be recovered. 81 + </Text> 82 + </View> 83 + ) : ( 84 + <View style={styles.deleteInfoBox}> 85 + <Text style={styles.deleteInfoText}> 86 + Your watch history will remain on your PDS. You can use another app 87 + or re-authorize OpnShelf later to access it. 88 + </Text> 89 + </View> 90 + )} 91 + 92 + <View style={styles.deleteModalButtons}> 93 + <Button 94 + variant="outlined" 95 + onPress={onClose} 96 + disabled={isDeleting} 97 + style={styles.deleteModalButton} 98 + > 99 + <Text style={styles.deleteModalButtonText}>Cancel</Text> 100 + </Button> 101 + <Button 102 + variant="filled" 103 + onPress={() => onConfirm(deletePDSData)} 104 + isLoading={isDeleting} 105 + disabled={isDeleting} 106 + style={styles.deleteModalButton} 107 + > 108 + Delete Account 109 + </Button> 110 + </View> 111 + </View> 112 + </View> 113 + </Modal> 114 + ); 115 + } 116 + 117 + const createStyles = (colors: ExtendedThemeColors) => 118 + StyleSheet.create({ 119 + modalOverlay: { 120 + flex: 1, 121 + backgroundColor: "rgba(0, 0, 0, 0.7)", 122 + justifyContent: "center", 123 + alignItems: "center", 124 + padding: spacing.lg, 125 + }, 126 + deleteModalContent: { 127 + backgroundColor: colors.card, 128 + borderRadius: borderRadius.xl, 129 + padding: spacing.xl, 130 + width: "100%", 131 + maxWidth: 340, 132 + alignItems: "center", 133 + }, 134 + deleteModalIcon: { 135 + width: 64, 136 + height: 64, 137 + borderRadius: 32, 138 + backgroundColor: "rgba(239, 68, 68, 0.1)", 139 + justifyContent: "center", 140 + alignItems: "center", 141 + marginBottom: spacing.md, 142 + }, 143 + deleteModalTitle: { 144 + fontSize: 20, 145 + fontWeight: "600", 146 + color: colors.text, 147 + marginBottom: spacing.sm, 148 + textAlign: "center", 149 + }, 150 + deleteModalDescription: { 151 + fontSize: 14, 152 + color: colors.textMuted, 153 + textAlign: "center", 154 + marginBottom: spacing.md, 155 + lineHeight: 20, 156 + }, 157 + deleteDataBox: { 158 + backgroundColor: colors.background, 159 + borderRadius: borderRadius.lg, 160 + padding: spacing.md, 161 + width: "100%", 162 + marginBottom: spacing.md, 163 + gap: spacing.xs, 164 + }, 165 + deleteDataBoxTitle: { 166 + fontSize: 14, 167 + fontWeight: "500", 168 + color: colors.textMuted, 169 + marginBottom: spacing.xs, 170 + }, 171 + deleteDataItem: { 172 + flexDirection: "row", 173 + alignItems: "flex-start", 174 + gap: spacing.sm, 175 + marginBottom: spacing.xs, 176 + }, 177 + deleteDataCheck: { 178 + color: colors.primary, 179 + fontSize: 14, 180 + fontWeight: "600", 181 + }, 182 + deleteDataText: { 183 + fontSize: 13, 184 + color: colors.text, 185 + flex: 1, 186 + }, 187 + pdsSwitchRow: { 188 + flexDirection: "row", 189 + alignItems: "center", 190 + justifyContent: "space-between", 191 + gap: spacing.md, 192 + width: "100%", 193 + padding: spacing.md, 194 + borderRadius: borderRadius.lg, 195 + backgroundColor: colors.background, 196 + marginBottom: spacing.md, 197 + }, 198 + pdsSwitchLabel: { 199 + fontSize: 14, 200 + color: colors.text, 201 + flex: 1, 202 + }, 203 + deleteWarningBox: { 204 + width: "100%", 205 + padding: spacing.sm, 206 + borderRadius: borderRadius.md, 207 + backgroundColor: `${colors.error}18`, 208 + borderWidth: 1, 209 + borderColor: `${colors.error}33`, 210 + marginBottom: spacing.md, 211 + }, 212 + deleteWarningText: { 213 + fontSize: 13, 214 + lineHeight: 18, 215 + color: colors.error, 216 + }, 217 + deleteInfoBox: { 218 + width: "100%", 219 + padding: spacing.sm, 220 + borderRadius: borderRadius.md, 221 + backgroundColor: colors.background, 222 + marginBottom: spacing.md, 223 + }, 224 + deleteInfoText: { 225 + fontSize: 13, 226 + lineHeight: 18, 227 + color: colors.textMuted, 228 + }, 229 + deleteModalButtons: { 230 + flexDirection: "row", 231 + gap: spacing.sm, 232 + width: "100%", 233 + }, 234 + deleteModalButton: { 235 + flex: 1, 236 + }, 237 + deleteModalButtonText: { 238 + color: colors.text, 239 + fontSize: 14, 240 + fontWeight: "500", 241 + }, 242 + });
+51
apps/mobile/components/settings/SettingsHeader.tsx
··· 1 + import { ArrowLeft, Globe } from "lucide-react-native"; 2 + import { useMemo } from "react"; 3 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 4 + import type { ExtendedThemeColors } from "@/constants/extended-theme"; 5 + import { spacing } from "@/constants/spacing"; 6 + import { useTheme } from "@/contexts/theme"; 7 + 8 + interface SettingsHeaderProps { 9 + onBack: () => void; 10 + } 11 + 12 + export function SettingsHeader({ onBack }: SettingsHeaderProps) { 13 + const { colors } = useTheme(); 14 + const styles = useMemo(() => createStyles(colors), [colors]); 15 + 16 + return ( 17 + <View style={styles.header}> 18 + <TouchableOpacity onPress={onBack} style={styles.backButton}> 19 + <ArrowLeft size={24} color={colors.onBackground} /> 20 + </TouchableOpacity> 21 + <View style={styles.headerLeft}> 22 + <Globe size={28} color={colors.primary} /> 23 + <Text style={styles.title}>Settings</Text> 24 + </View> 25 + </View> 26 + ); 27 + } 28 + 29 + const createStyles = (colors: ExtendedThemeColors) => 30 + StyleSheet.create({ 31 + header: { 32 + paddingHorizontal: spacing.lg, 33 + paddingVertical: spacing.md, 34 + flexDirection: "row", 35 + alignItems: "center", 36 + gap: spacing.md, 37 + }, 38 + backButton: { 39 + padding: spacing.sm, 40 + }, 41 + headerLeft: { 42 + flexDirection: "row", 43 + alignItems: "center", 44 + gap: spacing.sm, 45 + }, 46 + title: { 47 + fontSize: 28, 48 + fontWeight: "bold", 49 + color: colors.text, 50 + }, 51 + });
+226
apps/mobile/components/settings/TimeRegionCard.tsx
··· 1 + import { ChevronRight, Clock, Globe, Loader2 } from "lucide-react-native"; 2 + import { useMemo } from "react"; 3 + import { Pressable, StyleSheet, Text, View } from "react-native"; 4 + import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 5 + import { Switch } from "@/components/ui/Switch"; 6 + import type { ExtendedThemeColors } from "@/constants/extended-theme"; 7 + import { borderRadius, spacing } from "@/constants/spacing"; 8 + import { useTheme } from "@/contexts/theme"; 9 + 10 + interface TimeRegionCardProps { 11 + timezone: string; 12 + is24Hour: boolean; 13 + isSettingsLoading: boolean; 14 + isUpdating: boolean; 15 + currentTimeDisplay: string; 16 + onOpenTimezoneModal: () => void; 17 + onToggleTimeFormat: (value: boolean) => void; 18 + } 19 + 20 + export function TimeRegionCard({ 21 + timezone, 22 + is24Hour, 23 + isSettingsLoading, 24 + isUpdating, 25 + currentTimeDisplay, 26 + onOpenTimezoneModal, 27 + onToggleTimeFormat, 28 + }: TimeRegionCardProps) { 29 + const { colors } = useTheme(); 30 + const styles = useMemo(() => createStyles(colors), [colors]); 31 + 32 + return ( 33 + <Card style={styles.card}> 34 + <CardHeader style={styles.cardHeader}> 35 + <View style={styles.cardHeaderContent}> 36 + <View style={styles.iconContainer}> 37 + <Globe size={20} color={colors.onPrimaryContainer} /> 38 + </View> 39 + <View style={styles.cardTitleContainer}> 40 + <Text style={styles.cardTitle}>Time & Region</Text> 41 + <Text style={styles.cardDescription}> 42 + Customize how dates and times are displayed 43 + </Text> 44 + </View> 45 + </View> 46 + </CardHeader> 47 + <CardContent style={styles.cardContent}> 48 + <Pressable 49 + onPress={onOpenTimezoneModal} 50 + style={styles.settingRow} 51 + disabled={isSettingsLoading || isUpdating} 52 + > 53 + <View style={styles.settingLabelContainer}> 54 + <Text style={styles.settingLabel}>Timezone</Text> 55 + {isSettingsLoading ? ( 56 + <View style={styles.skeleton} /> 57 + ) : ( 58 + <Text style={styles.settingValue}>{timezone.replace(/_/g, " ")}</Text> 59 + )} 60 + </View> 61 + {isUpdating && ( 62 + <Loader2 size={16} color={colors.primary} style={styles.spinner} /> 63 + )} 64 + <ChevronRight size={20} color={colors.textMuted} /> 65 + </Pressable> 66 + 67 + <View style={styles.divider} /> 68 + 69 + <View style={styles.settingRow}> 70 + <View style={styles.settingLabelContainer}> 71 + <Text style={styles.settingLabel}>Time Format</Text> 72 + <Text style={styles.settingDescription}> 73 + {is24Hour ? "24-hour (14:00)" : "12-hour (2:00 PM)"} 74 + </Text> 75 + </View> 76 + {isSettingsLoading ? ( 77 + <View style={styles.switchSkeleton} /> 78 + ) : ( 79 + <View style={styles.switchContainer}> 80 + {isUpdating && ( 81 + <Loader2 82 + size={14} 83 + color={colors.primary} 84 + style={styles.spinnerSmall} 85 + /> 86 + )} 87 + <Switch 88 + value={is24Hour} 89 + onValueChange={onToggleTimeFormat} 90 + disabled={isUpdating} 91 + /> 92 + </View> 93 + )} 94 + </View> 95 + 96 + <View style={styles.divider} /> 97 + 98 + {!isSettingsLoading && ( 99 + <View style={styles.previewContainer}> 100 + <View style={styles.previewContent}> 101 + <Clock size={20} color={colors.primary} /> 102 + <View> 103 + <Text style={styles.previewLabel}>Current time preview</Text> 104 + <Text style={styles.previewValue}>{currentTimeDisplay}</Text> 105 + </View> 106 + </View> 107 + </View> 108 + )} 109 + </CardContent> 110 + </Card> 111 + ); 112 + } 113 + 114 + const createStyles = (colors: ExtendedThemeColors) => 115 + StyleSheet.create({ 116 + card: { 117 + marginHorizontal: spacing.lg, 118 + marginBottom: spacing.lg, 119 + }, 120 + cardHeader: { 121 + paddingBottom: spacing.sm, 122 + }, 123 + cardHeaderContent: { 124 + flexDirection: "row", 125 + alignItems: "center", 126 + gap: spacing.md, 127 + }, 128 + iconContainer: { 129 + width: 40, 130 + height: 40, 131 + borderRadius: borderRadius.lg, 132 + backgroundColor: colors.primaryContainer, 133 + justifyContent: "center", 134 + alignItems: "center", 135 + }, 136 + cardTitleContainer: { 137 + flex: 1, 138 + }, 139 + cardTitle: { 140 + fontSize: 18, 141 + fontWeight: "600", 142 + color: colors.text, 143 + marginBottom: spacing.xs / 2, 144 + }, 145 + cardDescription: { 146 + fontSize: 14, 147 + color: colors.textMuted, 148 + flexShrink: 1, 149 + }, 150 + cardContent: { 151 + paddingTop: 0, 152 + }, 153 + settingRow: { 154 + flexDirection: "row", 155 + alignItems: "center", 156 + justifyContent: "space-between", 157 + paddingVertical: spacing.md, 158 + }, 159 + settingLabelContainer: { 160 + flex: 1, 161 + gap: spacing.xs / 2, 162 + }, 163 + settingLabel: { 164 + fontSize: 16, 165 + fontWeight: "500", 166 + color: colors.text, 167 + }, 168 + settingValue: { 169 + fontSize: 14, 170 + color: colors.textMuted, 171 + }, 172 + settingDescription: { 173 + fontSize: 14, 174 + color: colors.textMuted, 175 + }, 176 + switchContainer: { 177 + flexDirection: "row", 178 + alignItems: "center", 179 + gap: spacing.sm, 180 + }, 181 + spinner: { 182 + marginRight: spacing.sm, 183 + }, 184 + spinnerSmall: { 185 + marginRight: spacing.xs, 186 + }, 187 + divider: { 188 + height: 1, 189 + backgroundColor: colors.border, 190 + }, 191 + skeleton: { 192 + height: 20, 193 + width: 120, 194 + backgroundColor: colors.cardMuted, 195 + borderRadius: borderRadius.sm, 196 + }, 197 + switchSkeleton: { 198 + height: 28, 199 + width: 52, 200 + backgroundColor: colors.cardMuted, 201 + borderRadius: borderRadius.full, 202 + }, 203 + previewContainer: { 204 + marginTop: spacing.md, 205 + padding: spacing.md, 206 + backgroundColor: colors.background, 207 + borderRadius: borderRadius.lg, 208 + borderWidth: 1, 209 + borderColor: colors.border, 210 + }, 211 + previewContent: { 212 + flexDirection: "row", 213 + alignItems: "center", 214 + gap: spacing.md, 215 + }, 216 + previewLabel: { 217 + fontSize: 14, 218 + color: colors.textMuted, 219 + marginBottom: spacing.xs / 2, 220 + }, 221 + previewValue: { 222 + fontSize: 24, 223 + fontWeight: "600", 224 + color: colors.primary, 225 + }, 226 + });
+154
apps/mobile/components/settings/TimezoneModal.tsx
··· 1 + import { useEffect, useMemo, useState } from "react"; 2 + import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; 3 + import { SafeAreaView } from "react-native-safe-area-context"; 4 + import { Button } from "@/components/ui/Button"; 5 + import { M3TextField } from "@/components/ui/m3"; 6 + import type { ExtendedThemeColors } from "@/constants/extended-theme"; 7 + import { borderRadius, spacing } from "@/constants/spacing"; 8 + import { useTheme } from "@/contexts/theme"; 9 + import { ALL_ZONES } from "./timezones"; 10 + 11 + interface TimezoneModalProps { 12 + visible: boolean; 13 + timezone: string; 14 + onClose: () => void; 15 + onSelectTimezone: (timezone: string) => void; 16 + } 17 + 18 + export function TimezoneModal({ 19 + visible, 20 + timezone, 21 + onClose, 22 + onSelectTimezone, 23 + }: TimezoneModalProps) { 24 + const { colors } = useTheme(); 25 + const styles = useMemo(() => createStyles(colors), [colors]); 26 + const [search, setSearch] = useState(""); 27 + 28 + useEffect(() => { 29 + if (!visible) { 30 + setSearch(""); 31 + } 32 + }, [visible]); 33 + 34 + const filteredZones = useMemo(() => { 35 + if (!search) { 36 + return ALL_ZONES; 37 + } 38 + 39 + const lowerSearch = search.toLowerCase(); 40 + return ALL_ZONES.filter( 41 + (zone) => 42 + zone.zone.toLowerCase().includes(lowerSearch) || 43 + zone.region.toLowerCase().includes(lowerSearch), 44 + ); 45 + }, [search]); 46 + 47 + return ( 48 + <Modal 49 + visible={visible} 50 + animationType="slide" 51 + presentationStyle="pageSheet" 52 + onRequestClose={onClose} 53 + > 54 + <SafeAreaView style={styles.modalContainer}> 55 + <View style={styles.modalHeader}> 56 + <Text style={styles.modalTitle}>Select Timezone</Text> 57 + <Button variant="text" size="sm" onPress={onClose}> 58 + <Text style={styles.modalCloseText}>Close</Text> 59 + </Button> 60 + </View> 61 + 62 + <M3TextField 63 + label="Timezone" 64 + containerStyle={styles.searchInput} 65 + placeholder="Search timezones..." 66 + value={search} 67 + onChangeText={setSearch} 68 + variant="outlined" 69 + /> 70 + 71 + <ScrollView style={styles.modalScroll}> 72 + {filteredZones.map((zone) => ( 73 + <Pressable 74 + key={zone.zone} 75 + style={[ 76 + styles.zoneItem, 77 + timezone === zone.zone && styles.zoneItemActive, 78 + ]} 79 + onPress={() => onSelectTimezone(zone.zone)} 80 + > 81 + <Text 82 + style={[ 83 + styles.zoneText, 84 + timezone === zone.zone && styles.zoneTextActive, 85 + ]} 86 + > 87 + {zone.zone.replace(/_/g, " ")} 88 + </Text> 89 + <Text style={styles.zoneRegion}>{zone.region}</Text> 90 + </Pressable> 91 + ))} 92 + </ScrollView> 93 + 94 + </SafeAreaView> 95 + </Modal> 96 + ); 97 + } 98 + 99 + const createStyles = (colors: ExtendedThemeColors) => 100 + StyleSheet.create({ 101 + modalContainer: { 102 + flex: 1, 103 + backgroundColor: colors.background, 104 + }, 105 + modalHeader: { 106 + flexDirection: "row", 107 + alignItems: "center", 108 + justifyContent: "space-between", 109 + paddingHorizontal: spacing.lg, 110 + paddingVertical: spacing.md, 111 + borderBottomWidth: 1, 112 + borderBottomColor: colors.border, 113 + }, 114 + modalTitle: { 115 + fontSize: 18, 116 + fontWeight: "600", 117 + color: colors.text, 118 + }, 119 + modalCloseText: { 120 + color: colors.primary, 121 + fontSize: 16, 122 + fontWeight: "500", 123 + }, 124 + searchInput: { 125 + marginHorizontal: spacing.lg, 126 + marginVertical: spacing.md, 127 + }, 128 + modalScroll: { 129 + flex: 1, 130 + paddingHorizontal: spacing.lg, 131 + }, 132 + zoneItem: { 133 + paddingVertical: spacing.md, 134 + paddingHorizontal: spacing.md, 135 + borderRadius: borderRadius.md, 136 + marginBottom: spacing.xs, 137 + }, 138 + zoneItemActive: { 139 + backgroundColor: colors.card, 140 + }, 141 + zoneText: { 142 + fontSize: 16, 143 + color: colors.text, 144 + fontWeight: "500", 145 + }, 146 + zoneTextActive: { 147 + color: colors.primary, 148 + }, 149 + zoneRegion: { 150 + fontSize: 12, 151 + color: colors.textMuted, 152 + marginTop: spacing.xs / 2, 153 + }, 154 + });
+5
apps/mobile/components/settings/index.ts
··· 1 + export { AccountCard } from "./AccountCard"; 2 + export { DeleteAccountModal } from "./DeleteAccountModal"; 3 + export { SettingsHeader } from "./SettingsHeader"; 4 + export { TimeRegionCard } from "./TimeRegionCard"; 5 + export { TimezoneModal } from "./TimezoneModal";
+80
apps/mobile/components/settings/timezones.ts
··· 1 + export const TIMEZONE_GROUPS = [ 2 + { region: "UTC", zones: ["UTC"] }, 3 + { 4 + region: "Americas", 5 + zones: [ 6 + "America/New_York", 7 + "America/Chicago", 8 + "America/Denver", 9 + "America/Los_Angeles", 10 + "America/Toronto", 11 + "America/Vancouver", 12 + "America/Mexico_City", 13 + "America/Sao_Paulo", 14 + "America/Buenos_Aires", 15 + ], 16 + }, 17 + { 18 + region: "Europe", 19 + zones: [ 20 + "Europe/London", 21 + "Europe/Paris", 22 + "Europe/Berlin", 23 + "Europe/Rome", 24 + "Europe/Madrid", 25 + "Europe/Amsterdam", 26 + "Europe/Zurich", 27 + "Europe/Stockholm", 28 + "Europe/Oslo", 29 + "Europe/Copenhagen", 30 + "Europe/Helsinki", 31 + "Europe/Warsaw", 32 + "Europe/Prague", 33 + "Europe/Vienna", 34 + "Europe/Budapest", 35 + "Europe/Moscow", 36 + "Europe/Istanbul", 37 + ], 38 + }, 39 + { 40 + region: "Asia & Pacific", 41 + zones: [ 42 + "Asia/Tokyo", 43 + "Asia/Seoul", 44 + "Asia/Shanghai", 45 + "Asia/Hong_Kong", 46 + "Asia/Singapore", 47 + "Asia/Taipei", 48 + "Asia/Manila", 49 + "Asia/Bangkok", 50 + "Asia/Jakarta", 51 + "Asia/Kuala_Lumpur", 52 + "Asia/Ho_Chi_Minh", 53 + "Asia/Dubai", 54 + "Asia/Mumbai", 55 + "Asia/Kolkata", 56 + "Asia/Dhaka", 57 + "Asia/Karachi", 58 + "Pacific/Auckland", 59 + "Pacific/Sydney", 60 + "Pacific/Melbourne", 61 + "Pacific/Perth", 62 + ], 63 + }, 64 + { 65 + region: "Middle East & Africa", 66 + zones: [ 67 + "Africa/Cairo", 68 + "Africa/Johannesburg", 69 + "Africa/Lagos", 70 + "Africa/Nairobi", 71 + "Asia/Jerusalem", 72 + "Asia/Riyadh", 73 + "Asia/Tehran", 74 + ], 75 + }, 76 + ]; 77 + 78 + export const ALL_ZONES = TIMEZONE_GROUPS.flatMap((group) => 79 + group.zones.map((zone) => ({ zone, region: group.region })), 80 + );
+2 -2
apps/mobile/components/ui/Switch.tsx
··· 31 31 disabled={disabled} 32 32 style={[ 33 33 styles.container, 34 - { backgroundColor: value ? colors.tertiary : colors.surfaceContainerHigh }, 34 + { backgroundColor: value ? colors.primary : colors.surfaceContainerHigh }, 35 35 disabled && styles.disabled, 36 36 ]} 37 37 > ··· 39 39 style={[ 40 40 styles.thumb, 41 41 translateX, 42 - { backgroundColor: colors.onTertiary }, 42 + { backgroundColor: value ? colors.onPrimary : colors.onSurfaceVariant }, 43 43 ]} 44 44 /> 45 45 </Pressable>
+250
apps/mobile/lib/onboarding-import.ts
··· 1 + import type { NormalizedImportItemDto } from "@opnshelf/api"; 2 + import Papa from "papaparse"; 3 + 4 + export type CsvParseError = { row: number; message: string }; 5 + 6 + export type ImportProgressUpdate = { 7 + totalItems: number; 8 + processedItems: number; 9 + currentBatch: number; 10 + totalBatches: number; 11 + imported: number; 12 + skipped: number; 13 + failed: number; 14 + }; 15 + 16 + const MAX_BATCH_SIZE = 25; 17 + const CSV_HEADERS = [ 18 + "watched_at", 19 + "action", 20 + "type", 21 + "tmdb_id", 22 + "season_number", 23 + "episode_number", 24 + ] as const; 25 + 26 + export async function runImportInChunks( 27 + items: NormalizedImportItemDto[], 28 + importMutate: (payload: { 29 + body: { items: NormalizedImportItemDto[] }; 30 + }) => Promise<{ 31 + imported: number; 32 + skipped: number; 33 + failed: number; 34 + errors: Array<{ message: string }>; 35 + }>, 36 + onProgress?: (update: ImportProgressUpdate) => void, 37 + ) { 38 + let imported = 0; 39 + let skipped = 0; 40 + let failed = 0; 41 + const errors: string[] = []; 42 + const totalItems = items.length; 43 + const totalBatches = Math.ceil(totalItems / MAX_BATCH_SIZE); 44 + 45 + onProgress?.({ 46 + totalItems, 47 + processedItems: 0, 48 + currentBatch: 0, 49 + totalBatches, 50 + imported, 51 + skipped, 52 + failed, 53 + }); 54 + 55 + for (let start = 0; start < totalItems; start += MAX_BATCH_SIZE) { 56 + const currentBatch = Math.floor(start / MAX_BATCH_SIZE) + 1; 57 + const chunk = items.slice(start, start + MAX_BATCH_SIZE); 58 + 59 + onProgress?.({ 60 + totalItems, 61 + processedItems: start, 62 + currentBatch, 63 + totalBatches, 64 + imported, 65 + skipped, 66 + failed, 67 + }); 68 + 69 + const result = await importMutate({ body: { items: chunk } }); 70 + imported += result.imported; 71 + skipped += result.skipped; 72 + failed += result.failed; 73 + errors.push(...result.errors.map((error) => error.message)); 74 + 75 + onProgress?.({ 76 + totalItems, 77 + processedItems: Math.min(start + chunk.length, totalItems), 78 + currentBatch, 79 + totalBatches, 80 + imported, 81 + skipped, 82 + failed, 83 + }); 84 + } 85 + 86 + return { 87 + imported, 88 + skipped, 89 + failed, 90 + errors, 91 + }; 92 + } 93 + 94 + export async function parseCsvText(csvText: string): Promise<{ 95 + items: NormalizedImportItemDto[]; 96 + errors: CsvParseError[]; 97 + }> { 98 + return new Promise((resolve, reject) => { 99 + Papa.parse<Record<string, string>>(csvText, { 100 + header: true, 101 + skipEmptyLines: true, 102 + complete: (results) => { 103 + const items: NormalizedImportItemDto[] = []; 104 + const errors: CsvParseError[] = []; 105 + const headers = (results.meta.fields ?? []).map((header) => 106 + header.trim(), 107 + ); 108 + 109 + for (const expectedHeader of CSV_HEADERS) { 110 + if (!headers.includes(expectedHeader)) { 111 + errors.push({ 112 + row: 1, 113 + message: `Missing required header: ${expectedHeader}`, 114 + }); 115 + } 116 + } 117 + 118 + if (errors.length > 0) { 119 + resolve({ items, errors }); 120 + return; 121 + } 122 + 123 + for (let rowIndex = 0; rowIndex < results.data.length; rowIndex++) { 124 + const row = results.data[rowIndex] ?? {}; 125 + const normalized = normalizeCsvRow(row, rowIndex + 2); 126 + if (normalized.item) { 127 + items.push(normalized.item); 128 + } else if (normalized.error) { 129 + errors.push(normalized.error); 130 + } 131 + } 132 + 133 + resolve({ items, errors }); 134 + }, 135 + error: (error: Error) => { 136 + reject(error); 137 + }, 138 + }); 139 + }); 140 + } 141 + 142 + function normalizeCsvRow( 143 + row: Record<string, string>, 144 + rowNumber: number, 145 + ): { item?: NormalizedImportItemDto; error?: CsvParseError } { 146 + const type = getCsvValue(row, "type").toLowerCase(); 147 + const watchedAtRaw = getCsvValue(row, "watched_at"); 148 + const watchedAt = Number.isNaN(Date.parse(watchedAtRaw)) 149 + ? "" 150 + : new Date(watchedAtRaw).toISOString(); 151 + const actionRaw = getCsvValue(row, "action").toLowerCase(); 152 + const action = actionRaw || "watch"; 153 + 154 + if (!["watch", "scrobble", "checkin"].includes(action)) { 155 + return { 156 + error: { 157 + row: rowNumber, 158 + message: `Row ${rowNumber}: unsupported action "${actionRaw || "unknown"}"`, 159 + }, 160 + }; 161 + } 162 + 163 + if (!watchedAt) { 164 + return { 165 + error: { 166 + row: rowNumber, 167 + message: `Row ${rowNumber}: invalid watched_at`, 168 + }, 169 + }; 170 + } 171 + 172 + if (type === "movie") { 173 + const movieTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 174 + if (!Number.isInteger(movieTmdbId) || movieTmdbId < 1) { 175 + return { 176 + error: { 177 + row: rowNumber, 178 + message: `Row ${rowNumber}: missing movie TMDB id`, 179 + }, 180 + }; 181 + } 182 + 183 + return { 184 + item: { 185 + type: "movie", 186 + movieTmdbId, 187 + action: action as "watch" | "scrobble" | "checkin", 188 + watchedAt, 189 + }, 190 + }; 191 + } 192 + 193 + if (type === "episode") { 194 + const showTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 195 + const seasonNumber = Number.parseInt(getCsvValue(row, "season_number"), 10); 196 + const episodeNumber = Number.parseInt( 197 + getCsvValue(row, "episode_number"), 198 + 10, 199 + ); 200 + 201 + if (!Number.isInteger(showTmdbId) || showTmdbId < 1) { 202 + return { 203 + error: { 204 + row: rowNumber, 205 + message: `Row ${rowNumber}: missing show TMDB id`, 206 + }, 207 + }; 208 + } 209 + 210 + if ( 211 + !Number.isInteger(seasonNumber) || 212 + seasonNumber < 0 || 213 + !Number.isInteger(episodeNumber) || 214 + episodeNumber < 1 215 + ) { 216 + return { 217 + error: { 218 + row: rowNumber, 219 + message: `Row ${rowNumber}: invalid season/episode values`, 220 + }, 221 + }; 222 + } 223 + 224 + return { 225 + item: { 226 + type: "episode", 227 + showTmdbId, 228 + seasonNumber, 229 + episodeNumber, 230 + action: action as "watch" | "scrobble" | "checkin", 231 + watchedAt, 232 + }, 233 + }; 234 + } 235 + 236 + return { 237 + error: { 238 + row: rowNumber, 239 + message: `Row ${rowNumber}: unsupported type "${type || "unknown"}"`, 240 + }, 241 + }; 242 + } 243 + 244 + function getCsvValue(row: Record<string, string>, key: string): string { 245 + const value = row[key]; 246 + if (typeof value === "string" && value.trim()) { 247 + return value.trim(); 248 + } 249 + return ""; 250 + }
+3
apps/mobile/package.json
··· 30 30 "expo-constants": "~18.0.13", 31 31 "expo-dev-client": "~6.0.20", 32 32 "expo-device": "~8.0.10", 33 + "expo-document-picker": "^55.0.8", 33 34 "expo-file-system": "~19.0.21", 34 35 "expo-font": "~14.0.11", 35 36 "expo-haptics": "~15.0.8", ··· 45 46 "expo-system-ui": "~6.0.9", 46 47 "expo-web-browser": "~15.0.10", 47 48 "lucide-react-native": "^0.563.0", 49 + "papaparse": "^5.5.3", 48 50 "posthog-react-native": "^4.36.1", 49 51 "react": "19.1.0", 50 52 "react-dom": "19.1.0", ··· 62 64 }, 63 65 "devDependencies": { 64 66 "@biomejs/biome": "2.2.4", 67 + "@types/papaparse": "^5.5.2", 65 68 "@types/react": "~19.1.0", 66 69 "typescript": "~5.9.2" 67 70 },
+1 -1
apps/web/src/routes/__root.tsx
··· 132 132 } 133 133 134 134 if (!user.needsOnboarding && pathname === "/onboarding") { 135 - navigate({ to: "/profile/shelf", replace: true }); 135 + navigate({ to: "/", replace: true }); 136 136 } 137 137 }, [location.pathname, navigate, user]); 138 138
+1 -1
apps/web/src/routes/auth/complete.tsx
··· 62 62 } 63 63 } else { 64 64 navigate({ 65 - to: user?.needsOnboarding ? "/onboarding" : "/profile/shelf", 65 + to: user?.needsOnboarding ? "/onboarding" : "/", 66 66 }); 67 67 } 68 68 } catch (error) {
+239 -50
apps/web/src/routes/index.tsx
··· 8 8 import { createFileRoute, Link } from "@tanstack/react-router"; 9 9 import { 10 10 CalendarRange, 11 + Clock3, 12 + Database, 11 13 Film, 12 14 LayoutDashboard, 13 15 ListChecks, 16 + LogIn, 14 17 Search, 18 + ShieldCheck, 19 + Tv, 15 20 } from "lucide-react"; 16 21 import { useMemo, useState } from "react"; 17 22 import { CreateListDialog } from "@/components/CreateListDialog"; ··· 29 34 30 35 export const Route = createFileRoute("/")({ 31 36 head: () => ({ 32 - meta: [{ title: "OpnShelf" }], 37 + meta: [ 38 + { title: "Track Movies and Shows | OpnShelf" }, 39 + { 40 + name: "description", 41 + content: 42 + "Track movies and shows at movie, season, and episode level with watch history, lists, and AT Protocol account portability.", 43 + }, 44 + ], 33 45 }), 34 46 component: HomePage, 35 47 }); ··· 67 79 } 68 80 69 81 function LandingHomePage() { 82 + const featureCards = [ 83 + { 84 + icon: Tv, 85 + title: "Movie, show, season, episode", 86 + description: 87 + "Track at exactly the level you want, from full-series completion down to single episodes.", 88 + }, 89 + { 90 + icon: Clock3, 91 + title: "Full watch history", 92 + description: 93 + "Log rewatches, keep each watch date, and build a complete timeline of your viewing activity.", 94 + }, 95 + { 96 + icon: ListChecks, 97 + title: "Powerful list workflows", 98 + description: 99 + "Use default lists and custom lists to organize favorites, queues, themes, and deep cuts.", 100 + }, 101 + { 102 + icon: Database, 103 + title: "Import your history", 104 + description: 105 + "Import history from a public Trakt username or CSV to start with real data instead of a blank slate.", 106 + }, 107 + { 108 + icon: CalendarRange, 109 + title: "Timezone-aware activity", 110 + description: 111 + "Keep your watch dates accurate with timezone and 12h/24h preferences built into your profile.", 112 + }, 113 + { 114 + icon: ShieldCheck, 115 + title: "AT Protocol identity", 116 + description: 117 + "Sign in with your Atmosphere account and keep your identity and data model portable across apps.", 118 + }, 119 + ]; 120 + 70 121 return ( 71 122 <div 72 123 className="min-h-screen" ··· 75 126 color: "var(--md-sys-color-on-background)", 76 127 }} 77 128 > 78 - <div className="container mx-auto px-4 py-16 max-w-4xl"> 79 - <div className="text-center mb-12"> 80 - <div className="flex justify-center mb-6"> 81 - <img 82 - src="/icon.png" 83 - alt="OpnShelf" 84 - className="w-24 h-24 rounded-2xl" 85 - /> 129 + <div 130 + className="border-b" 131 + style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 132 + > 133 + <div className="container mx-auto px-4 py-14 md:py-20 max-w-6xl"> 134 + <div className="grid gap-10 lg:grid-cols-[1.15fr_0.85fr] lg:items-center"> 135 + <div> 136 + <div className="flex items-center gap-3 mb-6"> 137 + <img 138 + src="/icon.png" 139 + alt="OpnShelf" 140 + className="w-14 h-14 rounded-xl" 141 + /> 142 + <span 143 + className="md-label-large px-3 py-1 rounded-full" 144 + style={{ 145 + backgroundColor: "var(--md-sys-color-secondary-container)", 146 + color: "var(--md-sys-color-on-secondary-container)", 147 + }} 148 + > 149 + Built for serious tracking 150 + </span> 151 + </div> 152 + <h1 className="md-display-medium mb-4"> 153 + Track every watch. Organize every obsession. 154 + </h1> 155 + <p 156 + className="md-title-large mb-6" 157 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 158 + > 159 + OpnShelf gives you movie and show tracking down to season and 160 + episode level, complete watch history, list organization, and a 161 + portable AT Protocol account. 162 + </p> 163 + <div className="flex flex-wrap gap-3"> 164 + <M3Button variant="filled" size="lg" asChild> 165 + <Link to="/login"> 166 + <LogIn className="w-5 h-5 mr-2" /> 167 + Sign in to start tracking 168 + </Link> 169 + </M3Button> 170 + <M3Button variant="outlined" size="lg" asChild> 171 + <Link to="/search" search={{ q: "", type: "all" }}> 172 + <Search className="w-5 h-5 mr-2" /> 173 + Browse catalog 174 + </Link> 175 + </M3Button> 176 + </div> 177 + </div> 178 + 179 + <M3Card variant="elevated" className="h-fit"> 180 + <M3CardHeader> 181 + <M3CardTitle>Why people use OpnShelf</M3CardTitle> 182 + <M3CardDescription> 183 + Built for people who want more than a single watched toggle. 184 + </M3CardDescription> 185 + </M3CardHeader> 186 + <M3CardContent> 187 + <div className="space-y-4"> 188 + <div className="flex items-start gap-3"> 189 + <span 190 + className="md-label-large w-7 h-7 rounded-full flex items-center justify-center" 191 + style={{ 192 + backgroundColor: 193 + "var(--md-sys-color-primary-container)", 194 + color: "var(--md-sys-color-on-primary-container)", 195 + }} 196 + > 197 + 1 198 + </span> 199 + <div> 200 + <p className="md-title-small">Granular tracking</p> 201 + <p 202 + className="md-body-small" 203 + style={{ 204 + color: "var(--md-sys-color-on-surface-variant)", 205 + }} 206 + > 207 + Track movies, shows, seasons, and episodes as separate 208 + items. 209 + </p> 210 + </div> 211 + </div> 212 + <div className="flex items-start gap-3"> 213 + <span 214 + className="md-label-large w-7 h-7 rounded-full flex items-center justify-center" 215 + style={{ 216 + backgroundColor: 217 + "var(--md-sys-color-primary-container)", 218 + color: "var(--md-sys-color-on-primary-container)", 219 + }} 220 + > 221 + 2 222 + </span> 223 + <div> 224 + <p className="md-title-small">Real watch history</p> 225 + <p 226 + className="md-body-small" 227 + style={{ 228 + color: "var(--md-sys-color-on-surface-variant)", 229 + }} 230 + > 231 + Keep every watch date and rewatch, not just a binary 232 + status. 233 + </p> 234 + </div> 235 + </div> 236 + <div className="flex items-start gap-3"> 237 + <span 238 + className="md-label-large w-7 h-7 rounded-full flex items-center justify-center" 239 + style={{ 240 + backgroundColor: 241 + "var(--md-sys-color-primary-container)", 242 + color: "var(--md-sys-color-on-primary-container)", 243 + }} 244 + > 245 + 3 246 + </span> 247 + <div> 248 + <p className="md-title-small">Lists that stay useful</p> 249 + <p 250 + className="md-body-small" 251 + style={{ 252 + color: "var(--md-sys-color-on-surface-variant)", 253 + }} 254 + > 255 + Combine default lists with your own lists for any 256 + workflow. 257 + </p> 258 + </div> 259 + </div> 260 + </div> 261 + </M3CardContent> 262 + </M3Card> 86 263 </div> 87 - <h1 className="md-display-large mb-4">OpnShelf</h1> 264 + </div> 265 + </div> 266 + 267 + <div className="container mx-auto px-4 py-12 max-w-6xl"> 268 + <div className="mb-6"> 269 + <h2 className="md-headline-small mb-2">Features</h2> 88 270 <p 89 - className="md-headline-small mb-8" 271 + className="md-body-large" 90 272 style={{ color: "var(--md-sys-color-on-surface-variant)" }} 91 273 > 92 - Your personal media tracker powered by AT Protocol 274 + Everything you need to track and organize what you watch. 93 275 </p> 94 - <M3Button variant="filled" size="lg" asChild> 95 - <Link to="/search" search={{ q: "", type: "all" }}> 96 - <Search className="w-5 h-5 mr-2" /> 97 - Search 98 - </Link> 99 - </M3Button> 100 276 </div> 101 277 102 - <div className="grid md:grid-cols-3 gap-6 mt-16"> 103 - <M3Card variant="elevated"> 104 - <M3CardHeader> 105 - <M3CardTitle>Track Your Media</M3CardTitle> 106 - </M3CardHeader> 107 - <M3CardContent> 108 - <M3CardDescription> 109 - Keep track of movies, shows, and games you&apos;ve watched and 110 - played 111 - </M3CardDescription> 112 - </M3CardContent> 113 - </M3Card> 114 - <M3Card variant="elevated"> 115 - <M3CardHeader> 116 - <M3CardTitle>Own Your Data</M3CardTitle> 117 - </M3CardHeader> 118 - <M3CardContent> 119 - <M3CardDescription> 120 - Built on AT Protocol - your data belongs to you 121 - </M3CardDescription> 122 - </M3CardContent> 123 - </M3Card> 124 - <M3Card variant="elevated"> 125 - <M3CardHeader> 126 - <M3CardTitle>Discover & Share</M3CardTitle> 127 - </M3CardHeader> 128 - <M3CardContent> 129 - <M3CardDescription> 130 - See what others are watching and share your favorites 131 - </M3CardDescription> 132 - </M3CardContent> 133 - </M3Card> 278 + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> 279 + {featureCards.map((card) => { 280 + const Icon = card.icon; 281 + 282 + return ( 283 + <M3Card key={card.title} variant="elevated"> 284 + <M3CardHeader> 285 + <M3CardTitle className="flex items-center gap-2"> 286 + <Icon className="w-5 h-5" /> 287 + {card.title} 288 + </M3CardTitle> 289 + </M3CardHeader> 290 + <M3CardContent> 291 + <M3CardDescription>{card.description}</M3CardDescription> 292 + </M3CardContent> 293 + </M3Card> 294 + ); 295 + })} 134 296 </div> 297 + 298 + <M3Card variant="elevated" className="mt-8"> 299 + <M3CardHeader> 300 + <M3CardTitle className="flex items-center gap-2"> 301 + <Film className="w-5 h-5" /> 302 + Explore without signing in 303 + </M3CardTitle> 304 + <M3CardDescription> 305 + Explore movies and shows right away, then sign in when you are 306 + ready to track. 307 + </M3CardDescription> 308 + </M3CardHeader> 309 + <M3CardContent className="flex flex-wrap gap-3"> 310 + <M3Button variant="filled-tonal" asChild> 311 + <Link to="/search" search={{ q: "", type: "all" }}> 312 + <Search className="w-4 h-4 mr-2" /> 313 + Start searching 314 + </Link> 315 + </M3Button> 316 + <M3Button variant="filled" asChild> 317 + <Link to="/login"> 318 + <LogIn className="w-4 h-4 mr-2" /> 319 + Unlock full tracking 320 + </Link> 321 + </M3Button> 322 + </M3CardContent> 323 + </M3Card> 135 324 </div> 136 325 </div> 137 326 );
+27 -3
apps/web/src/routes/onboarding.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 + listsControllerGetUserListsOptions, 4 + shelfControllerGetUserShelfOptions, 3 5 usersControllerCompleteOnboardingMutation, 4 6 usersControllerFetchMyTraktPublicHistoryMutation, 5 7 usersControllerGetMySettingsOptions, ··· 128 130 } 129 131 130 132 if (needsShelfRedirect) { 131 - navigate({ to: "/profile/shelf" }); 133 + navigate({ to: "/" }); 132 134 } 133 135 }, [navigate, needsAuthRedirect, needsShelfRedirect]); 134 136 ··· 150 152 if (isAuthLoading) { 151 153 return ( 152 154 <div className="flex-1 flex items-center justify-center"> 153 - <div className="w-8 h-8 border-4 border-t-transparent rounded-full animate-spin border-[var(--md-sys-color-primary)]" /> 155 + <div className="w-8 h-8 border-4 border-t-transparent rounded-full animate-spin border-(--md-sys-color-primary)" /> 154 156 </div> 155 157 ); 156 158 } ··· 193 195 }, 194 196 ); 195 197 196 - navigate({ to: "/profile/shelf", replace: true }); 198 + await queryClient.invalidateQueries({ 199 + predicate: (query) => { 200 + const key = query.queryKey[0] as { _id?: string } | undefined; 201 + return ( 202 + key?._id === "shelfControllerGetUserShelf" || 203 + key?._id === "listsControllerGetUserLists" 204 + ); 205 + }, 206 + }); 207 + 208 + if (user?.did) { 209 + await Promise.all([ 210 + queryClient.prefetchQuery( 211 + shelfControllerGetUserShelfOptions({ 212 + path: { userDid: user.did }, 213 + query: { limit: 6 }, 214 + }), 215 + ), 216 + queryClient.prefetchQuery(listsControllerGetUserListsOptions()), 217 + ]); 218 + } 219 + 220 + navigate({ to: "/", replace: true }); 197 221 void queryClient.invalidateQueries({ 198 222 queryKey: authControllerMeOptions().queryKey, 199 223 });
+266 -465
pnpm-lock.yaml
··· 62 62 expo-device: 63 63 specifier: ~8.0.10 64 64 version: 8.0.10(expo@54.0.33) 65 + expo-document-picker: 66 + specifier: ^55.0.8 67 + version: 55.0.8(expo@54.0.33) 65 68 expo-file-system: 66 69 specifier: ~19.0.21 67 70 version: 19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) ··· 107 110 lucide-react-native: 108 111 specifier: ^0.563.0 109 112 version: 0.563.0(react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 113 + papaparse: 114 + specifier: ^5.5.3 115 + version: 5.5.3 110 116 posthog-react-native: 111 117 specifier: ^4.36.1 112 118 version: 4.36.1(@react-navigation/native@7.1.28(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(expo-application@7.0.8(expo@54.0.33))(expo-device@8.0.10(expo@54.0.33))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)))(expo-localization@17.0.8(expo@54.0.33)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) ··· 153 159 '@biomejs/biome': 154 160 specifier: 2.2.4 155 161 version: 2.2.4 162 + '@types/papaparse': 163 + specifier: ^5.5.2 164 + version: 5.5.2 156 165 '@types/react': 157 166 specifier: ~19.1.0 158 167 version: 19.1.17 ··· 197 206 version: 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 198 207 '@tanstack/react-start': 199 208 specifier: ^1.132.0 200 - version: 1.157.16(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) 209 + version: 1.157.16(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) 201 210 class-variance-authority: 202 211 specifier: ^0.7.1 203 212 version: 0.7.1 ··· 212 221 version: 0.561.0(react@19.2.4) 213 222 nitro: 214 223 specifier: npm:nitro-nightly@latest 215 - version: nitro-nightly@3.0.1-20260127-164246-ef01b092(@electric-sql/pglite@0.3.15)(chokidar@5.0.0)(lru-cache@11.2.5)(mysql2@3.15.3)(rollup@4.57.0)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) 224 + version: nitro-nightly@3.0.1-20260227-181935-bfbb207c(@electric-sql/pglite@0.3.15)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(lru-cache@11.2.5)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) 216 225 papaparse: 217 226 specifier: ^5.5.3 218 227 version: 5.5.3 ··· 2286 2295 resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} 2287 2296 engines: {node: '>=14'} 2288 2297 2289 - '@oxc-minify/binding-android-arm-eabi@0.111.0': 2290 - resolution: {integrity: sha512-MkDWMUkYjfzcIA/StNBN/mi17WjdKnt7Fa2ESOND3b333dLCfaiS3zy+p7IYvAPV+osaK8DtcmUVlstX6l9Smw==} 2291 - engines: {node: ^20.19.0 || >=22.12.0} 2292 - cpu: [arm] 2293 - os: [android] 2294 - 2295 - '@oxc-minify/binding-android-arm64@0.111.0': 2296 - resolution: {integrity: sha512-KzeDAiB6sybY7+1dK6qJu7QDhWQuYgeh7UZiPQHn+jWyWgdnobhYCCUP46XfMXlP1u0/wabFKmvV6iLNLfdX+g==} 2297 - engines: {node: ^20.19.0 || >=22.12.0} 2298 - cpu: [arm64] 2299 - os: [android] 2300 - 2301 - '@oxc-minify/binding-darwin-arm64@0.111.0': 2302 - resolution: {integrity: sha512-1WJMKAWH7Zxue0oNtJ12kcP85g//d8g/sTmbYMZ6TaFGaxym5KwgtdCan21k7V9NUaPajffKzd8oqTcBHSo/SA==} 2303 - engines: {node: ^20.19.0 || >=22.12.0} 2304 - cpu: [arm64] 2305 - os: [darwin] 2306 - 2307 - '@oxc-minify/binding-darwin-x64@0.111.0': 2308 - resolution: {integrity: sha512-HVcHVkBnGf4dN44bkw/W+ZkBWm69mCo3mDdzY70l23fkSpVIuFIog9zKT97pvA4MMFnKM8fm0de7/kAnlJW9+Q==} 2309 - engines: {node: ^20.19.0 || >=22.12.0} 2310 - cpu: [x64] 2311 - os: [darwin] 2312 - 2313 - '@oxc-minify/binding-freebsd-x64@0.111.0': 2314 - resolution: {integrity: sha512-4V74yRfYCrC50QWYIFRkUKLABfTY4xI1HvSq2+9iqDuTGjADIox7Y/4XxDP7JKuF1zzuETiODZBQck/rHC7pug==} 2315 - engines: {node: ^20.19.0 || >=22.12.0} 2316 - cpu: [x64] 2317 - os: [freebsd] 2318 - 2319 - '@oxc-minify/binding-linux-arm-gnueabihf@0.111.0': 2320 - resolution: {integrity: sha512-kDjfiayel9TQq/da673LF9OZcAVKVVFlSf88x1zARyZpSxMOacHVDRX7Xs+BwoIMLsfPieHNzClqlnc9tnubdw==} 2321 - engines: {node: ^20.19.0 || >=22.12.0} 2322 - cpu: [arm] 2323 - os: [linux] 2324 - 2325 - '@oxc-minify/binding-linux-arm-musleabihf@0.111.0': 2326 - resolution: {integrity: sha512-rH97TIfhDSJbgJIWbNRYJqO3jxXNZ05drDyHfXibq9PuC6NJPQtarUtXeKHtzVAZzs5N9uLyv2ioLe6xyjFKqA==} 2327 - engines: {node: ^20.19.0 || >=22.12.0} 2328 - cpu: [arm] 2329 - os: [linux] 2330 - 2331 - '@oxc-minify/binding-linux-arm64-gnu@0.111.0': 2332 - resolution: {integrity: sha512-ljCl7ONCSgrLd9mx08Kiz196zar/YonzAnQI+XsW9+Gad1Mm8qIcBnQL7wo6rJSVIehFnDo0AIGNXkXTVPl9eQ==} 2333 - engines: {node: ^20.19.0 || >=22.12.0} 2334 - cpu: [arm64] 2335 - os: [linux] 2336 - libc: [glibc] 2337 - 2338 - '@oxc-minify/binding-linux-arm64-musl@0.111.0': 2339 - resolution: {integrity: sha512-2uLSY9VIS2ALoWjq1S36L0J5tABMTWTHY8TkTk5LcctCq80xIFkdia5cRv+mbxb7GiEFMxh8dqgk9t5t0pms9A==} 2340 - engines: {node: ^20.19.0 || >=22.12.0} 2341 - cpu: [arm64] 2342 - os: [linux] 2343 - libc: [musl] 2344 - 2345 - '@oxc-minify/binding-linux-ppc64-gnu@0.111.0': 2346 - resolution: {integrity: sha512-DVWUVhPwiNtdDTpXccOTe/L8BPIjZMnceua9eQBK7qknEMobP1IFLp2IohjPYdCUsmbat7saHlCqXKeVg4Gt3Q==} 2347 - engines: {node: ^20.19.0 || >=22.12.0} 2348 - cpu: [ppc64] 2349 - os: [linux] 2350 - libc: [glibc] 2351 - 2352 - '@oxc-minify/binding-linux-riscv64-gnu@0.111.0': 2353 - resolution: {integrity: sha512-M0iAuPkJ3jKqQzuub6JLP80ftYRbwE1fCxLvHwxpB98rlNlHjGG/+R9dMR55/aZhRPzBqQE0fFfOFUsnsVs5gg==} 2354 - engines: {node: ^20.19.0 || >=22.12.0} 2355 - cpu: [riscv64] 2356 - os: [linux] 2357 - libc: [glibc] 2358 - 2359 - '@oxc-minify/binding-linux-riscv64-musl@0.111.0': 2360 - resolution: {integrity: sha512-n2khk4qOfmVLbjr5xXL/8YgvkTRe8bV/D8/dT6BWOqknwfweWtXumpcD/CVFC1majM+HoMDetuPbgm4BTcqAyQ==} 2361 - engines: {node: ^20.19.0 || >=22.12.0} 2362 - cpu: [riscv64] 2363 - os: [linux] 2364 - libc: [musl] 2365 - 2366 - '@oxc-minify/binding-linux-s390x-gnu@0.111.0': 2367 - resolution: {integrity: sha512-CFCM6d1RkWr8Z4/w6sL8CuGN6ruzm0gAD9FIyRmi6xDhCUyDitpnhVJ5WXkuREpSzVFiBY8qsZ9I2djtsuTo5A==} 2368 - engines: {node: ^20.19.0 || >=22.12.0} 2369 - cpu: [s390x] 2370 - os: [linux] 2371 - libc: [glibc] 2372 - 2373 - '@oxc-minify/binding-linux-x64-gnu@0.111.0': 2374 - resolution: {integrity: sha512-/e/PAlEoJ8VFJvmgAiQZloUUNyPbokl+hKIEfcV2GWWViNrs/1hFAnommeUoaF1+SSngTjvZ4zTFXcQfY3AYWA==} 2375 - engines: {node: ^20.19.0 || >=22.12.0} 2376 - cpu: [x64] 2377 - os: [linux] 2378 - libc: [glibc] 2379 - 2380 - '@oxc-minify/binding-linux-x64-musl@0.111.0': 2381 - resolution: {integrity: sha512-cZp0X4P6RbZZ226pRWCntzGzSTAQqaGM5Q/aw+hbHswlx8eXoVvdy3krdloGxvQUU1DCpJY2QGNvW0xWVSdp+A==} 2382 - engines: {node: ^20.19.0 || >=22.12.0} 2383 - cpu: [x64] 2384 - os: [linux] 2385 - libc: [musl] 2386 - 2387 - '@oxc-minify/binding-openharmony-arm64@0.111.0': 2388 - resolution: {integrity: sha512-HY9OKzZ2GW7o/YE5fwp2SDk81H38TqkXegFS55LFJKyEsJlwiUsi2EG59KiMrMfHAwpasGBE9WSyEcm+hEDEsw==} 2389 - engines: {node: ^20.19.0 || >=22.12.0} 2390 - cpu: [arm64] 2391 - os: [openharmony] 2392 - 2393 - '@oxc-minify/binding-wasm32-wasi@0.111.0': 2394 - resolution: {integrity: sha512-4CF7xeQhlM34//Rmmog82m3SeEV0aEhAJaNl+fcsMuuq/+rfwNct/kBaf1qSPqvhuWiPQWVcXrSE38BPWW/vnw==} 2395 - engines: {node: '>=14.0.0'} 2396 - cpu: [wasm32] 2397 - 2398 - '@oxc-minify/binding-win32-arm64-msvc@0.111.0': 2399 - resolution: {integrity: sha512-loUKo//QHI61Nj3HODrQes1u+8Mx+4YsN56+QyCKWyRTQNY+V4TqAtcoOsZTVKSQFm6+8IgV9KVtc8hOpiO5Kw==} 2400 - engines: {node: ^20.19.0 || >=22.12.0} 2401 - cpu: [arm64] 2402 - os: [win32] 2403 - 2404 - '@oxc-minify/binding-win32-ia32-msvc@0.111.0': 2405 - resolution: {integrity: sha512-JopCb4BLw9UjcCsJkLP5ZJ7JjJj3oZHQzaOB6udQTkN+0kOHpySCr9BCYkrx1fA7afh6V8W88lYJc1BmM9qD7Q==} 2406 - engines: {node: ^20.19.0 || >=22.12.0} 2407 - cpu: [ia32] 2408 - os: [win32] 2409 - 2410 - '@oxc-minify/binding-win32-x64-msvc@0.111.0': 2411 - resolution: {integrity: sha512-XDbGGYuY2W5edAwd+clMul4Cw4TqZR83//XqowLXbd1sGYq1m2Zo+vXHXvl3LS0Z2xdoJRJl+dJ0GPATwLx3JQ==} 2412 - engines: {node: ^20.19.0 || >=22.12.0} 2413 - cpu: [x64] 2414 - os: [win32] 2415 - 2416 - '@oxc-transform/binding-android-arm-eabi@0.111.0': 2417 - resolution: {integrity: sha512-NdFLicvorfHYu0g2ftjVJaH7+Dz27AQUNJOq8t/ofRUoWmczOodgUCHx8C1M1htCN4ZmhS/FzfSy6yd/UngJGg==} 2418 - engines: {node: ^20.19.0 || >=22.12.0} 2419 - cpu: [arm] 2420 - os: [android] 2421 - 2422 - '@oxc-transform/binding-android-arm64@0.111.0': 2423 - resolution: {integrity: sha512-J2v9ajarD2FYlhHtjbgZUFsS2Kvi27pPxDWLGCy7i8tO60xBoozX9/ktSgbiE/QsxKaUhfv4zVKppKWUo71PmQ==} 2424 - engines: {node: ^20.19.0 || >=22.12.0} 2425 - cpu: [arm64] 2426 - os: [android] 2427 - 2428 - '@oxc-transform/binding-darwin-arm64@0.111.0': 2429 - resolution: {integrity: sha512-2UYmExxpXzmiHTldhNlosWqG9Nc4US51K0GB9RLcGlTE23WO33vVo1NVAKwxPE+KYuhffwDnRYTovTMUjzwvZA==} 2430 - engines: {node: ^20.19.0 || >=22.12.0} 2431 - cpu: [arm64] 2432 - os: [darwin] 2433 - 2434 - '@oxc-transform/binding-darwin-x64@0.111.0': 2435 - resolution: {integrity: sha512-c4YRwfLV8Pj/ToiTCbndZaHxM2BD4W3bltr/fjXZcGypEK+U2RZFDL7tIZYT/tyneAC9hCORZKDaKhLLNuzPtA==} 2436 - engines: {node: ^20.19.0 || >=22.12.0} 2437 - cpu: [x64] 2438 - os: [darwin] 2439 - 2440 - '@oxc-transform/binding-freebsd-x64@0.111.0': 2441 - resolution: {integrity: sha512-prvf32IcEuLnLZbNVomFosBu0CaZpyj3YsZ6epbOgJy8iJjfLsXBb+PrkO/NBKzjuJoJa2+u7jFKRE0KT7gSOw==} 2442 - engines: {node: ^20.19.0 || >=22.12.0} 2443 - cpu: [x64] 2444 - os: [freebsd] 2445 - 2446 - '@oxc-transform/binding-linux-arm-gnueabihf@0.111.0': 2447 - resolution: {integrity: sha512-+se3579Wp7VOk8TnTZCpT+obTAyzOw2b/UuoM0+51LtbzCSfjKxd4A+o7zRl7GyPrPZvx57KdbMOC9rWB1xNrw==} 2448 - engines: {node: ^20.19.0 || >=22.12.0} 2449 - cpu: [arm] 2450 - os: [linux] 2451 - 2452 - '@oxc-transform/binding-linux-arm-musleabihf@0.111.0': 2453 - resolution: {integrity: sha512-8faC99pStqaSDPK/vBgaagAHUeL0LcIzfeSjSiDTtvPGc3AwZIeqC1tx3CP15a6tWXjdgS/IUw4IjfD5HweBlg==} 2454 - engines: {node: ^20.19.0 || >=22.12.0} 2455 - cpu: [arm] 2456 - os: [linux] 2457 - 2458 - '@oxc-transform/binding-linux-arm64-gnu@0.111.0': 2459 - resolution: {integrity: sha512-HtfQv8j796gzI5WR/RaP6IMwFpiL0vYeDrUA1hYhlPzTHKYan/B+NlhJkKOI1v24yAl/yEnFmb0pxIxLNqBqBA==} 2460 - engines: {node: ^20.19.0 || >=22.12.0} 2461 - cpu: [arm64] 2462 - os: [linux] 2463 - libc: [glibc] 2464 - 2465 - '@oxc-transform/binding-linux-arm64-musl@0.111.0': 2466 - resolution: {integrity: sha512-ARyfcMCIxVLDgLf6FQ8Oo1/TFySpnquV+vuSb4SFQZfYDqgMklzwv0NYXxWD0aB6enElyMDs6pQJBzusEKCkOg==} 2467 - engines: {node: ^20.19.0 || >=22.12.0} 2468 - cpu: [arm64] 2469 - os: [linux] 2470 - libc: [musl] 2471 - 2472 - '@oxc-transform/binding-linux-ppc64-gnu@0.111.0': 2473 - resolution: {integrity: sha512-PKpVRrSvBNK3tv9vwxn7Fay+QWZmprPGlEqJcseBJllQc5mFMD4Q/w44chu5iR9ZLsDeSHzmNWrgMLo4J0sP2A==} 2474 - engines: {node: ^20.19.0 || >=22.12.0} 2475 - cpu: [ppc64] 2476 - os: [linux] 2477 - libc: [glibc] 2478 - 2479 - '@oxc-transform/binding-linux-riscv64-gnu@0.111.0': 2480 - resolution: {integrity: sha512-9bUml6rMgk+8GF5rvNMweFspkzSiCjqpV6HduwiUyexqfGKrmjq9IZOxxvnzkE2RGdQzP507NNDoVNYIoGQYuA==} 2481 - engines: {node: ^20.19.0 || >=22.12.0} 2482 - cpu: [riscv64] 2483 - os: [linux] 2484 - libc: [glibc] 2485 - 2486 - '@oxc-transform/binding-linux-riscv64-musl@0.111.0': 2487 - resolution: {integrity: sha512-tzGCohGxaeH6KRJjfYZd4mHCoGjCai6N+zZi1Oj+tSDMAAdyvs1dRzYb8PNUGnybCg3Te4M0jLPzWZaSmnKraQ==} 2488 - engines: {node: ^20.19.0 || >=22.12.0} 2489 - cpu: [riscv64] 2490 - os: [linux] 2491 - libc: [musl] 2492 - 2493 - '@oxc-transform/binding-linux-s390x-gnu@0.111.0': 2494 - resolution: {integrity: sha512-sRG1KIfZ0ML9ToEygm5aM/5GJeBA05uHlgW3M0Rx/DNWMJhuahLmqWuB02aWSmijndLfEKXLLXIWhvWupRG8lg==} 2495 - engines: {node: ^20.19.0 || >=22.12.0} 2496 - cpu: [s390x] 2497 - os: [linux] 2498 - libc: [glibc] 2499 - 2500 - '@oxc-transform/binding-linux-x64-gnu@0.111.0': 2501 - resolution: {integrity: sha512-T0Kmvk+OdlUdABdXlDIf3MQReMzFfC75NEI9x8jxy5pKooACEFg0k0V8gyR3gq4DzbDCfucqFQDWNvSgIopAbQ==} 2502 - engines: {node: ^20.19.0 || >=22.12.0} 2503 - cpu: [x64] 2504 - os: [linux] 2505 - libc: [glibc] 2506 - 2507 - '@oxc-transform/binding-linux-x64-musl@0.111.0': 2508 - resolution: {integrity: sha512-EgoutsP3YfqzN8a9vpc9+XLr0bmBl0dA3uOMiP77+exATCPxJBkJErGmQkqk6RtTp5XqX6q6mB45qWQyKk6+pA==} 2509 - engines: {node: ^20.19.0 || >=22.12.0} 2510 - cpu: [x64] 2511 - os: [linux] 2512 - libc: [musl] 2513 - 2514 - '@oxc-transform/binding-openharmony-arm64@0.111.0': 2515 - resolution: {integrity: sha512-d8J+ejc0j5WODbVwR/QxFaI65YMwvG0W53vcVCHwa6ja1QI5lpe7sislrefG2EFYgnY47voMRzlXab5d4gEcDw==} 2516 - engines: {node: ^20.19.0 || >=22.12.0} 2517 - cpu: [arm64] 2518 - os: [openharmony] 2519 - 2520 - '@oxc-transform/binding-wasm32-wasi@0.111.0': 2521 - resolution: {integrity: sha512-HtyIZO8IwuZgXkyb56rysLz1OLbfLhEu8A3BeuyJXzUseAj96yuxgGt3cu3QYX9AXb9pfRfA3c/fvlhsDugyTQ==} 2522 - engines: {node: '>=14.0.0'} 2523 - cpu: [wasm32] 2524 - 2525 - '@oxc-transform/binding-win32-arm64-msvc@0.111.0': 2526 - resolution: {integrity: sha512-YeP80Riptc0MkVVBnzbmoFuHVLUq278+MbwNo9sTLALmzTIJxJqN029xRZbG+Bun7aLsoZhmRnm3J5JZ1NcP5w==} 2527 - engines: {node: ^20.19.0 || >=22.12.0} 2528 - cpu: [arm64] 2529 - os: [win32] 2530 - 2531 - '@oxc-transform/binding-win32-ia32-msvc@0.111.0': 2532 - resolution: {integrity: sha512-A6ztCXpoSHt6PbvGAFqB0MLOcGG7ZJrrPXY1iB0zfOB1atLgI8oNePGxPl03XSbwpiTsFJ1oo8rj9DXcBzgT9g==} 2533 - engines: {node: ^20.19.0 || >=22.12.0} 2534 - cpu: [ia32] 2535 - os: [win32] 2536 - 2537 - '@oxc-transform/binding-win32-x64-msvc@0.111.0': 2538 - resolution: {integrity: sha512-QddKW4kBH0Wof6Y65eYCNHM4iOGmCTWLLcNYY1FGswhzmTYOUVXajNROR+iCXAOFnOF0ldtsR79SyqgyHH1Bgg==} 2539 - engines: {node: ^20.19.0 || >=22.12.0} 2540 - cpu: [x64] 2541 - os: [win32] 2298 + '@oxc-project/types@0.115.0': 2299 + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} 2542 2300 2543 2301 '@paralleldrive/cuid2@2.3.1': 2544 2302 resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} ··· 3476 3234 '@react-navigation/routers@7.5.3': 3477 3235 resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} 3478 3236 3237 + '@rolldown/binding-android-arm64@1.0.0-rc.7': 3238 + resolution: {integrity: sha512-/uadfNUaMLFFBGvcIOiq8NnlhvTZTjOyybJaJnhGxD0n9k5vZRJfTaitH5GHnbwmc6T2PC+ZpS1FQH+vXyS/UA==} 3239 + engines: {node: ^20.19.0 || >=22.12.0} 3240 + cpu: [arm64] 3241 + os: [android] 3242 + 3243 + '@rolldown/binding-darwin-arm64@1.0.0-rc.7': 3244 + resolution: {integrity: sha512-zokYr1KgRn0hRA89dmgtPj/BmKp9DxgrfAJvOEFfXa8nfYWW2nmgiYIBGpSIAJrEg7Qc/Qznovy6xYwmKh0M8g==} 3245 + engines: {node: ^20.19.0 || >=22.12.0} 3246 + cpu: [arm64] 3247 + os: [darwin] 3248 + 3249 + '@rolldown/binding-darwin-x64@1.0.0-rc.7': 3250 + resolution: {integrity: sha512-eZFjbmrapCBVgMmuLALH3pmQQQStHFuRhsFceJHk6KISW8CkI2e9OPLp9V4qXksrySQcD8XM8fpvGLs5l5C7LQ==} 3251 + engines: {node: ^20.19.0 || >=22.12.0} 3252 + cpu: [x64] 3253 + os: [darwin] 3254 + 3255 + '@rolldown/binding-freebsd-x64@1.0.0-rc.7': 3256 + resolution: {integrity: sha512-xjMrh8Dmu2DNwdY6DZsrF6YPGeesc3PaTlkh8v9cqmkSCNeTxnhX3ErhVnuv1j3n8t2IuuhQIwM9eZDINNEt5Q==} 3257 + engines: {node: ^20.19.0 || >=22.12.0} 3258 + cpu: [x64] 3259 + os: [freebsd] 3260 + 3261 + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.7': 3262 + resolution: {integrity: sha512-mOvftrHiXg4/xFdxJY3T9Wl1/zDAOSlMN8z9an2bXsCwuvv3RdyhYbSMZDuDO52S04w9z7+cBd90lvQSPTAQtw==} 3263 + engines: {node: ^20.19.0 || >=22.12.0} 3264 + cpu: [arm] 3265 + os: [linux] 3266 + 3267 + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.7': 3268 + resolution: {integrity: sha512-TuUkeuEEPRyXMBbJ86NRhAiPNezxHW8merl3Om2HASA9Pl1rI+VZcTtsVQ6v/P0MDIFpSl0k0+tUUze9HIXyEw==} 3269 + engines: {node: ^20.19.0 || >=22.12.0} 3270 + cpu: [arm64] 3271 + os: [linux] 3272 + libc: [glibc] 3273 + 3274 + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.7': 3275 + resolution: {integrity: sha512-G43ZElEvaby+YSOgrXfBgpeQv42LdS0ivFFYQufk2tBDWeBfzE/+ob5DmO8Izbyn4Y8k6GgLF11jFDYNnmU/3w==} 3276 + engines: {node: ^20.19.0 || >=22.12.0} 3277 + cpu: [arm64] 3278 + os: [linux] 3279 + libc: [musl] 3280 + 3281 + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.7': 3282 + resolution: {integrity: sha512-Y48ShVxGE2zUTt0A0PR3grCLNxW4DWtAfe5lxf6L3uYEQujwo/LGuRogMsAtOJeYLCPTJo2i714LOdnK34cHpw==} 3283 + engines: {node: ^20.19.0 || >=22.12.0} 3284 + cpu: [ppc64] 3285 + os: [linux] 3286 + libc: [glibc] 3287 + 3288 + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.7': 3289 + resolution: {integrity: sha512-KU5DUYvX3qI8/TX6D3RA4awXi4Ge/1+M6Jqv7kRiUndpqoVGgD765xhV3Q6QvtABnYjLJenrWDl3S1B5U56ixA==} 3290 + engines: {node: ^20.19.0 || >=22.12.0} 3291 + cpu: [s390x] 3292 + os: [linux] 3293 + libc: [glibc] 3294 + 3295 + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.7': 3296 + resolution: {integrity: sha512-1THb6FdBkAEL12zvUue2bmK4W1+P+tz8Pgu5uEzq+xrtYa3iBzmmKNlyfUzCFNCqsPd8WJEQrYdLcw4iMW4AVw==} 3297 + engines: {node: ^20.19.0 || >=22.12.0} 3298 + cpu: [x64] 3299 + os: [linux] 3300 + libc: [glibc] 3301 + 3302 + '@rolldown/binding-linux-x64-musl@1.0.0-rc.7': 3303 + resolution: {integrity: sha512-12o73atFNWDgYnLyA52QEUn9AH8pHIe12W28cmqjyHt4bIEYRzMICvYVCPa2IQm6DJBvCBrEhD9K+ct4wr2hwg==} 3304 + engines: {node: ^20.19.0 || >=22.12.0} 3305 + cpu: [x64] 3306 + os: [linux] 3307 + libc: [musl] 3308 + 3309 + '@rolldown/binding-openharmony-arm64@1.0.0-rc.7': 3310 + resolution: {integrity: sha512-+uUgGwvuUCXl894MTsmTS2J0BnCZccFsmzV7y1jFxW5pTSxkuwL5agyPuDvDOztPeS6RrdqWkn7sT0jRd0ECkg==} 3311 + engines: {node: ^20.19.0 || >=22.12.0} 3312 + cpu: [arm64] 3313 + os: [openharmony] 3314 + 3315 + '@rolldown/binding-wasm32-wasi@1.0.0-rc.7': 3316 + resolution: {integrity: sha512-53p2L/NSy21UiFOqUGlC11kJDZS2Nx2GJRz1QvbkXovypA3cOHbsyZHLkV72JsLSbiEQe+kg4tndUhSiC31UEA==} 3317 + engines: {node: '>=14.0.0'} 3318 + cpu: [wasm32] 3319 + 3320 + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.7': 3321 + resolution: {integrity: sha512-K6svNRljO6QrL6VTKxwh4yThhlR9DT/tK0XpaFQMnJwwQKng+NYcVEtUkAM0WsoiZHw+Hnh3DGnn3taf/pNYGg==} 3322 + engines: {node: ^20.19.0 || >=22.12.0} 3323 + cpu: [arm64] 3324 + os: [win32] 3325 + 3326 + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.7': 3327 + resolution: {integrity: sha512-3ZJBT47VWLKVKIyvHhUSUgVwHzzZW761YAIkM3tOT+8ZTjFVp0acCM0Y2Z2j3jCl+XYi2d9y2uEWQ8H0PvvpPw==} 3328 + engines: {node: ^20.19.0 || >=22.12.0} 3329 + cpu: [x64] 3330 + os: [win32] 3331 + 3479 3332 '@rolldown/pluginutils@1.0.0-beta.40': 3480 3333 resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} 3481 3334 3482 3335 '@rolldown/pluginutils@1.0.0-beta.53': 3483 3336 resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} 3337 + 3338 + '@rolldown/pluginutils@1.0.0-rc.7': 3339 + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} 3484 3340 3485 3341 '@rollup/rollup-android-arm-eabi@4.57.0': 3486 3342 resolution: {integrity: sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==} ··· 5508 5364 peerDependencies: 5509 5365 expo: '*' 5510 5366 5367 + expo-document-picker@55.0.8: 5368 + resolution: {integrity: sha512-p6rYEQ1/h3UqGl3+hzTjv51fsNxoOVfMGSYjHX2/e3cvcy02MWWE+bpj4QEGo9MBwU4RyyIbuv/SCxGtAtG+eA==} 5369 + peerDependencies: 5370 + expo: '*' 5371 + 5511 5372 expo-file-system@19.0.21: 5512 5373 resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==} 5513 5374 peerDependencies: ··· 5887 5748 5888 5749 glob@10.5.0: 5889 5750 resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} 5751 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 5890 5752 hasBin: true 5891 5753 5892 5754 glob@13.0.0: ··· 5895 5757 5896 5758 glob@7.2.3: 5897 5759 resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} 5898 - deprecated: Glob versions prior to v9 are no longer supported 5760 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 5899 5761 5900 5762 global-dirs@0.1.1: 5901 5763 resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} ··· 5931 5793 crossws: 5932 5794 optional: true 5933 5795 5796 + h3@2.0.1-rc.14: 5797 + resolution: {integrity: sha512-163qbGmTr/9rqQRNuqMqtgXnOUAkE4KTdauiC9y0E5iG1I65kte9NyfWvZw5RTDMt6eY+DtyoNzrQ9wA2BfvGQ==} 5798 + engines: {node: '>=20.11.1'} 5799 + hasBin: true 5800 + peerDependencies: 5801 + crossws: ^0.4.1 5802 + peerDependenciesMeta: 5803 + crossws: 5804 + optional: true 5805 + 5934 5806 handlebars@4.7.8: 5935 5807 resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} 5936 5808 engines: {node: '>=0.4.7'} ··· 5974 5846 hono@4.11.4: 5975 5847 resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} 5976 5848 engines: {node: '>=16.9.0'} 5849 + 5850 + hookable@6.0.1: 5851 + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} 5977 5852 5978 5853 hosted-git-info@7.0.2: 5979 5854 resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} ··· 6940 6815 nested-error-stacks@2.0.1: 6941 6816 resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} 6942 6817 6943 - nf3@0.3.7: 6944 - resolution: {integrity: sha512-wL73kyZbBoeTWlvQWQ0gQDZnqp+aNlUN5YIqsc3fv5V/06LAlwrwt+G7TpugFLJIai0AhrmnKJ2kgW0xprj+yQ==} 6818 + nf3@0.3.10: 6819 + resolution: {integrity: sha512-UlqmHkZiHGgSkRj17yrOXEsSu5ECvtlJ3Xm1W5WsWrTKgu9m7OjrMZh9H/ME2LcWrTlMD0/vmmNVpyBG4yRdGg==} 6945 6820 6946 - nitro-nightly@3.0.1-20260127-164246-ef01b092: 6947 - resolution: {integrity: sha512-0NILypBGQR+TyxV8FF8vhEYK/sdJztWN88OlFzDWKnJqLrJtVv/c9tPK3elo/br2ejP8eK8swtdCWo15noDjGA==} 6821 + nitro-nightly@3.0.1-20260227-181935-bfbb207c: 6822 + resolution: {integrity: sha512-J9dNAHHuZqzDra4Zr9N6+4hkRPgfrUVw++9I32BHDmwo6MkyRdvmqucWTB5T+J8G3xAMc4+XskExmUEEqxpbZw==} 6948 6823 engines: {node: ^20.19.0 || >=22.12.0} 6949 6824 hasBin: true 6950 6825 peerDependencies: 6951 - rolldown: '>=1.0.0-beta.0' 6952 - rollup: ^4 6826 + dotenv: '*' 6827 + giget: '*' 6828 + jiti: ^2.6.1 6829 + rollup: ^4.59.0 6953 6830 vite: ^7 || ^8 || >=8.0.0-0 6954 6831 xml2js: ^0.6.2 6955 6832 peerDependenciesMeta: 6956 - rolldown: 6833 + dotenv: 6834 + optional: true 6835 + giget: 6836 + optional: true 6837 + jiti: 6957 6838 optional: true 6958 6839 rollup: 6959 6840 optional: true ··· 7087 6968 ora@5.4.1: 7088 6969 resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} 7089 6970 engines: {node: '>=10'} 7090 - 7091 - oxc-minify@0.111.0: 7092 - resolution: {integrity: sha512-tooT6OU4dv8esdLxpELcBYc3R3zN+Bn0llM58RP8djBrxN57l7E5KqfTFq035kEaYl58C0fEgsOEL9G6zO6oQA==} 7093 - engines: {node: ^20.19.0 || >=22.12.0} 7094 - 7095 - oxc-transform@0.111.0: 7096 - resolution: {integrity: sha512-oa5KKSDNLHZGaiqIGAbCWXeN9IJUAz9MElWcQX90epDxdKc9Hrt/BsLj3K4gDqfAYa5dwdH+ZCFJG9hR74fiGg==} 7097 - engines: {node: ^20.19.0 || >=22.12.0} 7098 6971 7099 6972 p-finally@1.0.0: 7100 6973 resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} ··· 7842 7715 deprecated: Rimraf versions prior to v4 are no longer supported 7843 7716 hasBin: true 7844 7717 7718 + rolldown@1.0.0-rc.7: 7719 + resolution: {integrity: sha512-5X0zEeQFzDpB3MqUWQZyO2TUQqP9VnT7CqXHF2laTFRy487+b6QZyotCazOySAuZLAvplCaOVsg1tVn/Zlmwfg==} 7720 + engines: {node: ^20.19.0 || >=22.12.0} 7721 + hasBin: true 7722 + 7845 7723 rollup@4.57.0: 7846 7724 resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==} 7847 7725 engines: {node: '>=18.0.0', npm: '>=8.0.0'} ··· 8089 7967 engines: {node: '>=20.16.0'} 8090 7968 hasBin: true 8091 7969 7970 + srvx@0.11.8: 7971 + resolution: {integrity: sha512-2n9t0YnAXPJjinytvxccNgs7rOA5gmE7Wowt/8Dy2dx2fDC6sBhfBpbrCvjYKALlVukPS/Uq3QwkolKNa7P/2Q==} 7972 + engines: {node: '>=20.16.0'} 7973 + hasBin: true 7974 + 8092 7975 stack-utils@2.0.6: 8093 7976 resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} 8094 7977 engines: {node: '>=10'} ··· 8263 8146 tar@7.5.7: 8264 8147 resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} 8265 8148 engines: {node: '>=18'} 8149 + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 8266 8150 8267 8151 temp-dir@2.0.0: 8268 8152 resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} ··· 11653 11537 11654 11538 '@opentelemetry/semantic-conventions@1.40.0': {} 11655 11539 11656 - '@oxc-minify/binding-android-arm-eabi@0.111.0': 11657 - optional: true 11658 - 11659 - '@oxc-minify/binding-android-arm64@0.111.0': 11660 - optional: true 11661 - 11662 - '@oxc-minify/binding-darwin-arm64@0.111.0': 11663 - optional: true 11664 - 11665 - '@oxc-minify/binding-darwin-x64@0.111.0': 11666 - optional: true 11667 - 11668 - '@oxc-minify/binding-freebsd-x64@0.111.0': 11669 - optional: true 11670 - 11671 - '@oxc-minify/binding-linux-arm-gnueabihf@0.111.0': 11672 - optional: true 11673 - 11674 - '@oxc-minify/binding-linux-arm-musleabihf@0.111.0': 11675 - optional: true 11676 - 11677 - '@oxc-minify/binding-linux-arm64-gnu@0.111.0': 11678 - optional: true 11679 - 11680 - '@oxc-minify/binding-linux-arm64-musl@0.111.0': 11681 - optional: true 11682 - 11683 - '@oxc-minify/binding-linux-ppc64-gnu@0.111.0': 11684 - optional: true 11685 - 11686 - '@oxc-minify/binding-linux-riscv64-gnu@0.111.0': 11687 - optional: true 11688 - 11689 - '@oxc-minify/binding-linux-riscv64-musl@0.111.0': 11690 - optional: true 11691 - 11692 - '@oxc-minify/binding-linux-s390x-gnu@0.111.0': 11693 - optional: true 11694 - 11695 - '@oxc-minify/binding-linux-x64-gnu@0.111.0': 11696 - optional: true 11697 - 11698 - '@oxc-minify/binding-linux-x64-musl@0.111.0': 11699 - optional: true 11700 - 11701 - '@oxc-minify/binding-openharmony-arm64@0.111.0': 11702 - optional: true 11703 - 11704 - '@oxc-minify/binding-wasm32-wasi@0.111.0': 11705 - dependencies: 11706 - '@napi-rs/wasm-runtime': 1.1.1 11707 - optional: true 11708 - 11709 - '@oxc-minify/binding-win32-arm64-msvc@0.111.0': 11710 - optional: true 11711 - 11712 - '@oxc-minify/binding-win32-ia32-msvc@0.111.0': 11713 - optional: true 11714 - 11715 - '@oxc-minify/binding-win32-x64-msvc@0.111.0': 11716 - optional: true 11717 - 11718 - '@oxc-transform/binding-android-arm-eabi@0.111.0': 11719 - optional: true 11720 - 11721 - '@oxc-transform/binding-android-arm64@0.111.0': 11722 - optional: true 11723 - 11724 - '@oxc-transform/binding-darwin-arm64@0.111.0': 11725 - optional: true 11726 - 11727 - '@oxc-transform/binding-darwin-x64@0.111.0': 11728 - optional: true 11729 - 11730 - '@oxc-transform/binding-freebsd-x64@0.111.0': 11731 - optional: true 11732 - 11733 - '@oxc-transform/binding-linux-arm-gnueabihf@0.111.0': 11734 - optional: true 11735 - 11736 - '@oxc-transform/binding-linux-arm-musleabihf@0.111.0': 11737 - optional: true 11738 - 11739 - '@oxc-transform/binding-linux-arm64-gnu@0.111.0': 11740 - optional: true 11741 - 11742 - '@oxc-transform/binding-linux-arm64-musl@0.111.0': 11743 - optional: true 11744 - 11745 - '@oxc-transform/binding-linux-ppc64-gnu@0.111.0': 11746 - optional: true 11747 - 11748 - '@oxc-transform/binding-linux-riscv64-gnu@0.111.0': 11749 - optional: true 11750 - 11751 - '@oxc-transform/binding-linux-riscv64-musl@0.111.0': 11752 - optional: true 11753 - 11754 - '@oxc-transform/binding-linux-s390x-gnu@0.111.0': 11755 - optional: true 11756 - 11757 - '@oxc-transform/binding-linux-x64-gnu@0.111.0': 11758 - optional: true 11759 - 11760 - '@oxc-transform/binding-linux-x64-musl@0.111.0': 11761 - optional: true 11762 - 11763 - '@oxc-transform/binding-openharmony-arm64@0.111.0': 11764 - optional: true 11765 - 11766 - '@oxc-transform/binding-wasm32-wasi@0.111.0': 11767 - dependencies: 11768 - '@napi-rs/wasm-runtime': 1.1.1 11769 - optional: true 11770 - 11771 - '@oxc-transform/binding-win32-arm64-msvc@0.111.0': 11772 - optional: true 11773 - 11774 - '@oxc-transform/binding-win32-ia32-msvc@0.111.0': 11775 - optional: true 11776 - 11777 - '@oxc-transform/binding-win32-x64-msvc@0.111.0': 11778 - optional: true 11540 + '@oxc-project/types@0.115.0': {} 11779 11541 11780 11542 '@paralleldrive/cuid2@2.3.1': 11781 11543 dependencies: ··· 13047 12809 dependencies: 13048 12810 nanoid: 3.3.11 13049 12811 12812 + '@rolldown/binding-android-arm64@1.0.0-rc.7': 12813 + optional: true 12814 + 12815 + '@rolldown/binding-darwin-arm64@1.0.0-rc.7': 12816 + optional: true 12817 + 12818 + '@rolldown/binding-darwin-x64@1.0.0-rc.7': 12819 + optional: true 12820 + 12821 + '@rolldown/binding-freebsd-x64@1.0.0-rc.7': 12822 + optional: true 12823 + 12824 + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.7': 12825 + optional: true 12826 + 12827 + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.7': 12828 + optional: true 12829 + 12830 + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.7': 12831 + optional: true 12832 + 12833 + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.7': 12834 + optional: true 12835 + 12836 + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.7': 12837 + optional: true 12838 + 12839 + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.7': 12840 + optional: true 12841 + 12842 + '@rolldown/binding-linux-x64-musl@1.0.0-rc.7': 12843 + optional: true 12844 + 12845 + '@rolldown/binding-openharmony-arm64@1.0.0-rc.7': 12846 + optional: true 12847 + 12848 + '@rolldown/binding-wasm32-wasi@1.0.0-rc.7': 12849 + dependencies: 12850 + '@napi-rs/wasm-runtime': 1.1.1 12851 + optional: true 12852 + 12853 + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.7': 12854 + optional: true 12855 + 12856 + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.7': 12857 + optional: true 12858 + 13050 12859 '@rolldown/pluginutils@1.0.0-beta.40': {} 13051 12860 13052 12861 '@rolldown/pluginutils@1.0.0-beta.53': {} 12862 + 12863 + '@rolldown/pluginutils@1.0.0-rc.7': {} 13053 12864 13054 12865 '@rollup/rollup-android-arm-eabi@4.57.0': 13055 12866 optional: true ··· 13389 13200 tiny-invariant: 1.3.3 13390 13201 tiny-warning: 1.0.3 13391 13202 13392 - '@tanstack/react-start-server@1.157.16(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': 13203 + '@tanstack/react-start-server@1.157.16(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': 13393 13204 dependencies: 13394 13205 '@tanstack/history': 1.154.14 13395 13206 '@tanstack/react-router': 1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 13396 13207 '@tanstack/router-core': 1.157.16 13397 13208 '@tanstack/start-client-core': 1.157.16 13398 - '@tanstack/start-server-core': 1.157.16(crossws@0.4.4(srvx@0.10.1)) 13209 + '@tanstack/start-server-core': 1.157.16(crossws@0.4.4(srvx@0.11.8)) 13399 13210 react: 19.2.4 13400 13211 react-dom: 19.2.4(react@19.2.4) 13401 13212 transitivePeerDependencies: 13402 13213 - crossws 13403 13214 13404 - '@tanstack/react-start@1.157.16(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': 13215 + '@tanstack/react-start@1.157.16(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': 13405 13216 dependencies: 13406 13217 '@tanstack/react-router': 1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 13407 13218 '@tanstack/react-start-client': 1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 13408 - '@tanstack/react-start-server': 1.157.16(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 13219 + '@tanstack/react-start-server': 1.157.16(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 13409 13220 '@tanstack/router-utils': 1.154.7 13410 13221 '@tanstack/start-client-core': 1.157.16 13411 - '@tanstack/start-plugin-core': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) 13412 - '@tanstack/start-server-core': 1.157.16(crossws@0.4.4(srvx@0.10.1)) 13222 + '@tanstack/start-plugin-core': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) 13223 + '@tanstack/start-server-core': 1.157.16(crossws@0.4.4(srvx@0.11.8)) 13413 13224 pathe: 2.0.3 13414 13225 react: 19.2.4 13415 13226 react-dom: 19.2.4(react@19.2.4) ··· 13506 13317 13507 13318 '@tanstack/start-fn-stubs@1.154.7': {} 13508 13319 13509 - '@tanstack/start-plugin-core@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': 13320 + '@tanstack/start-plugin-core@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': 13510 13321 dependencies: 13511 13322 '@babel/code-frame': 7.27.1 13512 13323 '@babel/core': 7.28.6 ··· 13517 13328 '@tanstack/router-plugin': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) 13518 13329 '@tanstack/router-utils': 1.154.7 13519 13330 '@tanstack/start-client-core': 1.157.16 13520 - '@tanstack/start-server-core': 1.157.16(crossws@0.4.4(srvx@0.10.1)) 13331 + '@tanstack/start-server-core': 1.157.16(crossws@0.4.4(srvx@0.11.8)) 13521 13332 babel-dead-code-elimination: 1.0.12 13522 13333 cheerio: 1.2.0 13523 13334 exsolve: 1.0.8 ··· 13537 13348 - vite-plugin-solid 13538 13349 - webpack 13539 13350 13540 - '@tanstack/start-server-core@1.157.16(crossws@0.4.4(srvx@0.10.1))': 13351 + '@tanstack/start-server-core@1.157.16(crossws@0.4.4(srvx@0.11.8))': 13541 13352 dependencies: 13542 13353 '@tanstack/history': 1.154.14 13543 13354 '@tanstack/router-core': 1.157.16 13544 13355 '@tanstack/start-client-core': 1.157.16 13545 13356 '@tanstack/start-storage-context': 1.157.16 13546 - h3-v2: h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)) 13357 + h3-v2: h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.11.8)) 13547 13358 seroval: 1.5.0 13548 13359 tiny-invariant: 1.3.3 13549 13360 transitivePeerDependencies: ··· 14772 14583 shebang-command: 2.0.0 14773 14584 which: 2.0.2 14774 14585 14775 - crossws@0.4.4(srvx@0.10.1): 14586 + crossws@0.4.4(srvx@0.11.8): 14776 14587 optionalDependencies: 14777 - srvx: 0.10.1 14588 + srvx: 0.11.8 14778 14589 14779 14590 crypto-random-string@2.0.0: {} 14780 14591 ··· 15169 14980 dependencies: 15170 14981 expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15171 14982 ua-parser-js: 0.7.41 14983 + 14984 + expo-document-picker@55.0.8(expo@54.0.33): 14985 + dependencies: 14986 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15172 14987 15173 14988 expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 15174 14989 dependencies: ··· 15711 15526 15712 15527 graphmatch@1.1.0: {} 15713 15528 15714 - h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)): 15529 + h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.11.8)): 15715 15530 dependencies: 15716 15531 rou3: 0.7.12 15717 15532 srvx: 0.10.1 15718 15533 optionalDependencies: 15719 - crossws: 0.4.4(srvx@0.10.1) 15534 + crossws: 0.4.4(srvx@0.11.8) 15535 + 15536 + h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.8)): 15537 + dependencies: 15538 + rou3: 0.7.12 15539 + srvx: 0.11.8 15540 + optionalDependencies: 15541 + crossws: 0.4.4(srvx@0.11.8) 15720 15542 15721 15543 handlebars@4.7.8: 15722 15544 dependencies: ··· 15758 15580 react-is: 16.13.1 15759 15581 15760 15582 hono@4.11.4: {} 15583 + 15584 + hookable@6.0.1: {} 15761 15585 15762 15586 hosted-git-info@7.0.2: 15763 15587 dependencies: ··· 16977 16801 16978 16802 nested-error-stacks@2.0.1: {} 16979 16803 16980 - nf3@0.3.7: {} 16804 + nf3@0.3.10: {} 16981 16805 16982 - nitro-nightly@3.0.1-20260127-164246-ef01b092(@electric-sql/pglite@0.3.15)(chokidar@5.0.0)(lru-cache@11.2.5)(mysql2@3.15.3)(rollup@4.57.0)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): 16806 + nitro-nightly@3.0.1-20260227-181935-bfbb207c(@electric-sql/pglite@0.3.15)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(lru-cache@11.2.5)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): 16983 16807 dependencies: 16984 16808 consola: 3.4.2 16985 - crossws: 0.4.4(srvx@0.10.1) 16809 + crossws: 0.4.4(srvx@0.11.8) 16986 16810 db0: 0.3.4(@electric-sql/pglite@0.3.15)(mysql2@3.15.3) 16987 - h3: 2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)) 16988 - jiti: 2.6.1 16989 - nf3: 0.3.7 16811 + h3: 2.0.1-rc.14(crossws@0.4.4(srvx@0.11.8)) 16812 + hookable: 6.0.1 16813 + nf3: 0.3.10 16990 16814 ofetch: 2.0.0-alpha.3 16991 16815 ohash: 2.0.11 16992 - oxc-minify: 0.111.0 16993 - oxc-transform: 0.111.0 16994 - srvx: 0.10.1 16995 - undici: 7.19.2 16816 + rolldown: 1.0.0-rc.7 16817 + srvx: 0.11.8 16996 16818 unenv: 2.0.0-rc.24 16997 16819 unstorage: 2.0.0-alpha.5(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.15)(mysql2@3.15.3))(lru-cache@11.2.5)(ofetch@2.0.0-alpha.3) 16998 16820 optionalDependencies: 16999 - rollup: 4.57.0 16821 + dotenv: 17.2.3 16822 + giget: 2.0.0 16823 + jiti: 2.6.1 17000 16824 vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) 17001 16825 transitivePeerDependencies: 17002 16826 - '@azure/app-configuration' ··· 17156 16980 log-symbols: 4.1.0 17157 16981 strip-ansi: 6.0.1 17158 16982 wcwidth: 1.0.1 17159 - 17160 - oxc-minify@0.111.0: 17161 - optionalDependencies: 17162 - '@oxc-minify/binding-android-arm-eabi': 0.111.0 17163 - '@oxc-minify/binding-android-arm64': 0.111.0 17164 - '@oxc-minify/binding-darwin-arm64': 0.111.0 17165 - '@oxc-minify/binding-darwin-x64': 0.111.0 17166 - '@oxc-minify/binding-freebsd-x64': 0.111.0 17167 - '@oxc-minify/binding-linux-arm-gnueabihf': 0.111.0 17168 - '@oxc-minify/binding-linux-arm-musleabihf': 0.111.0 17169 - '@oxc-minify/binding-linux-arm64-gnu': 0.111.0 17170 - '@oxc-minify/binding-linux-arm64-musl': 0.111.0 17171 - '@oxc-minify/binding-linux-ppc64-gnu': 0.111.0 17172 - '@oxc-minify/binding-linux-riscv64-gnu': 0.111.0 17173 - '@oxc-minify/binding-linux-riscv64-musl': 0.111.0 17174 - '@oxc-minify/binding-linux-s390x-gnu': 0.111.0 17175 - '@oxc-minify/binding-linux-x64-gnu': 0.111.0 17176 - '@oxc-minify/binding-linux-x64-musl': 0.111.0 17177 - '@oxc-minify/binding-openharmony-arm64': 0.111.0 17178 - '@oxc-minify/binding-wasm32-wasi': 0.111.0 17179 - '@oxc-minify/binding-win32-arm64-msvc': 0.111.0 17180 - '@oxc-minify/binding-win32-ia32-msvc': 0.111.0 17181 - '@oxc-minify/binding-win32-x64-msvc': 0.111.0 17182 - 17183 - oxc-transform@0.111.0: 17184 - optionalDependencies: 17185 - '@oxc-transform/binding-android-arm-eabi': 0.111.0 17186 - '@oxc-transform/binding-android-arm64': 0.111.0 17187 - '@oxc-transform/binding-darwin-arm64': 0.111.0 17188 - '@oxc-transform/binding-darwin-x64': 0.111.0 17189 - '@oxc-transform/binding-freebsd-x64': 0.111.0 17190 - '@oxc-transform/binding-linux-arm-gnueabihf': 0.111.0 17191 - '@oxc-transform/binding-linux-arm-musleabihf': 0.111.0 17192 - '@oxc-transform/binding-linux-arm64-gnu': 0.111.0 17193 - '@oxc-transform/binding-linux-arm64-musl': 0.111.0 17194 - '@oxc-transform/binding-linux-ppc64-gnu': 0.111.0 17195 - '@oxc-transform/binding-linux-riscv64-gnu': 0.111.0 17196 - '@oxc-transform/binding-linux-riscv64-musl': 0.111.0 17197 - '@oxc-transform/binding-linux-s390x-gnu': 0.111.0 17198 - '@oxc-transform/binding-linux-x64-gnu': 0.111.0 17199 - '@oxc-transform/binding-linux-x64-musl': 0.111.0 17200 - '@oxc-transform/binding-openharmony-arm64': 0.111.0 17201 - '@oxc-transform/binding-wasm32-wasi': 0.111.0 17202 - '@oxc-transform/binding-win32-arm64-msvc': 0.111.0 17203 - '@oxc-transform/binding-win32-ia32-msvc': 0.111.0 17204 - '@oxc-transform/binding-win32-x64-msvc': 0.111.0 17205 16983 17206 16984 p-finally@1.0.0: {} 17207 16985 ··· 18061 17839 dependencies: 18062 17840 glob: 7.2.3 18063 17841 17842 + rolldown@1.0.0-rc.7: 17843 + dependencies: 17844 + '@oxc-project/types': 0.115.0 17845 + '@rolldown/pluginutils': 1.0.0-rc.7 17846 + optionalDependencies: 17847 + '@rolldown/binding-android-arm64': 1.0.0-rc.7 17848 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.7 17849 + '@rolldown/binding-darwin-x64': 1.0.0-rc.7 17850 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.7 17851 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.7 17852 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.7 17853 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.7 17854 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.7 17855 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.7 17856 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.7 17857 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.7 17858 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.7 17859 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.7 17860 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.7 17861 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.7 17862 + 18064 17863 rollup@4.57.0: 18065 17864 dependencies: 18066 17865 '@types/estree': 1.0.8 ··· 18341 18140 sqlstring@2.3.3: {} 18342 18141 18343 18142 srvx@0.10.1: {} 18143 + 18144 + srvx@0.11.8: {} 18344 18145 18345 18146 stack-utils@2.0.6: 18346 18147 dependencies:
+1
pnpm-workspace.yaml
··· 11 11 - core-js 12 12 - esbuild 13 13 - prisma 14 + - protobufjs 14 15 - unrs-resolver