Compare commits

..
Author SHA1 Message Date
ajnart f7dae52b7e style: auto-fix lint and format 2026-06-01 15:43:07 +00:00
Thomas Camlong 0f3eabfb11 perf(integrations): speed up mediaRequestList Overseerr fetches
Reuse a shared TLS agent with timeouts, parallelize list calls, dedupe
detail fetches, and cap concurrency to reduce cron job duration.
2026-06-01 17:41:53 +02:00
Thomas Camlong b2b22aace0 feat(i18n): add media mode help text and request modal status translations
- Add search.mode.media.help for the Modes list entry
- Add table header 'Status' and status badge 'Requested' for the
  request modal season table
2026-05-30 00:56:39 +02:00
Thomas Camlong 3e856c648f feat(media-request): add availability indicators and allow TV re-requests
- Extend searchSchema to parse mediaInfo.status and map to
  MediaAvailability (available, partial, processing, requested)
- Extend mediaInformationSchema to parse already-requested seasons
  from mediaInfo.requests[].seasons[].seasonNumber
- Show colored availability badge on search results in spotlight
- Allow re-requesting TV shows (hide Request only for fully available
  movies), so users can request additional seasons
2026-05-30 00:56:32 +02:00
Thomas Camlong 8a0c438a7d fix(spotlight): add media mode to Modes list and remove from search results
Media request search now appears in the Modes section (with no keyboard
shortcut) instead of being mixed into search results when typing. Changed
keyPath from 'character' to 'modeKey' to support modes without a trigger
character. Removed the mediaRequestSearchEntry from home search results
since it is now accessible via the Modes list.
2026-05-30 00:56:24 +02:00
Thomas Camlong 5a697f911a fix(api): move getMediaRequestOptions and requestMedia to integration router
The procedures were on the searchEngine router which only supported
jellyseerr and overseerr kinds. Moved to the integration router with
seerr added as a supported kind, fixing NOT_FOUND errors for seerr
integrations. Updated RequestMediaModal to use the new endpoint paths.
2026-05-30 00:56:15 +02:00
Thomas Camlong d4279a938d wip on fixing seerr media request 2026-05-29 23:42:39 +02:00
103 changed files with 1780 additions and 3714 deletions

No files matched your search

@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import { DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -18,12 +18,7 @@ export const CustomMantineProvider = ({
const manager = useColorSchemeManager();
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
cssVariablesResolver={v8CssVariablesResolver}
>
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
{children}
</MantineProvider>
</DirectionProvider>
@@ -17,13 +17,10 @@ import {
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -59,20 +56,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: {
staleTime: 5 * 1000,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
},
}),
);
@@ -1,14 +1,11 @@
import { redirect } from "next/navigation";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
@@ -40,13 +37,6 @@ export default async function Login(props: LoginProps) {
{t("subtitle")}
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
@@ -42,9 +42,8 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess || isLoading) {
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
@@ -52,13 +51,11 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && <AddMenu />}
<EditModeMenu demoMode={demoMode ?? false} />
<EditModeMenu />
{!demoMode && (
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
)}
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
<SelectBoardsMenu />
</>
@@ -144,7 +141,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const EditModeMenu = () => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -167,18 +164,10 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
if (isEditMode) return saveBoard(board);
open();
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
}, [board, isEditMode, saveBoard, open]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
@@ -33,12 +33,7 @@ export const BoardMantineProvider = ({
});
return (
<MantineProvider
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
{children}
</MantineProvider>
);
@@ -1,15 +1,15 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => {
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
-1
View File
@@ -25,7 +25,6 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -96,7 +96,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION || env.DEMO_MODE} />
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</MenuDropdown>
</Menu>
);
@@ -107,23 +107,6 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
const table = useTranslatedMantineReactTable({
data: data.containers,
enableDensityToggle: false,
@@ -142,11 +125,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
},
initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
{tDocker("action.refresh.label")}
</Button>
),
renderTopToolbarCustomActions: () => {
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
return (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isPending}
>
{tDocker("action.refresh.label")}
</Button>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return (
@@ -1,7 +1,20 @@
"use client";
import { startTransition, useCallback, useState } from "react";
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -11,11 +24,10 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user";
@@ -155,10 +167,18 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<form>
<Card p="xl">
<Stack gap="md">
<UserCreatePasswordFields
<CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
variant="filled"
passwordInputProps={securityForm.getInputProps("password")}
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
withAsterisk
{...securityForm.getInputProps("password")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
/>
</Stack>
</Card>
@@ -85,7 +85,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(t) {
-1
View File
@@ -3,7 +3,6 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
+2 -2
View File
@@ -6,9 +6,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.34.1"
"pnpm": ">=10.33.4"
},
"packageManager": "pnpm@10.34.1",
"packageManager": "pnpm@10.33.4",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
-3
View File
@@ -19,9 +19,6 @@
<!-- Links -->
<p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
+6 -6
View File
@@ -60,9 +60,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.34.1"
"pnpm": ">=10.33.4"
},
"packageManager": "pnpm@10.34.1",
"packageManager": "pnpm@10.33.4",
"pnpm": {
"onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml",
@@ -76,14 +76,14 @@
"tree-sitter-json"
],
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.2",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.23",
"hono@<4.6.5": ">=4.12.22",
"linkifyjs@<4.3.2": ">=4.3.3",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
@@ -93,7 +93,7 @@
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
"tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
},
"patchedDependencies": {
+1 -3
View File
@@ -1,14 +1,12 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
},
});
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { isDemoMode, publicProcedure } from "../trpc";
import { publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER || isDemoMode) {
if (env.ENABLE_DOCKER) {
return await next();
}
throw new TRPCError({
+1 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -13,5 +13,4 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version,
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
});
+8 -50
View File
@@ -81,7 +81,13 @@ describe("initUser should initialize the first user", () => {
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
it.each([
["aB2%"], // too short
["abc123DEF"], // does not contain special characters
["abcDEFghi+"], // does not contain numbers
["ABC123+/-"], // does not contain lowercase
["abc123+/-"], // does not contain uppercase
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
@@ -97,55 +103,7 @@ describe("initUser should initialize the first user", () => {
confirmPassword: password,
});
await expect(actAsync()).rejects.toThrow();
});
it("should accept passwords without complexity requirements", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "abc123DEF",
confirmPassword: "abc123DEF",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
});
it("should accept passwords with special characters like LoveHomarr<3", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "LoveHomarr<3",
confirmPassword: "LoveHomarr<3",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
await expect(actAsync()).rejects.toThrow("passwordRequirements");
});
});
+2 -2
View File
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, input.id));
.where(eq(users.id, ctx.session.user.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
.where(eq(users.id, input.id));
.where(eq(users.id, ctx.session.user.id));
}),
});
+7 -17
View File
@@ -18,7 +18,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -88,20 +87,6 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
*
@@ -109,14 +94,19 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = baseProcedure;
export const publicProcedure = t.procedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -131,7 +121,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
@@ -14,7 +14,9 @@ export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MIN
}),
);
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE, {
expectedMaximumDurationInMillis: 15_000,
}).withCallback(
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
widgetKinds: ["mediaRequests-requestList"],
getInput: {
+5 -203
View File
@@ -1,5 +1,3 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
@@ -10,7 +8,6 @@ import {
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -21,17 +18,12 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import {
apps,
boards,
groupMembers,
groupPermissions,
groups,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
onboarding,
searchEngines,
@@ -47,7 +39,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
if (process.env.DEMO_MODE === "true") {
await seedDemoUserAsync(db);
}
};
@@ -217,134 +209,6 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`);
};
interface DemoWidget {
kind: WidgetKind;
width: number;
height: number;
needsIntegration: boolean;
options?: Record<string, unknown>;
}
const demoApps = [
{
name: "Sonarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
href: "https://sonarr.tv",
},
{
name: "Radarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
href: "https://radarr.video",
},
{
name: "Plex",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
href: "https://plex.tv",
},
{
name: "Jellyfin",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
href: "https://jellyfin.org",
},
{
name: "Proxmox",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
href: "https://proxmox.com",
},
{
name: "Pi-hole",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
href: "https://pi-hole.net",
},
{
name: "Grafana",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
href: "https://grafana.com",
},
{
name: "Portainer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
href: "https://portainer.io",
},
{
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
href: "https://nextcloud.com",
},
{
name: "qBittorrent",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
href: "https://qbittorrent.org",
},
{
name: "Overseerr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
href: "https://overseerr.dev",
},
] as const;
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: false,
},
},
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId): DemoWidget => ({
kind: "app",
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"),
@@ -361,7 +225,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId,
name: "demo",
email: "demo@example.com",
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
});
const maxPosition = await getMaxGroupPositionAsync(db);
@@ -386,27 +250,6 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings",
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Demo Integration",
url: "https://demo.homarr.dev",
kind: "mock",
appId: null,
});
const appIds: string[] = [];
for (const app of demoApps) {
const appId = createId();
appIds.push(appId);
await db.insert(apps).values({
id: appId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
}
const boardId = createId();
await db.insert(boards).values({
id: boardId,
@@ -414,64 +257,23 @@ const seedDemoUserAsync = async (db: Database) => {
isPublic: false,
creatorId: userId,
});
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
id: createId(),
name: "Base",
columnCount: 12,
breakpoint: 0,
boardId,
});
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId,
kind: widget.kind,
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
if (widget.needsIntegration) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log(
"Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
);
};
-2
View File
@@ -5,5 +5,3 @@ export * from "./icon-picker/icon-picker";
export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media";
export * from "./user-create/user-create-password-fields";
+34 -90
View File
@@ -1,7 +1,7 @@
"use client";
import type { ChangeEventHandler } from "react";
import { useEffect, useImperativeHandle, useRef } from "react";
import { useEffect, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
@@ -17,19 +17,6 @@ import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>;
const toFormValues = (values: FormType | undefined): FormType => ({
name: values?.name ?? "",
description: values?.description ?? "",
iconUrl: values?.iconUrl ?? "",
href: values?.href ?? "",
pingUrl: values?.pingUrl ?? "",
});
export interface AppFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
}
interface AppFormProps {
showBackToOverview: boolean;
buttonLabels: {
@@ -37,10 +24,8 @@ interface AppFormProps {
submitAndCreateAnother?: string;
};
initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise<void>;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
isPending: boolean;
hideButtons?: boolean;
formRef?: React.Ref<AppFormHandle>;
}
export const AppForm = ({
@@ -49,31 +34,19 @@ export const AppForm = ({
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
hideButtons,
formRef,
}: AppFormProps) => {
const t = useI18n();
const form = useZodForm(appManageSchema, {
initialValues: toFormValues(initialValues),
initialValues: {
name: initialValues?.name ?? "",
description: initialValues?.description ?? "",
iconUrl: initialValues?.iconUrl ?? "",
href: initialValues?.href ?? "",
pingUrl: initialValues?.pingUrl ?? "",
},
});
const initialValuesKey = [
initialValues?.name,
initialValues?.description,
initialValues?.iconUrl,
initialValues?.href,
initialValues?.pingUrl,
].join("\0");
useEffect(() => {
if (!initialValues || form.isDirty()) {
return;
}
form.initialize(toFormValues(initialValues));
}, [initialValuesKey, form, initialValues]);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
@@ -89,29 +62,6 @@ export const AppForm = ({
originalHandleSubmit(values, redirect, afterSuccess);
};
useImperativeHandle(
formRef,
() => ({
submit: () =>
new Promise<boolean>((resolve) => {
form.onSubmit(
async (values) => {
try {
await Promise.resolve(handleSubmit(values));
form.initialize(values);
resolve(true);
} catch {
resolve(false);
}
},
() => resolve(false),
)();
}),
isDirty: () => form.isDirty(),
}),
[form, handleSubmit],
);
const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => {
@@ -142,32 +92,32 @@ export const AppForm = ({
}
}, [debouncedName, iconsData]);
const formFields = (
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
autosize
minRows={2}
resize="vertical"
/>
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
autosize
minRows={2}
resize="vertical"
/>
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Checkbox
checked={opened}
onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
mt="md"
/>
<Checkbox
checked={opened}
onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
mt="md"
/>
<Collapse expanded={opened}>
<TextInput {...form.getInputProps("pingUrl")} />
</Collapse>
<Collapse expanded={opened}>
<TextInput {...form.getInputProps("pingUrl")} />
</Collapse>
{!hideButtons && (
<Group justify="end">
{showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps">
@@ -189,13 +139,7 @@ export const AppForm = ({
{buttonLabels.submit}
</Button>
</Group>
)}
</Stack>
</Stack>
</form>
);
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(handleSubmit)}>{formFields}</form>;
};
@@ -1,33 +0,0 @@
"use client";
import { PasswordInput, Stack } from "@mantine/core";
import type { PasswordInputProps } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
interface UserCreatePasswordFieldsProps {
passwordInputProps: PasswordInputProps;
confirmPasswordInputProps: PasswordInputProps;
variant?: PasswordInputProps["variant"];
}
export const UserCreatePasswordFields = ({
passwordInputProps,
confirmPasswordInputProps,
variant,
}: UserCreatePasswordFieldsProps) => {
const t = useScopedI18n("user.field");
return (
<Stack gap="md">
<CustomPasswordInput
withPasswordRequirements
label={t("password.label")}
variant={variant}
{...passwordInputProps}
/>
<PasswordInput label={t("passwordConfirm.label")} variant={variant} {...confirmPasswordInputProps} />
</Stack>
);
};
@@ -12,14 +12,14 @@ interface SeriesInformation {
overview: string;
seasons: SerieSeason[];
posterPath: string;
requestedSeasons?: number[];
requestedSeasons: number[];
}
interface MovieInformation {
id: number;
overview: string;
posterPath: string;
requestedSeasons?: number[];
requestedSeasons: number[];
}
export type MediaInformation = SeriesInformation | MovieInformation;
+69 -191
View File
@@ -1,130 +1,24 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
interface EventTemplate {
title: string;
subTitle: string;
description: string;
dayOffset: number;
hours: number;
image: CalendarEvent["image"];
indicatorColor: string;
metadata?: CalendarEvent["metadata"];
location?: string;
links: CalendarEvent["links"];
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
return await Promise.resolve(result);
}
}
const eventTemplates: EventTemplate[] = [
{
title: "The Mandalorian",
subTitle: "Season 4",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
dayOffset: 0,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S4/E3", color: "red" },
},
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Silo",
subTitle: "Season 2",
description: "In a ruined and toxic future, thousands live in a giant underground silo.",
dayOffset: 3,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E8", color: "teal" },
},
indicatorColor: "teal",
links: [
{
href: "https://www.imdb.com/title/tt14688458/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Dune: Part Two",
subTitle: "Digital Release",
description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
dayOffset: 5,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt15239678/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Severance",
subTitle: "Season 2",
description: "Mark leads a team of office workers whose memories are surgically divided.",
dayOffset: 7,
hours: 22,
image: {
src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E5", color: "violet" },
},
indicatorColor: "violet",
links: [],
},
{
title: "Oppenheimer",
subTitle: "Physical Release",
description: "The story of the development of the atomic bomb.",
dayOffset: 10,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "physicalRelease" },
indicatorColor: "orange",
links: [
{
href: "https://www.imdb.com/title/tt15398776/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
const startDate = randomDateBetween(start, end);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
return {
title: "Homarr Meetup",
subTitle: "",
description: "Yearly meetup of the Homarr community",
dayOffset: 14,
hours: 14,
location: "Mountains",
startDate,
endDate,
image: null,
location: "Mountains",
indicatorColor: "#fa5252",
links: [
{
@@ -135,79 +29,63 @@ const eventTemplates: EventTemplate[] = [
isDark: true,
},
],
},
{
title: "The Bear",
subTitle: "Season 3",
description: "A young chef fights to transform a sandwich shop.",
dayOffset: 18,
hours: 3,
image: {
src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "yellow" },
},
indicatorColor: "yellow",
links: [],
},
{
title: "Foundation",
subTitle: "Season 3",
description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
dayOffset: 21,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "indigo" },
},
indicatorColor: "indigo",
links: [],
},
{
title: "Blade Runner 2099",
subTitle: "Premiere",
description: "A new chapter in the Blade Runner universe.",
dayOffset: 25,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "grape",
links: [],
},
];
const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
const rangeStart = new Date(start);
rangeStart.setDate(rangeStart.getDate() - 3);
return eventTemplates
.map((tpl) => {
const startDate = new Date(rangeStart);
startDate.setDate(startDate.getDate() + tpl.dayOffset);
startDate.setHours(tpl.hours, 0, 0, 0);
return {
title: tpl.title,
subTitle: tpl.subTitle,
description: tpl.description,
startDate,
endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
image: tpl.image,
location: tpl.location ?? null,
indicatorColor: tpl.indicatorColor,
links: tpl.links,
...(tpl.metadata ? { metadata: tpl.metadata } : {}),
};
})
.filter((event) => event.startDate >= start && event.startDate <= end);
};
};
export class CalendarMockService implements ICalendarIntegration {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
return await Promise.resolve(buildEventsForRange(start, end));
}
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
title: "Titanic",
subTitle: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
},
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
title: "The Mandalorian",
subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: {
content: "S1/E1",
color: "red",
},
},
location: null,
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
}
+29 -135
View File
@@ -2,163 +2,57 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
const mockItems: Omit<DownloadClientItem, "id" | "index">[] = [
{
name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
type: "torrent",
size: 4_700_000_000,
progress: 0.47,
state: "leeching",
downSpeed: 12_400_000,
upSpeed: 890_000,
sent: 420_000_000,
received: 2_209_000_000,
time: 201_000,
category: "movies",
},
{
name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
type: "torrent",
size: 5_800_000_000,
progress: 0.82,
state: "leeching",
downSpeed: 34_500_000,
upSpeed: 2_100_000,
sent: 1_200_000_000,
received: 4_756_000_000,
time: 30_000,
category: "iso",
},
{
name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
type: "torrent",
size: 18_200_000_000,
progress: 0.03,
state: "leeching",
downSpeed: 1_200_000,
upSpeed: 50_000,
sent: 30_000_000,
received: 546_000_000,
time: 14_880_000,
category: "movies",
},
{
name: "Arch.Linux.2024.12.01.x86_64.iso",
type: "torrent",
size: 1_100_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 4_500_000,
sent: 3_300_000_000,
received: 1_100_000_000,
time: -7_200_000,
category: "iso",
},
{
name: "Tears.of.Steel.2012.1080p.DTS.mkv",
type: "torrent",
size: 7_340_000_000,
progress: 0,
state: "paused",
downSpeed: 0,
upSpeed: 0,
time: 0,
category: "movies",
},
{
name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
type: "torrent",
size: 12_000_000_000,
progress: 0.65,
state: "stalled",
downSpeed: 0,
upSpeed: 0,
sent: 200_000_000,
received: 7_800_000_000,
time: 0,
category: "movies",
},
{
name: "Spring.2019.4K.HDR.mkv",
type: "usenet",
size: 3_200_000_000,
progress: 0.91,
state: "downloading",
downSpeed: 45_000_000,
time: 6_500,
category: "movies",
},
{
name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
type: "usenet",
size: 1_800_000_000,
progress: 1,
state: "completed",
time: -3_600_000,
category: "movies",
},
{
name: "Caminandes.3.Llamigos.2016.1080p.mkv",
type: "torrent",
size: 890_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 1_800_000,
sent: 2_670_000_000,
received: 890_000_000,
time: -86_400_000,
category: "movies",
},
{
name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
type: "usenet",
size: 9_400_000_000,
progress: 0.22,
state: "downloading",
downSpeed: 28_000_000,
time: 244_000,
category: "tv",
},
];
export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
return await Promise.resolve({
status: {
paused: false,
paused: Math.random() < 0.5,
rates: {
down: 121_100_000,
up: 9_340_000,
down: Math.floor(Math.random() * 5000),
up: Math.floor(Math.random() * 5000),
},
types: ["torrent", "usenet"] as const,
types: ["torrent", "usenet"],
},
items: mockItems.slice(0, input.limit).map((item, index) => ({
...item,
id: `mock-${index}`,
index,
})),
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
0,
input.limit,
),
});
}
public async pauseQueueAsync(): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
public async resumeQueueAsync(): Promise<void> {
await Promise.resolve();
return Promise.resolve();
}
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
await Promise.resolve();
return await Promise.resolve();
}
private static createItem(index: number): DownloadClientItem {
const progress = Math.random() < 0.5 ? Math.random() : 1;
return {
id: `item-${index}`,
index,
name: `Item ${index}`,
type: Math.random() > 0.5 ? "torrent" : "usenet",
progress,
size: Math.floor(Math.random() * 10000) + 1,
downSpeed: Math.floor(Math.random() * 5000),
upSpeed: Math.floor(Math.random() * 5000),
state: progress >= 1 ? "completed" : "downloading",
time: 0,
};
}
}
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
title: "Inception",
subtitle: "A mind-bending thriller",
description:
"A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/inception_backdrop.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
href: "https://www.themoviedb.org/movie/27205-inception",
href: "https://example.com/inception",
length: 148,
},
{
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
href: "https://www.themoviedb.org/tv/1396-breaking-bad",
href: "https://example.com/breaking_bad",
},
{
id: "3",
@@ -50,86 +50,79 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ram_backdrop.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
tags: ["Electronic", "Funk"],
href: "https://www.daftpunk.com",
tags: ["Electronic", "Dance", "Pop", "Funk"],
href: "https://example.com/ram",
},
{
id: "4",
type: "movie",
title: "The Dark Knight",
subtitle: "Theatrical Release",
description:
"Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
releaseDate: new Date("2008-07-18"),
type: "book",
title: "The Great Gatsby",
subtitle: "F. Scott Fitzgerald",
description: "A novel about the American dream and the disillusionment that comes with it.",
releaseDate: new Date("1925-04-10"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/gatsby_backdrop.jpg",
},
producer: "Warner Bros.",
price: 12.99,
rating: "9.0/10",
tags: ["Action", "Crime", "Drama"],
href: "https://www.themoviedb.org/movie/155-the-dark-knight",
length: 152,
producer: "Scribner",
price: 10.99,
rating: "4.2/5",
tags: ["Classic", "Fiction"],
href: "https://example.com/gatsby",
},
{
id: "5",
type: "movie",
title: "Interstellar",
subtitle: "Digital Release",
description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
releaseDate: new Date("2014-11-07"),
type: "game",
title: "The Legend of Zelda: Breath of the Wild",
subtitle: "Nintendo Switch",
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
releaseDate: new Date("2017-03-03"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/zelda_backdrop.jpg",
},
producer: "Paramount",
price: 14.99,
rating: "8.7/10",
tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/157336-interstellar",
length: 169,
producer: "Nintendo",
price: 59.99,
rating: "10/10",
tags: ["Action", "Adventure"],
href: "https://example.com/zelda",
},
{
id: "6",
type: "tv",
title: "Severance",
subtitle: "S2E1 - Premiere",
description:
"Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
releaseDate: new Date("2025-01-17"),
type: "article",
title: "The Rise of AI in Healthcare",
subtitle: "Tech Innovations",
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
releaseDate: new Date("2023-10-01"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
},
producer: "Apple TV+",
rating: "8.7/10",
tags: ["Thriller", "Sci-Fi"],
href: "https://www.themoviedb.org/tv/95396-severance",
producer: "Tech Innovations",
rating: "4.8/5",
tags: ["Technology", "Healthcare"],
href: "https://example.com/ai_healthcare",
},
{
id: "7",
type: "movie",
title: "Dune: Part Two",
subtitle: "Theatrical Release",
description:
"Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
releaseDate: new Date("2024-03-01"),
type: "video",
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
releaseDate: new Date("2024-05-18T17:00:00Z"),
imageUrls: {
poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
poster:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
},
producer: "Warner Bros.",
price: 19.99,
rating: "8.5/10",
tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/693134-dune-part-two",
length: 166,
producer: "PietSmiet",
rating: "1K",
tags: [],
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
},
];
@@ -1,95 +1,36 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
const sessions: StreamSession[] = [
{
sessionId: "s-0",
sessionName: "Living Room TV",
user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Interstellar",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-1",
sessionName: "Bedroom Chromecast",
user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-2",
sessionName: "Office Desktop",
user: { userId: "u-2", username: "mike", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "The Dark Knight",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-3",
sessionName: "iPad",
user: { userId: "u-3", username: "emily", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Severance",
seasonName: "Season 2",
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-4",
sessionName: "Mobile",
user: { userId: "u-4", username: "alex", profilePictureUrl: null },
currentlyPlaying: {
type: "audio",
name: "Daft Punk",
seasonName: undefined,
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-5",
sessionName: "Guest Room",
user: { userId: "u-5", username: "guest", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Oppenheimer",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
];
export class MediaServerMockService implements IMediaServerIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(sessions);
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
return await Promise.resolve(
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
),
);
}
private static createSession(index: number): StreamSession {
return {
sessionId: `session-${index}`,
sessionName: `Session ${index}`,
user: {
userId: `user-${index}`,
username: `User${index}`,
profilePictureUrl: "/images/mock/avatar.jpg",
},
currentlyPlaying:
Math.random() > 0.9 // 10% chance of being null (not currently playing)
? {
type: "movie",
name: `Movie ${index}`,
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
}
: null,
};
}
}
@@ -4,11 +4,27 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
return await Promise.resolve({
lan: { guests: 5, users: 18, status: "enabled" },
vpn: { users: 3, status: "enabled" },
wanStatus: "enabled",
wifi: { status: "enabled", guests: 2, users: 12 },
www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
lan: {
guests: 5,
users: 10,
status: "enabled",
},
vpn: {
users: 3,
status: "enabled",
},
wanStatus: "disabled",
wifi: {
status: "disabled",
guests: 0,
users: 0,
},
www: {
latency: 22,
status: "enabled",
ping: 32,
uptime: 3600,
},
});
}
}
@@ -85,7 +85,7 @@ export class MockIntegration
// Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
// IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -104,7 +104,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
// MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
@@ -1,6 +1,10 @@
import { z } from "zod/v4";
import type { RequestInit, Response } from "undici";
import { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { splitToChunksWithNItems } from "@homarr/common";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -24,6 +28,14 @@ import {
const logger = createLogger({ module: "overseerrIntegration" });
const REQUEST_TIMEOUT_MS = 10_000;
const DETAIL_FETCH_BATCH_SIZE = 5;
interface OverseerrFetchClient {
fetchAsync: (url: URL, init?: RequestInit) => Promise<Response>;
fetchUncheckedAsync: (url: URL, init?: RequestInit) => Promise<Response>;
}
interface OverseerrSearchResult {
id: number;
name: string;
@@ -40,12 +52,43 @@ export class OverseerrIntegration
extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{
public async searchAsync(query: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
private async createFetchClientAsync(): Promise<OverseerrFetchClient> {
const agent = await createCertificateAgentAsync(undefined);
const apiKey = this.getSecretValue("apiKey");
const requestAsync = async (url: URL, init?: RequestInit) => {
return await withTimeoutAsync(
(signal) =>
fetch(url, {
...init,
signal,
dispatcher: agent,
headers: {
"X-Api-Key": apiKey,
...init?.headers,
},
}),
REQUEST_TIMEOUT_MS,
);
};
return {
fetchAsync: async (url, init) => {
const response = await requestAsync(url, init);
if (!response.ok) {
throw new Error(`Overseerr request failed: ${response.status} ${response.statusText} (${url.toString()})`);
}
return response;
},
});
fetchUncheckedAsync: requestAsync,
};
}
public async searchAsync(query: string) {
const client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/search", { query }));
const schemaData = await searchSchema.parseAsync(await response.json());
if (!schemaData.results) {
@@ -67,12 +110,9 @@ export class OverseerrIntegration
}
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const client = await this.createFetchClientAsync();
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const response = await client.fetchAsync(url);
const data = await mediaInformationSchema.parseAsync(await response.json());
const requestedSeasons = [
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
@@ -88,8 +128,9 @@ export class OverseerrIntegration
* @param seasons A list of the seasons that should be requested.
*/
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
const client = await this.createFetchClientAsync();
const url = this.url("/api/v1/request");
const response = await fetchWithTrustedCertificatesAsync(url, {
const response = await client.fetchUncheckedAsync(url, {
method: "POST",
body: JSON.stringify({
mediaType,
@@ -97,7 +138,6 @@ export class OverseerrIntegration
seasons,
}),
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
"Content-Type": "application/json",
},
});
@@ -123,25 +163,16 @@ export class OverseerrIntegration
}
public async getRequestsAsync(): Promise<MediaRequest[]> {
const pendingRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, filter: "pending" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
const client = await this.createFetchClientAsync();
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const [pendingResponse, allResponse] = await Promise.all([
client.fetchAsync(this.url("/api/v1/request", { take: 20, filter: "pending" })),
client.fetchAsync(this.url("/api/v1/request", { take: 20 })),
]);
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
const pendingResults = (await getRequestsSchema.parseAsync(await pendingResponse.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allResponse.json())).results;
//Concat the 2 lists while remove any duplicate pending from the all items list
let requests;
if (pendingResults.length > 0 && allResults.length > 0) {
@@ -150,37 +181,53 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return Promise.all([]);
else return [];
return await Promise.all(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
const uniqueKeys = [...new Set(requests.map((request) => `${request.type}:${request.media.tmdbId}`))];
const informationByKey = new Map<string, MediaInformation>();
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
for (const batch of splitToChunksWithNItems(uniqueKeys, DETAIL_FETCH_BATCH_SIZE)) {
const batchResults = await Promise.all(
batch.map(async (key) => {
const separatorIndex = key.indexOf(":");
const type = key.slice(0, separatorIndex) as MediaRequest["type"];
const tmdbId = Number(key.slice(separatorIndex + 1));
const information = await this.getItemInformationAsync(client, type, tmdbId);
return [key, information] as const;
}),
);
return {
id: request.id,
name: information.name,
status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
}),
);
for (const [key, information] of batchResults) {
informationByKey.set(key, information);
}
}
return requests.map((request): MediaRequest => {
const information = informationByKey.get(`${request.type}:${request.media.tmdbId}`)!;
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
return {
id: request.id,
name: information.name,
status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
});
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -220,20 +267,14 @@ export class OverseerrIntegration
}
public async getStatsAsync(): Promise<RequestStats> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/request/count"));
return await getStatsSchema.parseAsync(await response.json());
}
public async getUsersAsync(): Promise<RequestUser[]> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }));
const users = (await getUsersSchema.parseAsync(await response.json())).results;
return users.map((user): RequestUser => {
return {
@@ -246,75 +287,58 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
const client = await this.createFetchClientAsync();
await client
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
})
.then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
}
public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name });
const client = await this.createFetchClientAsync();
await client
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
})
.then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (type === "tv") {
const series = (await response.json()) as TvInformation;
return {
name: series.name,
backdropPath: series.backdropPath ?? series.posterPath,
posterPath: series.posterPath ?? series.backdropPath,
airDate: series.firstAirDate,
} satisfies MediaInformation;
}
const movie = (await response.json()) as MovieInformation;
return {
name: movie.title,
backdropPath: movie.backdropPath ?? movie.posterPath,
posterPath: movie.posterPath ?? movie.backdropPath,
airDate: movie.releaseDate,
} satisfies MediaInformation;
private async getItemInformationAsync(
client: OverseerrFetchClient,
type: MediaRequest["type"],
id: number,
): Promise<MediaInformation> {
const response = await client.fetchAsync(this.url(`/api/v1/${type}/${id}`));
const rawData = (await response.json()) as TvInformation | MovieInformation;
return mediaInformationFromResponse[type](rawData as never);
}
private constructAvatarUrl(avatar: string) {
@@ -349,6 +373,21 @@ interface MovieInformation {
releaseDate: string;
}
const mediaInformationFromResponse = {
tv: (series: TvInformation): MediaInformation => ({
name: series.name,
backdropPath: series.backdropPath ?? series.posterPath,
posterPath: series.posterPath ?? series.backdropPath,
airDate: series.firstAirDate,
}),
movie: (movie: MovieInformation): MediaInformation => ({
name: movie.title,
backdropPath: movie.backdropPath ?? movie.posterPath,
posterPath: movie.posterPath ?? movie.backdropPath,
airDate: movie.releaseDate,
}),
} satisfies Record<MediaRequest["type"], (data: TvInformation | MovieInformation) => MediaInformation>;
const mediaInfoRequestsSchema = z
.object({
requests: z
+1 -8
View File
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import { IconCheck, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) =>
notifications.show({
@@ -15,10 +15,3 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red",
icon: <IconX size={20} />,
});
export const showWarningNotification = (props: NotificationData) =>
notifications.show({
...props,
color: "yellow",
icon: <IconInfoCircle size={20} />,
});
@@ -44,7 +44,6 @@ const optionMapping: OptionMapping = {
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
withBorder: () => undefined,
},
calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
+1 -158
View File
@@ -7,174 +7,17 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { ContainerState, Port } from "@homarr/docker";
import type { ContainerState } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
PrivatePort: privatePort,
PublicPort: publicPort,
Type: type,
});
const mockContainers: {
id: string;
name: string;
host: string;
state: ContainerState;
image: string;
iconUrl: string;
cpuUsage: number;
memoryUsage: number;
ports: Port[];
}[] = [
{
id: "a1b2c3d4e5f6",
name: "sonarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/sonarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
cpuUsage: 2.3,
memoryUsage: 256 * 1024 * 1024,
ports: [port(8989, 8989, "tcp")],
},
{
id: "b2c3d4e5f6a7",
name: "radarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/radarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
cpuUsage: 1.8,
memoryUsage: 220 * 1024 * 1024,
ports: [port(7878, 7878, "tcp")],
},
{
id: "c3d4e5f6a7b8",
name: "plex",
host: "local",
state: "running",
image: "lscr.io/linuxserver/plex:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
cpuUsage: 12.5,
memoryUsage: 1024 * 1024 * 1024,
ports: [port(32400, 32400, "tcp")],
},
{
id: "d4e5f6a7b8c9",
name: "qbittorrent",
host: "local",
state: "running",
image: "lscr.io/linuxserver/qbittorrent:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
cpuUsage: 5.1,
memoryUsage: 380 * 1024 * 1024,
ports: [port(8080, 8080, "tcp")],
},
{
id: "e5f6a7b8c9d0",
name: "prowlarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/prowlarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
cpuUsage: 0.8,
memoryUsage: 120 * 1024 * 1024,
ports: [port(9696, 9696, "tcp")],
},
{
id: "f6a7b8c9d0e1",
name: "overseerr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/overseerr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
cpuUsage: 1.2,
memoryUsage: 180 * 1024 * 1024,
ports: [port(5055, 5055, "tcp")],
},
{
id: "a7b8c9d0e1f2",
name: "homarr",
host: "local",
state: "running",
image: "ghcr.io/homarr-labs/homarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
cpuUsage: 3.4,
memoryUsage: 290 * 1024 * 1024,
ports: [port(7575, 7575, "tcp")],
},
{
id: "b8c9d0e1f2a3",
name: "pihole",
host: "local",
state: "running",
image: "pihole/pihole:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
cpuUsage: 0.5,
memoryUsage: 95 * 1024 * 1024,
ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
},
{
id: "c9d0e1f2a3b4",
name: "nginx-proxy",
host: "local",
state: "running",
image: "jc21/nginx-proxy-manager:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
cpuUsage: 0.3,
memoryUsage: 65 * 1024 * 1024,
ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
},
{
id: "d0e1f2a3b4c5",
name: "watchtower",
host: "local",
state: "running",
image: "containrrr/watchtower:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
cpuUsage: 0.1,
memoryUsage: 30 * 1024 * 1024,
ports: [],
},
{
id: "e1f2a3b4c5d6",
name: "tdarr",
host: "local",
state: "exited",
image: "ghcr.io/haveagitgat/tdarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
cpuUsage: 0,
memoryUsage: 0,
ports: [],
},
{
id: "f2a3b4c5d6e7",
name: "bazarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/bazarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
cpuUsage: 0.6,
memoryUsage: 110 * 1024 * 1024,
ports: [port(6767, 6767, "tcp")],
},
];
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),
-1
View File
@@ -1,2 +1 @@
export * from "./src/context";
export * from "./src/preferences/definitions";
@@ -1,78 +0,0 @@
export const userPreferenceDefinitions = [
{
key: "colorScheme",
kind: "select",
guest: true,
mutationGroup: "colorScheme",
aliases: ["theme", "dark", "light", "auto"],
},
{ key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] },
{
key: "defaultSearchEngineId",
kind: "searchEngine",
guest: false,
mutationGroup: "searchPreferences",
aliases: ["search", "search engine"],
},
{
key: "openSearchInNewTab",
kind: "boolean",
guest: false,
mutationGroup: "searchPreferences",
aliases: ["search", "new tab", "external"],
},
{
key: "ddgBangs",
kind: "boolean",
guest: false,
mutationGroup: "searchPreferences",
aliases: ["duckduckgo", "bangs", "search"],
},
{
key: "firstDayOfWeek",
kind: "select",
guest: false,
mutationGroup: "firstDayOfWeek",
aliases: ["calendar", "week", "weekday"],
},
{
key: "homeBoardId",
kind: "board",
guest: false,
mutationGroup: "homeBoards",
aliases: ["home", "board", "dashboard"],
},
{
key: "mobileHomeBoardId",
kind: "board",
guest: false,
mutationGroup: "homeBoards",
aliases: ["mobile", "home", "board", "dashboard"],
},
{
key: "pingIconsEnabled",
kind: "boolean",
guest: false,
mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"],
},
{
key: "fullPreferencesPage",
kind: "link",
guest: false,
mutationGroup: null,
aliases: ["settings", "manage", "profile"],
},
] as const;
export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
export type UserPreferenceKey = UserPreferenceDefinition["key"];
export type UserPreferenceKind = UserPreferenceDefinition["kind"];
export type UserPreferenceMutationGroup = Exclude<UserPreferenceDefinition["mutationGroup"], null>;
export const userPreferenceDefinitionByKey = Object.fromEntries(
userPreferenceDefinitions.map((definition) => [definition.key, definition]),
) as Record<UserPreferenceKey, UserPreferenceDefinition>;
export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
-1
View File
@@ -29,7 +29,6 @@
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
@@ -33,10 +33,7 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
<Spotlight.Action
renderRoot={renderRoot}
onClick={onClick}
closeSpotlightOnTrigger={
interaction.type !== "children" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
closeSpotlightOnTrigger={interaction.type !== "children"}
className={classes.spotlightAction}
>
<action.Component {...childrenOptions.option} />
@@ -61,8 +61,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
interaction.type !== "mode" &&
interaction.type !== "children" &&
interaction.type !== "none" &&
interaction.type !== "setQuery" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
interaction.type !== "setQuery"
}
className={classes.spotlightAction}
>
+32 -66
View File
@@ -1,10 +1,10 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
import { IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions";
@@ -15,7 +15,7 @@ import type { OpenMediaRequestSearchOptions } from "../index";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
import {
mediaRequestSearchEvent,
mediaRequestSearchScopeAtom,
@@ -27,7 +27,6 @@ import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
type ChildrenOptions = inferSearchInteractionOptions<"children">;
const defaultMode = "home";
export const Spotlight = () => {
@@ -40,6 +39,7 @@ export const Spotlight = () => {
return null;
}
// We use the "key" below to prevent the 'Different amounts of hooks' error
return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
);
@@ -54,41 +54,22 @@ interface SpotlightWithActiveModeProps {
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState;
const [mode, setMode] = modeState;
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
const childrenOptions = childrenStack.at(-1) ?? null;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
// Works as always the same amount of hooks are executed
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
const homeEmptyGroups = useHomeEmptyGroups();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
const clearChildrenStack = useCallback(() => {
setChildrenStack([]);
}, []);
const pushChildrenOptions = useCallback(
(options: ChildrenOptions) => {
setChildrenStack((currentStack) => [...currentStack, options]);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
},
[setQuery],
);
const popChildrenOptions = useCallback(() => {
setChildrenStack((currentStack) => currentStack.slice(0, -1));
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}, [setQuery]);
useEffect(() => {
const handleMediaRequestSearch = (event: Event) => {
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
setChildrenOptions(null);
setQuery(query ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
@@ -98,7 +79,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
}, [setMediaRequestSearchScope, setMode, setQuery]);
useEffect(() => {
if (mode !== "media") {
@@ -113,7 +94,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
onSpotlightClose={() => {
setMode(defaultMode);
clearChildrenStack();
setChildrenOptions(null);
setQuery("");
}}
query={query}
@@ -121,14 +102,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
setQuery(sanitizedQuery);
// Only switch modes when a single trigger character is typed
if (sanitizedQuery.length !== 1) return;
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
if (!modeToActivate) return;
setMode(modeToActivate.modeKey);
clearChildrenStack();
setChildrenOptions(null);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
store={spotlightStore}
@@ -151,53 +135,31 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
},
}}
rightSection={
mode === defaultMode && childrenStack.length === 0 ? null : (
mode === defaultMode ? null : (
<ActionIcon
title={
childrenStack.length > 0
? t("search.mode.command.group.preferences.children.detail.backAction")
: t("common.action.close")
}
onClick={() => {
if (childrenStack.length > 0) {
popChildrenOptions();
inputRef.current?.focus();
return;
}
setMode(defaultMode);
clearChildrenStack();
setChildrenOptions(null);
inputRef.current?.focus();
}}
variant="subtle"
aria-label={
childrenStack.length > 0
? t("search.mode.command.group.preferences.children.detail.backAction")
: t("common.action.close")
}
aria-label={t("common.action.close")}
>
{childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />}
<IconX stroke={1.5} />
</ActionIcon>
)
}
value={query}
onKeyDown={(event) => {
if (query.length !== 0 || event.key !== "Backspace") return;
if (childrenStack.length > 0) {
popChildrenOptions();
return;
}
if (mode !== defaultMode) {
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
setMode(defaultMode);
clearChildrenStack();
setChildrenOptions(null);
}
}}
/>
{childrenOptions ? (
<Group key={childrenStack.length}>
<Group>
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group>
) : null}
@@ -205,21 +167,25 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.ActionsList>
{childrenOptions ? (
<SpotlightChildrenActions
key={childrenStack.length}
childrenOptions={childrenOptions}
query={query}
setChildrenOptions={pushChildrenOptions}
setChildrenOptions={setChildrenOptions}
/>
) : (
<SpotlightActionGroups
setQuery={setQuery}
setMode={(nextMode) => {
setMode(nextMode);
clearChildrenStack();
setMode={(mode) => {
setMode(mode);
setChildrenOptions(null);
setTimeout(() => selectAction(0, spotlightStore));
}}
setChildrenOptions={(options) => {
pushChildrenOptions(options);
setChildrenOptions(options);
setTimeout(() => {
setQuery("");
selectAction(0, spotlightStore);
});
}}
query={query}
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
+1 -4
View File
@@ -10,10 +10,7 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
// This is used to define search interactions with their options
const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{
onSelect: () => MaybePromise<void>;
closeSpotlightOnTrigger?: boolean;
}>(),
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
createSearchInteraction("children").optionsType<{
@@ -1,17 +1,16 @@
import { Group, Text } from "@mantine/core";
import { Group, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import { useUserPreference } from "../../../preferences/use-user-preference";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
import { LanguageIcon } from "@homarr/ui";
import { createChildrenOptions } from "../../../lib/children";
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase();
const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale");
const currentLocale = useCurrentLocale();
return supportedLanguages
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
.filter(
@@ -19,7 +18,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
configuration.name.toLowerCase().includes(normalizedQuery) ||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
)
.toSorted(
.sort(
(languageA, languageB) =>
Math.min(
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
@@ -30,25 +29,38 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
),
)
.map(({ localeKey, configuration }) =>
createCheckmarkPreferenceAction({
key: localeKey,
isSelected: localeKey === currentLocale,
onSelect: () => setLocale(localeKey as never),
isPending,
labelContent: (
<Group wrap="nowrap">
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Group wrap="nowrap" gap="xs">
<Text>{configuration.name}</Text>
<Text size="xs" c="dimmed" inherit>
({configuration.translatedName})
</Text>
.map(({ localeKey, configuration }) => ({
key: localeKey,
Component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap">
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Group wrap="nowrap" gap="xs">
<Text>{configuration.name}</Text>
<Text size="xs" c="dimmed" inherit>
({configuration.translatedName})
</Text>
</Group>
</Group>
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
</Group>
),
}),
);
);
},
useInteraction() {
const { changeLocale } = useChangeLocale();
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
},
}));
},
DetailComponent: () => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
</Stack>
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey="locale.children.detail.title" />,
});
@@ -1,68 +0,0 @@
import type { ReactNode } from "react";
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { interaction } from "../../../../lib/interaction";
export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => {
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
const t = useScopedI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{tOption(titleKey as never)}</Text>
<Text size="xs" c="dimmed">
{t("children.detail.backHint")}
</Text>
</Stack>
);
};
export const createCheckmarkPreferenceAction = ({
key,
label,
labelContent,
isSelected,
onSelect,
dimmed,
isPending,
}: {
key: string;
label?: string;
labelContent?: ReactNode;
isSelected: boolean;
onSelect: () => void;
dimmed?: boolean;
isPending?: boolean;
}) => ({
key,
Component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%" opacity={isPending ? 0.5 : 1}>
{labelContent ?? <Text c={dimmed ? "dimmed" : undefined}>{label}</Text>}
{isSelected && <IconCheck color="currentColor" size={24} />}
</Group>
);
},
useInteraction: interaction.javaScript(() => ({
onSelect: isPending ? () => undefined : onSelect,
closeSpotlightOnTrigger: false,
})),
});
export const createLoadingPreferenceAction = () => ({
key: "loading",
Component() {
return (
<Center mx="md" my="sm">
<Loader size="sm" />
</Center>
);
},
useInteraction: interaction.javaScript(() => ({
onSelect: () => undefined,
closeSpotlightOnTrigger: false,
})),
});
@@ -1 +0,0 @@
export { settingsChildrenOptions } from "./settings-children";
@@ -1,55 +0,0 @@
import { createCheckmarkPreferenceAction } from "./action-row";
interface SelectablePreferenceItem {
key: string;
label: string;
value: string;
}
export const createSelectablePreferenceActions = ({
query,
currentValue,
noneLabel,
unavailableLabel,
items,
onSelect,
isPending,
}: {
query: string;
currentValue: string | null;
noneLabel: string;
unavailableLabel: string;
items: SelectablePreferenceItem[];
onSelect: (value: string | null) => void;
isPending?: boolean;
}) => {
const hasCurrentSelection = currentValue !== null && !items.some((item) => item.value === currentValue);
const orphanedItem: SelectablePreferenceItem | null = hasCurrentSelection
? { key: `orphaned-${currentValue}`, label: unavailableLabel, value: currentValue }
: null;
const normalizedQuery = query.trim().toLowerCase();
const filteredItems = items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
const visibleItems = orphanedItem ? [orphanedItem, ...filteredItems] : filteredItems;
const noneAction = createCheckmarkPreferenceAction({
key: "none",
label: noneLabel,
isSelected: currentValue === null,
onSelect: () => onSelect(null),
dimmed: true,
isPending,
});
const itemActions = visibleItems.map((item) =>
createCheckmarkPreferenceAction({
key: item.key,
label: item.label,
isSelected: currentValue === item.value,
onSelect: () => onSelect(item.value),
dimmed: item.key.startsWith("orphaned-"),
isPending,
}),
);
return [noneAction, ...itemActions];
};
@@ -1,156 +0,0 @@
"use client";
import { Group, Stack, Switch, Text } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { localeConfigurations } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../../../../lib/children";
import type { ChildrenAction } from "../../../../lib/children";
import { interaction } from "../../../../lib/interaction";
import { useUserPreferences } from "../../../../preferences/use-user-preference";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
dayjs.extend(localeData);
type SettingsAction = ChildrenAction<Record<string, unknown>>;
const BooleanRow = ({
label,
Icon,
checked,
isPending,
}: {
label: string;
Icon: TablerIcon;
checked: boolean;
isPending: boolean;
}) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Switch checked={checked} readOnly size="md" disabled={isPending} styles={{ track: { cursor: "pointer" } }} />
</Group>
);
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
</Group>
);
const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
<Group mx="md" my="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
);
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const t = useScopedI18n("search.mode.command.group.preferences.option");
const tColorScheme = useScopedI18n("common.colorScheme.options");
const { data: session } = useSession();
const isAuthenticated = Boolean(session?.user);
const normalizedQuery = query.trim().toLowerCase();
const preferences = useUserPreferences();
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
const valueLabelResolvers: Record<string, () => string> = {
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
locale: () => {
const val = preferences.getPreference("locale").value;
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
},
defaultSearchEngineId: () =>
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
?.label ?? t("searchEngine.none" as never),
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
homeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
t("board.none" as never),
mobileHomeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
t("board.none" as never),
};
return visiblePreferenceDefinitions(isAuthenticated)
.map((definition): SettingsAction | null => {
const label = t(`${definition.key}.label` as never);
const Icon = preferenceIcons[definition.key];
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
if (!matchesSearch) return null;
if (definition.kind === "boolean") {
const pref = preferences.getPreference(definition.key);
return {
key: definition.key,
Component: () => (
<BooleanRow label={label} Icon={Icon} checked={pref.value as boolean} isPending={pref.isPending} />
),
useInteraction: interaction.javaScript(() => ({
onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never),
closeSpotlightOnTrigger: false,
})),
};
}
if (definition.kind === "link") {
return {
key: definition.key,
Component: () => <LinkRow label={label} Icon={Icon} />,
useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })),
};
}
if (SELECT_KINDS.has(definition.kind)) {
const children = preferenceChildrenOptionsByKey[definition.key];
if (!children) return null;
return {
key: definition.key,
Component: () => (
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
),
useInteraction: interaction.children(children),
};
}
return null;
})
.filter((action): action is SettingsAction => action !== null);
};
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: useSettingsActions,
DetailComponent: () => {
const t = useScopedI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{t("title")}</Text>
<Text size="xs" c="dimmed">
{t("children.detail.backHint")}
</Text>
</Stack>
);
},
});
@@ -1,10 +1,14 @@
import { Group, Text } from "@mantine/core";
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import {
IconBox,
IconCategoryPlus,
IconDeviceDesktop,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
@@ -18,6 +22,7 @@ import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>.
@@ -47,9 +52,33 @@ export const globalCommandGroup = createGroup<Command>({
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
name:
colorScheme === "auto"
? tOption("colorScheme.auto")
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{
commandKey: "newBoard",
icon: IconCategoryPlus,
@@ -1,10 +1,9 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { globalCommandGroup } from "./global-group";
import { preferencesGroup } from "../preferences/groups";
export const commandMode = {
modeKey: "command",
character: ">",
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
} satisfies SearchMode;
@@ -21,11 +21,11 @@ import { commandMode } from "../command";
import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page";
import { preferencesGroup } from "../preferences/groups";
import { userGroupMode } from "../user-group";
// We need type to allow it as generic type for Record<string, unknown>
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type QuickLinkOption = {
key: string;
icon: TablerIcon;
name: string;
path: string;
@@ -42,13 +42,19 @@ export const useHomeEmptyGroups = () => {
return [
createGroup({
keyPath: "key",
keyPath: "path",
title: () => "Quick links",
options: (() => {
const quickLinks: QuickLinkOption[] = [];
if (session?.user) {
quickLinks.push({
icon: IconSettings,
path: `/manage/users/${session.user.id}/general`,
name: tPages("preferences.label"),
});
}
quickLinks.push({
key: "manageBoards",
icon: IconLayoutDashboard,
path: "/manage/boards",
name: tPages("manageBoard.label"),
@@ -56,7 +62,6 @@ export const useHomeEmptyGroups = () => {
if (session?.user.permissions.includes("admin")) {
quickLinks.push({
key: "manageSettings",
icon: IconSettings,
path: "/manage/settings",
name: tPages("manageSettings.label"),
@@ -65,7 +70,6 @@ export const useHomeEmptyGroups = () => {
if (session) {
quickLinks.push({
key: "manageSearchEngines",
icon: IconSearch,
path: "/manage/search-engines",
name: tPages("manageSearchEngine.label"),
@@ -135,5 +139,3 @@ export const useHomeEmptyGroups = () => {
}),
] as const;
};
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
+1 -2
View File
@@ -1,10 +1,9 @@
import type { SearchMode } from "../../lib/mode";
import { preferencesGroup } from "../preferences/groups";
import { contextSpecificSearchGroups } from "./context-specific-group";
import { homeSearchEngineGroup } from "./home-search-engine-group";
export const homeMode = {
character: undefined,
modeKey: "home",
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
} satisfies SearchMode;
@@ -15,8 +15,13 @@ import { createGroup } from "../../lib/group";
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
const availabilityLabelKeys = ["available", "partiallyAvailable", "processing", "requested", "pending"] as const;
type AvailabilityWithLabel = (typeof availabilityLabelKeys)[number];
const availabilityLabels: Partial<Record<MediaAvailability, string>> = {
available: "Available",
partiallyAvailable: "Partial",
processing: "Processing",
requested: "Requested",
pending: "Requested",
};
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
@@ -37,8 +42,6 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
keyPath: "key",
title: (t) => t("search.mode.media.group.title"),
Component(option) {
const tMedia = useScopedI18n("search.mode.media");
if (option.kind !== "result") {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
@@ -54,11 +57,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
}
const { result } = option;
const hasAvailabilityLabel =
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
const badgeLabel = hasAvailabilityLabel
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
: undefined;
const badgeLabel = result.availability ? availabilityLabels[result.availability as MediaAvailability] : undefined;
const badgeColor = result.availability
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
: undefined;
@@ -1,72 +0,0 @@
import { Group, Text } from "@mantine/core";
import { IconSettings } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { colorSchemes } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string };
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
export const preferencesGroup = createGroup<PreferencesGroupOption>({
keyPath: "key",
title: (t) => t("search.mode.command.group.preferences.title"),
useOptions(query) {
const t = useI18n();
const tColorScheme = useScopedI18n("common.colorScheme.options");
const { data: session } = useSession();
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
const q = query.trim().toLowerCase();
if (q.length === 0) return [settingsOption];
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
if (!preferenceChildrenOptionsByKey[def.key]) return [];
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const searchValues = [
label,
...def.aliases,
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
];
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
return [{ key: def.key, preferenceKey: def.key }];
});
if (matching.length > 0) return matching;
const allSearchValues = [
t("search.mode.command.group.preferences.title"),
...definitions.flatMap((d) => d.aliases),
];
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
},
Component: (option) => {
const t = useScopedI18n("search.mode.command.group.preferences");
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
const Icon = option.preferenceKey
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
: IconSettings;
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
return (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} />
<Text>{label}</Text>
</Group>
);
},
filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
settingsChildrenOptions({}))
: settingsChildrenOptions({})),
}),
});
@@ -1,136 +0,0 @@
"use client";
import {
IconActivity,
IconCalendarWeek,
IconDeviceDesktop,
IconExternalLink,
IconHome,
IconLanguage,
IconLayoutBoard,
IconSearch,
IconSettings,
IconWorldWww,
} from "@tabler/icons-react";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../lib/children";
import {
createCheckmarkPreferenceAction,
createLoadingPreferenceAction,
PreferenceDetailHeader,
} from "../modes/command/children/preferences/action-row";
import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions";
import { languageChildrenOptions } from "../modes/command/children/language";
import { useUserPreference } from "./use-user-preference";
dayjs.extend(localeData);
type ChildrenFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
colorScheme: IconDeviceDesktop,
locale: IconLanguage,
defaultSearchEngineId: IconSearch,
openSearchInNewTab: IconExternalLink,
ddgBangs: IconWorldWww,
firstDayOfWeek: IconCalendarWeek,
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
fullPreferencesPage: IconSettings,
};
const createStaticChildren = (
preferenceKey: UserPreferenceKey,
useOptions: () => Array<{ key: string; label: string; value: unknown }>,
): ChildrenFactory =>
createChildrenOptions<Record<string, unknown>>({
useActions: () => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
return useOptions().map((opt) =>
createCheckmarkPreferenceAction({
key: opt.key,
label: opt.label,
isSelected: value === opt.value,
onSelect: () => setValue(opt.value as never),
isPending,
}),
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
});
const createAsyncSelectableChildren = <TItem,>(
preferenceKey: UserPreferenceKey,
useQuery: () => { data: TItem[] | undefined; isLoading: boolean },
mapItem: (item: TItem) => { key: string; label: string; value: string },
noneKey: string,
unavailableKey: string,
): ChildrenFactory =>
createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
const result = useQuery();
const t = useScopedI18n("search.mode.command.group.preferences.option");
if (result.isLoading) return [createLoadingPreferenceAction()];
return createSelectablePreferenceActions({
query,
currentValue: value as string | null,
noneLabel: t(noneKey as never),
unavailableLabel: t(unavailableKey as never),
items: (result.data ?? []).map(mapItem),
onSelect: (v) => setValue(v as never),
isPending,
});
},
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
});
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
colorScheme: createStaticChildren("colorScheme", () => {
const t = useScopedI18n("common.colorScheme.options");
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
}),
locale: languageChildrenOptions,
defaultSearchEngineId: createAsyncSelectableChildren(
"defaultSearchEngineId",
() => clientApi.searchEngine.getSelectable.useQuery(),
(e) => ({ key: e.value, label: e.label, value: e.value }),
"searchEngine.none",
"searchEngine.unavailable",
),
firstDayOfWeek: createStaticChildren("firstDayOfWeek", () =>
firstDayOfWeekOptions.map((day) => ({
key: day.toString(),
label: dayjs.weekdays(false)[day] ?? day.toString(),
value: day,
})),
),
homeBoardId: createAsyncSelectableChildren(
"homeBoardId",
() => clientApi.board.getAllBoards.useQuery(),
(b) => ({ key: b.id, label: b.name, value: b.id }),
"board.none",
"board.unavailable",
),
mobileHomeBoardId: createAsyncSelectableChildren(
"mobileHomeBoardId",
() => clientApi.board.getAllBoards.useQuery(),
(b) => ({ key: b.id, label: b.name, value: b.id }),
"board.none",
"board.unavailable",
),
};
@@ -1,204 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { useMantineColorScheme } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { userPreferenceDefinitions, userPreferenceDefinitionByKey } from "@homarr/settings";
import type { UserPreferenceKey } from "@homarr/settings";
import { useSettings } from "@homarr/settings";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
// --- Optimistic store (module-level, shared across all hook instances) ---
const optimisticRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
let optimisticVersion = 0;
const optimisticListeners = new Set<() => void>();
const notifyListeners = () => {
optimisticVersion += 1;
optimisticListeners.forEach((fn) => fn());
};
const subscribeOptimistic = (listener: () => void) => {
optimisticListeners.add(listener);
return () => optimisticListeners.delete(listener);
};
const setOptimistic = (key: UserPreferenceKey, value: unknown) => {
optimisticRef.current[key] = value;
notifyListeners();
};
const clearOptimistic = (keys: readonly UserPreferenceKey[]) => {
let changed = false;
for (const key of keys) {
changed ||= key in optimisticRef.current;
delete optimisticRef.current[key];
}
if (changed) notifyListeners();
};
const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]);
const isDbBacked = (key: UserPreferenceKey) => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
return group !== null && !CLIENT_ONLY_GROUPS.has(group);
};
const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group) return [key];
return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key);
};
// --- Hook ---
export const useUserPreferences = () => {
const router = useRouter();
const settings = useSettings();
const settingsRef = useRef(settings);
settingsRef.current = settings;
const { data: session } = useSession();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const currentLocale = useCurrentLocale();
const { changeLocale } = useChangeLocale();
const t = useI18n();
const userId = session?.user.id;
const pendingRef = useRef<Record<string, boolean>>({});
const prevUserIdRef = useRef(userId);
useEffect(() => {
if (prevUserIdRef.current === userId) return;
prevUserIdRef.current = userId;
clearOptimistic(userPreferenceDefinitions.map((d) => d.key));
}, [userId]);
useSyncExternalStore(
subscribeOptimistic,
() => optimisticVersion,
() => optimisticVersion,
);
const refresh = () => router.refresh();
const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh });
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) =>
field === changedKey ? value : getEffective(field);
const groupDispatchers: Record<string, (uid: string, key: UserPreferenceKey, value: unknown) => Promise<unknown>> = {
searchPreferences: (uid, key, value) =>
searchMutation.mutateAsync({
userId: uid,
defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null,
openInNewTab: pick("openSearchInNewTab", key, value) as boolean,
ddgBangsEnabled: pick("ddgBangs", key, value) as boolean,
}),
homeBoards: (uid, key, value) =>
homeBoardsMutation.mutateAsync({
userId: uid,
homeBoardId: pick("homeBoardId", key, value) as string | null,
mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null,
}),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
};
const groupPending: Record<string, boolean> = {
searchPreferences: searchMutation.isPending,
homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending,
};
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
const values: Record<string, unknown> = {
colorScheme: colorScheme as ColorScheme,
locale: currentLocale,
fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null,
};
for (const def of userPreferenceDefinitions) {
if (def.key in values) continue;
values[def.key] = settings[def.key as keyof SettingsContextProps];
}
return values as Record<UserPreferenceKey, unknown>;
}, [colorScheme, currentLocale, userId, settings]);
useEffect(() => {
const matchedKeys = Object.entries(persistedValues)
.filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value)
.map(([key]) => key as UserPreferenceKey);
clearOptimistic(matchedKeys);
}, [persistedValues]);
const getValue = (key: UserPreferenceKey) =>
key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key];
const isPreferencePending = (key: UserPreferenceKey): boolean => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group || CLIENT_ONLY_GROUPS.has(group)) return false;
return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false);
};
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
colorScheme: (value) => setColorScheme(value as ColorScheme),
locale: (value) => changeLocale(value as typeof currentLocale),
fullPreferencesPage: () => undefined,
};
const setValueAsync = async (key: UserPreferenceKey, value: unknown) => {
if (isDbBacked(key) && !userId) return;
if (isPreferencePending(key)) return;
const clientSetter = clientSetters[key];
if (clientSetter) {
clientSetter(value);
return;
}
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group) return;
const dispatcher = groupDispatchers[group];
if (!dispatcher || !userId) return;
setOptimistic(key, value);
pendingRef.current[group] = true;
try {
await dispatcher(userId, key, value);
} catch {
clearOptimistic(getGroupKeys(key));
showErrorNotification({
message: t("search.mode.command.group.preferences.notification.error.message"),
});
} finally {
pendingRef.current[group] = false;
}
};
return {
getPreference(preferenceKey: UserPreferenceKey) {
return {
value: getValue(preferenceKey),
setValue: (value: never) => setValueAsync(preferenceKey, value),
isPending: isPreferencePending(preferenceKey),
};
},
};
};
export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key);
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Contrasenya",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules",
"uppercase": "Inclou majúscules",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "密码",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "至少包含 8 个字符",
"lowercase": "包含小写字母",
"uppercase": "包含大写字母",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "在新标签页中打开"
},
"withBorder": {
"label": ""
},
"items": {
"label": "书签",
"add": "添加书签"
+6 -10
View File
@@ -132,13 +132,12 @@
},
"password": {
"label": "crwdns26:0crwdne26:0",
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
"suggestion": {
"length": "crwdns4263:0crwdne4263:0",
"lowercase": "crwdns4265:0crwdne4265:0",
"uppercase": "crwdns4267:0crwdne4267:0",
"number": "crwdns4269:0crwdne4269:0",
"special": "crwdns4271:0crwdne4271:0"
"requirement": {
"length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns32:0crwdne32:0",
"number": "crwdns34:0crwdne34:0",
"special": "crwdns36:0crwdne36:0"
}
},
"passwordConfirm": {
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "crwdns2686:0crwdne2686:0"
},
"withBorder": {
"label": "crwdns4273:0crwdne4273:0"
},
"items": {
"label": "crwdns764:0crwdne764:0",
"add": "crwdns766:0crwdne766:0"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Heslo",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno",
"uppercase": "Obsahuje velké písmeno",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Adgangskode",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver",
"uppercase": "Inkluderer store bogstaver",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Åbn i nyt faneblad"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bogmærker",
"add": "Tilføj bogmærke"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Passwort",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Passwort",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Κωδικός",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα",
"uppercase": "Περιλαμβάνει κεφαλαίο γράμμα",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
"uppercase": "Includes an upper-case letter",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
+2 -127
View File
@@ -132,9 +132,8 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Password suggestions",
"suggestion": {
"length": "At least 8 characters",
"requirement": {
"length": "Includes at least 8 characters",
"lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter",
"number": "Includes number",
@@ -1303,13 +1302,6 @@
},
"edit": {
"title": "Edit item",
"tab": {
"widget": "Edit widget",
"app": "Edit app"
},
"app": {
"propagationNotice": "Changes to this app will be applied everywhere it is used across all dashboards."
},
"advancedOptions": {
"label": "Advanced options",
"title": "Advanced item options"
@@ -1413,9 +1405,6 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": "Show border"
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
@@ -4558,113 +4547,6 @@
"label": "Create a new group"
}
}
},
"preferences": {
"title": "Preferences",
"children": {
"detail": {
"title": "Select a preference to change",
"backHint": "Press Backspace or use the back button to go back",
"backAction": "Go back"
}
},
"notification": {
"error": {
"message": "Unable to update preference"
}
},
"option": {
"preferences": {
"label": "Open preferences"
},
"boolean": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"board": {
"none": "None",
"unavailable": "Currently selected (unavailable)"
},
"searchEngine": {
"none": "None",
"unavailable": "Currently selected (unavailable)"
},
"colorScheme": {
"label": "Color scheme",
"children": {
"detail": {
"title": "Select your color scheme"
}
}
},
"locale": {
"label": "Language",
"children": {
"detail": {
"title": "Select your preferred language"
}
}
},
"defaultSearchEngineId": {
"label": "Default search engine",
"children": {
"detail": {
"title": "Select your default search engine"
}
}
},
"openSearchInNewTab": {
"label": "Open search results in new tab",
"children": {
"detail": {
"title": "Open search results in new tab"
}
}
},
"ddgBangs": {
"label": "Enable DuckDuckGo bangs",
"children": {
"detail": {
"title": "Enable DuckDuckGo bangs"
}
}
},
"firstDayOfWeek": {
"label": "First day of the week",
"children": {
"detail": {
"title": "Select the first day of the week"
}
}
},
"homeBoardId": {
"label": "Home board",
"children": {
"detail": {
"title": "Select your home board"
}
}
},
"mobileHomeBoardId": {
"label": "Mobile home board",
"children": {
"detail": {
"title": "Select your mobile home board"
}
}
},
"pingIconsEnabled": {
"label": "Use icons for pings",
"children": {
"detail": {
"title": "Use icons for pings"
}
}
},
"fullPreferencesPage": {
"label": "Open full preferences page"
}
}
}
}
},
@@ -4685,13 +4567,6 @@
"scoped": "This widget does not have an accessible media request integration."
}
}
},
"availability": {
"available": "Available",
"partiallyAvailable": "Partial",
"processing": "Processing",
"requested": "Requested",
"pending": "Requested"
}
},
"external": {
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Contraseña",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula",
"uppercase": "Incluye al menos una letra mayúscula",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Abrir en una nueva pestaña"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Añadir marcador"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+2 -6
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Mot de passe",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule",
"uppercase": "Inclut une lettre majuscule",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Ouvrir dans un nouvel onglet"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Signets",
"add": "Ajouter un signet"
@@ -2092,7 +2088,7 @@
"empty": "",
"user": "Utilisateur",
"media": "Média",
"state": "État",
"state": "",
"quality": "Qualité",
"device": "Appareil",
"progress": "Progression",
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "סיסמה",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות",
"uppercase": "אפשר אותיות גדולות",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "פתיחה בכרטיסיה חדשה"
},
"withBorder": {
"label": ""
},
"items": {
"label": "סימניות",
"add": "הוסף סימניה"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Lozinka",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Uključuje mala slova",
"uppercase": "Uključuje veliko slovo",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Jelszó",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt",
"uppercase": "Tartalmazzon nagybetűt",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Megnyitás új lapon"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Könyjelzők",
"add": "Könyvjelző hozzáadása"
+6 -10
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Suggerimenti password",
"suggestion": {
"requirement": {
"length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola",
"uppercase": "Include lettera maiuscola",
@@ -1057,7 +1056,7 @@
}
},
"noResults": {
"title": "Nessun media caricato"
"title": ""
}
},
"common": {
@@ -1089,8 +1088,8 @@
"checkLogs": "Controlla i log per maggiori dettagli",
"tryAgain": "Riprova",
"loading": "Caricamento",
"show": "Visualizza",
"hide": "Nascondi"
"show": "",
"hide": ""
},
"here": "qui",
"iconPicker": {
@@ -1256,7 +1255,7 @@
"label": {
"create": "Nuova categoria",
"changePosition": "Cambia posizione",
"settings": "Impostazioni"
"settings": ""
}
},
"openAllInNewTabs": {
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Apri in una nuova scheda"
},
"withBorder": {
"label": "Mostra bordo"
},
"items": {
"label": "Segnalibri",
"add": "Aggiungi segnalibro"
@@ -3463,7 +3459,7 @@
"private": "Questa board è privata"
},
"noResults": {
"title": "Ancora nessuna board"
"title": ""
},
"modal": {
"createBoard": {
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "パスワード",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む",
"uppercase": "大文字を含む",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "新しいタブで開く"
},
"withBorder": {
"label": ""
},
"items": {
"label": "ブックマーク",
"add": "ブックマークに追加"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "비밀번호",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "소문자 포함",
"uppercase": "대문자 포함",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Slaptažodis",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Parole",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Ietver mazo burtu",
"uppercase": "Ietver lielo burtu",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Wachtwoord",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter",
"uppercase": "Inclusief hoofdletter",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "In nieuw tabblad openen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bladwijzers",
"add": "Bladwijzer toevoegen"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Passord",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav",
"uppercase": "Inkluderer stor bokstav",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Åpne i ny fane"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bokmerke",
"add": "Legg til bokmerke"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Hasło",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę",
"uppercase": "Zawiera wielką literę",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Otwórz w nowej karcie"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Zakładki",
"add": "Dodaj zakładkę"
+6 -10
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Senha",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas",
"uppercase": "Inclui letras maiúsculas",
@@ -1057,7 +1056,7 @@
}
},
"noResults": {
"title": "Nenhuma mídia enviada"
"title": ""
}
},
"common": {
@@ -1089,8 +1088,8 @@
"checkLogs": "Verifique os logs para mais detalhes",
"tryAgain": "Tente novamente",
"loading": "Carregando",
"show": "Mostrar",
"hide": "Esconder"
"show": "",
"hide": ""
},
"here": "aqui",
"iconPicker": {
@@ -1256,7 +1255,7 @@
"label": {
"create": "Nova categoria",
"changePosition": "Mudar de posição",
"settings": "Configurações"
"settings": ""
}
},
"openAllInNewTabs": {
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Abrir em nova guia"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Adicionar marcador"
@@ -3463,7 +3459,7 @@
"private": "Este quadro é privado"
},
"noResults": {
"title": "Nenhum painel ainda"
"title": ""
},
"modal": {
"createBoard": {
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Parola",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Să includă litere mici",
"uppercase": "Să includă litere mari",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Пароль",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Содержит не менее 8 символов",
"lowercase": "Содержит строчную букву",
"uppercase": "Содержит заглавную букву",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Открывать в новой вкладке"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Закладки",
"add": "Добавить закладку"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Heslo",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Obsahuje aspoň 8 znakov",
"lowercase": "Zahŕňa malé písmeno",
"uppercase": "Zahŕňa veľké písmená",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Otvoriť na novej karte"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Záložky",
"add": "Pridať záložku"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Geslo",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Vključuje male črke",
"uppercase": "Vključuje velike tiskane črke",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+107 -111
View File
@@ -82,7 +82,7 @@
"form": {
"name": {
"label": "Gruppens namn",
"description": "Namnet måste matcha admin-grupp i extern tjänst"
"description": ""
}
}
},
@@ -132,8 +132,7 @@
},
"password": {
"label": "Lösenord",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Använd minst 8 tecken",
"lowercase": "Använd liten bokstav",
"uppercase": "Använd stor bokstav",
@@ -160,7 +159,7 @@
"label": "Visa sökresultat i ny flik"
},
"ddgBangs": {
"label": "Aktivera DuckDuckGo bangs"
"label": ""
}
},
"error": {
@@ -253,10 +252,10 @@
"changeDdgBangs": {
"notification": {
"success": {
"message": "DuckDuckGo bangs inställningen ändrad"
"message": ""
},
"error": {
"message": "DuckDuckGo bangs inställningen kunde inte ändras"
"message": ""
}
}
},
@@ -351,12 +350,12 @@
"description": "Tillåt medlemmar att addera valfri applikation på sina tavlor"
},
"modify-all": {
"label": "Modifiera alla appar",
"description": "Tillåt att medlemmar modifierar alla appar"
"label": "",
"description": ""
},
"full-all": {
"label": "Fullständiga rättigheter till appar",
"description": "Tillåt att medlemmar hanterar, använder och raderar alla appar"
"label": "",
"description": ""
}
}
},
@@ -364,20 +363,20 @@
"title": "Tavlor",
"item": {
"create": {
"label": "Skapa tavlor",
"description": "Tillåt att medlemmar skapar tavlor"
"label": "",
"description": ""
},
"view-all": {
"label": "Se alla tavlor",
"description": "Tillåt att medlemmar ser alla tavlor"
"label": "",
"description": ""
},
"modify-all": {
"label": "Modifiera alla tavlor",
"description": "Tillåt att medlemmar modifierar alla tavlor (Detta inkluderar inte åtkomstkontroll eller farozonen)"
"label": "",
"description": ""
},
"full-all": {
"label": "Fullständiga rättigheter till tavlor",
"description": "Tillåt att medlemmar ser, modifierar och tar bort alla tavlor (detta inkluderar åtkomstkontroll och farozonen)"
"label": "",
"description": ""
}
}
},
@@ -393,38 +392,38 @@
"description": "Tillåter medlemmar att addera valfri integration till sina tavlor"
},
"interact-all": {
"label": "Interagera med alla integrationer",
"description": "Tillåt att medlemmar interagerar med alla integrationer"
"label": "",
"description": ""
},
"full-all": {
"label": "Fullständiga rättigheter till integrationer",
"description": "Tillåt att medlemmar hanterar, använder och interagerar med alla integrationer"
"label": "",
"description": ""
}
}
},
"media": {
"title": "Media",
"title": "",
"item": {
"upload": {
"label": "Ladda upp media",
"description": "Tillåt att medlemmar laddar upp media"
"label": "",
"description": ""
},
"view-all": {
"label": "Se all media",
"description": "Tillåt att medlemmar ser all media"
"label": "",
"description": ""
},
"full-all": {
"label": "Fullständiga rättigheter till media",
"description": "Tillåt att medlemmar hanterar och raderar all media"
"label": "",
"description": ""
}
}
},
"other": {
"title": "Annat",
"title": "",
"item": {
"view-logs": {
"label": "Se loggar",
"description": "Tillåt att medlemmar ser loggar"
"label": "",
"description": ""
}
}
},
@@ -436,32 +435,32 @@
"description": "Tillåt medlemmar att skapa sökmotorer"
},
"modify-all": {
"label": "Modifiera alla sökmotorer",
"description": "Tillåt att medlemmar modifierar alla sökmotorer"
"label": "",
"description": ""
},
"full-all": {
"label": "Fullständiga rättigheter till sökmotorer",
"description": "Tillåt att medlemmar hanterar och raderar alla sökmotorer"
"label": "",
"description": ""
}
}
}
},
"memberNotice": {
"mixed": "Vissa medlemmar är från externa tjänster och kan inte hantera här",
"external": "Alla medlemmar är från externa tjänster och kan inte hanteras här"
"mixed": "",
"external": ""
},
"reservedNotice": {
"message": "Denna grupp är reserverad för systemet så vissa åtgärder är begränsade. <checkoutDocs></checkoutDocs>"
"message": ""
},
"action": {
"create": {
"label": "Addera en grupp",
"notification": {
"success": {
"message": "Gruppen har skapats"
"message": ""
},
"error": {
"message": "Gruppen kunde inte skapas"
"message": ""
}
}
},
@@ -471,7 +470,7 @@
"confirm": "Är du säker på att du vill överföra ägarskapet för gruppen {name} till {username}?",
"notification": {
"success": {
"message": "Överfört grupp {group} till {user}"
"message": ""
},
"error": {
"message": "Kunde inte överföra ägarskap"
@@ -502,38 +501,38 @@
"notification": {
"success": {
"title": "Behörigheter sparade",
"message": "Rättigheterna sparade"
"message": ""
},
"error": {
"title": "Rättigheterna ej sparade",
"message": "Rättigheterna har inte sparats"
"title": "",
"message": ""
}
}
},
"update": {
"notification": {
"success": {
"message": "Gruppen {name} har sparats"
"message": ""
},
"error": {
"message": "Kunde inte spara gruppen {name}"
"message": ""
}
}
},
"select": {
"label": "Välj en grupp",
"notFound": "Ingen grupp hittad"
"notFound": ""
},
"settings": {
"board": {
"notification": {
"success": {
"title": "Inställningarna sparades",
"message": "Inställningarna för tavlan har sparats"
"message": ""
},
"error": {
"title": "Kunde inte spara inställningarna",
"message": "Kunde inte spara inställningarna för tavlan"
"message": ""
}
}
}
@@ -541,10 +540,10 @@
"changePosition": {
"notification": {
"success": {
"message": "Ordning ändrad"
"message": ""
},
"error": {
"message": "Ordningen kunde ändras"
"message": ""
}
}
}
@@ -560,20 +559,20 @@
"list": {
"title": "Applikationer",
"noResults": {
"title": "Det finns inga appar ännu",
"action": "Skapa din första app"
"title": "",
"action": ""
}
},
"create": {
"title": "Addera applikation",
"notification": {
"success": {
"title": "Skapandet lyckades",
"message": "Appen skapades framgångsrikt"
"title": "",
"message": ""
},
"error": {
"title": "Skapandet misslyckades",
"message": "Appen kunde inte skapas"
"title": "",
"message": ""
}
}
},
@@ -582,11 +581,11 @@
"notification": {
"success": {
"title": "Ändringarna har sparats",
"message": "Appen sparades"
"message": ""
},
"error": {
"title": "Misslyckades med att spara ändringarna",
"message": "Appen kunde inte sparas"
"message": ""
}
}
},
@@ -595,12 +594,12 @@
"message": "Är du säker på att du vill ta bort applikationen {name}?",
"notification": {
"success": {
"title": "Borttagning lyckades",
"message": "Appen har tagits bort"
"title": "",
"message": ""
},
"error": {
"title": "Borttagning misslyckades",
"message": "Kunde inte ta bort appen"
"title": "",
"message": ""
}
}
}
@@ -613,21 +612,21 @@
"label": "Beskrivning"
},
"url": {
"label": "URL"
"label": ""
},
"useDifferentUrlForPing": {
"checkbox": {
"label": "Använd annan URL för ping",
"description": "Användbart om Homarr kan komma åt appen direkt via ett internt host-namn eller nätverk för att undvika bandbreddsanvändning hos internetleverantör"
"label": "",
"description": ""
}
}
},
"action": {
"select": {
"label": "Välj app",
"notFound": "Ingen app hittades",
"label": "",
"notFound": "",
"search": "Sök efter en applikation",
"noResults": "Inga resultat",
"noResults": "",
"action": "Välj {app}",
"title": "Välj en applikation att lägga till på tavlan"
},
@@ -643,56 +642,56 @@
"page": {
"list": {
"title": "Integrationer",
"search": "Sök integrationer",
"search": "",
"noResults": {
"title": "Det finns inga integrationer ännu"
"title": ""
}
},
"create": {
"title": "Ny {name} integration",
"title": "",
"notification": {
"success": {
"title": "Skapandet lyckades",
"message": "Integrationen har skapats"
"title": "",
"message": ""
},
"error": {
"title": "Skapandet misslyckades",
"message": "Integrationen kunde inte skapas"
"title": "",
"message": ""
}
},
"app": {
"option": {
"existing": {
"title": "Befintlig",
"label": "Välj befintlig app"
"title": "",
"label": ""
},
"new": {
"title": "Ny",
"title": "",
"url": {
"label": "App URL",
"description": "URL:en som appen kommer att öppna när den används från tavlan"
"label": "",
"description": ""
}
}
}
}
},
"edit": {
"title": "Redigera {name} integration",
"title": "",
"notification": {
"success": {
"title": "Ändringarna har sparats",
"message": "Integrationen sparad"
"message": ""
},
"error": {
"title": "Kunde inte tillämpa ändringar",
"message": "Integrationen kunde inte sparas"
"title": "",
"message": ""
}
},
"app": {
"action": {
"add": "Länka en app",
"remove": "Ta bort länk",
"select": "Välj en app att länka till"
"add": "",
"remove": "",
"select": ""
}
}
},
@@ -701,12 +700,12 @@
"message": "Är du säker på att du vill ta bort integrationen {name}?",
"notification": {
"success": {
"title": "Borttagning lyckades",
"message": "Integrationen har tagits bort"
"title": "",
"message": ""
},
"error": {
"title": "Borttagning misslyckades",
"message": "Kunde inte ta bort integrationen"
"title": "",
"message": ""
}
}
}
@@ -716,44 +715,44 @@
"label": "Namn"
},
"url": {
"label": "URL"
"label": ""
},
"attemptSearchEngineCreation": {
"label": "Skapa sökmotor",
"description": "Integrationen \"{kind}\" kan inte användas med sökmotorer. Markera detta för att automatiskt konfigurera sökmotorn."
"label": "",
"description": ""
},
"app": {
"sectionTitle": "Länkad app"
"sectionTitle": ""
},
"createApp": {
"label": "Skapa app",
"description": "Skapa en app med samma namn och ikon som integrationen. Lämna fältet nedan tomt för att skapa en app med integrationens URL."
"label": "",
"description": ""
},
"appHref": {
"placeholder": "Egen app-URL"
"placeholder": ""
}
},
"action": {
"create": "Ny integration"
"create": ""
},
"testConnection": {
"action": {
"create": "Testa anslutningen och skapa",
"edit": "Testa anslutningen och skapa"
"create": "",
"edit": ""
},
"error": {
"common": {
"cause": {
"title": "Orsak med mer detaljer"
"title": ""
}
},
"unknown": {
"title": "Okänt fel",
"description": "Ett okänt fel inträffade, öppna orsaken nedan för att se mer detaljer"
"title": "",
"description": ""
},
"parse": {
"title": "Parsningsfel",
"description": "Svaret kunde inte analyseras. Verifiera att URL pekar på bas-URL för tjänsten."
"title": "",
"description": ""
},
"authorization": {
"title": "",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Öppna i en ny flik"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bokmärken",
"add": "Addera bokmärke"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Şifre",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "En az 8 karakter içermeli",
"lowercase": "Küçük harf içermeli",
"uppercase": "Büyük harf içermeli",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Yeni sekmede aç"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Yer İmleri",
"add": "Yer imi ekle"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Пароль",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Включає принаймні 8 символів",
"lowercase": "Включає малу літеру",
"uppercase": "Включає велику літеру",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Відкрити в новій вкладці"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Закладки",
"add": "Додати закладку"
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "Mật khẩu",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "Bao gồm ít nhất 8 kí tự",
"lowercase": "Bao gồm chữ thường",
"uppercase": "Bao gồm chữ in hoa",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+1 -5
View File
@@ -132,8 +132,7 @@
},
"password": {
"label": "密碼",
"suggestionsTitle": "",
"suggestion": {
"requirement": {
"length": "至少包含 8 個字符",
"lowercase": "包含小寫字母",
"uppercase": "包含大寫字母",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "於新分頁開啟"
},
"withBorder": {
"label": ""
},
"items": {
"label": "書籤",
"add": "新增書籤"
@@ -20,15 +20,15 @@ export const CustomPasswordInput = ({ withPasswordRequirements, ...props }: Cust
};
const WithPasswordRequirements = (props: PasswordInputProps) => {
const [password, setPassword] = useState("");
const [value, setValue] = useState("");
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.currentTarget.value);
setValue(event.currentTarget.value);
props.onChange?.(event);
};
return (
<PasswordRequirementsPopover password={password}>
<PasswordRequirementsPopover password={value}>
<PasswordInput {...props} onChange={onChange} />
</PasswordRequirementsPopover>
);
@@ -1,19 +1,14 @@
import { rem, Text } from "@mantine/core";
import { IconCheck, IconPoint } from "@tabler/icons-react";
const getRequirementDisplay = (meets: boolean) =>
({
true: { color: "teal", Icon: IconCheck },
false: { color: "dimmed", Icon: IconPoint },
})[String(meets) as "true" | "false"];
import { IconCheck, IconX } from "@tabler/icons-react";
export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
const display = getRequirementDisplay(meets);
const Icon = display.Icon;
return (
<Text c={display.color} display="flex" style={{ alignItems: "center" }} size="sm">
<Icon style={{ width: rem(14), height: rem(14) }} />
<Text c={meets ? "teal" : "red"} display="flex" style={{ alignItems: "center" }} size="sm">
{meets ? (
<IconCheck style={{ width: rem(14), height: rem(14) }} />
) : (
<IconX style={{ width: rem(14), height: rem(14) }} />
)}
<Text span ml={10}>
{label}
</Text>
@@ -1,24 +1,16 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { Popover, Progress, Text } from "@mantine/core";
import { Popover, Progress } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { passwordRequirements } from "@homarr/validation/user";
import { PasswordRequirement } from "./password-requirement";
const strengthColorMap = [
{ min: 100, color: "teal" },
{ min: 51, color: "yellow" },
{ min: 0, color: "dimmed" },
] as const;
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
const tPassword = useScopedI18n("user.field.password");
const requirements = useRequirements();
const strength = useStrength(password);
const [popoverOpened, setPopoverOpened] = useState(false);
const strengthColor = strengthColorMap.find(({ min }) => strength >= min)?.color ?? "dimmed";
const checks = (
<>
{requirements.map((requirement) => (
@@ -27,6 +19,8 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
</>
);
const color = strength === 100 ? "teal" : strength > 50 ? "yellow" : "red";
return (
<Popover opened={popoverOpened} position="bottom" width="target" transitionProps={{ transition: "pop" }}>
<Popover.Target>
@@ -35,10 +29,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
</div>
</Popover.Target>
<Popover.Dropdown>
<Progress color={strengthColor} value={strength} size={5} mb="xs" />
<Text size="sm" c="dimmed" mb="xs">
{tPassword("suggestionsTitle")}
</Text>
<Progress color={color} value={strength} size={5} mb="xs" />
{checks}
</Popover.Dropdown>
</Popover>
@@ -46,7 +37,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
};
const useRequirements = () => {
const t = useScopedI18n("user.field.password.suggestion");
const t = useScopedI18n("user.field.password.requirement");
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
};
+16 -2
View File
@@ -19,10 +19,24 @@ export const passwordRequirements = [
{ check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!\-~`"_/\\[\]{}]/), value: "special" },
] satisfies {
check: (value: string) => boolean;
value: keyof TranslationObject["user"]["field"]["password"]["suggestion"];
value: keyof TranslationObject["user"]["field"]["password"]["requirement"];
}[];
export const userPasswordSchema = z.string().min(8).max(255);
export const userPasswordSchema = z
.string()
.min(8)
.max(255)
.refine(
(value) => {
return passwordRequirements.every((requirement) => requirement.check(value));
},
{
params: createCustomErrorParams({
key: "passwordRequirements",
params: {},
}),
},
);
const addConfirmPasswordRefinement = <
TSchema extends z.ZodObject<{ password: z.core.$ZodString; confirmPassword: z.core.$ZodString }, z.core.$strip>,
+2 -17
View File
@@ -16,7 +16,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
const board = useRequiredBoard();
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
@@ -56,7 +56,6 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
@@ -68,7 +67,6 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
@@ -83,7 +81,6 @@ interface FlexLayoutProps {
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
@@ -94,7 +91,6 @@ const FlexLayout = ({
hideIcon,
hideHostname,
openNewTab,
withBorder,
hasIconColor,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
@@ -110,15 +106,7 @@ const FlexLayout = ({
key={app.id}
w="100%"
>
<Card
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
<Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={4} h="100%">
{direction === "row" ? (
<VerticalItem
app={app}
@@ -150,7 +138,6 @@ interface GridLayoutProps {
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
}
@@ -161,7 +148,6 @@ const GridLayout = ({
hideIcon,
hideHostname,
openNewTab,
withBorder,
itemDirection,
hasIconColor,
}: GridLayoutProps) => {
@@ -182,7 +168,6 @@ const GridLayout = ({
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
-1
View File
@@ -24,7 +24,6 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return (
@@ -1,10 +1,20 @@
.statItem {
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
background-color: var(--mantine-color-gray-0);
transition: all 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
background-color: var(--mantine-color-gray-1);
}
}
@media (prefers-color-scheme: dark) {
.statItem {
background-color: var(--mantine-color-dark-6);
&:hover {
background-color: var(--mantine-color-dark-5);
}
}
}
@@ -1,95 +0,0 @@
"use client";
import { useImperativeHandle, useRef } from "react";
import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { AppFormHandle } from "@homarr/forms-collection";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
export interface EmbeddedAppEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
interface EmbeddedAppEditFormProps {
appId: string;
handleRef: React.Ref<EmbeddedAppEditFormHandle>;
}
export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormProps) => {
const t = useI18n();
const utils = clientApi.useUtils();
const appFormRef = useRef<AppFormHandle>(null);
const { data: app, isPending: isLoadingApp, isError, refetch } = clientApi.app.byId.useQuery({ id: appId });
const { mutateAsync, isPending: isMutating } = clientApi.app.update.useMutation({
onSuccess: async () => {
await utils.app.invalidate();
showSuccessNotification({
title: t("app.page.edit.notification.success.title"),
message: t("app.page.edit.notification.success.message"),
});
},
onError: () => {
showErrorNotification({
title: t("app.page.edit.notification.error.title"),
message: t("app.page.edit.notification.error.message"),
});
},
});
useImperativeHandle(
handleRef,
() => ({
submitIfDirty: async () => {
if (!appFormRef.current?.isDirty()) {
return true;
}
return appFormRef.current.submit();
},
}),
[],
);
if (isLoadingApp) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
if (isError || !app) {
return (
<Stack align="center" gap="md" py="xl">
<Text c="dimmed">{t("app.action.select.notFound")}</Text>
<Button variant="light" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
);
}
return (
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.app.propagationNotice")}
</Alert>
<AppForm
formRef={appFormRef}
hideButtons
showBackToOverview={false}
buttonLabels={{ submit: "" }}
initialValues={app}
handleSubmit={async (values) => {
await mutateAsync({ id: appId, ...values });
}}
isPending={isMutating}
/>
</Stack>
);
};
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more