chore: upgrade Expo SDK 55 → 57 and React Native 0.83 → 0.86

This commit is contained in:
2026-07-12 16:55:34 -04:00
parent 553fe98013
commit 5cf0293220
57 changed files with 2111 additions and 1569 deletions
+1 -1
View File
@@ -69,7 +69,7 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "16.2"
"deploymentTarget": "16.4"
}
}
],
+48 -50
View File
@@ -17,75 +17,73 @@
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@expo/metro-runtime": "55.0.6",
"@expo/ui": "55.0.5",
"@formatjs/intl-datetimeformat": "7.3.1",
"@formatjs/intl-getcanonicallocales": "3.2.2",
"@formatjs/intl-locale": "5.3.1",
"@formatjs/intl-numberformat": "9.3.1",
"@formatjs/intl-pluralrules": "6.3.1",
"@formatjs/intl-relativetimeformat": "12.3.1",
"@expo/metro-runtime": "~57.0.3",
"@expo/ui": "~57.0.3",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@lingui/core": "catalog:",
"@lingui/react": "catalog:",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:",
"@react-native-menu/menu": "2.0.0",
"@react-navigation/elements": "2.9.11",
"@react-navigation/native": "7.1.34",
"@sentry/react-native": "8.6.0",
"@sentry/react-native": "~7.11.0",
"@shopify/flash-list": "2.0.2",
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.95.2",
"@tabler/icons-react-native": "3.44.0",
"@tanstack/query-async-storage-persister": "5.101.2",
"@tanstack/react-form": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.95.2",
"@tanstack/react-query-persist-client": "5.101.2",
"better-auth": "catalog:",
"burnt": "0.13.0",
"expo": "55.0.8",
"expo-application": "55.0.10",
"expo-build-properties": "55.0.10",
"expo-clipboard": "55.0.9",
"expo-constants": "55.0.9",
"expo-dev-client": "55.0.18",
"expo-device": "55.0.10",
"expo-font": "55.0.4",
"expo-glass-effect": "55.0.8",
"expo-haptics": "55.0.9",
"expo-image": "55.0.6",
"expo-image-picker": "55.0.13",
"expo-linear-gradient": "55.0.9",
"expo-linking": "55.0.8",
"expo-localization": "55.0.9",
"expo-network": "55.0.9",
"expo-router": "55.0.7",
"expo-secure-store": "55.0.9",
"expo-splash-screen": "55.0.12",
"expo-status-bar": "55.0.4",
"expo-system-ui": "55.0.10",
"expo-updates": "55.0.15",
"expo-web-browser": "55.0.10",
"expo-widgets": "55.0.7",
"posthog-react-native": "4.37.6",
"expo": "^57.0.0",
"expo-application": "~57.0.0",
"expo-build-properties": "~57.0.3",
"expo-clipboard": "~57.0.0",
"expo-constants": "~57.0.3",
"expo-dev-client": "~57.0.5",
"expo-device": "~57.0.0",
"expo-font": "~57.0.0",
"expo-glass-effect": "~57.0.0",
"expo-haptics": "~57.0.0",
"expo-image": "~57.0.0",
"expo-image-picker": "~57.0.2",
"expo-linear-gradient": "~57.0.0",
"expo-linking": "~57.0.1",
"expo-localization": "~57.0.0",
"expo-network": "~57.0.0",
"expo-router": "~57.0.3",
"expo-secure-store": "~57.0.0",
"expo-splash-screen": "~57.0.2",
"expo-status-bar": "~57.0.0",
"expo-system-ui": "~57.0.0",
"expo-updates": "~57.0.6",
"expo-web-browser": "~57.0.0",
"expo-widgets": "~57.0.2",
"posthog-react-native": "4.54.4",
"react": "catalog:",
"react-dom": "catalog:",
"react-native": "0.83.2",
"react-native-gesture-handler": "2.30.0",
"react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-ios-context-menu": "3.2.0",
"react-native-ios-utilities": "5.2.0",
"react-native-keyboard-controller": "1.20.7",
"react-native-mmkv": "4.2.0",
"react-native-nitro-modules": "0.35.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.23.0",
"react-native-svg": "15.15.3",
"react-native-worklets": "0.7.2",
"react-native-keyboard-controller": "1.21.9",
"react-native-mmkv": "4.3.2",
"react-native-nitro-modules": "0.36.1",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-worklets": "0.10.0",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:",
"uniwind": "1.6.1",
"uniwind": "1.10.0",
"zeego": "3.0.6",
"zod": "catalog:"
},
@@ -27,6 +27,20 @@ function LibraryHeaderRight() {
);
}
function renderLibraryHeaderRight() {
return <LibraryHeaderRight />;
}
function getLibraryHeaderRightItems() {
return [
{
type: "custom" as const,
element: <LibraryHeaderRight />,
hidesSharedBackground: true,
},
];
}
export default function LibraryLayout() {
const { t } = useLingui();
const contentStyle = useResolveClassNames("bg-background");
@@ -40,13 +54,7 @@ export default function LibraryLayout() {
<Stack
screenOptions={{
contentStyle,
unstable_headerRightItems: () => [
{
type: "custom" as const,
element: <LibraryHeaderRight />,
hidesSharedBackground: true,
},
],
unstable_headerRightItems: getLibraryHeaderRightItems,
}}
>
<Stack.Screen name="index">
@@ -76,7 +84,7 @@ export default function LibraryLayout() {
screenOptions={{
contentStyle,
headerTitleAlign: "left",
headerRight: () => <LibraryHeaderRight />,
headerRight: renderLibraryHeaderRight,
}}
>
<Stack.Screen name="index">
+1 -1
View File
@@ -1,13 +1,13 @@
import "@/lib/intl-polyfills";
import "@/global.css";
import { I18nProvider } from "@lingui/react";
import { ThemeProvider } from "@react-navigation/native";
import { QueryClientProvider } from "@tanstack/react-query";
import {
persistQueryClientRestore,
persistQueryClientSubscribe,
} from "@tanstack/react-query-persist-client";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import { ThemeProvider } from "expo-router/react-navigation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
+1 -1
View File
@@ -1,5 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list";
import {
IconAlertTriangle,
@@ -10,6 +9,7 @@ import {
} from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useHeaderHeight } from "expo-router/react-navigation";
import { useCallback, useEffect, useMemo } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
+3 -3
View File
@@ -1,5 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list";
import {
IconAlertTriangle,
@@ -18,6 +17,7 @@ import { useQuery } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Link, useLocalSearchParams, useRouter } from "expo-router";
import { useHeaderHeight } from "expo-router/react-navigation";
import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
@@ -57,10 +57,10 @@ const titleGenresContentStyle = { paddingHorizontal: 16 };
const titleAvailabilityContentStyle = { gap: 8, paddingHorizontal: 16 };
const titleDetailStyles = StyleSheet.create({
heroFill: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
heroBaseOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.1)",
},
heroGradient: {
+2 -1
View File
@@ -3,6 +3,7 @@ import type { StyleProp, ViewStyle } from "react-native";
import { ScrollView } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated";
import type { AnimatedStyle } from "react-native-reanimated";
import { SofaLogo } from "@/components/ui/sofa-logo";
import { Text } from "@/components/ui/text";
@@ -10,7 +11,7 @@ import { Text } from "@/components/ui/text";
interface AuthScreenProps {
title: string;
subtitle?: string;
logoStyle?: StyleProp<ViewStyle>;
logoStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
children: ReactNode;
}
@@ -40,7 +40,7 @@ export function NativeTabBar() {
<NativeTabs.Trigger.Label>{t`Settings`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
<NativeTabs.Trigger name="(search)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
</NativeTabs.Trigger>
@@ -4,6 +4,20 @@ import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
function renderHeaderAvatar() {
return <HeaderAvatar />;
}
function getHeaderRightItems() {
return [
{
type: "custom" as const,
element: <HeaderAvatar />,
hidesSharedBackground: true,
},
];
}
export function TabStack({ title, children }: { title?: string; children?: ReactNode }) {
const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
@@ -16,13 +30,7 @@ export function TabStack({ title, children }: { title?: string; children?: React
<Stack
screenOptions={{
contentStyle,
unstable_headerRightItems: () => [
{
type: "custom" as const,
element: <HeaderAvatar />,
hidesSharedBackground: true,
},
],
unstable_headerRightItems: getHeaderRightItems,
}}
>
{title ? (
@@ -55,7 +63,7 @@ export function TabStack({ title, children }: { title?: string; children?: React
screenOptions={{
contentStyle,
headerTitleAlign: "left",
headerRight: () => <HeaderAvatar />,
headerRight: renderHeaderAvatar,
}}
>
{title ? (
@@ -51,18 +51,18 @@ export function SeasonAccordion({
const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH);
useEffect(() => {
if (expanded) {
setVisibleCount(INITIAL_BATCH);
if (episodes.length > INITIAL_BATCH) {
const task = InteractionManager.runAfterInteractions(() => {
setVisibleCount(episodes.length);
});
return () => task.cancel();
}
if (expanded && episodes.length > INITIAL_BATCH) {
const task = InteractionManager.runAfterInteractions(() => {
setVisibleCount(episodes.length);
});
return () => task.cancel();
}
}, [expanded, episodes.length]);
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
const toggleExpanded = useCallback(() => {
if (!expanded) setVisibleCount(INITIAL_BATCH);
setExpanded((v) => !v);
}, [expanded]);
useEffect(() => {
chevronRotation.set(
+14 -14
View File
@@ -5,33 +5,33 @@ import { createMMKV } from "react-native-mmkv";
export const globalStorage = createMMKV();
// Server+user scoped storage — switches when scope changes
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
let _currentScopeKey: string | null = null;
let scopedStore: ReturnType<typeof createMMKV> | null = null;
let currentScopeKey: string | null = null;
const scopeChangeListeners: Array<() => void> = [];
export function scopedStorage() {
if (!_scopedStore) throw new Error("Scoped storage not initialized");
return _scopedStore;
if (!scopedStore) throw new Error("Scoped storage not initialized");
return scopedStore;
}
export function hasScopedStorage(): boolean {
return _scopedStore !== null;
return scopedStore !== null;
}
export function getScopeKey(): string | null {
return _currentScopeKey;
return currentScopeKey;
}
export function setStorageScope(instanceId: string, userId: string) {
_currentScopeKey = `${instanceId}_${userId}`;
_scopedStore = createMMKV({ id: _currentScopeKey });
currentScopeKey = `${instanceId}_${userId}`;
scopedStore = createMMKV({ id: currentScopeKey });
for (const listener of scopeChangeListeners) listener();
}
export function clearStorageScope() {
_currentScopeKey = null;
_scopedStore = null;
currentScopeKey = null;
scopedStore = null;
for (const listener of scopeChangeListeners) listener();
}
@@ -43,11 +43,11 @@ export function onStorageScopeChange(callback: () => void): () => void {
};
}
// Query persister — reads/writes through _scopedStore via closure
// Query persister — reads/writes through scopedStore via closure
const scopedMmkvStorage = {
getItem: (key: string) => _scopedStore?.getString(key) ?? null,
setItem: (key: string, value: string) => _scopedStore?.set(key, value),
removeItem: (key: string) => void _scopedStore?.remove(key),
getItem: (key: string) => scopedStore?.getString(key) ?? null,
setItem: (key: string, value: string) => scopedStore?.set(key, value),
removeItem: (key: string) => void scopedStore?.remove(key),
};
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
+8 -8
View File
@@ -380,7 +380,7 @@ function getCachedSession(): CachedSessionData | null {
}
}
let _cachedSessionSeeded = false;
let cachedSessionSeeded = false;
/**
* Seed the Better Auth session atom from SecureStore before React renders.
@@ -389,7 +389,7 @@ let _cachedSessionSeeded = false;
export function initSession(): void {
const cached = getCachedSession();
if (cached) {
_cachedSessionSeeded = true;
cachedSessionSeeded = true;
const sessionAtom = authClient.$store.atoms.session;
sessionAtom.set({
data: cached,
@@ -402,11 +402,11 @@ export function initSession(): void {
}
export function wasCachedSessionSeeded(): boolean {
return _cachedSessionSeeded;
return cachedSessionSeeded;
}
export function clearCachedSessionSeeded(): void {
_cachedSessionSeeded = false;
cachedSessionSeeded = false;
}
// ---------------------------------------------------------------------------
@@ -419,15 +419,15 @@ export function clearCachedSessionSeeded(): void {
// Signals that the next session-loss redirect should go to the server-url
// screen instead of login (set by the "Change Server" flow in settings).
let _serverChangeRequested = false;
let serverChangeRequested = false;
export function requestServerChange(): void {
_serverChangeRequested = true;
serverChangeRequested = true;
}
export function consumeServerChangeRequest(): boolean {
const was = _serverChangeRequested;
_serverChangeRequested = false;
const was = serverChangeRequested;
serverChangeRequested = false;
return was;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { DarkTheme } from "@react-navigation/native";
import { DarkTheme } from "expo-router/react-navigation";
export const sofaTheme = {
...DarkTheme,
+21 -11
View File
@@ -1,23 +1,33 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const CACHED_WIDGET_IMAGE_RE = /^file:\/\/\/group\/cw_/;
type LibraryResult = { items: Array<Record<string, unknown>> };
type SnapshotProps = Record<string, unknown>;
type TimelineEntry = { props?: Record<string, unknown> };
const platform = { OS: "ios" };
const resolveAssetSource = vi.fn(() => ({ uri: "file:///tmp/sofa-icon.png" }));
const continueWatching = vi.fn();
const upcoming = vi.fn();
const downloadWidgetImage = vi.fn(async (_url: string, key: string) => `file:///group/${key}`);
const copyBundledAsset = vi.fn(async (_assetUri: string, key: string) => `file:///group/${key}`);
const pruneWidgetImages = vi.fn(async () => undefined);
const getWidgetIconAsset = vi.fn(() => "widget-icon-asset");
const resolveAssetSource = vi.fn<() => { uri: string }>(() => ({
uri: "file:///tmp/sofa-icon.png",
}));
const continueWatching = vi.fn<() => Promise<LibraryResult>>();
const upcoming = vi.fn<() => Promise<LibraryResult>>();
const downloadWidgetImage = vi.fn<(url: string, key: string) => Promise<string>>(
async (_url: string, key: string) => `file:///group/${key}`,
);
const copyBundledAsset = vi.fn<(assetUri: string, key: string) => Promise<string>>(
async (_assetUri: string, key: string) => `file:///group/${key}`,
);
const pruneWidgetImages = vi.fn<() => Promise<void>>(async () => undefined);
const getWidgetIconAsset = vi.fn<() => string>(() => "widget-icon-asset");
const continueWatchingWidget = {
updateSnapshot: vi.fn(),
updateTimeline: vi.fn(),
updateSnapshot: vi.fn<(props: SnapshotProps) => void>(),
updateTimeline: vi.fn<(entries: TimelineEntry[]) => void>(),
};
const upcomingWidget = {
updateSnapshot: vi.fn(),
updateTimeline: vi.fn(),
updateSnapshot: vi.fn<(props: SnapshotProps) => void>(),
updateTimeline: vi.fn<(entries: TimelineEntry[]) => void>(),
};
vi.mock("react-native", () => ({
-1
View File
@@ -2,7 +2,6 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
+3 -3
View File
@@ -12,9 +12,9 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@hono/zod-validator": "0.7.6",
"@vercel/firewall": "1.1.2",
"hono": "4.12.9",
"@hono/zod-validator": "0.8.0",
"@vercel/firewall": "1.2.1",
"hono": "4.12.28",
"zod": "catalog:"
},
"devDependencies": {
+2 -2
View File
@@ -24,8 +24,8 @@
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"croner": "10.0.2-dev.2",
"hono": "4.12.9",
"croner": "10.0.2-dev.3",
"hono": "4.12.28",
"zod": "catalog:"
},
"devDependencies": {
+4 -4
View File
@@ -46,13 +46,13 @@ const DAY = 24 * 60 * 60 * 1000;
const RATE_LIMIT_MS = 300;
const globalForJobs = globalThis as unknown as {
_jobs: Map<string, Cron> | undefined;
cronJobs: Map<string, Cron> | undefined;
};
if (!globalForJobs._jobs) {
globalForJobs._jobs = new Map<string, Cron>();
if (!globalForJobs.cronJobs) {
globalForJobs.cronJobs = new Map<string, Cron>();
}
const jobs = globalForJobs._jobs;
const jobs = globalForJobs.cronJobs;
function schedule(name: string, cron: string, handler: () => Promise<void>) {
jobs.set(
+18 -18
View File
@@ -14,9 +14,9 @@
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "1.3.0",
"@base-ui/react": "1.6.0",
"@fontsource-variable/dm-sans": "5.2.8",
"@fontsource-variable/geist-mono": "5.2.7",
"@fontsource-variable/geist-mono": "5.2.8",
"@fontsource/dm-serif-display": "5.2.8",
"@lingui/core": "catalog:",
"@lingui/react": "catalog:",
@@ -24,24 +24,24 @@
"@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:",
"@player.style/sutro": "0.2.1",
"@rolldown/plugin-babel": "0.2.2",
"@rolldown/plugin-babel": "0.2.3",
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0",
"@tabler/icons-react": "3.44.0",
"@tanstack/react-form": "catalog:",
"@tanstack/react-hotkeys": "0.8.1",
"@tanstack/react-hotkeys": "0.10.0",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.5",
"@tanstack/zod-adapter": "1.166.9",
"@tanstack/react-router": "1.170.17",
"@tanstack/zod-adapter": "1.167.0",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"jotai": "2.19.0",
"motion": "12.38.0",
"jotai": "2.20.1",
"motion": "12.42.2",
"react": "catalog:",
"react-dom": "catalog:",
"shadcn": "4.1.0",
"shadcn": "4.13.0",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"thumbhash": "catalog:",
@@ -52,23 +52,23 @@
"@lingui/babel-plugin-lingui-macro": "catalog:",
"@lingui/vite-plugin": "catalog:",
"@sofa/tsconfig": "workspace:*",
"@tailwindcss/vite": "4.2.2",
"@tanstack/devtools-vite": "0.6.0",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.95.2",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/router-plugin": "1.167.6",
"@tailwindcss/vite": "4.3.2",
"@tanstack/devtools-vite": "0.8.1",
"@tanstack/react-devtools": "0.10.8",
"@tanstack/react-query-devtools": "5.101.2",
"@tanstack/react-router-devtools": "1.167.0",
"@tanstack/router-plugin": "1.168.19",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"@vitejs/plugin-react": "6.0.3",
"@vitest/browser": "catalog:",
"@vitest/browser-playwright": "catalog:",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "8.0.3",
"vite": "8.1.3",
"vitest": "catalog:"
}
}
@@ -25,6 +25,8 @@ interface TitleRowItem {
}
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
const EMPTY_STATUSES: Record<string, TitleStatus> = {};
const EMPTY_PROGRESS: Record<string, { watched: number; total: number }> = {};
interface FilterableTitleRowProps {
heading: string;
@@ -42,8 +44,8 @@ export function FilterableTitleRow({
mediaType,
defaultItems,
genres,
userStatuses: initialStatuses = {},
episodeProgress: initialProgress = {},
userStatuses: initialStatuses = EMPTY_STATUSES,
episodeProgress: initialProgress = EMPTY_PROGRESS,
}: FilterableTitleRowProps) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
+4 -1
View File
@@ -1,9 +1,11 @@
import { Trans } from "@lingui/react/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ErrorComponentProps } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
export function RouteError({ reset }: ErrorComponentProps) {
const { t } = useLingui();
return (
<div className="flex flex-col items-center gap-6 py-24 text-center">
<div className="border-destructive/20 bg-destructive/5 text-destructive flex size-14 items-center justify-center rounded-full border">
@@ -23,6 +25,7 @@ export function RouteError({ reset }: ErrorComponentProps) {
<div className="flex items-center gap-3">
<button
type="button"
aria-label={t`Try again`}
onClick={reset}
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-10 items-center justify-center gap-2 overflow-hidden rounded-lg px-5 text-sm font-medium transition-shadow hover:shadow-lg"
>
@@ -171,6 +171,7 @@ export function AccountSection({
render={
<button
type="button"
aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`}
onClick={avatarUrl ? handleRemoveAvatar : () => fileInputRef.current?.click()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCloudUpload, IconLink } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -209,9 +209,6 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
onError: (err) => {
toast.error(getErrorMessage(err, t, t`Failed to parse file`));
},
onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = "";
},
}),
);
@@ -236,7 +233,14 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
} | null>(null);
function handleFileSelect(file: File) {
parseMutation.mutate({ source: config.source, file });
parseMutation.mutate(
{ source: config.source, file },
{
onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = "";
},
},
);
}
async function handleImport() {
@@ -323,13 +327,12 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
}
// Clean up poll timer on unmount or dialog close
const stopPollingRef = useRef(() => {
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
});
const stopPolling = stopPollingRef.current;
}, []);
function handleClose() {
stopPolling();
-1
View File
@@ -4,7 +4,6 @@ export function SofaLogo({ className }: { className?: string }) {
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className={className ?? "size-6"}
role="img"
aria-label="Sofa"
>
<path
+1 -1
View File
@@ -125,7 +125,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
<TooltipTrigger
onClick={handleClick}
className="absolute end-2 top-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
render={<button type="button" />}
render={<button type="button" aria-label={t`Add to Watchlist`} />}
>
{!addToWatchlistMutation.isPending && <IconPlus className="size-4" />}
{addToWatchlistMutation.isPending && <IconLoader className="size-4 animate-spin" />}
@@ -22,7 +22,6 @@ export function StarRating({ value, onChange }: StarRatingProps) {
return (
<div
className="flex items-center gap-0.5"
role="radiogroup"
aria-label={t`Rating`}
onMouseLeave={() => setHover(0)}
>
@@ -32,8 +31,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
<motion.button
key={star}
type="button"
role="radio"
aria-checked={star === value}
aria-pressed={star === value}
aria-label={t`Rate ${plural(star, { one: "# star", other: "# stars" })}`}
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
-1
View File
@@ -4,7 +4,6 @@ export function TmdbLogo({ className }: { className?: string }) {
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 190.24 81.52"
className={className}
role="img"
aria-label="TMDB"
>
<defs>
@@ -29,7 +29,6 @@ function ButtonGroup({
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
-1
View File
@@ -71,7 +71,6 @@ function Field({
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
+2 -9
View File
@@ -10,7 +10,6 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input bg-input/20 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-7 w-full min-w-0 items-center rounded-md border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot][aria-invalid=true]]:ring-2 has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5",
className,
@@ -44,7 +43,7 @@ function InputGroupAddon({
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
const focusInput = (e: React.SyntheticEvent<HTMLDivElement>) => {
const focusInput = (e: React.PointerEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
@@ -52,16 +51,10 @@ function InputGroupAddon({
};
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={focusInput}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
focusInput(e);
}
}}
onPointerDown={focusInput}
{...props}
/>
);
-1
View File
@@ -9,7 +9,6 @@ import { cn } from "@/lib/utils";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
+2 -2
View File
@@ -11,7 +11,7 @@ function Slider({
max = 100,
...props
}: SliderPrimitive.Root.Props) {
const _values = React.useMemo(
const values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
@@ -37,7 +37,7 @@ function Slider({
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
{Array.from({ length: values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
-1
View File
@@ -7,7 +7,6 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
const { t } = useLingui();
return (
<IconLoader
role="status"
aria-label={t`Loading`}
className={cn("size-4 animate-spin", className)}
{...props}
+2 -3
View File
@@ -3,7 +3,7 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
const [isMobile, setIsMobile] = React.useState(() => window.innerWidth < MOBILE_BREAKPOINT);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
@@ -11,9 +11,8 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
return isMobile;
}
+6
View File
@@ -91,6 +91,8 @@ function AppShell() {
}
function GlobalNotFound() {
const { t } = useLingui();
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6">
<div className="bg-primary/4 pointer-events-none absolute top-[40%] left-1/2 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[160px]" />
@@ -121,6 +123,7 @@ function GlobalNotFound() {
>
<a
href="/"
aria-label={t`Return home`}
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-10 items-center justify-center overflow-hidden rounded-lg px-6 text-sm font-medium transition-shadow hover:shadow-lg"
>
<span className="relative z-10">
@@ -141,6 +144,8 @@ function GlobalNotFound() {
}
function GlobalError({ reset }: { error: Error; reset: () => void }) {
const { t } = useLingui();
return (
<div className="flex flex-col items-center gap-6 py-24 text-center">
<div className="border-destructive/20 bg-destructive/5 text-destructive flex size-14 items-center justify-center rounded-full border">
@@ -160,6 +165,7 @@ function GlobalError({ reset }: { error: Error; reset: () => void }) {
<div className="flex items-center gap-3">
<button
type="button"
aria-label={t`Try again`}
onClick={reset}
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-10 items-center justify-center gap-2 overflow-hidden rounded-lg px-5 text-sm font-medium transition-shadow hover:shadow-lg"
>