From 2c7068ced33971c7799697fcb30e5c3157f5960c Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 22 Mar 2026 13:50:24 -0400 Subject: [PATCH] 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 --- .oxlintrc.json | 35 +++++---- apps/native/.oxlintrc.json | 32 +++++++- apps/native/modules/widget-images/index.ts | 1 - apps/native/src/app/(auth)/server-url.tsx | 20 ++--- .../src/app/(tabs)/(settings)/index.tsx | 16 ++-- apps/native/src/app/title/[id].tsx | 18 +++-- .../components/settings/integration-card.tsx | 2 +- .../src/components/ui/expandable-text.tsx | 13 ++-- apps/native/src/hooks/use-debounce.ts | 2 +- .../native/src/hooks/use-server-connection.ts | 27 ++++--- apps/native/src/lib/server.ts | 23 +++--- apps/public-api/src/app.ts | 3 +- apps/server/src/cron.ts | 2 +- apps/server/src/orpc/openapi-handler.ts | 8 +- apps/web/.oxlintrc.json | 33 +++++++- apps/web/package.json | 2 +- apps/web/src/components/command-palette.tsx | 40 +++++++--- apps/web/src/components/nav-bar.tsx | 30 ++++---- .../src/components/navigation-progress.tsx | 2 +- .../components/people/filmography-grid.tsx | 2 +- .../components/settings/account-section.tsx | 18 ++--- .../settings/backup-restore-section.tsx | 12 ++- .../components/settings/integration-card.tsx | 2 +- .../settings/system-health-section.tsx | 8 +- apps/web/src/components/setup/copy-button.tsx | 2 +- apps/web/src/components/title-card.tsx | 29 +++---- .../components/titles/title-availability.tsx | 9 ++- .../src/components/titles/title-seasons.tsx | 13 +--- apps/web/src/components/ui/breadcrumb.tsx | 1 + apps/web/src/components/ui/input-group.tsx | 14 +++- apps/web/src/components/ui/label.tsx | 1 + apps/web/src/components/ui/pagination.tsx | 1 + apps/web/src/hooks/use-debounce.ts | 2 +- apps/web/src/routes/_app/dashboard.tsx | 2 +- apps/web/src/routes/_app/explore.tsx | 2 +- apps/web/src/routes/_app/settings.tsx | 2 +- apps/web/src/routes/_app/upcoming.tsx | 2 +- apps/web/src/routes/setup.tsx | 2 +- apps/web/vitest.config.ts | 25 +++--- bun.lock | 30 ++++++-- package.json | 4 +- packages/core/src/discovery.ts | 10 ++- packages/core/src/image-cache.ts | 4 +- packages/core/src/update-check.ts | 3 +- packages/core/test/discovery.test.ts | 15 +++- packages/core/test/integrations.test.ts | 4 +- packages/core/test/telemetry.test.ts | 17 +++- packages/core/test/upcoming.test.ts | 15 +++- packages/i18n/scripts/claude.ts | 19 +++-- packages/i18n/src/format.ts | 4 +- packages/i18n/src/po/en.po | 77 +++++++++++++++++++ packages/tmdb/src/client.ts | 3 +- 52 files changed, 458 insertions(+), 205 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 22d2e10..967e0ca 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest"], - "jsPlugins": ["eslint-plugin-lingui"], + "plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest", "jsx-a11y"], + "jsPlugins": ["@e18e/eslint-plugin"], "env": { "builtin": true }, @@ -15,6 +15,7 @@ "react/style-prop-object": "off", "import/no-unassigned-import": "off", "import/no-named-as-default-member": "off", + "oxc/no-barrel-file": ["warn", { "threshold": 0 }], "unicorn/no-array-sort": "off", "unicorn/consistent-function-scoping": "off", "no-new": "off", @@ -22,20 +23,22 @@ "react/no-array-index-key": "off", "react/jsx-no-useless-fragment": "error", "react/rules-of-hooks": "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" + "e18e/prefer-array-at": "error", + "e18e/prefer-array-fill": "error", + "e18e/prefer-array-from-map": "error", + "e18e/prefer-array-some": "error", + "e18e/prefer-array-to-reversed": "error", + "e18e/prefer-array-to-sorted": "error", + "e18e/prefer-array-to-spliced": "error", + "e18e/prefer-date-now": "error", + "e18e/prefer-includes": "error", + "e18e/prefer-nullish-coalescing": "error", + "e18e/prefer-object-has-own": "error", + "e18e/prefer-regex-test": "error", + "e18e/prefer-spread-syntax": "off", + "e18e/prefer-static-regex": "error", + "e18e/prefer-timer-args": "error", + "e18e/prefer-url-canparse": "error" }, - "overrides": [ - { - "files": ["apps/web/src/**/*", "apps/native/src/**/*"], - "rules": { - "lingui/no-unlocalized-strings": "warn" - } - } - ], "ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"] } diff --git a/apps/native/.oxlintrc.json b/apps/native/.oxlintrc.json index e91d847..96b961a 100644 --- a/apps/native/.oxlintrc.json +++ b/apps/native/.oxlintrc.json @@ -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"] } diff --git a/apps/native/modules/widget-images/index.ts b/apps/native/modules/widget-images/index.ts index 182316d..efb9c80 100644 --- a/apps/native/modules/widget-images/index.ts +++ b/apps/native/modules/widget-images/index.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; } diff --git a/apps/native/src/app/(auth)/server-url.tsx b/apps/native/src/app/(auth)/server-url.tsx index 7073daf..576ff30 100644 --- a/apps/native/src/app/(auth)/server-url.tsx +++ b/apps/native/src/app/(auth)/server-url.tsx @@ -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 ( diff --git a/apps/native/src/app/(tabs)/(settings)/index.tsx b/apps/native/src/app/(tabs)/(settings)/index.tsx index c3140f0..257e6ae 100644 --- a/apps/native/src/app/(tabs)/(settings)/index.tsx +++ b/apps/native/src/app/(tabs)/(settings)/index.tsx @@ -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 /> updateName.mutate({ name: nameInput })}> diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index a0cdca9..af4bec9 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -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) { diff --git a/apps/native/src/components/settings/integration-card.tsx b/apps/native/src/components/settings/integration-card.tsx index c717fb2..ddb224d 100644 --- a/apps/native/src/components/settings/integration-card.tsx +++ b/apps/native/src/components/settings/integration-card.tsx @@ -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(() => { diff --git a/apps/native/src/components/ui/expandable-text.tsx b/apps/native/src/components/ui/expandable-text.tsx index 1451d4a..e851ef5 100644 --- a/apps/native/src/components/ui/expandable-text.tsx +++ b/apps/native/src/components/ui/expandable-text.tsx @@ -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; diff --git a/apps/native/src/hooks/use-debounce.ts b/apps/native/src/hooks/use-debounce.ts index 097cbf3..bdc6cb5 100644 --- a/apps/native/src/hooks/use-debounce.ts +++ b/apps/native/src/hooks/use-debounce.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; export function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay); + const timer = setTimeout(setDebouncedValue, delay, value); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; diff --git a/apps/native/src/hooks/use-server-connection.ts b/apps/native/src/hooks/use-server-connection.ts index a6fd94b..96acb87 100644 --- a/apps/native/src/hooks/use-server-connection.ts +++ b/apps/native/src/hooks/use-server-connection.ts @@ -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 }; } diff --git a/apps/native/src/lib/server.ts b/apps/native/src/lib/server.ts index d396877..915f958 100644 --- a/apps/native/src/lib/server.ts +++ b/apps/native/src/lib/server.ts @@ -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 { export async function validateServerUrl(url: string): Promise { 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); }, []); diff --git a/apps/public-api/src/app.ts b/apps/public-api/src/app.ts index ecf1592..b118947 100644 --- a/apps/public-api/src/app.ts +++ b/apps/public-api/src/app.ts @@ -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) { diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index 69115b0..b704e45 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -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, diff --git a/apps/server/src/orpc/openapi-handler.ts b/apps/server/src/orpc/openapi-handler.ts index 96e3798..1e23fdf 100644 --- a/apps/server/src/orpc/openapi-handler.ts +++ b/apps/server/src/orpc/openapi-handler.ts @@ -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(); diff --git a/apps/web/.oxlintrc.json b/apps/web/.oxlintrc.json index c4b9c9d..527b1bd 100644 --- a/apps/web/.oxlintrc.json +++ b/apps/web/.oxlintrc.json @@ -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" } } diff --git a/apps/web/package.json b/apps/web/package.json index 479f793..04879bc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:", diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index 15e2e64..4c7bcec 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -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>(null); @@ -170,7 +190,7 @@ export function CommandPalette() { return ( <> - + Command Palette diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 66a6de8..e109899 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -70,37 +70,35 @@ function useActiveIndicator( itemRefs: React.MutableRefObject<(HTMLElement | null)[]>, measure: (itemRect: DOMRect, containerRect: DOMRect) => T, ): { value: T | null; instant: boolean } { - const [value, setValue] = useState(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({ diff --git a/apps/web/src/components/navigation-progress.tsx b/apps/web/src/components/navigation-progress.tsx index db5b6a4..11fe5b6 100644 --- a/apps/web/src/components/navigation-progress.tsx +++ b/apps/web/src/components/navigation-progress.tsx @@ -61,7 +61,7 @@ export function NavigationProgress() { finishTimerRef.current = setTimeout(() => { setVisible(false); - setTimeout(() => setProgress(0), 200); + setTimeout(setProgress, 200, 0); }, 200); }); diff --git a/apps/web/src/components/people/filmography-grid.tsx b/apps/web/src/components/people/filmography-grid.tsx index 41d51e2..d8f9843 100644 --- a/apps/web/src/components/people/filmography-grid.tsx +++ b/apps/web/src/components/people/filmography-grid.tsx @@ -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); } diff --git a/apps/web/src/components/settings/account-section.tsx b/apps/web/src/components/settings/account-section.tsx index 4065c04..9a3c868 100644 --- a/apps/web/src/components/settings/account-section.tsx +++ b/apps/web/src/components/settings/account-section.tsx @@ -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) { + 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) { - const file = e.target.files?.[0]; - if (!file) return; - uploadAvatarMutation.mutate(file); + }); } const removeAvatarMutation = useMutation( @@ -579,7 +577,7 @@ function SofaImportDialog() { <> - Import Sofa export + Import Sofa data Review what was found and choose what to import. diff --git a/apps/web/src/components/settings/backup-restore-section.tsx b/apps/web/src/components/settings/backup-restore-section.tsx index adc2322..2d38e06 100644 --- a/apps/web/src/components/settings/backup-restore-section.tsx +++ b/apps/web/src/components/settings/backup-restore-section.tsx @@ -26,8 +26,10 @@ export function BackupRestoreSection() { const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(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 ( diff --git a/apps/web/src/components/settings/integration-card.tsx b/apps/web/src/components/settings/integration-card.tsx index 46ecb69..6302c11 100644 --- a/apps/web/src/components/settings/integration-card.tsx +++ b/apps/web/src/components/settings/integration-card.tsx @@ -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 ( diff --git a/apps/web/src/components/settings/system-health-section.tsx b/apps/web/src/components/settings/system-health-section.tsx index 78b2e3b..e7a7dc0 100644 --- a/apps/web/src/components/settings/system-health-section.tsx +++ b/apps/web/src/components/settings/system-health-section.tsx @@ -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; diff --git a/apps/web/src/components/setup/copy-button.tsx b/apps/web/src/components/setup/copy-button.tsx index 4b5854e..445f3aa 100644 --- a/apps/web/src/components/setup/copy-button.tsx +++ b/apps/web/src/components/setup/copy-button.tsx @@ -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 ( diff --git a/apps/web/src/components/title-card.tsx b/apps/web/src/components/title-card.tsx index b7411c4..361beaa 100644 --- a/apps/web/src/components/title-card.tsx +++ b/apps/web/src/components/title-card.tsx @@ -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(userStatus ?? null); - - // Sync local state when prop changes (e.g. after navigation or SWR revalidation) - useEffect(() => { - setAddedStatus(userStatus ?? null); - }, [userStatus]); + const [optimisticStatus, setOptimisticStatus] = useState(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 = ( - + diff --git a/apps/web/src/components/titles/title-availability.tsx b/apps/web/src/components/titles/title-availability.tsx index 5e375b2..fe97b9c 100644 --- a/apps/web/src/components/titles/title-availability.tsx +++ b/apps/web/src/components/titles/title-availability.tsx @@ -23,7 +23,14 @@ function ProviderBadge({ , + render: ( + + ), } : {})} 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"}`} diff --git a/apps/web/src/components/titles/title-seasons.tsx b/apps/web/src/components/titles/title-seasons.tsx index e04024e..af94494 100644 --- a/apps/web/src/components/titles/title-seasons.tsx +++ b/apps/web/src/components/titles/title-seasons.tsx @@ -151,16 +151,9 @@ export function TitleSeasons({ key={season.id} className="border-border/50 bg-card/50 overflow-hidden rounded-xl border" > -
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" >
@@ -217,7 +210,7 @@ export function TitleSeasons({ )}
-
+ {isOpen && ( diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index be70e41..4d1dda6 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -54,6 +54,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { return ( & VariantProps) { + const focusInput = (e: React.SyntheticEvent) => { + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }; return (
{ - 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} /> diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx index c164772..933dc1e 100644 --- a/apps/web/src/components/ui/label.tsx +++ b/apps/web/src/components/ui/label.tsx @@ -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