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
+19 -16
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "./node_modules/oxlint/configuration_schema.json", "$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest"], "plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest", "jsx-a11y"],
"jsPlugins": ["eslint-plugin-lingui"], "jsPlugins": ["@e18e/eslint-plugin"],
"env": { "env": {
"builtin": true "builtin": true
}, },
@@ -15,6 +15,7 @@
"react/style-prop-object": "off", "react/style-prop-object": "off",
"import/no-unassigned-import": "off", "import/no-unassigned-import": "off",
"import/no-named-as-default-member": "off", "import/no-named-as-default-member": "off",
"oxc/no-barrel-file": ["warn", { "threshold": 0 }],
"unicorn/no-array-sort": "off", "unicorn/no-array-sort": "off",
"unicorn/consistent-function-scoping": "off", "unicorn/consistent-function-scoping": "off",
"no-new": "off", "no-new": "off",
@@ -22,20 +23,22 @@
"react/no-array-index-key": "off", "react/no-array-index-key": "off",
"react/jsx-no-useless-fragment": "error", "react/jsx-no-useless-fragment": "error",
"react/rules-of-hooks": "error", "react/rules-of-hooks": "error",
"lingui/no-unlocalized-strings": "off", "e18e/prefer-array-at": "error",
"lingui/t-call-in-function": "error", "e18e/prefer-array-fill": "error",
"lingui/no-single-variables-to-translate": "error", "e18e/prefer-array-from-map": "error",
"lingui/no-expression-in-message": "error", "e18e/prefer-array-some": "error",
"lingui/no-single-tag-to-translate": "error", "e18e/prefer-array-to-reversed": "error",
"lingui/no-trans-inside-trans": "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"] "ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"]
} }
+30 -2
View File
@@ -1,12 +1,40 @@
{ {
"$schema": "../../node_modules/oxlint/configuration_schema.json", "$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.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": { "rules": {
"@tanstack/query/exhaustive-deps": "error", "@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-rest-destructuring": "warn", "@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/stable-query-client": "error", "@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"] "ignorePatterns": ["node_modules", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
} }
@@ -1,7 +1,6 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
function getModule() { function getModule() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("./src/WidgetImagesModule").default; 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 { getServerUrl, serverManager, type ValidationError } from "@/lib/server";
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
const TRAILING_SLASHES_RE = /\/+$/;
type ConnectionState = type ConnectionState =
| { phase: "idle" } | { phase: "idle" }
| { phase: "connecting" } | { phase: "connecting" }
@@ -91,14 +93,12 @@ export default function ServerUrlScreen() {
}; };
const handleConnect = async () => { const handleConnect = async () => {
const trimmed = url.trim().replace(/\/+$/, ""); const trimmed = url.trim().replace(TRAILING_SLASHES_RE, "");
if (!trimmed) return; if (!trimmed) return;
const fullUrl = serverManager.normalizeUrl(trimmed); const fullUrl = serverManager.normalizeUrl(trimmed);
try { if (!URL.canParse(fullUrl)) {
new URL(fullUrl);
} catch {
setConnection({ phase: "error", error: "invalid_url" }); setConnection({ phase: "error", error: "invalid_url" });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
return; return;
@@ -122,16 +122,8 @@ export default function ServerUrlScreen() {
const isConnecting = connection.phase === "connecting"; const isConnecting = connection.phase === "connecting";
const isSuccess = connection.phase === "success"; const isSuccess = connection.phase === "success";
const trimmedUrl = url.trim().replace(/\/+$/, ""); const trimmedUrl = url.trim().replace(TRAILING_SLASHES_RE, "");
const isValidUrl = (() => { const isValidUrl = !!trimmedUrl && URL.canParse(serverManager.normalizeUrl(trimmedUrl));
if (!trimmedUrl) return false;
try {
new URL(serverManager.normalizeUrl(trimmedUrl));
return true;
} catch {
return false;
}
})();
const isDisabled = isConnecting || isSuccess; const isDisabled = isConnecting || isSuccess;
return ( return (
@@ -25,7 +25,7 @@ import { reloadAppAsync } from "expo";
import * as Application from "expo-application"; import * as Application from "expo-application";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { import {
Alert, Alert,
Linking, Linking,
@@ -69,11 +69,16 @@ export default function SettingsScreen() {
const { push } = useRouter(); const { push } = useRouter();
const { data: session, refetch: refetchSession } = authClient.useSession(); const { data: session, refetch: refetchSession } = authClient.useSession();
const [isEditingName, setIsEditingName] = useState(false); 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 (sessionUserName !== prevSessionName) {
if (!isEditingName && session?.user?.name) setNameInput(session.user.name); setPrevSessionName(sessionUserName);
}, [session?.user?.name, isEditingName]); if (!isEditingName && sessionUserName) {
setNameInput(sessionUserName);
}
}
const [languageModalOpen, setLanguageModalOpen] = useState(false); const [languageModalOpen, setLanguageModalOpen] = useState(false);
const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale; const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
@@ -305,7 +310,6 @@ export default function SettingsScreen() {
accessibilityLabel="Display name" accessibilityLabel="Display name"
onChangeText={setNameInput} onChangeText={setNameInput}
className="border-primary text-foreground min-h-10 flex-1 border-b py-2 font-sans text-base" 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 })}> <Pressable onPress={() => updateName.mutate({ name: nameInput })}>
<Text className="text-primary text-sm"> <Text className="text-primary text-sm">
+11 -7
View File
@@ -180,22 +180,26 @@ export default function TitleDetailScreen() {
() => (useAutomaticInsets ? { marginTop: -headerHeight } : undefined), () => (useAutomaticInsets ? { marginTop: -headerHeight } : undefined),
[useAutomaticInsets, headerHeight], [useAutomaticInsets, headerHeight],
); );
const darkMuted = palette?.darkMuted;
const vibrant = palette?.vibrant;
const darkVibrant = palette?.darkVibrant;
const darkMutedOverlayStyle = useMemo( const darkMutedOverlayStyle = useMemo(
() => (palette?.darkMuted ? { backgroundColor: palette.darkMuted, opacity: 0.2 } : undefined), () => (darkMuted ? { backgroundColor: darkMuted, opacity: 0.2 } : undefined),
[palette?.darkMuted], [darkMuted],
); );
const vibrantOverlayStyle = useMemo( const vibrantOverlayStyle = useMemo(
() => (palette?.vibrant ? { backgroundColor: palette.vibrant, opacity: 0.06 } : undefined), () => (vibrant ? { backgroundColor: vibrant, opacity: 0.06 } : undefined),
[palette?.vibrant], [vibrant],
); );
const posterShadowStyle = useMemo( const posterShadowStyle = useMemo(
() => () =>
palette?.darkVibrant darkVibrant
? { ? {
boxShadow: `0 12px 28px -8px ${palette.darkVibrant}80`, boxShadow: `0 12px 28px -8px ${darkVibrant}80`,
} }
: undefined, : undefined,
[palette?.darkVibrant], [darkVibrant],
); );
if (detail.isPending) { if (detail.isPending) {
@@ -124,7 +124,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
setCopied(true); setCopied(true);
toast.success(t`URL copied to clipboard`); toast.success(t`URL copied to clipboard`);
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); copiedTimerRef.current = setTimeout(setCopied, 2000, false);
}, [url, t]); }, [url, t]);
const handleRegenerate = useCallback(() => { const handleRegenerate = useCallback(() => {
@@ -1,5 +1,5 @@
import { useLingui } from "@lingui/react/macro"; 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 type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
import { Platform, Pressable, View } from "react-native"; import { Platform, Pressable, View } from "react-native";
@@ -15,14 +15,15 @@ export function ExpandableText({
actionColor?: string; actionColor?: string;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const prevTextRef = useRef(text); const [prevText, setPrevText] = useState(text);
let expanded: boolean;
let needsTruncation: boolean;
const [expandedState, setExpanded] = useState(false); const [expandedState, setExpanded] = useState(false);
const [needsTruncationState, setNeedsTruncation] = useState(false); const [needsTruncationState, setNeedsTruncation] = useState(false);
if (prevTextRef.current !== text) { let expanded: boolean;
prevTextRef.current = text; let needsTruncation: boolean;
if (prevText !== text) {
setPrevText(text);
setExpanded(false); setExpanded(false);
setNeedsTruncation(false); setNeedsTruncation(false);
expanded = 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 { export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value); const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay); const timer = setTimeout(setDebouncedValue, delay, value);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [value, delay]); }, [value, delay]);
return debouncedValue; return debouncedValue;
+18 -9
View File
@@ -1,6 +1,6 @@
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv"; import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv";
import { queryClient } from "@/lib/query-client"; 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 // Track whether the session was seeded from cache and hasn't yet been
// confirmed by the server. Once confirmed, flip to false so explicit // confirmed by the server. Once confirmed, flip to false so explicit
// sign-outs don't show a misleading "session expired" toast. // sign-outs don't show a misleading "session expired" toast.
const hadOptimisticSession = useRef(wasCachedSessionSeeded()); const [hadOptimisticSession, setHadOptimisticSession] = useState(wasCachedSessionSeeded);
const prevSession = useRef(session); const [prevSession, setPrevSession] = useState(session);
if (hadOptimisticSession.current && session && !isRefetching) { if (hadOptimisticSession && session && !isRefetching) {
hadOptimisticSession.current = false; 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(); const { replace } = useRouter();
@@ -107,19 +117,18 @@ export function useServerConnection() {
// availability, but enableFreeze can prevent the navigator from // availability, but enableFreeze can prevent the navigator from
// transitioning on its own. // transitioning on its own.
useEffect(() => { useEffect(() => {
if (prevSession.current && !session) { if (sessionLost) {
const changingServer = consumeServerChangeRequest(); const changingServer = consumeServerChangeRequest();
replace(changingServer ? "/(auth)/server-url" : "/(auth)/login"); replace(changingServer ? "/(auth)/server-url" : "/(auth)/login");
if (hadOptimisticSession.current) { if (hadOptimisticSession) {
toast.info(i18n._(msg`Session expired`), { toast.info(i18n._(msg`Session expired`), {
description: i18n._(msg`Please sign in again.`), description: i18n._(msg`Please sign in again.`),
}); });
clearCachedSessionSeeded(); clearCachedSessionSeeded();
} }
} }
prevSession.current = session; }, [sessionLost, replace, hadOptimisticSession]);
}, [session, replace]);
return { session, isPending, hasServerUrl, instanceId }; 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 SERVERS_MAP_KEY = "sofa_servers";
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id"; const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com"; const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
const TRAILING_SLASHES_RE = /\/+$/;
const PROTOCOL_RE = /^(https?:\/\/)(.*)/;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// URL helpers // URL helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function normalizeUrl(input: string): string { export function normalizeUrl(input: string): string {
let url = input.trim().replace(/\/+$/, ""); let url = input.trim().replace(TRAILING_SLASHES_RE, "");
if (url && !url.includes("://")) { if (url && !url.includes("://")) {
url = `http://${url}`; url = `http://${url}`;
} }
@@ -61,7 +63,7 @@ export function normalizeUrl(input: string): string {
} }
export function splitUrl(input: string): { protocol: string; host: string } { export function splitUrl(input: string): { protocol: string; host: string } {
const match = input.match(/^(https?:\/\/)(.*)/); const match = input.match(PROTOCOL_RE);
if (match) { if (match) {
return { protocol: match[1], host: match[2] }; return { protocol: match[1], host: match[2] };
} }
@@ -85,7 +87,7 @@ export function getServerUrl(): string {
} }
function setServerUrlInternal(url: string): void { function setServerUrlInternal(url: string): void {
const normalized = url.replace(/\/+$/, ""); const normalized = url.replace(TRAILING_SLASHES_RE, "");
globalStorage.set(SERVER_URL_KEY, normalized); 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> { export async function validateServerUrl(url: string): Promise<ValidationResult> {
const normalized = normalizeUrl(url); const normalized = normalizeUrl(url);
if (!normalized || !normalized.includes("://")) { if (!normalized || !normalized.includes("://") || !URL.canParse(normalized)) {
return { status: "error", error: "invalid_url" };
}
try {
new URL(normalized);
} catch {
return { status: "error", error: "invalid_url" }; return { status: "error", error: "invalid_url" };
} }
@@ -309,9 +305,14 @@ export function startReachabilityMonitor(): () => void {
export function useServerReachability() { export function useServerReachability() {
const [reachable, setReachableState] = useState(isReachable); const [reachable, setReachableState] = useState(isReachable);
const [prevReachable, setPrevReachable] = useState(isReachable);
if (isReachable !== prevReachable) {
setPrevReachable(isReachable);
setReachableState(isReachable);
}
useEffect(() => { useEffect(() => {
setReachableState(isReachable);
return onServerReachabilityChange(setReachableState); return onServerReachabilityChange(setReachableState);
}, []); }, []);
+2 -1
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { getImporter, getImporterConfig } from "./importers"; import { getImporter, getImporterConfig } from "./importers";
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest"; const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const VERSION_PREFIX_RE = /^v/;
const app = new Hono(); 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"); c.header("Cache-Control", "public, s-maxage=900, stale-while-revalidate=3600");
return c.json({ return c.json({
version: data.tag_name.replace(/^v/, ""), version: data.tag_name.replace(VERSION_PREFIX_RE, ""),
release_url: data.html_url, release_url: data.html_url,
}); });
} catch (e) { } catch (e) {
+1 -1
View File
@@ -80,7 +80,7 @@ export function getJobSchedules(): {
pattern: string; pattern: string;
nextRunAt: string | null; nextRunAt: string | null;
}[] { }[] {
return Array.from(jobs.entries()).map(([name, cron]) => ({ return Array.from(jobs.entries(), ([name, cron]) => ({
jobName: name, jobName: name,
pattern: cron.getPattern() ?? "", pattern: cron.getPattern() ?? "",
nextRunAt: cron.nextRun()?.toISOString() ?? null, 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 { generateOpenApiSpec, openApiTags, schemaConverters } from "./openapi-spec";
import { implementedRouter } from "./router"; import { implementedRouter } from "./router";
const TRAILING_SLASH_RE = /\/$/;
const log = createLogger("openapi"); const log = createLogger("openapi");
const isSecure = (process.env.BETTER_AUTH_URL ?? "").startsWith("https://"); const isSecure = (process.env.BETTER_AUTH_URL ?? "").startsWith("https://");
@@ -79,9 +81,9 @@ export const openApiHandler = new OpenAPIHandler(implementedRouter, {
], ],
interceptors: [ interceptors: [
async (options) => { async (options) => {
const requestPathname = options.request.url.pathname.replace(/\/$/, "") || "/"; const requestPathname = options.request.url.pathname.replace(TRAILING_SLASH_RE, "") || "/";
const prefix = options.prefix?.replace(/\/$/, "") || ""; const prefix = options.prefix?.replace(TRAILING_SLASH_RE, "") || "";
const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/"; const specPath = `${prefix}/spec.json`.replace(TRAILING_SLASH_RE, "") || "/";
if (options.request.method !== "GET" || requestPathname !== specPath) { if (options.request.method !== "GET" || requestPathname !== specPath) {
return options.next(); return options.next();
+31 -2
View File
@@ -1,12 +1,41 @@
{ {
"$schema": "../../node_modules/oxlint/configuration_schema.json", "$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.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": { "rules": {
"@tanstack/query/exhaustive-deps": "error", "@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-rest-destructuring": "warn", "@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/stable-query-client": "error", "@tanstack/query/stable-query-client": "error",
"@tanstack/query/no-unstable-deps": "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/api": "workspace:*",
"@sofa/i18n": "workspace:*", "@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.2", "@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.2", "@tanstack/react-router": "1.168.2",
"better-auth": "catalog:", "better-auth": "catalog:",
+30 -10
View File
@@ -49,6 +49,8 @@ const SHORTCUT_DESCRIPTIONS = [
{ scope: "Global", description: "Keyboard shortcuts", keys: ["?"] }, { scope: "Global", description: "Keyboard shortcuts", keys: ["?"] },
{ scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] }, { scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] },
{ scope: "Navigation", description: "Go to explore", keys: ["g", "e"] }, { 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: "Cycle status", keys: ["w"] },
{ scope: "Title", description: "Mark watched", keys: ["m"] }, { scope: "Title", description: "Mark watched", keys: ["m"] },
{ scope: "Title", description: "Go back", keys: ["Escape"] }, { scope: "Title", description: "Go back", keys: ["Escape"] },
@@ -95,8 +97,19 @@ export function CommandPalette() {
const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? []; const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? [];
const enabled = !commandPaletteOpen; const enabled = !commandPaletteOpen;
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev)); const handleOpenChange = useCallback(
useHotkey("/", () => setCommandPaletteOpen(true), { enabled }); (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 }); useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled });
useHotkeySequence( useHotkeySequence(
["G", "H"], ["G", "H"],
@@ -112,13 +125,20 @@ export function CommandPalette() {
}, },
{ enabled, timeout: 500 }, { enabled, timeout: 500 },
); );
useHotkeySequence(
// Reset query when palette opens ["G", "U"],
useEffect(() => { () => {
if (commandPaletteOpen) { void navigate({ to: "/upcoming" });
setQuery(""); },
} { enabled, timeout: 500 },
}, [commandPaletteOpen]); );
useHotkeySequence(
["G", "S"],
() => {
void navigate({ to: "/settings" });
},
{ enabled, timeout: 500 },
);
// Save to recent searches after user stops typing for a while // Save to recent searches after user stops typing for a while
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(null); const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
@@ -170,7 +190,7 @@ export function CommandPalette() {
return ( return (
<> <>
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}> <Dialog open={commandPaletteOpen} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle> <DialogTitle>Command Palette</DialogTitle>
<DialogDescription> <DialogDescription>
+14 -16
View File
@@ -70,37 +70,35 @@ function useActiveIndicator<T>(
itemRefs: React.MutableRefObject<(HTMLElement | null)[]>, itemRefs: React.MutableRefObject<(HTMLElement | null)[]>,
measure: (itemRect: DOMRect, containerRect: DOMRect) => T, measure: (itemRect: DOMRect, containerRect: DOMRect) => T,
): { value: T | null; instant: boolean } { ): { value: T | null; instant: boolean } {
const [value, setValue] = useState<T | null>(null); const [state, setState] = useState<{ value: T | null; instant: boolean }>({
const instantRef = useRef(true); value: null,
instant: true,
});
useLayoutEffect(() => { useLayoutEffect(() => {
const update = () => { const computeValue = (): T | null => {
if (activeIndex === -1) { if (activeIndex === -1) return null;
setValue(null);
return;
}
const item = itemRefs.current[activeIndex]; const item = itemRefs.current[activeIndex];
const container = containerRef.current; const container = containerRef.current;
if (item && container && container.offsetWidth > 0) { if (item && container && container.offsetWidth > 0) {
setValue(measure(item.getBoundingClientRect(), container.getBoundingClientRect())); return measure(item.getBoundingClientRect(), container.getBoundingClientRect());
} else {
setValue(null);
} }
return null;
}; };
update(); // Initial measurement is instant; subsequent activeIndex changes animate
// After the initial measurement, allow subsequent changes to animate setState({ value: computeValue(), instant: true });
instantRef.current = false; // Use microtask to flip instant to false after the synchronous paint
queueMicrotask(() => setState((prev) => (prev.instant ? { ...prev, instant: false } : prev)));
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
instantRef.current = true; setState({ value: computeValue(), instant: true });
update();
}); });
observer.observe(container); observer.observe(container);
return () => observer.disconnect(); return () => observer.disconnect();
}, [activeIndex, containerRef, itemRefs, measure]); }, [activeIndex, containerRef, itemRefs, measure]);
return { value, instant: instantRef.current }; return state;
} }
export function NavBar({ export function NavBar({
@@ -61,7 +61,7 @@ export function NavigationProgress() {
finishTimerRef.current = setTimeout(() => { finishTimerRef.current = setTimeout(() => {
setVisible(false); setVisible(false);
setTimeout(() => setProgress(0), 200); setTimeout(setProgress, 200, 0);
}, 200); }, 200);
}); });
@@ -40,7 +40,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
return true; return true;
}); });
return [...list].sort((a, b) => { return list.toSorted((a, b) => {
if (sort === "rating") { if (sort === "rating") {
return (b.voteAverage ?? 0) - (a.voteAverage ?? 0); return (b.voteAverage ?? 0) - (a.voteAverage ?? 0);
} }
@@ -98,8 +98,12 @@ export function AccountSection({
}); });
const initial = displayName?.charAt(0).toUpperCase() ?? "?"; const initial = displayName?.charAt(0).toUpperCase() ?? "?";
const uploadAvatarMutation = useMutation( const uploadAvatarMutation = useMutation(orpc.account.uploadAvatar.mutationOptions());
orpc.account.uploadAvatar.mutationOptions({
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
uploadAvatarMutation.mutate(file, {
onSuccess: (data) => { onSuccess: (data) => {
setAvatarUrl(data.imageUrl); setAvatarUrl(data.imageUrl);
toast.success(t`Profile picture updated`); toast.success(t`Profile picture updated`);
@@ -111,13 +115,7 @@ export function AccountSection({
onSettled: () => { onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = ""; 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( const removeAvatarMutation = useMutation(
@@ -579,7 +577,7 @@ function SofaImportDialog() {
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Import Sofa export</Trans> <Trans>Import Sofa data</Trans>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<Trans>Review what was found and choose what to import.</Trans> <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 [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const restoreMutation = useMutation( const restoreMutation = useMutation(orpc.admin.backups.restore.mutationOptions());
orpc.admin.backups.restore.mutationOptions({
function handleRestore(file: File) {
restoreMutation.mutate(file, {
onSuccess: () => { onSuccess: () => {
toast.success(t`Database restored. Reloading...`); toast.success(t`Database restored. Reloading...`);
setTimeout(() => window.location.reload(), 1500); setTimeout(() => window.location.reload(), 1500);
@@ -38,11 +40,7 @@ export function BackupRestoreSection() {
onSettled: () => { onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}, },
}), });
);
function handleRestore(file: File) {
restoreMutation.mutate(file);
} }
return ( return (
@@ -135,7 +135,7 @@ export function IntegrationCard({
if (!url) return; if (!url) return;
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(setCopied, 2000, false);
} }
return ( return (
@@ -29,6 +29,8 @@ import { useTimeAgo } from "@/hooks/use-time-ago";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas"; import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
const DIGITS_ONLY_RE = /^\d+$/;
/** Convert a cron pattern to a short human-readable string */ /** Convert a cron pattern to a short human-readable string */
function cronToHuman(pattern: string): string { function cronToHuman(pattern: string): string {
const parts = pattern.split(" "); const parts = pattern.split(" ");
@@ -50,12 +52,12 @@ function cronToHuman(pattern: string): string {
} }
// Daily at specific time: "0 3 * * *" // 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")}`; return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
} }
// Weekly // Weekly
if (/^\d+$/.test(dow)) { if (DIGITS_ONLY_RE.test(dow)) {
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return `Weekly on ${days[Number(dow)] ?? dow}`; return `Weekly on ${days[Number(dow)] ?? dow}`;
} }
@@ -326,7 +328,7 @@ function BackgroundJobsCard({
? (triggerJobMutation.variables?.name ?? null) ? (triggerJobMutation.variables?.name ?? null)
: 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.disabled !== b.disabled) return a.disabled ? 1 : -1;
if (!a.nextRunAt && !b.nextRunAt) return 0; if (!a.nextRunAt && !b.nextRunAt) return 0;
if (!a.nextRunAt) return 1; if (!a.nextRunAt) return 1;
@@ -10,7 +10,7 @@ export function CopyButton({ code }: { code: string }) {
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(setCopied, 2000, false);
} }
return ( return (
+16 -13
View File
@@ -12,7 +12,7 @@ import {
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { type MotionStyle, type MotionValue, motion } from "motion/react"; 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 { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -85,19 +85,15 @@ function useStatusConfig() {
function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) { function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) {
const { t } = useLingui(); const { t } = useLingui();
const statusConfig = useStatusConfig(); const statusConfig = useStatusConfig();
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(userStatus ?? null); const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null);
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
useEffect(() => {
setAddedStatus(userStatus ?? null);
}, [userStatus]);
const quickAddMutation = useMutation( const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({ orpc.titles.quickAdd.mutationOptions({
onSuccess: () => setAddedStatus("in_watchlist"), onSuccess: () => setOptimisticStatus("in_watchlist"),
}), }),
); );
const addedStatus = optimisticStatus ?? userStatus ?? null;
const isAdded = addedStatus != null; const isAdded = addedStatus != null;
const config = addedStatus ? statusConfig[addedStatus] : null; const config = addedStatus ? statusConfig[addedStatus] : null;
@@ -282,10 +278,17 @@ export function TitleCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
}: TitleCardProps) { }: TitleCardProps) {
const tilt = useTiltEffect(); const {
ref: tiltRef,
containerStyle,
imageStyle,
glareBackground,
glareOpacity,
handlers,
} = useTiltEffect();
const cardContent = ( const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}> <motion.div ref={tiltRef} style={containerStyle} {...handlers}>
<CardInner <CardInner
title={title} title={title}
type={type} type={type}
@@ -296,9 +299,9 @@ export function TitleCard({
userStatus={userStatus} userStatus={userStatus}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
tiltStyles={{ tiltStyles={{
imageStyle: tilt.imageStyle, imageStyle,
glareBackground: tilt.glareBackground, glareBackground,
glareOpacity: tilt.glareOpacity, glareOpacity,
}} }}
/> />
</motion.div> </motion.div>
@@ -23,7 +23,14 @@ function ProviderBadge({
<TooltipTrigger <TooltipTrigger
{...(watchUrl {...(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"}`} 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} key={season.id}
className="border-border/50 bg-card/50 overflow-hidden rounded-xl border" className="border-border/50 bg-card/50 overflow-hidden rounded-xl border"
> >
<div <button
role="button" type="button"
tabIndex={0}
onClick={() => setOpenSeason(isOpen ? null : season.seasonNumber)} 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" 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"> <div className="flex items-center gap-3">
@@ -217,7 +210,7 @@ export function TitleSeasons({
<IconChevronDown aria-hidden={true} className="text-muted-foreground size-4" /> <IconChevronDown aria-hidden={true} className="text-muted-foreground size-4" />
)} )}
</div> </div>
</div> </button>
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
@@ -54,6 +54,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="breadcrumb-page" 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" role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
+10 -4
View File
@@ -44,17 +44,23 @@ function InputGroupAddon({
align = "inline-start", align = "inline-start",
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) { }: 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 ( return (
<div <div
role="group" role="group"
data-slot="input-group-addon" data-slot="input-group-addon"
data-align={align} data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)} className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => { onClick={focusInput}
if ((e.target as HTMLElement).closest("button")) { onKeyDown={(e) => {
return; if (e.key === "Enter" || e.key === " ") {
focusInput(e);
} }
e.currentTarget.parentElement?.querySelector("input")?.focus();
}} }}
{...props} {...props}
/> />
+1
View File
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) { function Label({ className, ...props }: React.ComponentProps<"label">) {
return ( return (
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- htmlFor is passed via props spread
<label <label
data-slot="label" data-slot="label"
className={cn( className={cn(
@@ -42,6 +42,7 @@ function PaginationLink({ className, isActive, size = "icon", ...props }: Pagina
className={cn(className)} className={cn(className)}
nativeButton={false} nativeButton={false}
render={ render={
// oxlint-disable-next-line jsx-a11y/anchor-has-content -- content is provided by the Button's children
<a <a
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
data-slot="pagination-link" 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); const [debounced, setDebounced] = useState(value);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay); const timer = setTimeout(setDebounced, delay, value);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [value, delay]); }, [value, delay]);
+1 -1
View File
@@ -14,7 +14,6 @@ import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/dashboard")({ export const Route = createFileRoute("/_app/dashboard")({
staleTime: 30_000, staleTime: 30_000,
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
loader: async ({ context }) => { loader: async ({ context }) => {
await Promise.all([ await Promise.all([
context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()), context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
@@ -33,6 +32,7 @@ export const Route = createFileRoute("/_app/dashboard")({
), ),
]); ]);
}, },
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
pendingComponent: DashboardSkeleton, pendingComponent: DashboardSkeleton,
component: DashboardPage, component: DashboardPage,
}); });
+1 -1
View File
@@ -12,7 +12,6 @@ import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/explore")({ export const Route = createFileRoute("/_app/explore")({
staleTime: 60_000, staleTime: 60_000,
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
loader: async ({ context }) => { loader: async ({ context }) => {
await Promise.all([ await Promise.all([
context.queryClient.ensureInfiniteQueryData( context.queryClient.ensureInfiniteQueryData(
@@ -37,6 +36,7 @@ export const Route = createFileRoute("/_app/explore")({
), ),
]); ]);
}, },
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
pendingComponent: ExploreSkeletons, pendingComponent: ExploreSkeletons,
component: ExplorePage, component: ExplorePage,
}); });
+1 -1
View File
@@ -29,7 +29,6 @@ const GITHUB_REPO = "jakejarvis/sofa";
export const Route = createFileRoute("/_app/settings")({ export const Route = createFileRoute("/_app/settings")({
staleTime: 30_000, staleTime: 30_000,
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
loader: async ({ context }) => { loader: async ({ context }) => {
const promises: Promise<unknown>[] = [ const promises: Promise<unknown>[] = [
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()), context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
@@ -45,6 +44,7 @@ export const Route = createFileRoute("/_app/settings")({
} }
await Promise.all(promises); await Promise.all(promises);
}, },
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
pendingComponent: SettingsSkeleton, pendingComponent: SettingsSkeleton,
component: SettingsPage, component: SettingsPage,
}); });
+1 -1
View File
@@ -11,7 +11,6 @@ import { groupByDateBucket } from "@sofa/i18n/date-buckets";
export const Route = createFileRoute("/_app/upcoming")({ export const Route = createFileRoute("/_app/upcoming")({
staleTime: 30_000, staleTime: 30_000,
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
loader: async ({ context }) => { loader: async ({ context }) => {
await context.queryClient.ensureInfiniteQueryData( await context.queryClient.ensureInfiniteQueryData(
orpc.dashboard.upcoming.infiniteOptions({ orpc.dashboard.upcoming.infiniteOptions({
@@ -25,6 +24,7 @@ export const Route = createFileRoute("/_app/upcoming")({
}), }),
); );
}, },
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
pendingComponent: UpcomingSkeleton, pendingComponent: UpcomingSkeleton,
component: UpcomingPage, component: UpcomingPage,
}); });
+1 -1
View File
@@ -78,11 +78,11 @@ const envSnippets = [
]; ];
export const Route = createFileRoute("/setup")({ export const Route = createFileRoute("/setup")({
head: () => ({ meta: [{ title: "Setup — Sofa" }] }),
beforeLoad: async () => { beforeLoad: async () => {
const info = await client.system.publicInfo({}); const info = await client.system.publicInfo({});
if (info.tmdbConfigured) throw redirect({ to: "/" }); if (info.tmdbConfigured) throw redirect({ to: "/" });
}, },
head: () => ({ meta: [{ title: "Setup — Sofa" }] }),
component: SetupPage, component: SetupPage,
}); });
+15 -10
View File
@@ -1,13 +1,18 @@
import { playwright } from "@vitest/browser-playwright"; import { playwright } from "@vitest/browser-playwright";
import { defineProject } from "vitest/config"; import { defineProject, mergeConfig } from "vitest/config";
export default defineProject({ import viteConfig from "./vite.config";
test: {
include: ["src/**/*.test.{ts,tsx}"], export default mergeConfig(
browser: { viteConfig,
enabled: true, defineProject({
provider: playwright(), test: {
instances: [{ browser: "chromium", headless: true }], include: ["src/**/*.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium", headless: true }],
},
}, },
}, }),
}); );
+24 -6
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "sofa-monorepo", "name": "sofa-monorepo",
"devDependencies": { "devDependencies": {
"@e18e/eslint-plugin": "0.3.0",
"@lingui/cli": "catalog:", "@lingui/cli": "catalog:",
"@lingui/conf": "catalog:", "@lingui/conf": "catalog:",
"@lingui/format-po": "catalog:", "@lingui/format-po": "catalog:",
@@ -15,6 +16,7 @@
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"eslint-plugin-drizzle": "0.2.3", "eslint-plugin-drizzle": "0.2.3",
"eslint-plugin-lingui": "0.11.0", "eslint-plugin-lingui": "0.11.0",
"eslint-plugin-react-hooks": "7.0.1",
"oxfmt": "0.41.0", "oxfmt": "0.41.0",
"oxlint": "1.56.0", "oxlint": "1.56.0",
"playwright": "1.58.2", "playwright": "1.58.2",
@@ -165,7 +167,7 @@
"@sofa/api": "workspace:*", "@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*", "@sofa/i18n": "workspace:*",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.2", "@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.2", "@tanstack/react-router": "1.168.2",
"better-auth": "catalog:", "better-auth": "catalog:",
@@ -618,6 +620,8 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@e18e/eslint-plugin": ["@e18e/eslint-plugin@0.3.0", "", { "dependencies": { "eslint-plugin-depend": "^1.5.0" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0", "oxlint": "^1.55.0" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg=="],
"@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
@@ -1332,7 +1336,7 @@
"@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
"@tanstack/hotkeys": ["@tanstack/hotkeys@0.4.2", "", { "dependencies": { "@tanstack/store": "^0.9.2" } }, "sha512-dCCu6Q91wZ2Mz7Vb+tzzpbKH0cSY9JXqJS7ZyouxewNL8oVmI228P9BmP94/1255g5WjPS+njenyrbWVeEQP5Q=="], "@tanstack/hotkeys": ["@tanstack/hotkeys@0.4.3", "", { "dependencies": { "@tanstack/store": "^0.9.2" } }, "sha512-xl4ri2ALFS6U+5lUqZUGchsABIXi+fkfBgOxjW9i7KqmMcac8ZUZw/c99SYxnpOdSY7jSs7J09XHcrPz/ieFOw=="],
"@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="],
@@ -1348,7 +1352,7 @@
"@tanstack/react-form": ["@tanstack/react-form@1.28.5", "", { "dependencies": { "@tanstack/form-core": "1.28.5", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CL8IeWkeXnEEDsHt5wBuIOZvSYrKiLRtsC9ca0LzfJJ22SYCma9cBmh1UX1EBX0o3gH2U21PmUf+y5f9OJNoEQ=="], "@tanstack/react-form": ["@tanstack/react-form@1.28.5", "", { "dependencies": { "@tanstack/form-core": "1.28.5", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CL8IeWkeXnEEDsHt5wBuIOZvSYrKiLRtsC9ca0LzfJJ22SYCma9cBmh1UX1EBX0o3gH2U21PmUf+y5f9OJNoEQ=="],
"@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.4.2", "", { "dependencies": { "@tanstack/hotkeys": "0.4.2", "@tanstack/react-store": "^0.9.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-7AMAmX+l1k0tHCaDgT5lHHF0cmtrmK0aaKxR6DliSdAVVjfmyraPZQapLwpyNKoTKNYIROj+2Wg+OvWYP52Oog=="], "@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.5.1", "", { "dependencies": { "@tanstack/hotkeys": "0.4.3", "@tanstack/react-store": "^0.9.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-YWuUDUNS96FC/TtlsRK57StaGIqyhjO1oSjEOKq6P0mVgAYY1k6GejxIyXNqcadCuG5iy+peYmN62VEUc35y0A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.94.5", "", { "dependencies": { "@tanstack/query-core": "5.94.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-1wmrxKFkor+q8l+ygdHmv0Sq5g84Q3p4xvuJ7AdSIAhQQ7udOt+ZSZ19g1Jea3mHqtlTslLGJsmC4vHFgP0P3A=="], "@tanstack/react-query": ["@tanstack/react-query@5.94.5", "", { "dependencies": { "@tanstack/query-core": "5.94.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-1wmrxKFkor+q8l+ygdHmv0Sq5g84Q3p4xvuJ7AdSIAhQQ7udOt+ZSZ19g1Jea3mHqtlTslLGJsmC4vHFgP0P3A=="],
@@ -1816,6 +1820,8 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
@@ -1848,10 +1854,14 @@
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint-plugin-depend": ["eslint-plugin-depend@1.5.0", "", { "dependencies": { "empathic": "^2.0.0", "module-replacements": "^2.10.1", "semver": "^7.6.3" }, "peerDependencies": { "eslint": ">=8.40.0" } }, "sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A=="],
"eslint-plugin-drizzle": ["eslint-plugin-drizzle@0.2.3", "", { "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-BO+ymHo33IUNoJlC0rbd7HP9EwwpW4VIp49R/tWQF/d2E1K2kgTf0tCXT0v9MSiBr6gGR1LtPwMLapTKEWSg9A=="], "eslint-plugin-drizzle": ["eslint-plugin-drizzle@0.2.3", "", { "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-BO+ymHo33IUNoJlC0rbd7HP9EwwpW4VIp49R/tWQF/d2E1K2kgTf0tCXT0v9MSiBr6gGR1LtPwMLapTKEWSg9A=="],
"eslint-plugin-lingui": ["eslint-plugin-lingui@0.11.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.0.0", "micromatch": "^4.0.0" }, "peerDependencies": { "eslint": "^8.37.0 || ^9.0.0" } }, "sha512-O2Ixoapt5fa4VKZJgXhVwb6BHnzByIUDNMfZOhHWGMYk40GfGCho4MUfspLVrHAFLimgBPKXtCcJ8GC4YNZmfg=="], "eslint-plugin-lingui": ["eslint-plugin-lingui@0.11.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.0.0", "micromatch": "^4.0.0" }, "peerDependencies": { "eslint": "^8.37.0 || ^9.0.0" } }, "sha512-O2Ixoapt5fa4VKZJgXhVwb6BHnzByIUDNMfZOhHWGMYk40GfGCho4MUfspLVrHAFLimgBPKXtCcJ8GC4YNZmfg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
@@ -2062,7 +2072,7 @@
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
@@ -2096,9 +2106,9 @@
"hermes-compiler": ["hermes-compiler@0.14.1", "", {}, "sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA=="], "hermes-compiler": ["hermes-compiler@0.14.1", "", {}, "sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA=="],
"hermes-estree": ["hermes-estree@0.32.1", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
@@ -2412,6 +2422,8 @@
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"module-replacements": ["module-replacements@2.11.0", "", {}, "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA=="],
"moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="],
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
@@ -3090,6 +3102,8 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
@@ -3156,6 +3170,8 @@
"@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@expo/metro-config/hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="],
"@expo/metro-config/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@expo/metro-config/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
@@ -3534,6 +3550,8 @@
"@expo/metro-config/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@expo/metro-config/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.32.1", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="],
"@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+3 -1
View File
@@ -50,12 +50,13 @@
"format:check": "turbo run format:check", "format:check": "turbo run format:check",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"test": "turbo run test", "test": "turbo run test",
"clean": "rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules bun.lock", "clean": "rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" \"apps/*/dist\" \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules bun.lock",
"generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs", "generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs",
"i18n:extract": "lingui extract", "i18n:extract": "lingui extract",
"i18n:claude": "bun packages/i18n/scripts/claude.ts" "i18n:claude": "bun packages/i18n/scripts/claude.ts"
}, },
"devDependencies": { "devDependencies": {
"@e18e/eslint-plugin": "0.3.0",
"@lingui/cli": "catalog:", "@lingui/cli": "catalog:",
"@lingui/conf": "catalog:", "@lingui/conf": "catalog:",
"@lingui/format-po": "catalog:", "@lingui/format-po": "catalog:",
@@ -66,6 +67,7 @@
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"eslint-plugin-drizzle": "0.2.3", "eslint-plugin-drizzle": "0.2.3",
"eslint-plugin-lingui": "0.11.0", "eslint-plugin-lingui": "0.11.0",
"eslint-plugin-react-hooks": "7.0.1",
"oxfmt": "0.41.0", "oxfmt": "0.41.0",
"oxlint": "1.56.0", "oxlint": "1.56.0",
"playwright": "1.58.2", "playwright": "1.58.2",
+7 -3
View File
@@ -338,7 +338,11 @@ export function getRecommendationsFeed(userId: string) {
} }
} }
const sorted = [...recs.values()].sort((a, b) => b.score - a.score).slice(0, 20); const sorted = recs
.values()
.toArray()
.toSorted((a, b) => b.score - a.score)
.slice(0, 20);
if (sorted.length === 0) return []; if (sorted.length === 0) return [];
@@ -495,7 +499,7 @@ export function getUpcomingFeed(
// Compute cursor from the last item on this page // Compute cursor from the last item on this page
let nextCursor: string | null = null; let nextCursor: string | null = null;
if (hasMore && pageItems.length > 0) { if (hasMore && pageItems.length > 0) {
const last = pageItems[pageItems.length - 1]!; const last = pageItems.at(-1)!;
nextCursor = btoa(JSON.stringify({ d: last.date, n: last.titleName, i: last.titleId })); nextCursor = btoa(JSON.stringify({ d: last.date, n: last.titleName, i: last.titleId }));
} }
@@ -579,7 +583,7 @@ export function getRecommendationsForTitle(titleId: string) {
tmdb_recommendations: 0, tmdb_recommendations: 0,
tmdb_similar: 1, tmdb_similar: 1,
} as const; } as const;
const orderedRecs = [...recs].sort( const orderedRecs = recs.toSorted(
(a, b) => a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source], (a, b) => a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
); );
+2 -2
View File
@@ -224,7 +224,7 @@ export async function cacheProviderLogos(titleId: string) {
} }
} }
const checks = await Promise.all( const checks = await Promise.all(
[...uniqueLogos.entries()].map(async ([basename, logoPath]) => ({ Array.from(uniqueLogos.entries(), async ([basename, logoPath]) => ({
logoPath, logoPath,
cached: await isImageCached("logos", basename), cached: await isImageCached("logos", basename),
})), })),
@@ -248,7 +248,7 @@ export async function cacheProfilePhotos(titleId: string) {
} }
} }
const checks = await Promise.all( const checks = await Promise.all(
[...uniqueProfiles.entries()].map(async ([basename, profilePath]) => ({ Array.from(uniqueProfiles.entries(), async ([basename, profilePath]) => ({
profilePath, profilePath,
cached: await isImageCached("profiles", basename), cached: await isImageCached("profiles", basename),
})), })),
+2 -1
View File
@@ -6,6 +6,7 @@ const APP_VERSION = process.env.APP_VERSION || "0.0.0";
const log = createLogger("update-check"); const log = createLogger("update-check");
const VERSION_PREFIX_RE = /^v/;
const PUBLIC_API_URL = process.env.PUBLIC_API_URL || "https://public-api.sofa.watch"; const PUBLIC_API_URL = process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
@@ -25,7 +26,7 @@ export function isUpdateCheckEnabled(): boolean {
/** @internal Returns true if `latest` is strictly newer than `current` using semver comparison. */ /** @internal Returns true if `latest` is strictly newer than `current` using semver comparison. */
export function isNewerVersion(latest: string, current: string): boolean { export function isNewerVersion(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number); const parse = (v: string) => v.replace(VERSION_PREFIX_RE, "").split(".").map(Number);
const [lMajor = 0, lMinor = 0, lPatch = 0] = parse(latest); const [lMajor = 0, lMinor = 0, lPatch = 0] = parse(latest);
const [cMajor = 0, cMinor = 0, cPatch = 0] = parse(current); const [cMajor = 0, cMinor = 0, cPatch = 0] = parse(current);
if (lMajor !== cMajor) return lMajor > cMajor; if (lMajor !== cMajor) return lMajor > cMajor;
+13 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
clearAllTables, clearAllTables,
@@ -24,6 +24,17 @@ import {
getWatchHistory, getWatchHistory,
} from "../src/discovery"; } from "../src/discovery";
const TEST_NOW = new Date("2026-03-01T12:00:00Z");
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(TEST_NOW);
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => { beforeEach(() => {
clearAllTables(); clearAllTables();
}); });
@@ -56,7 +67,7 @@ describe("getWatchCount", () => {
insertUser(); insertUser();
insertTitle({ id: "m1", tmdbId: 1 }); insertTitle({ id: "m1", tmdbId: 1 });
// Watch from 2 years ago // Watch from 2 years ago
const oldDate = new Date(); const oldDate = new Date(TEST_NOW);
oldDate.setFullYear(oldDate.getFullYear() - 2); oldDate.setFullYear(oldDate.getFullYear() - 2);
insertMovieWatch("user-1", "m1", oldDate); insertMovieWatch("user-1", "m1", oldDate);
+3 -1
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from "vitest";
import { clearAllTables, insertUser } from "@sofa/test/db"; import { clearAllTables, insertUser } from "@sofa/test/db";
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T/;
import { import {
createOrUpdateIntegration, createOrUpdateIntegration,
deleteIntegration, deleteIntegration,
@@ -82,7 +84,7 @@ describe("listUserIntegrations", () => {
test("serializes dates as ISO strings", () => { test("serializes dates as ISO strings", () => {
createOrUpdateIntegration("user-1", "plex"); createOrUpdateIntegration("user-1", "plex");
const result = listUserIntegrations("user-1"); const result = listUserIntegrations("user-1");
expect(result.integrations[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(result.integrations[0].createdAt).toMatch(ISO_DATE_RE);
}); });
}); });
+14 -3
View File
@@ -1,10 +1,21 @@
import { beforeEach, describe, expect, test, vi } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { clearAllTables, insertUser } from "@sofa/test/db"; import { clearAllTables, insertUser } from "@sofa/test/db";
import { setSetting } from "../src/settings"; import { setSetting } from "../src/settings";
import { isTelemetryEnabled, performTelemetryReport } from "../src/telemetry"; import { isTelemetryEnabled, performTelemetryReport } from "../src/telemetry";
const TEST_NOW = new Date("2026-03-01T12:00:00Z");
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(TEST_NOW);
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => { beforeEach(() => {
clearAllTables(); clearAllTables();
insertUser("user-1"); insertUser("user-1");
@@ -60,7 +71,7 @@ describe("performTelemetryReport", () => {
test("respects 24-hour report interval", async () => { test("respects 24-hour report interval", async () => {
setSetting("telemetryEnabled", "true"); setSetting("telemetryEnabled", "true");
setSetting("telemetryLastReportedAt", new Date().toISOString()); setSetting("telemetryLastReportedAt", TEST_NOW.toISOString());
const fetchSpy = vi.spyOn(globalThis, "fetch"); const fetchSpy = vi.spyOn(globalThis, "fetch");
await performTelemetryReport(); await performTelemetryReport();
@@ -69,7 +80,7 @@ describe("performTelemetryReport", () => {
test("reports again after interval expires", async () => { test("reports again after interval expires", async () => {
setSetting("telemetryEnabled", "true"); setSetting("telemetryEnabled", "true");
const oldDate = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); const oldDate = new Date(TEST_NOW.getTime() - 25 * 60 * 60 * 1000).toISOString();
setSetting("telemetryLastReportedAt", oldDate); setSetting("telemetryLastReportedAt", oldDate);
vi.spyOn(globalThis, "fetch").mockImplementation( vi.spyOn(globalThis, "fetch").mockImplementation(
+13 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
clearAllTables, clearAllTables,
@@ -12,8 +12,19 @@ import {
import { getUpcomingFeed } from "../src/discovery"; import { getUpcomingFeed } from "../src/discovery";
const TEST_NOW = new Date("2026-03-01T12:00:00Z");
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(TEST_NOW);
});
afterAll(() => {
vi.useRealTimers();
});
function daysFromNow(offset: number): string { function daysFromNow(offset: number): string {
const d = new Date(); const d = new Date(TEST_NOW);
d.setDate(d.getDate() + offset); d.setDate(d.getDate() + offset);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
} }
+13 -6
View File
@@ -13,6 +13,13 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
const SIMPLE_PLACEHOLDER_RE = /^[a-zA-Z0-9_]+$/;
const ICU_ARG_NAME_RE = /^([a-zA-Z0-9_]+)\s*,/;
const NUMBERED_TAG_RE = /<\/?\d+\/?>/g;
const LEADING_WS_RE = /^\s*/;
const TRAILING_WS_RE = /\s*$/;
const EDGE_WS_RE = /^\s|\s$/;
import type { CatalogType } from "@lingui/conf"; import type { CatalogType } from "@lingui/conf";
import { formatter } from "@lingui/format-po"; import { formatter } from "@lingui/format-po";
@@ -198,11 +205,11 @@ function extractSimplePlaceholders(text: string): string[] {
if (depth === 0 && argStart >= 0) { if (depth === 0 && argStart >= 0) {
const inner = text.slice(argStart + 1, i); const inner = text.slice(argStart + 1, i);
// Top-level simple placeholder: just an identifier, no comma (not plural/select) // Top-level simple placeholder: just an identifier, no comma (not plural/select)
if (/^[a-zA-Z0-9_]+$/.test(inner)) { if (SIMPLE_PLACEHOLDER_RE.test(inner)) {
results.push(`{${inner}}`); results.push(`{${inner}}`);
} else { } else {
// Complex ICU argument — extract just the argument name before the comma // Complex ICU argument — extract just the argument name before the comma
const argName = inner.match(/^([a-zA-Z0-9_]+)\s*,/)?.[1]; const argName = inner.match(ICU_ARG_NAME_RE)?.[1];
if (argName) results.push(`{${argName}}`); if (argName) results.push(`{${argName}}`);
} }
argStart = -1; argStart = -1;
@@ -215,15 +222,15 @@ function extractSimplePlaceholders(text: string): string[] {
function extractNumberedTags(text: string): string[] { function extractNumberedTags(text: string): string[] {
// Matches <0>, </0>, <0/>, <12>, </12>, <12/> // Matches <0>, </0>, <0/>, <12>, </12>, <12/>
return [...text.matchAll(/<\/?\d+\/?>/g)].map((m) => m[0]).sort(); return Array.from(text.matchAll(NUMBERED_TAG_RE), (m) => m[0]).sort();
} }
function leadingWhitespace(text: string): string { function leadingWhitespace(text: string): string {
return text.match(/^\s*/)?.[0] ?? ""; return text.match(LEADING_WS_RE)?.[0] ?? "";
} }
function trailingWhitespace(text: string): string { function trailingWhitespace(text: string): string {
return text.match(/\s*$/)?.[0] ?? ""; return text.match(TRAILING_WS_RE)?.[0] ?? "";
} }
function validateTranslation(source: string, translated: string): string[] { function validateTranslation(source: string, translated: string): string[] {
@@ -246,7 +253,7 @@ function validateTranslation(source: string, translated: string): string[] {
} }
// Only enforce whitespace when the source has meaningful edge whitespace // Only enforce whitespace when the source has meaningful edge whitespace
if (/^\s|\s$/.test(source)) { if (EDGE_WS_RE.test(source)) {
if (leadingWhitespace(source) !== leadingWhitespace(translated)) { if (leadingWhitespace(source) !== leadingWhitespace(translated)) {
issues.push("leading whitespace mismatch"); issues.push("leading whitespace mismatch");
} }
+3 -1
View File
@@ -1,8 +1,10 @@
import { i18n } from "./index"; import { i18n } from "./index";
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
/** Whether a string looks like a date-only value (no time component). */ /** Whether a string looks like a date-only value (no time component). */
function isDateOnly(value: string): boolean { function isDateOnly(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value); return DATE_ONLY_RE.test(value);
} }
function toDate(date: Date | string): Date { function toDate(date: Date | string): Date {
+77
View File
@@ -22,6 +22,7 @@ msgstr ""
#~ msgid "...and {0} more" #~ msgid "...and {0} more"
#~ msgstr "" #~ msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "...and {remainingErrors} more" msgid "...and {remainingErrors} more"
msgstr "...and {remainingErrors} more" msgstr "...and {remainingErrors} more"
@@ -145,10 +146,12 @@ msgstr ""
msgid "{label} URL regenerated" msgid "{label} URL regenerated"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "{movieCount} movies, {episodeCount} episodes" msgid "{movieCount} movies, {episodeCount} episodes"
msgstr "{movieCount} movies, {episodeCount} episodes" msgstr "{movieCount} movies, {episodeCount} episodes"
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "{ratingCount} ratings" msgid "{ratingCount} ratings"
msgstr "{ratingCount} ratings" msgstr "{ratingCount} ratings"
@@ -185,6 +188,7 @@ msgstr "{watchedCount}/{episodeCount, plural, one {# episode} other {# episodes}
msgid "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}" msgid "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}"
msgstr "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}" msgstr "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}"
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "{watchlistCount} items" msgid "{watchlistCount} items"
msgstr "{watchlistCount} items" msgstr "{watchlistCount} items"
@@ -385,6 +389,7 @@ msgstr ""
#: apps/native/src/components/settings/integration-card.tsx #: apps/native/src/components/settings/integration-card.tsx
#: apps/native/src/components/titles/status-action-button.tsx #: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/settings/account-section.tsx #: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/backup-restore-section.tsx #: apps/web/src/components/settings/backup-restore-section.tsx
#: apps/web/src/components/settings/backup-section.tsx #: apps/web/src/components/settings/backup-section.tsx
#: apps/web/src/components/settings/danger-section.tsx #: apps/web/src/components/settings/danger-section.tsx
@@ -611,6 +616,10 @@ msgstr ""
msgid "Could not load person details" msgid "Could not load person details"
msgstr "" msgstr ""
#: apps/native/src/app/title/[id].tsx
msgid "Could not load title details"
msgstr "Could not load title details"
#: apps/web/src/components/auth-form.tsx #: apps/web/src/components/auth-form.tsx
msgid "Create account" msgid "Create account"
msgstr "" msgstr ""
@@ -726,6 +735,7 @@ msgstr ""
msgid "Don't have an account?" msgid "Don't have an account?"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Done" msgid "Done"
msgstr "" msgstr ""
@@ -747,6 +757,10 @@ msgstr ""
msgid "Download backup" msgid "Download backup"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Download your library, watch history, and ratings as JSON"
msgstr "Download your library, watch history, and ratings as JSON"
#: apps/native/src/app/person/[id].tsx #: apps/native/src/app/person/[id].tsx
#: apps/native/src/components/search/recently-viewed-row-content.tsx #: apps/native/src/components/search/recently-viewed-row-content.tsx
#: apps/web/src/components/people/person-hero.tsx #: apps/web/src/components/people/person-hero.tsx
@@ -819,6 +833,7 @@ msgstr ""
msgid "Episode watched" msgid "Episode watched"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/components/titles/title-seasons.tsx #: apps/web/src/components/titles/title-seasons.tsx
msgid "Episodes" msgid "Episodes"
@@ -857,6 +872,7 @@ msgstr ""
#~ msgid "Errors ({0})" #~ msgid "Errors ({0})"
#~ msgstr "" #~ msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Errors ({errorCount})" msgid "Errors ({errorCount})"
msgstr "Errors ({errorCount})" msgstr "Errors ({errorCount})"
@@ -874,6 +890,11 @@ msgstr ""
msgid "Explore titles" msgid "Explore titles"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Export"
msgstr "Export"
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Failed" msgid "Failed"
msgstr "" msgstr ""
@@ -949,6 +970,7 @@ msgstr ""
msgid "Failed to mark some episodes" msgid "Failed to mark some episodes"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Failed to parse file" msgid "Failed to parse file"
msgstr "" msgstr ""
@@ -1063,6 +1085,10 @@ msgstr ""
msgid "Finished importing from {source}." msgid "Finished importing from {source}."
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Finished importing your Sofa export."
msgstr "Finished importing your Sofa export."
#: apps/web/src/components/titles/title-availability.tsx #: apps/web/src/components/titles/title-availability.tsx
msgid "Free" msgid "Free"
msgstr "" msgstr ""
@@ -1086,6 +1112,7 @@ msgstr ""
#: apps/native/src/app/person/[id].tsx #: apps/native/src/app/person/[id].tsx
#: apps/native/src/app/person/[id].tsx #: apps/native/src/app/person/[id].tsx
#: apps/native/src/app/title/[id].tsx #: apps/native/src/app/title/[id].tsx
#: apps/native/src/app/title/[id].tsx
msgid "Go back" msgid "Go back"
msgstr "" msgstr ""
@@ -1137,18 +1164,22 @@ msgstr ""
msgid "Image cache and backup disk usage" msgid "Image cache and backup disk usage"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Import {totalItems} items" msgid "Import {totalItems} items"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Import complete" msgid "Import complete"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Import failed" msgid "Import failed"
msgstr "" msgstr ""
@@ -1165,15 +1196,23 @@ msgstr ""
msgid "Import from {sourceLabel}" msgid "Import from {sourceLabel}"
msgstr "Import from {sourceLabel}" msgstr "Import from {sourceLabel}"
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Import is still running in the background. Check back later." msgid "Import is still running in the background. Check back later."
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Import options" msgid "Import options"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Import Sofa data"
msgstr "Import Sofa data"
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Imported" msgid "Imported"
msgstr "" msgstr ""
@@ -1186,6 +1225,14 @@ msgstr ""
msgid "Imported {importedCount} items from {sourceLabel}" msgid "Imported {importedCount} items from {sourceLabel}"
msgstr "Imported {importedCount} items from {sourceLabel}" msgstr "Imported {importedCount} items from {sourceLabel}"
#: apps/web/src/components/settings/account-section.tsx
msgid "Imported {importedCount} items from Sofa export"
msgstr "Imported {importedCount} items from Sofa export"
#: apps/web/src/components/settings/account-section.tsx
msgid "Importing data"
msgstr "Importing data"
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Importing from {source}" msgid "Importing from {source}"
msgstr "" msgstr ""
@@ -1292,14 +1339,23 @@ msgstr ""
msgid "Last run succeeded" msgid "Last run succeeded"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Library"
msgstr "Library"
#: apps/web/src/components/settings/system-health-section.tsx #: apps/web/src/components/settings/system-health-section.tsx
msgid "Library refresh" msgid "Library refresh"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Library statuses"
msgstr "Library statuses"
#: apps/web/src/components/auth-form.tsx #: apps/web/src/components/auth-form.tsx
msgid "Loading…" msgid "Loading…"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Lost connection to import. Check status in settings." msgid "Lost connection to import. Check status in settings."
msgstr "" msgstr ""
@@ -1404,6 +1460,7 @@ msgid "Movie"
msgstr "" msgstr ""
#: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Movies" msgid "Movies"
msgstr "" msgstr ""
@@ -1602,6 +1659,10 @@ msgstr ""
msgid "Page Not Found" msgid "Page Not Found"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Parsing file..."
msgstr "Parsing file..."
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Parsing..." msgid "Parsing..."
msgstr "" msgstr ""
@@ -1770,6 +1831,8 @@ msgstr ""
msgid "Rating removed" msgid "Rating removed"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Ratings" msgid "Ratings"
@@ -1938,6 +2001,10 @@ msgstr ""
msgid "Restore failed" msgid "Restore failed"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
msgid "Restore from a Sofa export file"
msgstr "Restore from a Sofa export file"
#: apps/web/src/components/settings/backup-restore-section.tsx #: apps/web/src/components/settings/backup-restore-section.tsx
msgid "Restoring…" msgid "Restoring…"
msgstr "" msgstr ""
@@ -1961,6 +2028,7 @@ msgstr ""
msgid "Return home" msgid "Return home"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Review what was found and choose what to import." msgid "Review what was found and choose what to import."
msgstr "" msgstr ""
@@ -2190,6 +2258,7 @@ msgstr ""
msgid "Sign out of other sessions" msgid "Sign out of other sessions"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Skipped" msgid "Skipped"
msgstr "" msgstr ""
@@ -2217,6 +2286,7 @@ msgstr ""
#: apps/native/src/app/change-password.tsx #: apps/native/src/app/change-password.tsx
#: apps/native/src/app/person/[id].tsx #: apps/native/src/app/person/[id].tsx
#: apps/native/src/app/title/[id].tsx
#: apps/web/src/components/settings/account-section.tsx #: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/routes/__root.tsx #: apps/web/src/routes/__root.tsx
msgid "Something went wrong" msgid "Something went wrong"
@@ -2251,6 +2321,7 @@ msgstr ""
msgid "Starting at" msgid "Starting at"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Starting import..." msgid "Starting import..."
msgstr "" msgstr ""
@@ -2291,6 +2362,7 @@ msgstr ""
msgid "The title you're looking for doesn't exist or may have been removed from the database." msgid "The title you're looking for doesn't exist or may have been removed from the database."
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "This may take a few minutes for large libraries. Please don't close this tab." msgid "This may take a few minutes for large libraries. Please don't close this tab."
msgstr "" msgstr ""
@@ -2440,6 +2512,7 @@ msgstr ""
msgid "Trigger job" msgid "Trigger job"
msgstr "" msgstr ""
#: apps/native/src/app/title/[id].tsx
#: apps/web/src/routes/__root.tsx #: apps/web/src/routes/__root.tsx
msgid "Try again" msgid "Try again"
msgstr "" msgstr ""
@@ -2591,6 +2664,7 @@ msgstr ""
msgid "Waiting for authorization..." msgid "Waiting for authorization..."
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Warnings" msgid "Warnings"
msgstr "" msgstr ""
@@ -2599,6 +2673,7 @@ msgstr ""
#~ msgid "Warnings ({0})" #~ msgid "Warnings ({0})"
#~ msgstr "" #~ msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Warnings ({warningCount})" msgid "Warnings ({warningCount})"
msgstr "Warnings ({warningCount})" msgstr "Warnings ({warningCount})"
@@ -2607,10 +2682,12 @@ msgstr "Warnings ({warningCount})"
msgid "Watch all" msgid "Watch all"
msgstr "" msgstr ""
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx #: apps/web/src/components/settings/imports-section.tsx
msgid "Watch history" msgid "Watch history"
msgstr "" msgstr ""
#: apps/web/src/components/titles/title-availability.tsx
#: apps/web/src/components/titles/title-availability.tsx #: apps/web/src/components/titles/title-availability.tsx
msgid "Watch on {name}" msgid "Watch on {name}"
msgstr "" msgstr ""
+2 -1
View File
@@ -111,6 +111,7 @@ function getApiKey() {
const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3"; const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3";
const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE; const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE;
const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE; const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE;
const TMDB_VERSION_PREFIX_RE = /^\/3\//;
const baseUrl = configuredBase.replace(/\/3\/?$/, ""); const baseUrl = configuredBase.replace(/\/3\/?$/, "");
const baseUrlRewriteMiddleware: Middleware | null = isCustomBase const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
@@ -119,7 +120,7 @@ const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
// Custom proxy: strip the /3 prefix from schema paths so requests // Custom proxy: strip the /3 prefix from schema paths so requests
// go to e.g. https://tmdb.internal/search/multi instead of /3/search/multi // go to e.g. https://tmdb.internal/search/multi instead of /3/search/multi
const url = new URL(request.url); const url = new URL(request.url);
url.pathname = url.pathname.replace(/^\/3\//, "/"); url.pathname = url.pathname.replace(TMDB_VERSION_PREFIX_RE, "/");
return new Request(url.toString(), request); return new Request(url.toString(), request);
}, },
} }