mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
chore: migrate oxlint config to e18e plugin and fix lint violations
- Replace root `eslint-plugin-lingui` jsPlugin with `@e18e/eslint-plugin`; move lingui rules into per-app `.oxlintrc.json` overrides for native and web
- Add `jsx-a11y`, `react-hooks-js` (native), and expanded lingui rule sets to per-app configs
- Add `oxc/no-barrel-file` warning at threshold 0 to root config
- Fix `e18e/prefer-url-canparse`: replace `try { new URL() } catch` with `URL.canParse()` in server-url screen and server lib
- Fix `e18e/prefer-timer-args`: pass callback args directly to `setTimeout` instead of wrapping in arrow functions (use-debounce, integration-card)
- Fix `e18e/prefer-static-regex`: hoist `/\/+$/` to module-level constant in server-url screen
- Fix `react-hooks-js/refs` and `react-hooks-js/set-state-in-effect`: convert `useRef` tracking patterns to `useState` + render-time derived updates in `use-server-connection`, `expandable-text`, and settings screen
- Fix `react-hooks-js/immutability`: extract stable palette sub-values before `useMemo` deps in title detail screen
- Apply `e18e` and other rule fixes across core, tmdb, web components, and i18n packages
This commit is contained in:
@@ -49,6 +49,8 @@ const SHORTCUT_DESCRIPTIONS = [
|
||||
{ scope: "Global", description: "Keyboard shortcuts", keys: ["?"] },
|
||||
{ scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] },
|
||||
{ scope: "Navigation", description: "Go to explore", keys: ["g", "e"] },
|
||||
{ scope: "Navigation", description: "Go to upcoming", keys: ["g", "u"] },
|
||||
{ scope: "Navigation", description: "Go to settings", keys: ["g", "s"] },
|
||||
{ scope: "Title", description: "Cycle status", keys: ["w"] },
|
||||
{ scope: "Title", description: "Mark watched", keys: ["m"] },
|
||||
{ scope: "Title", description: "Go back", keys: ["Escape"] },
|
||||
@@ -95,8 +97,19 @@ export function CommandPalette() {
|
||||
const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? [];
|
||||
const enabled = !commandPaletteOpen;
|
||||
|
||||
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
|
||||
useHotkey("/", () => setCommandPaletteOpen(true), { enabled });
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean | ((prev: boolean) => boolean)) => {
|
||||
setCommandPaletteOpen((prev) => {
|
||||
const next = typeof open === "function" ? open(prev) : open;
|
||||
if (next) setQuery("");
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setCommandPaletteOpen],
|
||||
);
|
||||
|
||||
useHotkey("Mod+K", () => handleOpenChange((prev) => !prev));
|
||||
useHotkey("/", () => handleOpenChange(true), { enabled });
|
||||
useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled });
|
||||
useHotkeySequence(
|
||||
["G", "H"],
|
||||
@@ -112,13 +125,20 @@ export function CommandPalette() {
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
|
||||
// Reset query when palette opens
|
||||
useEffect(() => {
|
||||
if (commandPaletteOpen) {
|
||||
setQuery("");
|
||||
}
|
||||
}, [commandPaletteOpen]);
|
||||
useHotkeySequence(
|
||||
["G", "U"],
|
||||
() => {
|
||||
void navigate({ to: "/upcoming" });
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
useHotkeySequence(
|
||||
["G", "S"],
|
||||
() => {
|
||||
void navigate({ to: "/settings" });
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
|
||||
// Save to recent searches after user stops typing for a while
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
@@ -170,7 +190,7 @@ export function CommandPalette() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<Dialog open={commandPaletteOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Command Palette</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -70,37 +70,35 @@ function useActiveIndicator<T>(
|
||||
itemRefs: React.MutableRefObject<(HTMLElement | null)[]>,
|
||||
measure: (itemRect: DOMRect, containerRect: DOMRect) => T,
|
||||
): { value: T | null; instant: boolean } {
|
||||
const [value, setValue] = useState<T | null>(null);
|
||||
const instantRef = useRef(true);
|
||||
const [state, setState] = useState<{ value: T | null; instant: boolean }>({
|
||||
value: null,
|
||||
instant: true,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (activeIndex === -1) {
|
||||
setValue(null);
|
||||
return;
|
||||
}
|
||||
const computeValue = (): T | null => {
|
||||
if (activeIndex === -1) return null;
|
||||
const item = itemRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (item && container && container.offsetWidth > 0) {
|
||||
setValue(measure(item.getBoundingClientRect(), container.getBoundingClientRect()));
|
||||
} else {
|
||||
setValue(null);
|
||||
return measure(item.getBoundingClientRect(), container.getBoundingClientRect());
|
||||
}
|
||||
return null;
|
||||
};
|
||||
update();
|
||||
// After the initial measurement, allow subsequent changes to animate
|
||||
instantRef.current = false;
|
||||
// Initial measurement is instant; subsequent activeIndex changes animate
|
||||
setState({ value: computeValue(), instant: true });
|
||||
// Use microtask to flip instant to false after the synchronous paint
|
||||
queueMicrotask(() => setState((prev) => (prev.instant ? { ...prev, instant: false } : prev)));
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
instantRef.current = true;
|
||||
update();
|
||||
setState({ value: computeValue(), instant: true });
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [activeIndex, containerRef, itemRefs, measure]);
|
||||
|
||||
return { value, instant: instantRef.current };
|
||||
return state;
|
||||
}
|
||||
|
||||
export function NavBar({
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NavigationProgress() {
|
||||
|
||||
finishTimerRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => setProgress(0), 200);
|
||||
setTimeout(setProgress, 200, 0);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
return list.toSorted((a, b) => {
|
||||
if (sort === "rating") {
|
||||
return (b.voteAverage ?? 0) - (a.voteAverage ?? 0);
|
||||
}
|
||||
|
||||
@@ -98,8 +98,12 @@ export function AccountSection({
|
||||
});
|
||||
const initial = displayName?.charAt(0).toUpperCase() ?? "?";
|
||||
|
||||
const uploadAvatarMutation = useMutation(
|
||||
orpc.account.uploadAvatar.mutationOptions({
|
||||
const uploadAvatarMutation = useMutation(orpc.account.uploadAvatar.mutationOptions());
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadAvatarMutation.mutate(file, {
|
||||
onSuccess: (data) => {
|
||||
setAvatarUrl(data.imageUrl);
|
||||
toast.success(t`Profile picture updated`);
|
||||
@@ -111,13 +115,7 @@ export function AccountSection({
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadAvatarMutation.mutate(file);
|
||||
});
|
||||
}
|
||||
|
||||
const removeAvatarMutation = useMutation(
|
||||
@@ -579,7 +577,7 @@ function SofaImportDialog() {
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Import Sofa export</Trans>
|
||||
<Trans>Import Sofa data</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Review what was found and choose what to import.</Trans>
|
||||
|
||||
@@ -26,8 +26,10 @@ export function BackupRestoreSection() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const restoreMutation = useMutation(
|
||||
orpc.admin.backups.restore.mutationOptions({
|
||||
const restoreMutation = useMutation(orpc.admin.backups.restore.mutationOptions());
|
||||
|
||||
function handleRestore(file: File) {
|
||||
restoreMutation.mutate(file, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Database restored. Reloading...`);
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
@@ -38,11 +40,7 @@ export function BackupRestoreSection() {
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleRestore(file: File) {
|
||||
restoreMutation.mutate(file);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -135,7 +135,7 @@ export function IntegrationCard({
|
||||
if (!url) return;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(setCopied, 2000, false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
|
||||
|
||||
const DIGITS_ONLY_RE = /^\d+$/;
|
||||
|
||||
/** Convert a cron pattern to a short human-readable string */
|
||||
function cronToHuman(pattern: string): string {
|
||||
const parts = pattern.split(" ");
|
||||
@@ -50,12 +52,12 @@ function cronToHuman(pattern: string): string {
|
||||
}
|
||||
|
||||
// Daily at specific time: "0 3 * * *"
|
||||
if (/^\d+$/.test(hour) && /^\d+$/.test(min) && dow === "*") {
|
||||
if (DIGITS_ONLY_RE.test(hour) && DIGITS_ONLY_RE.test(min) && dow === "*") {
|
||||
return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Weekly
|
||||
if (/^\d+$/.test(dow)) {
|
||||
if (DIGITS_ONLY_RE.test(dow)) {
|
||||
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
return `Weekly on ${days[Number(dow)] ?? dow}`;
|
||||
}
|
||||
@@ -326,7 +328,7 @@ function BackgroundJobsCard({
|
||||
? (triggerJobMutation.variables?.name ?? null)
|
||||
: null;
|
||||
|
||||
const sortedJobs = [...jobs].sort((a, b) => {
|
||||
const sortedJobs = jobs.toSorted((a, b) => {
|
||||
if (a.disabled !== b.disabled) return a.disabled ? 1 : -1;
|
||||
if (!a.nextRunAt && !b.nextRunAt) return 0;
|
||||
if (!a.nextRunAt) return 1;
|
||||
|
||||
@@ -10,7 +10,7 @@ export function CopyButton({ code }: { code: string }) {
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(setCopied, 2000, false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { type MotionStyle, type MotionValue, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -85,19 +85,15 @@ function useStatusConfig() {
|
||||
function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) {
|
||||
const { t } = useLingui();
|
||||
const statusConfig = useStatusConfig();
|
||||
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(userStatus ?? null);
|
||||
|
||||
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
|
||||
useEffect(() => {
|
||||
setAddedStatus(userStatus ?? null);
|
||||
}, [userStatus]);
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => setAddedStatus("in_watchlist"),
|
||||
onSuccess: () => setOptimisticStatus("in_watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
const addedStatus = optimisticStatus ?? userStatus ?? null;
|
||||
const isAdded = addedStatus != null;
|
||||
const config = addedStatus ? statusConfig[addedStatus] : null;
|
||||
|
||||
@@ -282,10 +278,17 @@ export function TitleCard({
|
||||
userStatus,
|
||||
episodeProgress,
|
||||
}: TitleCardProps) {
|
||||
const tilt = useTiltEffect();
|
||||
const {
|
||||
ref: tiltRef,
|
||||
containerStyle,
|
||||
imageStyle,
|
||||
glareBackground,
|
||||
glareOpacity,
|
||||
handlers,
|
||||
} = useTiltEffect();
|
||||
|
||||
const cardContent = (
|
||||
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
|
||||
<motion.div ref={tiltRef} style={containerStyle} {...handlers}>
|
||||
<CardInner
|
||||
title={title}
|
||||
type={type}
|
||||
@@ -296,9 +299,9 @@ export function TitleCard({
|
||||
userStatus={userStatus}
|
||||
episodeProgress={episodeProgress}
|
||||
tiltStyles={{
|
||||
imageStyle: tilt.imageStyle,
|
||||
glareBackground: tilt.glareBackground,
|
||||
glareOpacity: tilt.glareOpacity,
|
||||
imageStyle,
|
||||
glareBackground,
|
||||
glareOpacity,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -23,7 +23,14 @@ function ProviderBadge({
|
||||
<TooltipTrigger
|
||||
{...(watchUrl
|
||||
? {
|
||||
render: <a href={watchUrl} target="_blank" rel="noopener noreferrer" />,
|
||||
render: (
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t`Watch on ${name}`}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
className={`border-border/30 bg-card flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border motion-safe:transition-transform motion-safe:hover:scale-105${watchUrl ? "" : "cursor-default"}`}
|
||||
|
||||
@@ -151,16 +151,9 @@ export function TitleSeasons({
|
||||
key={season.id}
|
||||
className="border-border/50 bg-card/50 overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSeason(isOpen ? null : season.seasonNumber)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpenSeason(isOpen ? null : season.seasonNumber);
|
||||
}
|
||||
}}
|
||||
className="group/season hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between p-4 text-start transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -217,7 +210,7 @@ export function TitleSeasons({
|
||||
<IconChevronDown aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
|
||||
@@ -54,6 +54,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- intentional: represents the current page in a breadcrumb, not a clickable link
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
|
||||
@@ -44,17 +44,23 @@ function InputGroupAddon({
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
const focusInput = (e: React.SyntheticEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
onClick={focusInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
focusInput(e);
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- htmlFor is passed via props spread
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
|
||||
@@ -42,6 +42,7 @@ function PaginationLink({ className, isActive, size = "icon", ...props }: Pagina
|
||||
className={cn(className)}
|
||||
nativeButton={false}
|
||||
render={
|
||||
// oxlint-disable-next-line jsx-a11y/anchor-has-content -- content is provided by the Button's children
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
|
||||
@@ -4,7 +4,7 @@ export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
const timer = setTimeout(setDebounced, delay, value);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_app/dashboard")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
|
||||
@@ -33,6 +32,7 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
|
||||
pendingComponent: DashboardSkeleton,
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_app/explore")({
|
||||
staleTime: 60_000,
|
||||
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
@@ -37,6 +36,7 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
|
||||
pendingComponent: ExploreSkeletons,
|
||||
component: ExplorePage,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ const GITHUB_REPO = "jakejarvis/sofa";
|
||||
|
||||
export const Route = createFileRoute("/_app/settings")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
const promises: Promise<unknown>[] = [
|
||||
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
|
||||
@@ -45,6 +44,7 @@ export const Route = createFileRoute("/_app/settings")({
|
||||
}
|
||||
await Promise.all(promises);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
|
||||
pendingComponent: SettingsSkeleton,
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
||||
|
||||
export const Route = createFileRoute("/_app/upcoming")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
@@ -25,6 +24,7 @@ export const Route = createFileRoute("/_app/upcoming")({
|
||||
}),
|
||||
);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
||||
pendingComponent: UpcomingSkeleton,
|
||||
component: UpcomingPage,
|
||||
});
|
||||
|
||||
@@ -78,11 +78,11 @@ const envSnippets = [
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/setup")({
|
||||
head: () => ({ meta: [{ title: "Setup — Sofa" }] }),
|
||||
beforeLoad: async () => {
|
||||
const info = await client.system.publicInfo({});
|
||||
if (info.tmdbConfigured) throw redirect({ to: "/" });
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Setup — Sofa" }] }),
|
||||
component: SetupPage,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user