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:
2026-03-22 13:50:24 -04:00
parent 373a5d3caf
commit 2c7068ced3
52 changed files with 458 additions and 205 deletions
+30 -2
View File
@@ -1,12 +1,40 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"jsPlugins": ["@tanstack/eslint-plugin-query"],
"jsPlugins": [
"@tanstack/eslint-plugin-query",
"eslint-plugin-lingui",
{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }
],
"rules": {
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/stable-query-client": "error",
"@tanstack/query/no-unstable-deps": "error"
"@tanstack/query/no-unstable-deps": "error",
"lingui/no-unlocalized-strings": "off",
"lingui/t-call-in-function": "error",
"lingui/no-single-variables-to-translate": "error",
"lingui/no-expression-in-message": "error",
"lingui/no-single-tag-to-translate": "error",
"lingui/no-trans-inside-trans": "error",
"react-hooks-js/rules-of-hooks": "off",
"react-hooks-js/exhaustive-deps": "off",
"react-hooks-js/static-components": "error",
"react-hooks-js/use-memo": "error",
"react-hooks-js/void-use-memo": "error",
"react-hooks-js/component-hook-factories": "error",
"react-hooks-js/preserve-manual-memoization": "error",
"react-hooks-js/incompatible-library": "warn",
"react-hooks-js/immutability": "error",
"react-hooks-js/globals": "error",
"react-hooks-js/refs": "error",
"react-hooks-js/set-state-in-effect": "error",
"react-hooks-js/error-boundaries": "error",
"react-hooks-js/purity": "error",
"react-hooks-js/set-state-in-render": "error",
"react-hooks-js/unsupported-syntax": "warn",
"react-hooks-js/config": "error",
"react-hooks-js/gating": "error"
},
"ignorePatterns": ["node_modules", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
}
@@ -1,7 +1,6 @@
import { Platform } from "react-native";
function getModule() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("./src/WidgetImagesModule").default;
}
+6 -14
View File
@@ -21,6 +21,8 @@ import { Input } from "@/components/ui/text-field";
import { getServerUrl, serverManager, type ValidationError } from "@/lib/server";
import * as Haptics from "@/utils/haptics";
const TRAILING_SLASHES_RE = /\/+$/;
type ConnectionState =
| { phase: "idle" }
| { phase: "connecting" }
@@ -91,14 +93,12 @@ export default function ServerUrlScreen() {
};
const handleConnect = async () => {
const trimmed = url.trim().replace(/\/+$/, "");
const trimmed = url.trim().replace(TRAILING_SLASHES_RE, "");
if (!trimmed) return;
const fullUrl = serverManager.normalizeUrl(trimmed);
try {
new URL(fullUrl);
} catch {
if (!URL.canParse(fullUrl)) {
setConnection({ phase: "error", error: "invalid_url" });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
return;
@@ -122,16 +122,8 @@ export default function ServerUrlScreen() {
const isConnecting = connection.phase === "connecting";
const isSuccess = connection.phase === "success";
const trimmedUrl = url.trim().replace(/\/+$/, "");
const isValidUrl = (() => {
if (!trimmedUrl) return false;
try {
new URL(serverManager.normalizeUrl(trimmedUrl));
return true;
} catch {
return false;
}
})();
const trimmedUrl = url.trim().replace(TRAILING_SLASHES_RE, "");
const isValidUrl = !!trimmedUrl && URL.canParse(serverManager.normalizeUrl(trimmedUrl));
const isDisabled = isConnecting || isSuccess;
return (
@@ -25,7 +25,7 @@ import { reloadAppAsync } from "expo";
import * as Application from "expo-application";
import * as ImagePicker from "expo-image-picker";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import {
Alert,
Linking,
@@ -69,11 +69,16 @@ export default function SettingsScreen() {
const { push } = useRouter();
const { data: session, refetch: refetchSession } = authClient.useSession();
const [isEditingName, setIsEditingName] = useState(false);
const [nameInput, setNameInput] = useState("");
const sessionUserName = session?.user?.name ?? "";
const [nameInput, setNameInput] = useState(sessionUserName);
const [prevSessionName, setPrevSessionName] = useState(sessionUserName);
useEffect(() => {
if (!isEditingName && session?.user?.name) setNameInput(session.user.name);
}, [session?.user?.name, isEditingName]);
if (sessionUserName !== prevSessionName) {
setPrevSessionName(sessionUserName);
if (!isEditingName && sessionUserName) {
setNameInput(sessionUserName);
}
}
const [languageModalOpen, setLanguageModalOpen] = useState(false);
const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
@@ -305,7 +310,6 @@ export default function SettingsScreen() {
accessibilityLabel="Display name"
onChangeText={setNameInput}
className="border-primary text-foreground min-h-10 flex-1 border-b py-2 font-sans text-base"
autoFocus
/>
<Pressable onPress={() => updateName.mutate({ name: nameInput })}>
<Text className="text-primary text-sm">
+11 -7
View File
@@ -180,22 +180,26 @@ export default function TitleDetailScreen() {
() => (useAutomaticInsets ? { marginTop: -headerHeight } : undefined),
[useAutomaticInsets, headerHeight],
);
const darkMuted = palette?.darkMuted;
const vibrant = palette?.vibrant;
const darkVibrant = palette?.darkVibrant;
const darkMutedOverlayStyle = useMemo(
() => (palette?.darkMuted ? { backgroundColor: palette.darkMuted, opacity: 0.2 } : undefined),
[palette?.darkMuted],
() => (darkMuted ? { backgroundColor: darkMuted, opacity: 0.2 } : undefined),
[darkMuted],
);
const vibrantOverlayStyle = useMemo(
() => (palette?.vibrant ? { backgroundColor: palette.vibrant, opacity: 0.06 } : undefined),
[palette?.vibrant],
() => (vibrant ? { backgroundColor: vibrant, opacity: 0.06 } : undefined),
[vibrant],
);
const posterShadowStyle = useMemo(
() =>
palette?.darkVibrant
darkVibrant
? {
boxShadow: `0 12px 28px -8px ${palette.darkVibrant}80`,
boxShadow: `0 12px 28px -8px ${darkVibrant}80`,
}
: undefined,
[palette?.darkVibrant],
[darkVibrant],
);
if (detail.isPending) {
@@ -124,7 +124,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
setCopied(true);
toast.success(t`URL copied to clipboard`);
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
copiedTimerRef.current = setTimeout(setCopied, 2000, false);
}, [url, t]);
const handleRegenerate = useCallback(() => {
@@ -1,5 +1,5 @@
import { useLingui } from "@lingui/react/macro";
import { useCallback, useRef, useState } from "react";
import { useCallback, useState } from "react";
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
import { Platform, Pressable, View } from "react-native";
@@ -15,14 +15,15 @@ export function ExpandableText({
actionColor?: string;
}) {
const { t } = useLingui();
const prevTextRef = useRef(text);
let expanded: boolean;
let needsTruncation: boolean;
const [prevText, setPrevText] = useState(text);
const [expandedState, setExpanded] = useState(false);
const [needsTruncationState, setNeedsTruncation] = useState(false);
if (prevTextRef.current !== text) {
prevTextRef.current = text;
let expanded: boolean;
let needsTruncation: boolean;
if (prevText !== text) {
setPrevText(text);
setExpanded(false);
setNeedsTruncation(false);
expanded = false;
+1 -1
View File
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
const timer = setTimeout(setDebouncedValue, delay, value);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
+18 -9
View File
@@ -1,6 +1,6 @@
import { msg } from "@lingui/core/macro";
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv";
import { queryClient } from "@/lib/query-client";
@@ -94,11 +94,21 @@ export function useServerConnection() {
// Track whether the session was seeded from cache and hasn't yet been
// confirmed by the server. Once confirmed, flip to false so explicit
// sign-outs don't show a misleading "session expired" toast.
const hadOptimisticSession = useRef(wasCachedSessionSeeded());
const prevSession = useRef(session);
const [hadOptimisticSession, setHadOptimisticSession] = useState(wasCachedSessionSeeded);
const [prevSession, setPrevSession] = useState(session);
if (hadOptimisticSession.current && session && !isRefetching) {
hadOptimisticSession.current = false;
if (hadOptimisticSession && session && !isRefetching) {
setHadOptimisticSession(false);
}
// Detect session loss during render so the effect doesn't need to call
// setPrevSession (which triggers the set-state-in-effect lint rule).
let sessionLost = false;
if (prevSession !== session) {
if (prevSession && !session) {
sessionLost = true;
}
setPrevSession(session);
}
const { replace } = useRouter();
@@ -107,19 +117,18 @@ export function useServerConnection() {
// availability, but enableFreeze can prevent the navigator from
// transitioning on its own.
useEffect(() => {
if (prevSession.current && !session) {
if (sessionLost) {
const changingServer = consumeServerChangeRequest();
replace(changingServer ? "/(auth)/server-url" : "/(auth)/login");
if (hadOptimisticSession.current) {
if (hadOptimisticSession) {
toast.info(i18n._(msg`Session expired`), {
description: i18n._(msg`Please sign in again.`),
});
clearCachedSessionSeeded();
}
}
prevSession.current = session;
}, [session, replace]);
}, [sessionLost, replace, hadOptimisticSession]);
return { session, isPending, hasServerUrl, instanceId };
}
+12 -11
View File
@@ -47,13 +47,15 @@ const SERVER_URL_KEY = "sofa_server_url";
const SERVERS_MAP_KEY = "sofa_servers";
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
const TRAILING_SLASHES_RE = /\/+$/;
const PROTOCOL_RE = /^(https?:\/\/)(.*)/;
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
export function normalizeUrl(input: string): string {
let url = input.trim().replace(/\/+$/, "");
let url = input.trim().replace(TRAILING_SLASHES_RE, "");
if (url && !url.includes("://")) {
url = `http://${url}`;
}
@@ -61,7 +63,7 @@ export function normalizeUrl(input: string): string {
}
export function splitUrl(input: string): { protocol: string; host: string } {
const match = input.match(/^(https?:\/\/)(.*)/);
const match = input.match(PROTOCOL_RE);
if (match) {
return { protocol: match[1], host: match[2] };
}
@@ -85,7 +87,7 @@ export function getServerUrl(): string {
}
function setServerUrlInternal(url: string): void {
const normalized = url.replace(/\/+$/, "");
const normalized = url.replace(TRAILING_SLASHES_RE, "");
globalStorage.set(SERVER_URL_KEY, normalized);
}
@@ -157,13 +159,7 @@ export async function ensureInstanceId(): Promise<string | null> {
export async function validateServerUrl(url: string): Promise<ValidationResult> {
const normalized = normalizeUrl(url);
if (!normalized || !normalized.includes("://")) {
return { status: "error", error: "invalid_url" };
}
try {
new URL(normalized);
} catch {
if (!normalized || !normalized.includes("://") || !URL.canParse(normalized)) {
return { status: "error", error: "invalid_url" };
}
@@ -309,9 +305,14 @@ export function startReachabilityMonitor(): () => void {
export function useServerReachability() {
const [reachable, setReachableState] = useState(isReachable);
const [prevReachable, setPrevReachable] = useState(isReachable);
if (isReachable !== prevReachable) {
setPrevReachable(isReachable);
setReachableState(isReachable);
}
useEffect(() => {
setReachableState(isReachable);
return onServerReachabilityChange(setReachableState);
}, []);
+2 -1
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { getImporter, getImporterConfig } from "./importers";
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const VERSION_PREFIX_RE = /^v/;
const app = new Hono();
@@ -36,7 +37,7 @@ app.get("/v1/version", async (c) => {
c.header("Cache-Control", "public, s-maxage=900, stale-while-revalidate=3600");
return c.json({
version: data.tag_name.replace(/^v/, ""),
version: data.tag_name.replace(VERSION_PREFIX_RE, ""),
release_url: data.html_url,
});
} catch (e) {
+1 -1
View File
@@ -80,7 +80,7 @@ export function getJobSchedules(): {
pattern: string;
nextRunAt: string | null;
}[] {
return Array.from(jobs.entries()).map(([name, cron]) => ({
return Array.from(jobs.entries(), ([name, cron]) => ({
jobName: name,
pattern: cron.getPattern() ?? "",
nextRunAt: cron.nextRun()?.toISOString() ?? null,
+5 -3
View File
@@ -8,6 +8,8 @@ import { createLogger } from "@sofa/logger";
import { generateOpenApiSpec, openApiTags, schemaConverters } from "./openapi-spec";
import { implementedRouter } from "./router";
const TRAILING_SLASH_RE = /\/$/;
const log = createLogger("openapi");
const isSecure = (process.env.BETTER_AUTH_URL ?? "").startsWith("https://");
@@ -79,9 +81,9 @@ export const openApiHandler = new OpenAPIHandler(implementedRouter, {
],
interceptors: [
async (options) => {
const requestPathname = options.request.url.pathname.replace(/\/$/, "") || "/";
const prefix = options.prefix?.replace(/\/$/, "") || "";
const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/";
const requestPathname = options.request.url.pathname.replace(TRAILING_SLASH_RE, "") || "/";
const prefix = options.prefix?.replace(TRAILING_SLASH_RE, "") || "";
const specPath = `${prefix}/spec.json`.replace(TRAILING_SLASH_RE, "") || "/";
if (options.request.method !== "GET" || requestPathname !== specPath) {
return options.next();
+31 -2
View File
@@ -1,12 +1,41 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"jsPlugins": ["@tanstack/eslint-plugin-query", "@tanstack/eslint-plugin-router"],
"jsPlugins": [
"@tanstack/eslint-plugin-query",
"@tanstack/eslint-plugin-router",
"eslint-plugin-lingui",
{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }
],
"rules": {
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/stable-query-client": "error",
"@tanstack/query/no-unstable-deps": "error",
"@tanstack/router/create-route-property-order": "warn"
"@tanstack/router/create-route-property-order": "warn",
"lingui/no-unlocalized-strings": "off",
"lingui/t-call-in-function": "error",
"lingui/no-single-variables-to-translate": "error",
"lingui/no-expression-in-message": "error",
"lingui/no-single-tag-to-translate": "error",
"lingui/no-trans-inside-trans": "error",
"react-hooks-js/rules-of-hooks": "off",
"react-hooks-js/exhaustive-deps": "off",
"react-hooks-js/static-components": "error",
"react-hooks-js/use-memo": "error",
"react-hooks-js/void-use-memo": "error",
"react-hooks-js/component-hook-factories": "error",
"react-hooks-js/preserve-manual-memoization": "error",
"react-hooks-js/incompatible-library": "warn",
"react-hooks-js/immutability": "error",
"react-hooks-js/globals": "error",
"react-hooks-js/refs": "error",
"react-hooks-js/set-state-in-effect": "error",
"react-hooks-js/error-boundaries": "error",
"react-hooks-js/purity": "error",
"react-hooks-js/set-state-in-render": "error",
"react-hooks-js/unsupported-syntax": "warn",
"react-hooks-js/config": "error",
"react-hooks-js/gating": "error"
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.2",
"@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.2",
"better-auth": "catalog:",
+30 -10
View File
@@ -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>
+14 -16
View File
@@ -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 (
+16 -13
View File
@@ -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"
+10 -4
View File
@@ -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}
/>
+1
View File
@@ -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"
+1 -1
View File
@@ -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]);
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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,
});
+15 -10
View File
@@ -1,13 +1,18 @@
import { playwright } from "@vitest/browser-playwright";
import { defineProject } from "vitest/config";
import { defineProject, mergeConfig } from "vitest/config";
export default defineProject({
test: {
include: ["src/**/*.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium", headless: true }],
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineProject({
test: {
include: ["src/**/*.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium", headless: true }],
},
},
},
});
}),
);