mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 17:05:56 -04:00
chore: migrate oxlint config to e18e plugin and fix lint violations
- Replace root `eslint-plugin-lingui` jsPlugin with `@e18e/eslint-plugin`; move lingui rules into per-app `.oxlintrc.json` overrides for native and web
- Add `jsx-a11y`, `react-hooks-js` (native), and expanded lingui rule sets to per-app configs
- Add `oxc/no-barrel-file` warning at threshold 0 to root config
- Fix `e18e/prefer-url-canparse`: replace `try { new URL() } catch` with `URL.canParse()` in server-url screen and server lib
- Fix `e18e/prefer-timer-args`: pass callback args directly to `setTimeout` instead of wrapping in arrow functions (use-debounce, integration-card)
- Fix `e18e/prefer-static-regex`: hoist `/\/+$/` to module-level constant in server-url screen
- Fix `react-hooks-js/refs` and `react-hooks-js/set-state-in-effect`: convert `useRef` tracking patterns to `useState` + render-time derived updates in `use-server-connection`, `expandable-text`, and settings screen
- Fix `react-hooks-js/immutability`: extract stable palette sub-values before `useMemo` deps in title detail screen
- Apply `e18e` and other rule fixes across core, tmdb, web components, and i18n packages
This commit is contained in:
+19
-16
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest"],
|
||||
"jsPlugins": ["eslint-plugin-lingui"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest", "jsx-a11y"],
|
||||
"jsPlugins": ["@e18e/eslint-plugin"],
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
@@ -15,6 +15,7 @@
|
||||
"react/style-prop-object": "off",
|
||||
"import/no-unassigned-import": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"oxc/no-barrel-file": ["warn", { "threshold": 0 }],
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"no-new": "off",
|
||||
@@ -22,20 +23,22 @@
|
||||
"react/no-array-index-key": "off",
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/rules-of-hooks": "error",
|
||||
"lingui/no-unlocalized-strings": "off",
|
||||
"lingui/t-call-in-function": "error",
|
||||
"lingui/no-single-variables-to-translate": "error",
|
||||
"lingui/no-expression-in-message": "error",
|
||||
"lingui/no-single-tag-to-translate": "error",
|
||||
"lingui/no-trans-inside-trans": "error"
|
||||
"e18e/prefer-array-at": "error",
|
||||
"e18e/prefer-array-fill": "error",
|
||||
"e18e/prefer-array-from-map": "error",
|
||||
"e18e/prefer-array-some": "error",
|
||||
"e18e/prefer-array-to-reversed": "error",
|
||||
"e18e/prefer-array-to-sorted": "error",
|
||||
"e18e/prefer-array-to-spliced": "error",
|
||||
"e18e/prefer-date-now": "error",
|
||||
"e18e/prefer-includes": "error",
|
||||
"e18e/prefer-nullish-coalescing": "error",
|
||||
"e18e/prefer-object-has-own": "error",
|
||||
"e18e/prefer-regex-test": "error",
|
||||
"e18e/prefer-spread-syntax": "off",
|
||||
"e18e/prefer-static-regex": "error",
|
||||
"e18e/prefer-timer-args": "error",
|
||||
"e18e/prefer-url-canparse": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["apps/web/src/**/*", "apps/native/src/**/*"],
|
||||
"rules": {
|
||||
"lingui/no-unlocalized-strings": "warn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["node_modules", "dist", "build", "docs", "**/routeTree.gen.ts"]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": ["@tanstack/eslint-plugin-query"],
|
||||
"jsPlugins": [
|
||||
"@tanstack/eslint-plugin-query",
|
||||
"eslint-plugin-lingui",
|
||||
{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }
|
||||
],
|
||||
"rules": {
|
||||
"@tanstack/query/exhaustive-deps": "error",
|
||||
"@tanstack/query/no-rest-destructuring": "warn",
|
||||
"@tanstack/query/stable-query-client": "error",
|
||||
"@tanstack/query/no-unstable-deps": "error"
|
||||
"@tanstack/query/no-unstable-deps": "error",
|
||||
"lingui/no-unlocalized-strings": "off",
|
||||
"lingui/t-call-in-function": "error",
|
||||
"lingui/no-single-variables-to-translate": "error",
|
||||
"lingui/no-expression-in-message": "error",
|
||||
"lingui/no-single-tag-to-translate": "error",
|
||||
"lingui/no-trans-inside-trans": "error",
|
||||
"react-hooks-js/rules-of-hooks": "off",
|
||||
"react-hooks-js/exhaustive-deps": "off",
|
||||
"react-hooks-js/static-components": "error",
|
||||
"react-hooks-js/use-memo": "error",
|
||||
"react-hooks-js/void-use-memo": "error",
|
||||
"react-hooks-js/component-hook-factories": "error",
|
||||
"react-hooks-js/preserve-manual-memoization": "error",
|
||||
"react-hooks-js/incompatible-library": "warn",
|
||||
"react-hooks-js/immutability": "error",
|
||||
"react-hooks-js/globals": "error",
|
||||
"react-hooks-js/refs": "error",
|
||||
"react-hooks-js/set-state-in-effect": "error",
|
||||
"react-hooks-js/error-boundaries": "error",
|
||||
"react-hooks-js/purity": "error",
|
||||
"react-hooks-js/set-state-in-render": "error",
|
||||
"react-hooks-js/unsupported-syntax": "warn",
|
||||
"react-hooks-js/config": "error",
|
||||
"react-hooks-js/gating": "error"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
function getModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require("./src/WidgetImagesModule").default;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Input } from "@/components/ui/text-field";
|
||||
import { getServerUrl, serverManager, type ValidationError } from "@/lib/server";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const TRAILING_SLASHES_RE = /\/+$/;
|
||||
|
||||
type ConnectionState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "connecting" }
|
||||
@@ -91,14 +93,12 @@ export default function ServerUrlScreen() {
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
const trimmed = url.trim().replace(/\/+$/, "");
|
||||
const trimmed = url.trim().replace(TRAILING_SLASHES_RE, "");
|
||||
if (!trimmed) return;
|
||||
|
||||
const fullUrl = serverManager.normalizeUrl(trimmed);
|
||||
|
||||
try {
|
||||
new URL(fullUrl);
|
||||
} catch {
|
||||
if (!URL.canParse(fullUrl)) {
|
||||
setConnection({ phase: "error", error: "invalid_url" });
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
return;
|
||||
@@ -122,16 +122,8 @@ export default function ServerUrlScreen() {
|
||||
|
||||
const isConnecting = connection.phase === "connecting";
|
||||
const isSuccess = connection.phase === "success";
|
||||
const trimmedUrl = url.trim().replace(/\/+$/, "");
|
||||
const isValidUrl = (() => {
|
||||
if (!trimmedUrl) return false;
|
||||
try {
|
||||
new URL(serverManager.normalizeUrl(trimmedUrl));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const trimmedUrl = url.trim().replace(TRAILING_SLASHES_RE, "");
|
||||
const isValidUrl = !!trimmedUrl && URL.canParse(serverManager.normalizeUrl(trimmedUrl));
|
||||
const isDisabled = isConnecting || isSuccess;
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,7 +25,7 @@ import { reloadAppAsync } from "expo";
|
||||
import * as Application from "expo-application";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
@@ -69,11 +69,16 @@ export default function SettingsScreen() {
|
||||
const { push } = useRouter();
|
||||
const { data: session, refetch: refetchSession } = authClient.useSession();
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const sessionUserName = session?.user?.name ?? "";
|
||||
const [nameInput, setNameInput] = useState(sessionUserName);
|
||||
const [prevSessionName, setPrevSessionName] = useState(sessionUserName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingName && session?.user?.name) setNameInput(session.user.name);
|
||||
}, [session?.user?.name, isEditingName]);
|
||||
if (sessionUserName !== prevSessionName) {
|
||||
setPrevSessionName(sessionUserName);
|
||||
if (!isEditingName && sessionUserName) {
|
||||
setNameInput(sessionUserName);
|
||||
}
|
||||
}
|
||||
|
||||
const [languageModalOpen, setLanguageModalOpen] = useState(false);
|
||||
const languageLabel = LOCALE_INFO.find((o) => o.code === i18n.locale)?.nativeName ?? i18n.locale;
|
||||
@@ -305,7 +310,6 @@ export default function SettingsScreen() {
|
||||
accessibilityLabel="Display name"
|
||||
onChangeText={setNameInput}
|
||||
className="border-primary text-foreground min-h-10 flex-1 border-b py-2 font-sans text-base"
|
||||
autoFocus
|
||||
/>
|
||||
<Pressable onPress={() => updateName.mutate({ name: nameInput })}>
|
||||
<Text className="text-primary text-sm">
|
||||
|
||||
@@ -180,22 +180,26 @@ export default function TitleDetailScreen() {
|
||||
() => (useAutomaticInsets ? { marginTop: -headerHeight } : undefined),
|
||||
[useAutomaticInsets, headerHeight],
|
||||
);
|
||||
const darkMuted = palette?.darkMuted;
|
||||
const vibrant = palette?.vibrant;
|
||||
const darkVibrant = palette?.darkVibrant;
|
||||
|
||||
const darkMutedOverlayStyle = useMemo(
|
||||
() => (palette?.darkMuted ? { backgroundColor: palette.darkMuted, opacity: 0.2 } : undefined),
|
||||
[palette?.darkMuted],
|
||||
() => (darkMuted ? { backgroundColor: darkMuted, opacity: 0.2 } : undefined),
|
||||
[darkMuted],
|
||||
);
|
||||
const vibrantOverlayStyle = useMemo(
|
||||
() => (palette?.vibrant ? { backgroundColor: palette.vibrant, opacity: 0.06 } : undefined),
|
||||
[palette?.vibrant],
|
||||
() => (vibrant ? { backgroundColor: vibrant, opacity: 0.06 } : undefined),
|
||||
[vibrant],
|
||||
);
|
||||
const posterShadowStyle = useMemo(
|
||||
() =>
|
||||
palette?.darkVibrant
|
||||
darkVibrant
|
||||
? {
|
||||
boxShadow: `0 12px 28px -8px ${palette.darkVibrant}80`,
|
||||
boxShadow: `0 12px 28px -8px ${darkVibrant}80`,
|
||||
}
|
||||
: undefined,
|
||||
[palette?.darkVibrant],
|
||||
[darkVibrant],
|
||||
);
|
||||
|
||||
if (detail.isPending) {
|
||||
|
||||
@@ -124,7 +124,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
||||
setCopied(true);
|
||||
toast.success(t`URL copied to clipboard`);
|
||||
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
|
||||
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
copiedTimerRef.current = setTimeout(setCopied, 2000, false);
|
||||
}, [url, t]);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { NativeSyntheticEvent, TextLayoutEventData } from "react-native";
|
||||
import { Platform, Pressable, View } from "react-native";
|
||||
|
||||
@@ -15,14 +15,15 @@ export function ExpandableText({
|
||||
actionColor?: string;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const prevTextRef = useRef(text);
|
||||
let expanded: boolean;
|
||||
let needsTruncation: boolean;
|
||||
const [prevText, setPrevText] = useState(text);
|
||||
const [expandedState, setExpanded] = useState(false);
|
||||
const [needsTruncationState, setNeedsTruncation] = useState(false);
|
||||
|
||||
if (prevTextRef.current !== text) {
|
||||
prevTextRef.current = text;
|
||||
let expanded: boolean;
|
||||
let needsTruncation: boolean;
|
||||
|
||||
if (prevText !== text) {
|
||||
setPrevText(text);
|
||||
setExpanded(false);
|
||||
setNeedsTruncation(false);
|
||||
expanded = false;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
const timer = setTimeout(setDebouncedValue, delay, value);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
@@ -94,11 +94,21 @@ export function useServerConnection() {
|
||||
// Track whether the session was seeded from cache and hasn't yet been
|
||||
// confirmed by the server. Once confirmed, flip to false so explicit
|
||||
// sign-outs don't show a misleading "session expired" toast.
|
||||
const hadOptimisticSession = useRef(wasCachedSessionSeeded());
|
||||
const prevSession = useRef(session);
|
||||
const [hadOptimisticSession, setHadOptimisticSession] = useState(wasCachedSessionSeeded);
|
||||
const [prevSession, setPrevSession] = useState(session);
|
||||
|
||||
if (hadOptimisticSession.current && session && !isRefetching) {
|
||||
hadOptimisticSession.current = false;
|
||||
if (hadOptimisticSession && session && !isRefetching) {
|
||||
setHadOptimisticSession(false);
|
||||
}
|
||||
|
||||
// Detect session loss during render so the effect doesn't need to call
|
||||
// setPrevSession (which triggers the set-state-in-effect lint rule).
|
||||
let sessionLost = false;
|
||||
if (prevSession !== session) {
|
||||
if (prevSession && !session) {
|
||||
sessionLost = true;
|
||||
}
|
||||
setPrevSession(session);
|
||||
}
|
||||
|
||||
const { replace } = useRouter();
|
||||
@@ -107,19 +117,18 @@ export function useServerConnection() {
|
||||
// availability, but enableFreeze can prevent the navigator from
|
||||
// transitioning on its own.
|
||||
useEffect(() => {
|
||||
if (prevSession.current && !session) {
|
||||
if (sessionLost) {
|
||||
const changingServer = consumeServerChangeRequest();
|
||||
replace(changingServer ? "/(auth)/server-url" : "/(auth)/login");
|
||||
|
||||
if (hadOptimisticSession.current) {
|
||||
if (hadOptimisticSession) {
|
||||
toast.info(i18n._(msg`Session expired`), {
|
||||
description: i18n._(msg`Please sign in again.`),
|
||||
});
|
||||
clearCachedSessionSeeded();
|
||||
}
|
||||
}
|
||||
prevSession.current = session;
|
||||
}, [session, replace]);
|
||||
}, [sessionLost, replace, hadOptimisticSession]);
|
||||
|
||||
return { session, isPending, hasServerUrl, instanceId };
|
||||
}
|
||||
|
||||
@@ -47,13 +47,15 @@ const SERVER_URL_KEY = "sofa_server_url";
|
||||
const SERVERS_MAP_KEY = "sofa_servers";
|
||||
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
|
||||
const DEFAULT_URL = process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
||||
const TRAILING_SLASHES_RE = /\/+$/;
|
||||
const PROTOCOL_RE = /^(https?:\/\/)(.*)/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizeUrl(input: string): string {
|
||||
let url = input.trim().replace(/\/+$/, "");
|
||||
let url = input.trim().replace(TRAILING_SLASHES_RE, "");
|
||||
if (url && !url.includes("://")) {
|
||||
url = `http://${url}`;
|
||||
}
|
||||
@@ -61,7 +63,7 @@ export function normalizeUrl(input: string): string {
|
||||
}
|
||||
|
||||
export function splitUrl(input: string): { protocol: string; host: string } {
|
||||
const match = input.match(/^(https?:\/\/)(.*)/);
|
||||
const match = input.match(PROTOCOL_RE);
|
||||
if (match) {
|
||||
return { protocol: match[1], host: match[2] };
|
||||
}
|
||||
@@ -85,7 +87,7 @@ export function getServerUrl(): string {
|
||||
}
|
||||
|
||||
function setServerUrlInternal(url: string): void {
|
||||
const normalized = url.replace(/\/+$/, "");
|
||||
const normalized = url.replace(TRAILING_SLASHES_RE, "");
|
||||
globalStorage.set(SERVER_URL_KEY, normalized);
|
||||
}
|
||||
|
||||
@@ -157,13 +159,7 @@ export async function ensureInstanceId(): Promise<string | null> {
|
||||
export async function validateServerUrl(url: string): Promise<ValidationResult> {
|
||||
const normalized = normalizeUrl(url);
|
||||
|
||||
if (!normalized || !normalized.includes("://")) {
|
||||
return { status: "error", error: "invalid_url" };
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(normalized);
|
||||
} catch {
|
||||
if (!normalized || !normalized.includes("://") || !URL.canParse(normalized)) {
|
||||
return { status: "error", error: "invalid_url" };
|
||||
}
|
||||
|
||||
@@ -309,9 +305,14 @@ export function startReachabilityMonitor(): () => void {
|
||||
|
||||
export function useServerReachability() {
|
||||
const [reachable, setReachableState] = useState(isReachable);
|
||||
const [prevReachable, setPrevReachable] = useState(isReachable);
|
||||
|
||||
if (isReachable !== prevReachable) {
|
||||
setPrevReachable(isReachable);
|
||||
setReachableState(isReachable);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setReachableState(isReachable);
|
||||
return onServerReachabilityChange(setReachableState);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { getImporter, getImporterConfig } from "./importers";
|
||||
|
||||
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
|
||||
const VERSION_PREFIX_RE = /^v/;
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -36,7 +37,7 @@ app.get("/v1/version", async (c) => {
|
||||
c.header("Cache-Control", "public, s-maxage=900, stale-while-revalidate=3600");
|
||||
|
||||
return c.json({
|
||||
version: data.tag_name.replace(/^v/, ""),
|
||||
version: data.tag_name.replace(VERSION_PREFIX_RE, ""),
|
||||
release_url: data.html_url,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export function getJobSchedules(): {
|
||||
pattern: string;
|
||||
nextRunAt: string | null;
|
||||
}[] {
|
||||
return Array.from(jobs.entries()).map(([name, cron]) => ({
|
||||
return Array.from(jobs.entries(), ([name, cron]) => ({
|
||||
jobName: name,
|
||||
pattern: cron.getPattern() ?? "",
|
||||
nextRunAt: cron.nextRun()?.toISOString() ?? null,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { createLogger } from "@sofa/logger";
|
||||
import { generateOpenApiSpec, openApiTags, schemaConverters } from "./openapi-spec";
|
||||
import { implementedRouter } from "./router";
|
||||
|
||||
const TRAILING_SLASH_RE = /\/$/;
|
||||
|
||||
const log = createLogger("openapi");
|
||||
|
||||
const isSecure = (process.env.BETTER_AUTH_URL ?? "").startsWith("https://");
|
||||
@@ -79,9 +81,9 @@ export const openApiHandler = new OpenAPIHandler(implementedRouter, {
|
||||
],
|
||||
interceptors: [
|
||||
async (options) => {
|
||||
const requestPathname = options.request.url.pathname.replace(/\/$/, "") || "/";
|
||||
const prefix = options.prefix?.replace(/\/$/, "") || "";
|
||||
const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/";
|
||||
const requestPathname = options.request.url.pathname.replace(TRAILING_SLASH_RE, "") || "/";
|
||||
const prefix = options.prefix?.replace(TRAILING_SLASH_RE, "") || "";
|
||||
const specPath = `${prefix}/spec.json`.replace(TRAILING_SLASH_RE, "") || "/";
|
||||
|
||||
if (options.request.method !== "GET" || requestPathname !== specPath) {
|
||||
return options.next();
|
||||
|
||||
+31
-2
@@ -1,12 +1,41 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": ["@tanstack/eslint-plugin-query", "@tanstack/eslint-plugin-router"],
|
||||
"jsPlugins": [
|
||||
"@tanstack/eslint-plugin-query",
|
||||
"@tanstack/eslint-plugin-router",
|
||||
"eslint-plugin-lingui",
|
||||
{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }
|
||||
],
|
||||
"rules": {
|
||||
"@tanstack/query/exhaustive-deps": "error",
|
||||
"@tanstack/query/no-rest-destructuring": "warn",
|
||||
"@tanstack/query/stable-query-client": "error",
|
||||
"@tanstack/query/no-unstable-deps": "error",
|
||||
"@tanstack/router/create-route-property-order": "warn"
|
||||
"@tanstack/router/create-route-property-order": "warn",
|
||||
"lingui/no-unlocalized-strings": "off",
|
||||
"lingui/t-call-in-function": "error",
|
||||
"lingui/no-single-variables-to-translate": "error",
|
||||
"lingui/no-expression-in-message": "error",
|
||||
"lingui/no-single-tag-to-translate": "error",
|
||||
"lingui/no-trans-inside-trans": "error",
|
||||
"react-hooks-js/rules-of-hooks": "off",
|
||||
"react-hooks-js/exhaustive-deps": "off",
|
||||
"react-hooks-js/static-components": "error",
|
||||
"react-hooks-js/use-memo": "error",
|
||||
"react-hooks-js/void-use-memo": "error",
|
||||
"react-hooks-js/component-hook-factories": "error",
|
||||
"react-hooks-js/preserve-manual-memoization": "error",
|
||||
"react-hooks-js/incompatible-library": "warn",
|
||||
"react-hooks-js/immutability": "error",
|
||||
"react-hooks-js/globals": "error",
|
||||
"react-hooks-js/refs": "error",
|
||||
"react-hooks-js/set-state-in-effect": "error",
|
||||
"react-hooks-js/error-boundaries": "error",
|
||||
"react-hooks-js/purity": "error",
|
||||
"react-hooks-js/set-state-in-render": "error",
|
||||
"react-hooks-js/unsupported-syntax": "warn",
|
||||
"react-hooks-js/config": "error",
|
||||
"react-hooks-js/gating": "error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@sofa/api": "workspace:*",
|
||||
"@sofa/i18n": "workspace:*",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-hotkeys": "0.4.2",
|
||||
"@tanstack/react-hotkeys": "0.5.1",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "1.168.2",
|
||||
"better-auth": "catalog:",
|
||||
|
||||
@@ -49,6 +49,8 @@ const SHORTCUT_DESCRIPTIONS = [
|
||||
{ scope: "Global", description: "Keyboard shortcuts", keys: ["?"] },
|
||||
{ scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] },
|
||||
{ scope: "Navigation", description: "Go to explore", keys: ["g", "e"] },
|
||||
{ scope: "Navigation", description: "Go to upcoming", keys: ["g", "u"] },
|
||||
{ scope: "Navigation", description: "Go to settings", keys: ["g", "s"] },
|
||||
{ scope: "Title", description: "Cycle status", keys: ["w"] },
|
||||
{ scope: "Title", description: "Mark watched", keys: ["m"] },
|
||||
{ scope: "Title", description: "Go back", keys: ["Escape"] },
|
||||
@@ -95,8 +97,19 @@ export function CommandPalette() {
|
||||
const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? [];
|
||||
const enabled = !commandPaletteOpen;
|
||||
|
||||
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
|
||||
useHotkey("/", () => setCommandPaletteOpen(true), { enabled });
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean | ((prev: boolean) => boolean)) => {
|
||||
setCommandPaletteOpen((prev) => {
|
||||
const next = typeof open === "function" ? open(prev) : open;
|
||||
if (next) setQuery("");
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setCommandPaletteOpen],
|
||||
);
|
||||
|
||||
useHotkey("Mod+K", () => handleOpenChange((prev) => !prev));
|
||||
useHotkey("/", () => handleOpenChange(true), { enabled });
|
||||
useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled });
|
||||
useHotkeySequence(
|
||||
["G", "H"],
|
||||
@@ -112,13 +125,20 @@ export function CommandPalette() {
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
|
||||
// Reset query when palette opens
|
||||
useEffect(() => {
|
||||
if (commandPaletteOpen) {
|
||||
setQuery("");
|
||||
}
|
||||
}, [commandPaletteOpen]);
|
||||
useHotkeySequence(
|
||||
["G", "U"],
|
||||
() => {
|
||||
void navigate({ to: "/upcoming" });
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
useHotkeySequence(
|
||||
["G", "S"],
|
||||
() => {
|
||||
void navigate({ to: "/settings" });
|
||||
},
|
||||
{ enabled, timeout: 500 },
|
||||
);
|
||||
|
||||
// Save to recent searches after user stops typing for a while
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
@@ -170,7 +190,7 @@ export function CommandPalette() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<Dialog open={commandPaletteOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Command Palette</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -70,37 +70,35 @@ function useActiveIndicator<T>(
|
||||
itemRefs: React.MutableRefObject<(HTMLElement | null)[]>,
|
||||
measure: (itemRect: DOMRect, containerRect: DOMRect) => T,
|
||||
): { value: T | null; instant: boolean } {
|
||||
const [value, setValue] = useState<T | null>(null);
|
||||
const instantRef = useRef(true);
|
||||
const [state, setState] = useState<{ value: T | null; instant: boolean }>({
|
||||
value: null,
|
||||
instant: true,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (activeIndex === -1) {
|
||||
setValue(null);
|
||||
return;
|
||||
}
|
||||
const computeValue = (): T | null => {
|
||||
if (activeIndex === -1) return null;
|
||||
const item = itemRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (item && container && container.offsetWidth > 0) {
|
||||
setValue(measure(item.getBoundingClientRect(), container.getBoundingClientRect()));
|
||||
} else {
|
||||
setValue(null);
|
||||
return measure(item.getBoundingClientRect(), container.getBoundingClientRect());
|
||||
}
|
||||
return null;
|
||||
};
|
||||
update();
|
||||
// After the initial measurement, allow subsequent changes to animate
|
||||
instantRef.current = false;
|
||||
// Initial measurement is instant; subsequent activeIndex changes animate
|
||||
setState({ value: computeValue(), instant: true });
|
||||
// Use microtask to flip instant to false after the synchronous paint
|
||||
queueMicrotask(() => setState((prev) => (prev.instant ? { ...prev, instant: false } : prev)));
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
instantRef.current = true;
|
||||
update();
|
||||
setState({ value: computeValue(), instant: true });
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [activeIndex, containerRef, itemRefs, measure]);
|
||||
|
||||
return { value, instant: instantRef.current };
|
||||
return state;
|
||||
}
|
||||
|
||||
export function NavBar({
|
||||
|
||||
@@ -61,7 +61,7 @@ export function NavigationProgress() {
|
||||
|
||||
finishTimerRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => setProgress(0), 200);
|
||||
setTimeout(setProgress, 200, 0);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
return list.toSorted((a, b) => {
|
||||
if (sort === "rating") {
|
||||
return (b.voteAverage ?? 0) - (a.voteAverage ?? 0);
|
||||
}
|
||||
|
||||
@@ -98,8 +98,12 @@ export function AccountSection({
|
||||
});
|
||||
const initial = displayName?.charAt(0).toUpperCase() ?? "?";
|
||||
|
||||
const uploadAvatarMutation = useMutation(
|
||||
orpc.account.uploadAvatar.mutationOptions({
|
||||
const uploadAvatarMutation = useMutation(orpc.account.uploadAvatar.mutationOptions());
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadAvatarMutation.mutate(file, {
|
||||
onSuccess: (data) => {
|
||||
setAvatarUrl(data.imageUrl);
|
||||
toast.success(t`Profile picture updated`);
|
||||
@@ -111,13 +115,7 @@ export function AccountSection({
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadAvatarMutation.mutate(file);
|
||||
});
|
||||
}
|
||||
|
||||
const removeAvatarMutation = useMutation(
|
||||
@@ -579,7 +577,7 @@ function SofaImportDialog() {
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Import Sofa export</Trans>
|
||||
<Trans>Import Sofa data</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Review what was found and choose what to import.</Trans>
|
||||
|
||||
@@ -26,8 +26,10 @@ export function BackupRestoreSection() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const restoreMutation = useMutation(
|
||||
orpc.admin.backups.restore.mutationOptions({
|
||||
const restoreMutation = useMutation(orpc.admin.backups.restore.mutationOptions());
|
||||
|
||||
function handleRestore(file: File) {
|
||||
restoreMutation.mutate(file, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Database restored. Reloading...`);
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
@@ -38,11 +40,7 @@ export function BackupRestoreSection() {
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleRestore(file: File) {
|
||||
restoreMutation.mutate(file);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -135,7 +135,7 @@ export function IntegrationCard({
|
||||
if (!url) return;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(setCopied, 2000, false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { CronJobName, SystemHealthData } from "@sofa/api/schemas";
|
||||
|
||||
const DIGITS_ONLY_RE = /^\d+$/;
|
||||
|
||||
/** Convert a cron pattern to a short human-readable string */
|
||||
function cronToHuman(pattern: string): string {
|
||||
const parts = pattern.split(" ");
|
||||
@@ -50,12 +52,12 @@ function cronToHuman(pattern: string): string {
|
||||
}
|
||||
|
||||
// Daily at specific time: "0 3 * * *"
|
||||
if (/^\d+$/.test(hour) && /^\d+$/.test(min) && dow === "*") {
|
||||
if (DIGITS_ONLY_RE.test(hour) && DIGITS_ONLY_RE.test(min) && dow === "*") {
|
||||
return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Weekly
|
||||
if (/^\d+$/.test(dow)) {
|
||||
if (DIGITS_ONLY_RE.test(dow)) {
|
||||
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
return `Weekly on ${days[Number(dow)] ?? dow}`;
|
||||
}
|
||||
@@ -326,7 +328,7 @@ function BackgroundJobsCard({
|
||||
? (triggerJobMutation.variables?.name ?? null)
|
||||
: null;
|
||||
|
||||
const sortedJobs = [...jobs].sort((a, b) => {
|
||||
const sortedJobs = jobs.toSorted((a, b) => {
|
||||
if (a.disabled !== b.disabled) return a.disabled ? 1 : -1;
|
||||
if (!a.nextRunAt && !b.nextRunAt) return 0;
|
||||
if (!a.nextRunAt) return 1;
|
||||
|
||||
@@ -10,7 +10,7 @@ export function CopyButton({ code }: { code: string }) {
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(setCopied, 2000, false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { type MotionStyle, type MotionValue, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -85,19 +85,15 @@ function useStatusConfig() {
|
||||
function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) {
|
||||
const { t } = useLingui();
|
||||
const statusConfig = useStatusConfig();
|
||||
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(userStatus ?? null);
|
||||
|
||||
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
|
||||
useEffect(() => {
|
||||
setAddedStatus(userStatus ?? null);
|
||||
}, [userStatus]);
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<TitleStatus | null>(null);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => setAddedStatus("in_watchlist"),
|
||||
onSuccess: () => setOptimisticStatus("in_watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
const addedStatus = optimisticStatus ?? userStatus ?? null;
|
||||
const isAdded = addedStatus != null;
|
||||
const config = addedStatus ? statusConfig[addedStatus] : null;
|
||||
|
||||
@@ -282,10 +278,17 @@ export function TitleCard({
|
||||
userStatus,
|
||||
episodeProgress,
|
||||
}: TitleCardProps) {
|
||||
const tilt = useTiltEffect();
|
||||
const {
|
||||
ref: tiltRef,
|
||||
containerStyle,
|
||||
imageStyle,
|
||||
glareBackground,
|
||||
glareOpacity,
|
||||
handlers,
|
||||
} = useTiltEffect();
|
||||
|
||||
const cardContent = (
|
||||
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
|
||||
<motion.div ref={tiltRef} style={containerStyle} {...handlers}>
|
||||
<CardInner
|
||||
title={title}
|
||||
type={type}
|
||||
@@ -296,9 +299,9 @@ export function TitleCard({
|
||||
userStatus={userStatus}
|
||||
episodeProgress={episodeProgress}
|
||||
tiltStyles={{
|
||||
imageStyle: tilt.imageStyle,
|
||||
glareBackground: tilt.glareBackground,
|
||||
glareOpacity: tilt.glareOpacity,
|
||||
imageStyle,
|
||||
glareBackground,
|
||||
glareOpacity,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -23,7 +23,14 @@ function ProviderBadge({
|
||||
<TooltipTrigger
|
||||
{...(watchUrl
|
||||
? {
|
||||
render: <a href={watchUrl} target="_blank" rel="noopener noreferrer" />,
|
||||
render: (
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t`Watch on ${name}`}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
className={`border-border/30 bg-card flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border motion-safe:transition-transform motion-safe:hover:scale-105${watchUrl ? "" : "cursor-default"}`}
|
||||
|
||||
@@ -151,16 +151,9 @@ export function TitleSeasons({
|
||||
key={season.id}
|
||||
className="border-border/50 bg-card/50 overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSeason(isOpen ? null : season.seasonNumber)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpenSeason(isOpen ? null : season.seasonNumber);
|
||||
}
|
||||
}}
|
||||
className="group/season hover:bg-accent/50 flex w-full cursor-pointer items-center justify-between p-4 text-start transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -217,7 +210,7 @@ export function TitleSeasons({
|
||||
<IconChevronDown aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
|
||||
@@ -54,6 +54,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- intentional: represents the current page in a breadcrumb, not a clickable link
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
|
||||
@@ -44,17 +44,23 @@ function InputGroupAddon({
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
const focusInput = (e: React.SyntheticEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
onClick={focusInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
focusInput(e);
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- htmlFor is passed via props spread
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
|
||||
@@ -42,6 +42,7 @@ function PaginationLink({ className, isActive, size = "icon", ...props }: Pagina
|
||||
className={cn(className)}
|
||||
nativeButton={false}
|
||||
render={
|
||||
// oxlint-disable-next-line jsx-a11y/anchor-has-content -- content is provided by the Button's children
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
|
||||
@@ -4,7 +4,7 @@ export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
const timer = setTimeout(setDebounced, delay, value);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_app/dashboard")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()),
|
||||
@@ -33,6 +32,7 @@ export const Route = createFileRoute("/_app/dashboard")({
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
|
||||
pendingComponent: DashboardSkeleton,
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
export const Route = createFileRoute("/_app/explore")({
|
||||
staleTime: 60_000,
|
||||
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureInfiniteQueryData(
|
||||
@@ -37,6 +36,7 @@ export const Route = createFileRoute("/_app/explore")({
|
||||
),
|
||||
]);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
|
||||
pendingComponent: ExploreSkeletons,
|
||||
component: ExplorePage,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ const GITHUB_REPO = "jakejarvis/sofa";
|
||||
|
||||
export const Route = createFileRoute("/_app/settings")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
const promises: Promise<unknown>[] = [
|
||||
context.queryClient.ensureQueryData(orpc.integrations.list.queryOptions()),
|
||||
@@ -45,6 +44,7 @@ export const Route = createFileRoute("/_app/settings")({
|
||||
}
|
||||
await Promise.all(promises);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
|
||||
pendingComponent: SettingsSkeleton,
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
||||
|
||||
export const Route = createFileRoute("/_app/upcoming")({
|
||||
staleTime: 30_000,
|
||||
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.dashboard.upcoming.infiniteOptions({
|
||||
@@ -25,6 +24,7 @@ export const Route = createFileRoute("/_app/upcoming")({
|
||||
}),
|
||||
);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
|
||||
pendingComponent: UpcomingSkeleton,
|
||||
component: UpcomingPage,
|
||||
});
|
||||
|
||||
@@ -78,11 +78,11 @@ const envSnippets = [
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/setup")({
|
||||
head: () => ({ meta: [{ title: "Setup — Sofa" }] }),
|
||||
beforeLoad: async () => {
|
||||
const info = await client.system.publicInfo({});
|
||||
if (info.tmdbConfigured) throw redirect({ to: "/" });
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Setup — Sofa" }] }),
|
||||
component: SetupPage,
|
||||
});
|
||||
|
||||
|
||||
+15
-10
@@ -1,13 +1,18 @@
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import { defineProject } from "vitest/config";
|
||||
import { defineProject, mergeConfig } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: "chromium", headless: true }],
|
||||
import viteConfig from "./vite.config";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineProject({
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: "chromium", headless: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "sofa-monorepo",
|
||||
"devDependencies": {
|
||||
"@e18e/eslint-plugin": "0.3.0",
|
||||
"@lingui/cli": "catalog:",
|
||||
"@lingui/conf": "catalog:",
|
||||
"@lingui/format-po": "catalog:",
|
||||
@@ -15,6 +16,7 @@
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint-plugin-drizzle": "0.2.3",
|
||||
"eslint-plugin-lingui": "0.11.0",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"oxfmt": "0.41.0",
|
||||
"oxlint": "1.56.0",
|
||||
"playwright": "1.58.2",
|
||||
@@ -165,7 +167,7 @@
|
||||
"@sofa/api": "workspace:*",
|
||||
"@sofa/i18n": "workspace:*",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-hotkeys": "0.4.2",
|
||||
"@tanstack/react-hotkeys": "0.5.1",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-router": "1.168.2",
|
||||
"better-auth": "catalog:",
|
||||
@@ -618,6 +620,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -1816,6 +1820,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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-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-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-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=="],
|
||||
|
||||
@@ -2096,9 +2106,9 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -2412,6 +2422,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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/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/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/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/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-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
+3
-1
@@ -50,12 +50,13 @@
|
||||
"format:check": "turbo run format:check",
|
||||
"check-types": "turbo run check-types",
|
||||
"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",
|
||||
"i18n:extract": "lingui extract",
|
||||
"i18n:claude": "bun packages/i18n/scripts/claude.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@e18e/eslint-plugin": "0.3.0",
|
||||
"@lingui/cli": "catalog:",
|
||||
"@lingui/conf": "catalog:",
|
||||
"@lingui/format-po": "catalog:",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint-plugin-drizzle": "0.2.3",
|
||||
"eslint-plugin-lingui": "0.11.0",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"oxfmt": "0.41.0",
|
||||
"oxlint": "1.56.0",
|
||||
"playwright": "1.58.2",
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -495,7 +499,7 @@ export function getUpcomingFeed(
|
||||
// Compute cursor from the last item on this page
|
||||
let nextCursor: string | null = null;
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -579,7 +583,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
tmdb_recommendations: 0,
|
||||
tmdb_similar: 1,
|
||||
} as const;
|
||||
const orderedRecs = [...recs].sort(
|
||||
const orderedRecs = recs.toSorted(
|
||||
(a, b) => a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
|
||||
);
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ export async function cacheProviderLogos(titleId: string) {
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all(
|
||||
[...uniqueLogos.entries()].map(async ([basename, logoPath]) => ({
|
||||
Array.from(uniqueLogos.entries(), async ([basename, logoPath]) => ({
|
||||
logoPath,
|
||||
cached: await isImageCached("logos", basename),
|
||||
})),
|
||||
@@ -248,7 +248,7 @@ export async function cacheProfilePhotos(titleId: string) {
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all(
|
||||
[...uniqueProfiles.entries()].map(async ([basename, profilePath]) => ({
|
||||
Array.from(uniqueProfiles.entries(), async ([basename, profilePath]) => ({
|
||||
profilePath,
|
||||
cached: await isImageCached("profiles", basename),
|
||||
})),
|
||||
|
||||
@@ -6,6 +6,7 @@ const APP_VERSION = process.env.APP_VERSION || "0.0.0";
|
||||
|
||||
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 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. */
|
||||
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 [cMajor = 0, cMinor = 0, cPatch = 0] = parse(current);
|
||||
if (lMajor !== cMajor) return lMajor > cMajor;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
@@ -24,6 +24,17 @@ import {
|
||||
getWatchHistory,
|
||||
} from "../src/discovery";
|
||||
|
||||
const TEST_NOW = new Date("2026-03-01T12:00:00Z");
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(TEST_NOW);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
@@ -56,7 +67,7 @@ describe("getWatchCount", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
// Watch from 2 years ago
|
||||
const oldDate = new Date();
|
||||
const oldDate = new Date(TEST_NOW);
|
||||
oldDate.setFullYear(oldDate.getFullYear() - 2);
|
||||
insertMovieWatch("user-1", "m1", oldDate);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { clearAllTables, insertUser } from "@sofa/test/db";
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T/;
|
||||
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration,
|
||||
@@ -82,7 +84,7 @@ describe("listUserIntegrations", () => {
|
||||
test("serializes dates as ISO strings", () => {
|
||||
createOrUpdateIntegration("user-1", "plex");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { setSetting } from "../src/settings";
|
||||
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(() => {
|
||||
clearAllTables();
|
||||
insertUser("user-1");
|
||||
@@ -60,7 +71,7 @@ describe("performTelemetryReport", () => {
|
||||
|
||||
test("respects 24-hour report interval", async () => {
|
||||
setSetting("telemetryEnabled", "true");
|
||||
setSetting("telemetryLastReportedAt", new Date().toISOString());
|
||||
setSetting("telemetryLastReportedAt", TEST_NOW.toISOString());
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
await performTelemetryReport();
|
||||
@@ -69,7 +80,7 @@ describe("performTelemetryReport", () => {
|
||||
|
||||
test("reports again after interval expires", async () => {
|
||||
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);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
@@ -12,8 +12,19 @@ import {
|
||||
|
||||
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 {
|
||||
const d = new Date();
|
||||
const d = new Date(TEST_NOW);
|
||||
d.setDate(d.getDate() + offset);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
|
||||
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 { formatter } from "@lingui/format-po";
|
||||
|
||||
@@ -198,11 +205,11 @@ function extractSimplePlaceholders(text: string): string[] {
|
||||
if (depth === 0 && argStart >= 0) {
|
||||
const inner = text.slice(argStart + 1, i);
|
||||
// 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}}`);
|
||||
} else {
|
||||
// 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}}`);
|
||||
}
|
||||
argStart = -1;
|
||||
@@ -215,15 +222,15 @@ function extractSimplePlaceholders(text: string): string[] {
|
||||
|
||||
function extractNumberedTags(text: string): string[] {
|
||||
// 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 {
|
||||
return text.match(/^\s*/)?.[0] ?? "";
|
||||
return text.match(LEADING_WS_RE)?.[0] ?? "";
|
||||
}
|
||||
|
||||
function trailingWhitespace(text: string): string {
|
||||
return text.match(/\s*$/)?.[0] ?? "";
|
||||
return text.match(TRAILING_WS_RE)?.[0] ?? "";
|
||||
}
|
||||
|
||||
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
|
||||
if (/^\s|\s$/.test(source)) {
|
||||
if (EDGE_WS_RE.test(source)) {
|
||||
if (leadingWhitespace(source) !== leadingWhitespace(translated)) {
|
||||
issues.push("leading whitespace mismatch");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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). */
|
||||
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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ msgstr ""
|
||||
#~ msgid "...and {0} more"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "...and {remainingErrors} more"
|
||||
msgstr "...and {remainingErrors} more"
|
||||
@@ -145,10 +146,12 @@ msgstr ""
|
||||
msgid "{label} URL regenerated"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "{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
|
||||
msgid "{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}}"
|
||||
msgstr "{watchedEpisodes}/{totalEpisodes, plural, one {# episode} other {# episodes}}"
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "{watchlistCount} items"
|
||||
msgstr "{watchlistCount} items"
|
||||
@@ -385,6 +389,7 @@ msgstr ""
|
||||
#: apps/native/src/components/settings/integration-card.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/backup-restore-section.tsx
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
#: apps/web/src/components/settings/danger-section.tsx
|
||||
@@ -611,6 +616,10 @@ msgstr ""
|
||||
msgid "Could not load person details"
|
||||
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
|
||||
msgid "Create account"
|
||||
msgstr ""
|
||||
@@ -726,6 +735,7 @@ msgstr ""
|
||||
msgid "Don't have an account?"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
@@ -747,6 +757,10 @@ msgstr ""
|
||||
msgid "Download backup"
|
||||
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/components/search/recently-viewed-row-content.tsx
|
||||
#: apps/web/src/components/people/person-hero.tsx
|
||||
@@ -819,6 +833,7 @@ msgstr ""
|
||||
msgid "Episode watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/components/titles/title-seasons.tsx
|
||||
msgid "Episodes"
|
||||
@@ -857,6 +872,7 @@ msgstr ""
|
||||
#~ msgid "Errors ({0})"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Errors ({errorCount})"
|
||||
msgstr "Errors ({errorCount})"
|
||||
@@ -874,6 +890,11 @@ msgstr ""
|
||||
msgid "Explore titles"
|
||||
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
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
@@ -949,6 +970,7 @@ msgstr ""
|
||||
msgid "Failed to mark some episodes"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Failed to parse file"
|
||||
msgstr ""
|
||||
@@ -1063,6 +1085,10 @@ msgstr ""
|
||||
msgid "Finished importing from {source}."
|
||||
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
|
||||
msgid "Free"
|
||||
msgstr ""
|
||||
@@ -1086,6 +1112,7 @@ msgstr ""
|
||||
#: 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
|
||||
msgid "Go back"
|
||||
msgstr ""
|
||||
|
||||
@@ -1137,18 +1164,22 @@ msgstr ""
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Import {totalItems} items"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Import complete"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Import failed"
|
||||
msgstr ""
|
||||
@@ -1165,15 +1196,23 @@ msgstr ""
|
||||
msgid "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
|
||||
msgid "Import is still running in the background. Check back later."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Import options"
|
||||
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
|
||||
msgid "Imported"
|
||||
msgstr ""
|
||||
@@ -1186,6 +1225,14 @@ msgstr ""
|
||||
msgid "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
|
||||
msgid "Importing from {source}"
|
||||
msgstr ""
|
||||
@@ -1292,14 +1339,23 @@ msgstr ""
|
||||
msgid "Last run succeeded"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Library refresh"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Library statuses"
|
||||
msgstr "Library statuses"
|
||||
|
||||
#: apps/web/src/components/auth-form.tsx
|
||||
msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Lost connection to import. Check status in settings."
|
||||
msgstr ""
|
||||
@@ -1404,6 +1460,7 @@ msgid "Movie"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Movies"
|
||||
msgstr ""
|
||||
@@ -1602,6 +1659,10 @@ msgstr ""
|
||||
msgid "Page Not Found"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
msgid "Parsing file..."
|
||||
msgstr "Parsing file..."
|
||||
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Parsing..."
|
||||
msgstr ""
|
||||
@@ -1770,6 +1831,8 @@ msgstr ""
|
||||
msgid "Rating removed"
|
||||
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
|
||||
msgid "Ratings"
|
||||
@@ -1938,6 +2001,10 @@ msgstr ""
|
||||
msgid "Restore failed"
|
||||
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
|
||||
msgid "Restoring…"
|
||||
msgstr ""
|
||||
@@ -1961,6 +2028,7 @@ msgstr ""
|
||||
msgid "Return home"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Review what was found and choose what to import."
|
||||
msgstr ""
|
||||
@@ -2190,6 +2258,7 @@ msgstr ""
|
||||
msgid "Sign out of other sessions"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Skipped"
|
||||
msgstr ""
|
||||
@@ -2217,6 +2286,7 @@ msgstr ""
|
||||
|
||||
#: apps/native/src/app/change-password.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/routes/__root.tsx
|
||||
msgid "Something went wrong"
|
||||
@@ -2251,6 +2321,7 @@ msgstr ""
|
||||
msgid "Starting at"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Starting import..."
|
||||
msgstr ""
|
||||
@@ -2291,6 +2362,7 @@ msgstr ""
|
||||
msgid "The title you're looking for doesn't exist or may have been removed from the database."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-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."
|
||||
msgstr ""
|
||||
@@ -2440,6 +2512,7 @@ msgstr ""
|
||||
msgid "Trigger job"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/web/src/routes/__root.tsx
|
||||
msgid "Try again"
|
||||
msgstr ""
|
||||
@@ -2591,6 +2664,7 @@ msgstr ""
|
||||
msgid "Waiting for authorization..."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Warnings"
|
||||
msgstr ""
|
||||
@@ -2599,6 +2673,7 @@ msgstr ""
|
||||
#~ msgid "Warnings ({0})"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Warnings ({warningCount})"
|
||||
msgstr "Warnings ({warningCount})"
|
||||
@@ -2607,10 +2682,12 @@ msgstr "Warnings ({warningCount})"
|
||||
msgid "Watch all"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
msgid "Watch history"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Watch on {name}"
|
||||
msgstr ""
|
||||
|
||||
@@ -111,6 +111,7 @@ function getApiKey() {
|
||||
const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3";
|
||||
const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE;
|
||||
const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE;
|
||||
const TMDB_VERSION_PREFIX_RE = /^\/3\//;
|
||||
const baseUrl = configuredBase.replace(/\/3\/?$/, "");
|
||||
|
||||
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
|
||||
// go to e.g. https://tmdb.internal/search/multi instead of /3/search/multi
|
||||
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);
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user