mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
fix: UX polish across web and native — error boundaries, query tuning, and i18n improvements
- Add `RouteError` component for route-level error boundaries (web) with "Try again" and "Dashboard" links; wire it into dashboard, explore, upcoming, people, and settings routes - Add `await queryClient.cancelQueries()` before every optimistic update in `useTitleActions` to prevent in-flight refetches from overwriting local state - Cap all `useInfiniteQuery` calls at `maxPages: 10` (web and native) to bound memory growth on scroll-heavy pages - Replace `ActivityIndicator` with `Spinner` in search, person detail, and list footer components (native) - Tune native query client defaults: add `staleTime: 60_000` and lower `gcTime` to `300_000` (was 24h) - Darken `--color-muted-foreground` for better contrast in the native theme - Improve error message copy in `error-messages.ts` for clarity (TMDB, import, registration errors) - Update i18n `.po` files across all 12 locales with new strings (route error, improved error messages)
This commit is contained in:
@@ -23,6 +23,7 @@ export default function ExploreScreen() {
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
const popularMovies = useQuery(orpc.explore.popular.queryOptions({ input: { type: "movie" } }));
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function UpcomingScreen() {
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FlashList } from "@shopify/flash-list";
|
||||
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
|
||||
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
||||
@@ -28,6 +28,7 @@ export default function SearchScreen() {
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -115,7 +116,7 @@ export default function SearchScreen() {
|
||||
ListFooterComponent={
|
||||
searchResults.isFetchingNextPage ? (
|
||||
<View className="items-center py-4">
|
||||
<ActivityIndicator />
|
||||
<Spinner />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, Pressable, useWindowDimensions, View } from "react-native";
|
||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
@@ -23,6 +23,7 @@ import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useTitleActions } from "@/hooks/use-title-actions";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
@@ -75,6 +76,7 @@ export default function PersonDetailScreen() {
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -310,7 +312,7 @@ export default function PersonDetailScreen() {
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className="items-center py-4">
|
||||
<ActivityIndicator />
|
||||
<Spinner />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function FilterableTitleRow({
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
--color-secondary: oklch(0.22 0.008 55);
|
||||
--color-secondary-foreground: oklch(0.85 0.02 80);
|
||||
--color-muted: oklch(0.22 0.008 55);
|
||||
--color-muted-foreground: oklch(0.71 0.02 80);
|
||||
--color-muted-foreground: oklch(0.63 0.02 80);
|
||||
--color-accent: oklch(0.25 0.01 55);
|
||||
--color-accent-foreground: oklch(0.93 0.015 80);
|
||||
--color-destructive: oklch(0.65 0.2 25);
|
||||
|
||||
@@ -24,14 +24,14 @@ export function appErrorMessages(
|
||||
INTEGRATION_NOT_FOUND: t`Integration not found`,
|
||||
BACKUP_NOT_FOUND: t`Backup not found`,
|
||||
BACKUP_DELETE_FAILED: t`Failed to delete backup`,
|
||||
BACKUP_RESTORE_FAILED: t`Restore failed`,
|
||||
BACKUP_RESTORE_FAILED: t`Backup restoration failed`,
|
||||
JOB_NOT_FOUND: t`Job not found`,
|
||||
TMDB_NOT_CONFIGURED: t`TMDB not configured`,
|
||||
IMPORT_INVALID_FILE: t`Invalid file`,
|
||||
IMPORT_PAYLOAD_TOO_LARGE: t`Import too large`,
|
||||
IMPORT_ALREADY_RUNNING: t`Import already running`,
|
||||
IMPORT_CANNOT_CANCEL: t`Cannot cancel import`,
|
||||
REGISTRATION_CLOSED: t`Registration closed`,
|
||||
TMDB_NOT_CONFIGURED: t`TMDB API key is not configured`,
|
||||
IMPORT_INVALID_FILE: t`Invalid import file`,
|
||||
IMPORT_PAYLOAD_TOO_LARGE: t`Import payload is too large`,
|
||||
IMPORT_ALREADY_RUNNING: t`An import is already in progress`,
|
||||
IMPORT_CANNOT_CANCEL: t`This import cannot be cancelled`,
|
||||
REGISTRATION_CLOSED: t`Registration is currently closed`,
|
||||
EXPORT_FAILED: t`Failed to export data`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import { getIsReachable, isNetworkError } from "@/lib/server";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { i18n } from "@sofa/i18n";
|
||||
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: ONE_DAY_MS,
|
||||
staleTime: 60_000,
|
||||
gcTime: 300_000,
|
||||
networkMode: "online",
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry network errors when server is unreachable — the
|
||||
|
||||
@@ -15,6 +15,7 @@ export function LibrarySection() {
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export function FilterableTitleRow({
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export function PersonDetailClient({ id }: { id: string }) {
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Trans } 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) {
|
||||
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">
|
||||
<IconAlertTriangle className="size-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-display text-2xl tracking-tight sm:text-3xl">
|
||||
<Trans>Something went wrong</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-sm text-sm leading-relaxed">
|
||||
<Trans>
|
||||
An unexpected error occurred while loading this page. You can try again or head back to
|
||||
the dashboard.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<span className="relative z-10">
|
||||
<Trans>Try again</Trans>
|
||||
</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="border-border hover:border-primary/40 hover:bg-primary/5 inline-flex h-10 items-center justify-center rounded-lg border px-5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Trans>Dashboard</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export function useTitleActions() {
|
||||
|
||||
const catchUp = useCallback(
|
||||
async (episodeIds: string[]) => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prev = getUserInfo();
|
||||
const newWatchSet = new Set(prev.episodeWatches);
|
||||
for (const id of episodeIds) newWatchSet.add(id);
|
||||
@@ -83,6 +84,7 @@ export function useTitleActions() {
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: string | null) => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
@@ -99,11 +101,12 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to update status`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, titleId, updateStatus, t],
|
||||
[getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, updateStatus, t],
|
||||
);
|
||||
|
||||
const handleRating = useCallback(
|
||||
async (ratingStars: number) => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prevRating = getUserInfo().rating;
|
||||
setUserInfo((old) => ({ ...old, rating: ratingStars }));
|
||||
try {
|
||||
@@ -121,10 +124,11 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to update rating`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, titleId, updateRating, t],
|
||||
[getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, updateRating, t],
|
||||
);
|
||||
|
||||
const handleWatchMovie = useCallback(async () => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({ ...old, status: "completed" }));
|
||||
try {
|
||||
@@ -134,10 +138,11 @@ export function useTitleActions() {
|
||||
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
||||
toast.error(t`Failed to mark as watched`);
|
||||
}
|
||||
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovie, t]);
|
||||
}, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, watchMovie, t]);
|
||||
|
||||
const handleWatchEpisode = useCallback(
|
||||
async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
setWatchingEp(episodeId);
|
||||
|
||||
if (isWatched) {
|
||||
@@ -218,11 +223,23 @@ export function useTitleActions() {
|
||||
|
||||
setWatchingEp(null);
|
||||
},
|
||||
[getUserInfo, setUserInfo, setWatchingEp, seasons, catchUp, unwatchEp, watchEp, t],
|
||||
[
|
||||
getUserInfo,
|
||||
setUserInfo,
|
||||
queryClient,
|
||||
userInfoKey,
|
||||
setWatchingEp,
|
||||
seasons,
|
||||
catchUp,
|
||||
unwatchEp,
|
||||
watchEp,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const watchedSet = new Set(prevWatches);
|
||||
@@ -284,6 +301,7 @@ export function useTitleActions() {
|
||||
|
||||
const handleUnmarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
|
||||
@@ -307,10 +325,11 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to unmark some episodes`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, unwatchSeason, t],
|
||||
[getUserInfo, setUserInfo, queryClient, userInfoKey, unwatchSeason, t],
|
||||
);
|
||||
|
||||
const handleMarkAllWatched = useCallback(async () => {
|
||||
await queryClient.cancelQueries({ queryKey: userInfoKey });
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
|
||||
@@ -35,7 +35,7 @@ function ScrollArea({
|
||||
className={cn(
|
||||
"no-scrollbar focus-visible:ring-ring/50 flex-1 rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:outline-1 data-has-overflow-x:overscroll-x-contain",
|
||||
scrollFade &&
|
||||
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem]",
|
||||
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem] rtl:mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] rtl:mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))]",
|
||||
scrollbarGutter && "data-has-overflow-x:pb-2.5 data-has-overflow-y:pe-2.5",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StatsSection } from "@/components/dashboard/stats-section";
|
||||
import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
|
||||
import { UpcomingSection } from "@/components/dashboard/upcoming-section";
|
||||
import { WelcomeHeader } from "@/components/dashboard/welcome-header";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
@@ -28,12 +29,14 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
|
||||
pendingComponent: DashboardSkeleton,
|
||||
errorComponent: RouteError,
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMemo } from "react";
|
||||
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
|
||||
import { HeroBanner } from "@/components/explore/hero-banner";
|
||||
import { TitleRow } from "@/components/explore/title-row";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
),
|
||||
context.queryClient.ensureQueryData(
|
||||
@@ -38,6 +40,7 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
|
||||
pendingComponent: ExploreSkeletons,
|
||||
errorComponent: RouteError,
|
||||
component: ExplorePage,
|
||||
});
|
||||
|
||||
@@ -82,6 +85,7 @@ function ExplorePage() {
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
|
||||
|
||||
import { PersonDetailClient, PersonDetailSkeleton } from "@/components/people/person-detail-client";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { getAppErrorCode } from "@/lib/error-messages";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
@@ -19,6 +20,7 @@ export const Route = createFileRoute("/_app/people/$id")({
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
return { personName: data.pages[0]?.person.name };
|
||||
@@ -34,6 +36,7 @@ export const Route = createFileRoute("/_app/people/$id")({
|
||||
return { meta: [{ title: `${loaderData.personName} — Sofa` }] };
|
||||
},
|
||||
pendingComponent: () => <PersonDetailSkeleton />,
|
||||
errorComponent: RouteError,
|
||||
notFoundComponent: PersonNotFound,
|
||||
component: PersonPage,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { BackupRestoreSection } from "@/components/settings/backup-restore-section";
|
||||
import { BackupScheduleSection } from "@/components/settings/backup-schedule-section";
|
||||
@@ -46,6 +47,7 @@ export const Route = createFileRoute("/_app/settings")({
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
|
||||
pendingComponent: SettingsSkeleton,
|
||||
errorComponent: RouteError,
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
@@ -169,7 +171,7 @@ function SettingsPage() {
|
||||
<Trans>Security</Trans>
|
||||
</h2>
|
||||
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] font-medium">
|
||||
Admin only
|
||||
<Trans>Admin only</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -192,7 +194,7 @@ function SettingsPage() {
|
||||
<Trans>Backups</Trans>
|
||||
</h2>
|
||||
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] font-medium">
|
||||
Admin only
|
||||
<Trans>Admin only</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -218,7 +220,7 @@ function SettingsPage() {
|
||||
<Trans>Danger Zone</Trans>
|
||||
</h2>
|
||||
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] font-medium">
|
||||
Admin only
|
||||
<Trans>Admin only</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<Card className="border-s-primary/30 border-s-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
@@ -21,11 +22,13 @@ export const Route = createFileRoute("/_app/upcoming")({
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
||||
pendingComponent: UpcomingSkeleton,
|
||||
errorComponent: RouteError,
|
||||
component: UpcomingPage,
|
||||
});
|
||||
|
||||
@@ -59,6 +62,7 @@ function UpcomingPage() {
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
maxPages: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user