Merge branch 'dev' into feat/cnmdk-preferences

Integrates latest dev changes including media request search as
first-class spotlight mode (#5823), bookmarks withBorder option,
password field extraction, and other fixes. Resolved spotlight.tsx
conflict to combine children stack navigation with media request
search event handling. Added missing withBorder mapping in old-import.
This commit is contained in:
Thomas Camlong committed 2026-06-05 16:18:05 +02:00
commit 114d9ce321
80 files changed
+2501 -1476

No files matched your search

@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -18,7 +18,12 @@ export const CustomMantineProvider = ({
const manager = useColorSchemeManager();
return (
<DirectionProvider>
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
</MantineProvider>
</DirectionProvider>
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
@@ -33,7 +33,12 @@ export const BoardMantineProvider = ({
});
return (
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
<MantineProvider
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
</MantineProvider>
);
@@ -1,15 +1,15 @@
"use client";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { Button, 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,12 +59,10 @@ export const InitUserForm = () => {
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
{...form.getInputProps("password")}
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
/>
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
@@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
);
};
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
const integrationCategories = integrationDefs[searchParams.kind].category.flat();
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -107,6 +107,23 @@ 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,
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
},
initialState: { density: "xs", showGlobalFilter: true },
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>
);
},
renderTopToolbarCustomActions: () => (
<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,20 +1,7 @@
"use client";
import { startTransition, useCallback, useState } from "react";
import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { Badge, Button, Card, Group, 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";
@@ -24,10 +11,11 @@ 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 { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user";
@@ -167,18 +155,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<form>
<Card p="xl">
<Stack gap="md">
<CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
<UserCreatePasswordFields
variant="filled"
withAsterisk
{...securityForm.getInputProps("password")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
passwordInputProps={securityForm.getInputProps("password")}
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
/>
</Stack>
</Card>
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(t) {
+2 -2
View File
@@ -6,9 +6,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.33.4"
"pnpm": ">=10.34.1"
},
"packageManager": "pnpm@10.33.4",
"packageManager": "pnpm@10.34.1",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
+6 -6
View File
@@ -60,9 +60,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.33.4"
"pnpm": ">=10.34.1"
},
"packageManager": "pnpm@10.33.4",
"packageManager": "pnpm@10.34.1",
"pnpm": {
"onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml",
@@ -76,14 +76,14 @@
"tree-sitter-json"
],
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.2",
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"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.22",
"hono@<4.6.5": ">=4.12.23",
"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.5",
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
},
"patchedDependencies": {
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations,
integrationSecrets,
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema,
integrationUpdateSchema,
} from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
@@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
.sort(
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
.sort(
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
.sort(
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
url: input.url,
});
if (
input.attemptSearchEngineCreation &&
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
user,
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupIntegrationPermission
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
},
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
searchMediaRequests: protectedProcedure
.input(
z.object({
query: z.string(),
integrationIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const query = input.query.trim();
if (query.length === 0) return [];
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
const results = await Promise.all(
integrationsWithAccess.map(async (integration) => {
const integrationInstance = await createIntegrationAsync({
id: integration.id,
name: integration.name,
url: integration.url,
kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
externalUrl: integration.app?.href ?? null,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
const permissions = constructIntegrationPermissions(integration, ctx.session);
return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
searchResults.map((result) => ({
...result,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
},
})),
);
}),
);
return results.flat();
}),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
});
interface IntegrationRouterContext {
db: Database;
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
}
const getAccessibleMediaRequestSearchIntegrationsAsync = async (
ctx: IntegrationRouterContext,
integrationIds?: string[],
) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
where:
integrationIds && integrationIds.length > 0
? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
: inArray(integrations.kind, mediaRequestSearchKinds),
orderBy: asc(integrations.name),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "One or more media request search integrations were not found",
});
}
const integrationsWithAccess = await Promise.all(
integrationsFromDb.map(async (integration) => ({
integration,
hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
})),
);
if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query one or more media request search integrations",
});
}
return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
};
interface UpdateSecretInput {
integrationId: string;
value: string;
@@ -640,35 +770,6 @@ interface AddSecretInput {
kind: IntegrationSecretKind;
}
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit,
});
}),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure
.requiresPermission("search-engine-create")
.input(searchEngineManageSchema)
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -59,6 +59,127 @@ describe("all should return all integrations", () => {
});
});
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
test("with integration use access should only return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
},
{
id: "2",
name: "Jellyfin",
kind: "jellyfin",
url: "http://jellyfin.local",
},
]);
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.kind).toBe("jellyseerr");
});
test("without access should not return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(0);
});
test("with direct integration use access should return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
});
});
describe("searchMediaRequests should validate scoped integrations", () => {
test("with a non media search integration should throw not found", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyfin",
kind: "jellyfin",
url: "http://jellyfin.local",
});
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
"One or more media request search integrations were not found",
);
});
test("without query access to a scoped media search integration should throw forbidden", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
"User does not have permission to query one or more media request search integrations",
);
});
});
describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => {
const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("with create integration access should create a new integration when creating search engine", async () => {
test("with create integration access should not create a search engine for media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
);
expect(dbSearchEngine).toBeUndefined();
});
test("with create integration access should create a new integration with new linked app", async () => {
+50 -8
View File
@@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => {
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
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) => {
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
@@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => {
confirmPassword: password,
});
await expect(actAsync()).rejects.toThrow("passwordRequirements");
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();
});
});
+2
View File
@@ -5,3 +5,5 @@ 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";
+90 -34
View File
@@ -1,7 +1,7 @@
"use client";
import type { ChangeEventHandler } from "react";
import { useEffect, useRef } from "react";
import { useEffect, useImperativeHandle, 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,6 +17,19 @@ 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: {
@@ -24,8 +37,10 @@ interface AppFormProps {
submitAndCreateAnother?: string;
};
initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise<void>;
isPending: boolean;
hideButtons?: boolean;
formRef?: React.Ref<AppFormHandle>;
}
export const AppForm = ({
@@ -34,19 +49,31 @@ export const AppForm = ({
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
hideButtons,
formRef,
}: AppFormProps) => {
const t = useI18n();
const form = useZodForm(appManageSchema, {
initialValues: {
name: initialValues?.name ?? "",
description: initialValues?.description ?? "",
iconUrl: initialValues?.iconUrl ?? "",
href: initialValues?.href ?? "",
pingUrl: initialValues?.pingUrl ?? "",
},
initialValues: toFormValues(initialValues),
});
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);
@@ -62,6 +89,29 @@ 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> = () => {
@@ -92,32 +142,32 @@ export const AppForm = ({
}
}, [debouncedName, iconsData]);
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")} />
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")} />
<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">
@@ -139,7 +189,13 @@ export const AppForm = ({
{buttonLabels.submit}
</Button>
</Group>
</Stack>
</form>
)}
</Stack>
);
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(handleSubmit)}>{formFields}</form>;
};
@@ -0,0 +1,33 @@
"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,12 +12,14 @@ interface SeriesInformation {
overview: string;
seasons: SerieSeason[];
posterPath: string;
requestedSeasons?: number[];
}
interface MovieInformation {
id: number;
overview: string;
posterPath: string;
requestedSeasons?: number[];
}
export type MediaInformation = SeriesInformation | MovieInformation;
@@ -60,6 +60,9 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined,
type: result.mediaType,
inLibrary: result.mediaInfo !== undefined,
availability: result.mediaInfo
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
: undefined,
}));
}
@@ -70,7 +73,12 @@ export class OverseerrIntegration
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
return await mediaInformationSchema.parseAsync(await response.json());
const data = await mediaInformationSchema.parseAsync(await response.json());
const requestedSeasons = [
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
];
const { mediaInfo: _strip, ...rest } = data;
return { ...rest, requestedSeasons };
}
/**
@@ -341,6 +349,22 @@ interface MovieInformation {
releaseDate: string;
}
const mediaInfoRequestsSchema = z
.object({
requests: z
.array(
z.object({
seasons: z.array(
z.object({
seasonNumber: z.number(),
}),
),
}),
)
.optional(),
})
.optional();
const mediaInformationSchema = z.union([
z.object({
id: z.number(),
@@ -355,14 +379,22 @@ const mediaInformationSchema = z.union([
),
numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}),
z.object({
id: z.number(),
overview: z.string(),
posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}),
]);
const searchMediaInfoSchema = z
.object({
status: z.number(),
})
.optional();
const searchSchema = z.object({
results: z
.array(
@@ -373,7 +405,7 @@ const searchSchema = z.object({
name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
mediaInfo: z.object({}).optional(),
mediaInfo: searchMediaInfoSchema,
}),
z.object({
id: z.number(),
@@ -381,14 +413,14 @@ const searchSchema = z.object({
title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
mediaInfo: z.object({}).optional(),
mediaInfo: searchMediaInfoSchema,
}),
z.object({
id: z.number(),
mediaType: z.literal("person"),
name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: z.object({}).optional(),
mediaInfo: searchMediaInfoSchema,
}),
]),
)
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table";
import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
}
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({
integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType,
});
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
onSuccess() {
actions.closeModal();
showSuccessNotification({
@@ -33,6 +33,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
const isPending = isPendingQuery || isPendingMutation;
const t = useI18n();
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
const columns = useMemo<MRT_ColumnDef<Season>[]>(
() => [
@@ -44,8 +45,18 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"),
},
{
id: "status",
header: t("search.engine.media.request.modal.table.header.status"),
Cell: ({ row }) =>
requestedSeasons.has(row.original.seasonNumber) ? (
<Badge size="xs" color="violet" variant="light">
{t("search.engine.media.request.modal.table.status.requested")}
</Badge>
) : null,
},
],
[],
[data?.requestedSeasons],
);
const table = useTranslatedMantineReactTable({
@@ -56,7 +67,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
enablePagination: false,
enableSorting: false,
enableSelectAll: true,
enableRowSelection: true,
enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
mantineTableProps: {
highlightOnHover: false,
striped: "odd",
@@ -44,6 +44,7 @@ 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],
@@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") {
if (interaction.query !== undefined) {
setQuery(interaction.query);
}
setMode(interaction.mode);
} else if (interaction.type === "children") {
setChildrenOptions(interaction);
@@ -1,20 +1,28 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, 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 { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
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 { selectAction, spotlightStore } from "../spotlight-store";
import {
mediaRequestSearchEvent,
mediaRequestSearchScopeAtom,
selectAction,
spotlightActions,
spotlightStore,
} from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -50,9 +58,11 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
const clearChildrenStack = useCallback(() => {
setChildrenStack([]);
@@ -73,6 +83,29 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
setTimeout(() => selectAction(0, spotlightStore));
}, [setQuery]);
useEffect(() => {
const handleMediaRequestSearch = (event: Event) => {
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
setQuery(query ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
useEffect(() => {
if (mode !== "media") {
setMediaRequestSearchScope({});
}
}, [mode, setMediaRequestSearchScope]);
return (
<MantineSpotlight.Root
shortcut={hotkeys.openSpotlight}
@@ -103,13 +136,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`}
ref={inputRef}
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
leftSectionWidth={hasModeCharacter ? 80 : 48}
leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%">
<IconSearch stroke={1.5} />
</Center>
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
styles={{
+17 -2
View File
@@ -1,9 +1,9 @@
"use client";
import { spotlightActions } from "./spotlight-store";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight };
export { openSpotlight, openMediaRequestSearch };
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
@@ -11,3 +11,18 @@ export {
} from "./modes/home/context";
const openSpotlight = spotlightActions.open;
export interface OpenMediaRequestSearchOptions {
integrationIds?: string[];
query?: string;
}
const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
}
spotlightActions.open();
};
export { mediaRequestSearchEvent };
+1 -1
View File
@@ -15,7 +15,7 @@ const searchInteractions = [
closeSpotlightOnTrigger?: boolean;
}>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"];
@@ -64,6 +64,36 @@ type MediaRequestChildrenProps = {
kind: IntegrationKind;
url: string;
id: string;
permissions?: {
hasInteractAccess: boolean;
};
};
};
export const useMediaRequestSearchInteraction = (
integration: MediaRequestChildrenProps["integration"],
searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "children"> => {
const { openSearchInNewTab } = useSettings();
const type = searchResult.type;
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: openSearchInNewTab,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration,
}),
};
};
@@ -71,8 +101,6 @@ export const useFromIntegrationSearchInteraction = (
searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type");
}
@@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = (
) &&
"type" in searchResult
) {
const type = searchResult.type;
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: openSearchInNewTab,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration: searchEngine.integration,
}),
};
return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
}
return {
@@ -115,13 +125,15 @@ export const useFromIntegrationSearchInteraction = (
};
};
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() {
const { openModal } = useModalAction(RequestMediaModal);
return [
{
key: "request",
hide: (option) => option.result.inLibrary,
hide: (option) =>
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) {
const t = useScopedI18n("search.mode.media");
return (
@@ -19,6 +19,7 @@ import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
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";
@@ -33,7 +34,7 @@ type QuickLinkOption = {
export const useHomeEmptyGroups = () => {
const { data: session } = useSession();
const tPages = useScopedI18n("search.mode.page.group.page.option");
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode);
@@ -83,7 +84,7 @@ export const useHomeEmptyGroups = () => {
useInteraction: interaction.link(({ path }) => ({ href: path })),
}),
createGroup({
keyPath: "character",
keyPath: "modeKey",
title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => {
@@ -92,7 +93,7 @@ export const useHomeEmptyGroups = () => {
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text>
<Kbd size="sm">{character}</Kbd>
{character && <Kbd size="sm">{character}</Kbd>}
</Group>
);
},
@@ -20,6 +20,7 @@ type GroupItem = {
name: string;
description?: string;
icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
};
@@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{item.name}</Text>
@@ -91,9 +92,7 @@ const createDefaultSearchEntries = (
query: string,
t: TranslationFunction,
): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) {
return [];
}
if (!session?.user && !defaultSearchEngine) return [];
if (!defaultSearchEngine) {
return [
+2
View File
@@ -2,6 +2,7 @@ import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
import { mediaMode } from "./media";
import { pageMode } from "./page";
import { userGroupMode } from "./user-group";
@@ -11,5 +12,6 @@ export const searchModes = [
externalMode,
commandMode,
pageMode,
mediaMode,
homeMode,
] as const;
@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { mediaRequestSearchGroup } from "./media-request-search-group";
export const mediaMode = {
modeKey: "media",
character: undefined,
groups: [mediaRequestSearchGroup],
} satisfies SearchMode;
@@ -0,0 +1,173 @@
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconMovie, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { MediaAvailability } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
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];
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
type MediaRequestSearchOption =
| {
key: string;
kind: "disabled" | "hint";
label: string;
description: string;
}
| {
key: string;
kind: "result";
result: MediaRequestSearchResult;
};
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}>
{option.kind === "disabled" ? <IconMovie stroke={1.5} /> : <IconSearch stroke={1.5} />}
<Stack gap={0}>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="gray.6">
{option.description}
</Text>
</Stack>
</Group>
);
}
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 badgeColor = result.availability
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
: undefined;
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{result.image ? (
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
) : (
<IconSearch stroke={1.5} size={35} />
)}
<Stack gap={2} style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Text>{result.name}</Text>
{badgeLabel && (
<Badge size="xs" color={badgeColor} variant="light">
{badgeLabel}
</Badge>
)}
</Group>
<Text c="dimmed" size="sm" lineClamp={2}>
{result.text ?? getIntegrationName(result.integration.kind)}
</Text>
</Stack>
</Group>
);
},
useInteraction(option) {
if (option.kind !== "result") {
return {
type: "none",
};
}
return useMediaRequestSearchInteraction(option.result.integration, option.result);
},
useQueryOptions(query) {
const tMedia = useScopedI18n("search.mode.media");
const scope = useAtomValue(mediaRequestSearchScopeAtom);
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
);
const hasScopedTargets = scopedTargets.length > 0;
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
{ query: debouncedQuery, integrationIds },
{
enabled: shouldSearch,
placeholderData: keepPreviousData,
select: (data) => data.slice(0, 10),
},
);
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
if (!targetsQuery.data) {
return {
isLoading,
isError,
data: [],
};
}
if (!hasScopedTargets) {
return {
isLoading,
isError,
data: [
{
key: "disabled",
kind: "disabled",
label: tMedia("action.search.label"),
description: scope.integrationIds
? tMedia("action.search.disabled.scoped")
: tMedia("action.search.disabled.noIntegration"),
},
],
};
}
if (debouncedQuery.length === 0) {
return {
isLoading,
isError,
data: [
{
key: "hint",
kind: "hint",
label: tMedia("action.search.label"),
description: tMedia("action.search.description"),
},
],
};
}
return {
isLoading,
isError,
data: (resultsQuery.data ?? []).map((result) => ({
key: `result-${result.integration.id}-${result.type}-${result.id}`,
kind: "result",
result,
})),
};
},
});
@@ -3,9 +3,17 @@
import { clamp } from "@mantine/hooks";
import type { SpotlightStore } from "@mantine/spotlight";
import { createSpotlight } from "@mantine/spotlight";
import { atom } from "jotai";
export const [spotlightStore, spotlightActions] = createSpotlight();
export interface MediaRequestSearchScope {
integrationIds?: string[];
}
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
};
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Contrasenya",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules",
"uppercase": "Inclou majúscules",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "密码",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "至少包含 8 个字符",
"lowercase": "包含小写字母",
"uppercase": "包含大写字母",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "在新标签页中打开"
},
"withBorder": {
"label": ""
},
"items": {
"label": "书签",
"add": "添加书签"
+10 -6
View File
@@ -132,12 +132,13 @@
},
"password": {
"label": "crwdns26:0crwdne26:0",
"requirement": {
"length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns32:0crwdne32:0",
"number": "crwdns34:0crwdne34:0",
"special": "crwdns36:0crwdne36: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"
}
},
"passwordConfirm": {
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "crwdns2686:0crwdne2686:0"
},
"withBorder": {
"label": "crwdns4273:0crwdne4273:0"
},
"items": {
"label": "crwdns764:0crwdne764:0",
"add": "crwdns766:0crwdne766:0"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Heslo",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno",
"uppercase": "Obsahuje velké písmeno",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Adgangskode",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver",
"uppercase": "Inkluderer store bogstaver",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Åbn i nyt faneblad"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bogmærker",
"add": "Tilføj bogmærke"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Passwort",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Passwort",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Lesezeichen",
"add": "Lesezeichen hinzufügen"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Κωδικός",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα",
"uppercase": "Περιλαμβάνει κεφαλαίο γράμμα",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+25 -3
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Password",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
"uppercase": "Includes an upper-case letter",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
@@ -4551,9 +4555,23 @@
}
},
"media": {
"help": "Search for movies or shows to request",
"requestMovie": "",
"requestSeries": "",
"openIn": ""
"openIn": "",
"group": {
"title": "Movies and shows"
},
"action": {
"search": {
"label": "Search for movies or shows",
"description": "Start typing to search your media request integrations",
"disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"scoped": "This widget does not have an accessible media request integration."
}
}
}
},
"external": {
"help": "",
@@ -4829,7 +4847,11 @@
"table": {
"header": {
"season": "Season",
"episodes": "Episodes"
"episodes": "Episodes",
"status": "Status"
},
"status": {
"requested": "Requested"
}
},
"button": {
+40 -4
View File
@@ -132,8 +132,9 @@
},
"password": {
"label": "Password",
"requirement": {
"length": "Includes at least 8 characters",
"suggestionsTitle": "Password suggestions",
"suggestion": {
"length": "At least 8 characters",
"lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter",
"number": "Includes number",
@@ -1302,6 +1303,13 @@
},
"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"
@@ -1405,6 +1413,9 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": "Show border"
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
@@ -4658,9 +4669,30 @@
}
},
"media": {
"help": "Search for movies or shows to request",
"requestMovie": "Request movie",
"requestSeries": "Request series",
"openIn": "Open in {kind}"
"openIn": "Open in {kind}",
"group": {
"title": "Movies and shows"
},
"action": {
"search": {
"label": "Search for movies or shows",
"description": "Start typing to search your media request integrations",
"disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"scoped": "This widget does not have an accessible media request integration."
}
}
},
"availability": {
"available": "Available",
"partiallyAvailable": "Partial",
"processing": "Processing",
"requested": "Requested",
"pending": "Requested"
}
},
"external": {
"help": "Use an external search engine",
@@ -4936,7 +4968,11 @@
"table": {
"header": {
"season": "Season",
"episodes": "Episodes"
"episodes": "Episodes",
"status": "Status"
},
"status": {
"requested": "Requested"
}
},
"button": {
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Contraseña",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula",
"uppercase": "Incluye al menos una letra mayúscula",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Abrir en una nueva pestaña"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Añadir marcador"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+6 -2
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Mot de passe",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule",
"uppercase": "Inclut une lettre majuscule",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Ouvrir dans un nouvel onglet"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Signets",
"add": "Ajouter un signet"
@@ -2088,7 +2092,7 @@
"empty": "",
"user": "Utilisateur",
"media": "Média",
"state": "",
"state": "État",
"quality": "Qualité",
"device": "Appareil",
"progress": "Progression",
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "סיסמה",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות",
"uppercase": "אפשר אותיות גדולות",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "פתיחה בכרטיסיה חדשה"
},
"withBorder": {
"label": ""
},
"items": {
"label": "סימניות",
"add": "הוסף סימניה"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Lozinka",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Uključuje mala slova",
"uppercase": "Uključuje veliko slovo",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Jelszó",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt",
"uppercase": "Tartalmazzon nagybetűt",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Megnyitás új lapon"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Könyjelzők",
"add": "Könyvjelző hozzáadása"
+10 -6
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Password",
"requirement": {
"suggestionsTitle": "Suggerimenti password",
"suggestion": {
"length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola",
"uppercase": "Include lettera maiuscola",
@@ -1056,7 +1057,7 @@
}
},
"noResults": {
"title": ""
"title": "Nessun media caricato"
}
},
"common": {
@@ -1088,8 +1089,8 @@
"checkLogs": "Controlla i log per maggiori dettagli",
"tryAgain": "Riprova",
"loading": "Caricamento",
"show": "",
"hide": ""
"show": "Visualizza",
"hide": "Nascondi"
},
"here": "qui",
"iconPicker": {
@@ -1255,7 +1256,7 @@
"label": {
"create": "Nuova categoria",
"changePosition": "Cambia posizione",
"settings": ""
"settings": "Impostazioni"
}
},
"openAllInNewTabs": {
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Apri in una nuova scheda"
},
"withBorder": {
"label": "Mostra bordo"
},
"items": {
"label": "Segnalibri",
"add": "Aggiungi segnalibro"
@@ -3459,7 +3463,7 @@
"private": "Questa board è privata"
},
"noResults": {
"title": ""
"title": "Ancora nessuna board"
},
"modal": {
"createBoard": {
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "パスワード",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む",
"uppercase": "大文字を含む",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "新しいタブで開く"
},
"withBorder": {
"label": ""
},
"items": {
"label": "ブックマーク",
"add": "ブックマークに追加"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "비밀번호",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "소문자 포함",
"uppercase": "대문자 포함",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Slaptažodis",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Parole",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Ietver mazo burtu",
"uppercase": "Ietver lielo burtu",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Wachtwoord",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter",
"uppercase": "Inclusief hoofdletter",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "In nieuw tabblad openen"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bladwijzers",
"add": "Bladwijzer toevoegen"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Passord",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav",
"uppercase": "Inkluderer stor bokstav",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Åpne i ny fane"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bokmerke",
"add": "Legg til bokmerke"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Hasło",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę",
"uppercase": "Zawiera wielką literę",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Otwórz w nowej karcie"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Zakładki",
"add": "Dodaj zakładkę"
+10 -6
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Senha",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas",
"uppercase": "Inclui letras maiúsculas",
@@ -1056,7 +1057,7 @@
}
},
"noResults": {
"title": ""
"title": "Nenhuma mídia enviada"
}
},
"common": {
@@ -1088,8 +1089,8 @@
"checkLogs": "Verifique os logs para mais detalhes",
"tryAgain": "Tente novamente",
"loading": "Carregando",
"show": "",
"hide": ""
"show": "Mostrar",
"hide": "Esconder"
},
"here": "aqui",
"iconPicker": {
@@ -1255,7 +1256,7 @@
"label": {
"create": "Nova categoria",
"changePosition": "Mudar de posição",
"settings": ""
"settings": "Configurações"
}
},
"openAllInNewTabs": {
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Abrir em nova guia"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Marcadores",
"add": "Adicionar marcador"
@@ -3459,7 +3463,7 @@
"private": "Este quadro é privado"
},
"noResults": {
"title": ""
"title": "Nenhum painel ainda"
},
"modal": {
"createBoard": {
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Parola",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Să includă litere mici",
"uppercase": "Să includă litere mari",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Пароль",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Содержит не менее 8 символов",
"lowercase": "Содержит строчную букву",
"uppercase": "Содержит заглавную букву",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Открывать в новой вкладке"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Закладки",
"add": "Добавить закладку"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Heslo",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Obsahuje aspoň 8 znakov",
"lowercase": "Zahŕňa malé písmeno",
"uppercase": "Zahŕňa veľké písmená",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Otvoriť na novej karte"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Záložky",
"add": "Pridať záložku"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Geslo",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "",
"lowercase": "Vključuje male črke",
"uppercase": "Vključuje velike tiskane črke",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+111 -107
View File
@@ -82,7 +82,7 @@
"form": {
"name": {
"label": "Gruppens namn",
"description": ""
"description": "Namnet måste matcha admin-grupp i extern tjänst"
}
}
},
@@ -132,7 +132,8 @@
},
"password": {
"label": "Lösenord",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Använd minst 8 tecken",
"lowercase": "Använd liten bokstav",
"uppercase": "Använd stor bokstav",
@@ -159,7 +160,7 @@
"label": "Visa sökresultat i ny flik"
},
"ddgBangs": {
"label": ""
"label": "Aktivera DuckDuckGo bangs"
}
},
"error": {
@@ -252,10 +253,10 @@
"changeDdgBangs": {
"notification": {
"success": {
"message": ""
"message": "DuckDuckGo bangs inställningen ändrad"
},
"error": {
"message": ""
"message": "DuckDuckGo bangs inställningen kunde inte ändras"
}
}
},
@@ -350,12 +351,12 @@
"description": "Tillåt medlemmar att addera valfri applikation på sina tavlor"
},
"modify-all": {
"label": "",
"description": ""
"label": "Modifiera alla appar",
"description": "Tillåt att medlemmar modifierar alla appar"
},
"full-all": {
"label": "",
"description": ""
"label": "Fullständiga rättigheter till appar",
"description": "Tillåt att medlemmar hanterar, använder och raderar alla appar"
}
}
},
@@ -363,20 +364,20 @@
"title": "Tavlor",
"item": {
"create": {
"label": "",
"description": ""
"label": "Skapa tavlor",
"description": "Tillåt att medlemmar skapar tavlor"
},
"view-all": {
"label": "",
"description": ""
"label": "Se alla tavlor",
"description": "Tillåt att medlemmar ser alla tavlor"
},
"modify-all": {
"label": "",
"description": ""
"label": "Modifiera alla tavlor",
"description": "Tillåt att medlemmar modifierar alla tavlor (Detta inkluderar inte åtkomstkontroll eller farozonen)"
},
"full-all": {
"label": "",
"description": ""
"label": "Fullständiga rättigheter till tavlor",
"description": "Tillåt att medlemmar ser, modifierar och tar bort alla tavlor (detta inkluderar åtkomstkontroll och farozonen)"
}
}
},
@@ -392,38 +393,38 @@
"description": "Tillåter medlemmar att addera valfri integration till sina tavlor"
},
"interact-all": {
"label": "",
"description": ""
"label": "Interagera med alla integrationer",
"description": "Tillåt att medlemmar interagerar med alla integrationer"
},
"full-all": {
"label": "",
"description": ""
"label": "Fullständiga rättigheter till integrationer",
"description": "Tillåt att medlemmar hanterar, använder och interagerar med alla integrationer"
}
}
},
"media": {
"title": "",
"title": "Media",
"item": {
"upload": {
"label": "",
"description": ""
"label": "Ladda upp media",
"description": "Tillåt att medlemmar laddar upp media"
},
"view-all": {
"label": "",
"description": ""
"label": "Se all media",
"description": "Tillåt att medlemmar ser all media"
},
"full-all": {
"label": "",
"description": ""
"label": "Fullständiga rättigheter till media",
"description": "Tillåt att medlemmar hanterar och raderar all media"
}
}
},
"other": {
"title": "",
"title": "Annat",
"item": {
"view-logs": {
"label": "",
"description": ""
"label": "Se loggar",
"description": "Tillåt att medlemmar ser loggar"
}
}
},
@@ -435,32 +436,32 @@
"description": "Tillåt medlemmar att skapa sökmotorer"
},
"modify-all": {
"label": "",
"description": ""
"label": "Modifiera alla sökmotorer",
"description": "Tillåt att medlemmar modifierar alla sökmotorer"
},
"full-all": {
"label": "",
"description": ""
"label": "Fullständiga rättigheter till sökmotorer",
"description": "Tillåt att medlemmar hanterar och raderar alla sökmotorer"
}
}
}
},
"memberNotice": {
"mixed": "",
"external": ""
"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"
},
"reservedNotice": {
"message": ""
"message": "Denna grupp är reserverad för systemet så vissa åtgärder är begränsade. <checkoutDocs></checkoutDocs>"
},
"action": {
"create": {
"label": "Addera en grupp",
"notification": {
"success": {
"message": ""
"message": "Gruppen har skapats"
},
"error": {
"message": ""
"message": "Gruppen kunde inte skapas"
}
}
},
@@ -470,7 +471,7 @@
"confirm": "Är du säker på att du vill överföra ägarskapet för gruppen {name} till {username}?",
"notification": {
"success": {
"message": ""
"message": "Överfört grupp {group} till {user}"
},
"error": {
"message": "Kunde inte överföra ägarskap"
@@ -501,38 +502,38 @@
"notification": {
"success": {
"title": "Behörigheter sparade",
"message": ""
"message": "Rättigheterna sparade"
},
"error": {
"title": "",
"message": ""
"title": "Rättigheterna ej sparade",
"message": "Rättigheterna har inte sparats"
}
}
},
"update": {
"notification": {
"success": {
"message": ""
"message": "Gruppen {name} har sparats"
},
"error": {
"message": ""
"message": "Kunde inte spara gruppen {name}"
}
}
},
"select": {
"label": "Välj en grupp",
"notFound": ""
"notFound": "Ingen grupp hittad"
},
"settings": {
"board": {
"notification": {
"success": {
"title": "Inställningarna sparades",
"message": ""
"message": "Inställningarna för tavlan har sparats"
},
"error": {
"title": "Kunde inte spara inställningarna",
"message": ""
"message": "Kunde inte spara inställningarna för tavlan"
}
}
}
@@ -540,10 +541,10 @@
"changePosition": {
"notification": {
"success": {
"message": ""
"message": "Ordning ändrad"
},
"error": {
"message": ""
"message": "Ordningen kunde ändras"
}
}
}
@@ -559,20 +560,20 @@
"list": {
"title": "Applikationer",
"noResults": {
"title": "",
"action": ""
"title": "Det finns inga appar ännu",
"action": "Skapa din första app"
}
},
"create": {
"title": "Addera applikation",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Skapandet lyckades",
"message": "Appen skapades framgångsrikt"
},
"error": {
"title": "",
"message": ""
"title": "Skapandet misslyckades",
"message": "Appen kunde inte skapas"
}
}
},
@@ -581,11 +582,11 @@
"notification": {
"success": {
"title": "Ändringarna har sparats",
"message": ""
"message": "Appen sparades"
},
"error": {
"title": "Misslyckades med att spara ändringarna",
"message": ""
"message": "Appen kunde inte sparas"
}
}
},
@@ -594,12 +595,12 @@
"message": "Är du säker på att du vill ta bort applikationen {name}?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Borttagning lyckades",
"message": "Appen har tagits bort"
},
"error": {
"title": "",
"message": ""
"title": "Borttagning misslyckades",
"message": "Kunde inte ta bort appen"
}
}
}
@@ -612,21 +613,21 @@
"label": "Beskrivning"
},
"url": {
"label": ""
"label": "URL"
},
"useDifferentUrlForPing": {
"checkbox": {
"label": "",
"description": ""
"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"
}
}
},
"action": {
"select": {
"label": "",
"notFound": "",
"label": "Välj app",
"notFound": "Ingen app hittades",
"search": "Sök efter en applikation",
"noResults": "",
"noResults": "Inga resultat",
"action": "Välj {app}",
"title": "Välj en applikation att lägga till på tavlan"
},
@@ -642,56 +643,56 @@
"page": {
"list": {
"title": "Integrationer",
"search": "",
"search": "Sök integrationer",
"noResults": {
"title": ""
"title": "Det finns inga integrationer ännu"
}
},
"create": {
"title": "",
"title": "Ny {name} integration",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Skapandet lyckades",
"message": "Integrationen har skapats"
},
"error": {
"title": "",
"message": ""
"title": "Skapandet misslyckades",
"message": "Integrationen kunde inte skapas"
}
},
"app": {
"option": {
"existing": {
"title": "",
"label": ""
"title": "Befintlig",
"label": "Välj befintlig app"
},
"new": {
"title": "",
"title": "Ny",
"url": {
"label": "",
"description": ""
"label": "App URL",
"description": "URL:en som appen kommer att öppna när den används från tavlan"
}
}
}
}
},
"edit": {
"title": "",
"title": "Redigera {name} integration",
"notification": {
"success": {
"title": "Ändringarna har sparats",
"message": ""
"message": "Integrationen sparad"
},
"error": {
"title": "",
"message": ""
"title": "Kunde inte tillämpa ändringar",
"message": "Integrationen kunde inte sparas"
}
},
"app": {
"action": {
"add": "",
"remove": "",
"select": ""
"add": "Länka en app",
"remove": "Ta bort länk",
"select": "Välj en app att länka till"
}
}
},
@@ -700,12 +701,12 @@
"message": "Är du säker på att du vill ta bort integrationen {name}?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Borttagning lyckades",
"message": "Integrationen har tagits bort"
},
"error": {
"title": "",
"message": ""
"title": "Borttagning misslyckades",
"message": "Kunde inte ta bort integrationen"
}
}
}
@@ -715,44 +716,44 @@
"label": "Namn"
},
"url": {
"label": ""
"label": "URL"
},
"attemptSearchEngineCreation": {
"label": "",
"description": ""
"label": "Skapa sökmotor",
"description": "Integrationen \"{kind}\" kan inte användas med sökmotorer. Markera detta för att automatiskt konfigurera sökmotorn."
},
"app": {
"sectionTitle": ""
"sectionTitle": "Länkad app"
},
"createApp": {
"label": "",
"description": ""
"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."
},
"appHref": {
"placeholder": ""
"placeholder": "Egen app-URL"
}
},
"action": {
"create": ""
"create": "Ny integration"
},
"testConnection": {
"action": {
"create": "",
"edit": ""
"create": "Testa anslutningen och skapa",
"edit": "Testa anslutningen och skapa"
},
"error": {
"common": {
"cause": {
"title": ""
"title": "Orsak med mer detaljer"
}
},
"unknown": {
"title": "",
"description": ""
"title": "Okänt fel",
"description": "Ett okänt fel inträffade, öppna orsaken nedan för att se mer detaljer"
},
"parse": {
"title": "",
"description": ""
"title": "Parsningsfel",
"description": "Svaret kunde inte analyseras. Verifiera att URL pekar på bas-URL för tjänsten."
},
"authorization": {
"title": "",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Öppna i en ny flik"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Bokmärken",
"add": "Addera bokmärke"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Şifre",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "En az 8 karakter içermeli",
"lowercase": "Küçük harf içermeli",
"uppercase": "Büyük harf içermeli",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Yeni sekmede aç"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Yer İmleri",
"add": "Yer imi ekle"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Пароль",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Включає принаймні 8 символів",
"lowercase": "Включає малу літеру",
"uppercase": "Включає велику літеру",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "Відкрити в новій вкладці"
},
"withBorder": {
"label": ""
},
"items": {
"label": "Закладки",
"add": "Додати закладку"
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "Mật khẩu",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "Bao gồm ít nhất 8 kí tự",
"lowercase": "Bao gồm chữ thường",
"uppercase": "Bao gồm chữ in hoa",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": ""
},
"withBorder": {
"label": ""
},
"items": {
"label": "",
"add": ""
+5 -1
View File
@@ -132,7 +132,8 @@
},
"password": {
"label": "密碼",
"requirement": {
"suggestionsTitle": "",
"suggestion": {
"length": "至少包含 8 個字符",
"lowercase": "包含小寫字母",
"uppercase": "包含大寫字母",
@@ -1405,6 +1406,9 @@
"openNewTab": {
"label": "於新分頁開啟"
},
"withBorder": {
"label": ""
},
"items": {
"label": "書籤",
"add": "新增書籤"
@@ -20,15 +20,15 @@ export const CustomPasswordInput = ({ withPasswordRequirements, ...props }: Cust
};
const WithPasswordRequirements = (props: PasswordInputProps) => {
const [value, setValue] = useState("");
const [password, setPassword] = useState("");
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setValue(event.currentTarget.value);
setPassword(event.currentTarget.value);
props.onChange?.(event);
};
return (
<PasswordRequirementsPopover password={value}>
<PasswordRequirementsPopover password={password}>
<PasswordInput {...props} onChange={onChange} />
</PasswordRequirementsPopover>
);
@@ -1,14 +1,19 @@
import { rem, Text } from "@mantine/core";
import { IconCheck, IconX } from "@tabler/icons-react";
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"];
export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
const display = getRequirementDisplay(meets);
const Icon = display.Icon;
return (
<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 c={display.color} display="flex" style={{ alignItems: "center" }} size="sm">
<Icon style={{ width: rem(14), height: rem(14) }} />
<Text span ml={10}>
{label}
</Text>
@@ -1,16 +1,24 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { Popover, Progress } from "@mantine/core";
import { Popover, Progress, Text } 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) => (
@@ -19,8 +27,6 @@ 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>
@@ -29,7 +35,10 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
</div>
</Popover.Target>
<Popover.Dropdown>
<Progress color={color} value={strength} size={5} mb="xs" />
<Progress color={strengthColor} value={strength} size={5} mb="xs" />
<Text size="sm" c="dimmed" mb="xs">
{tPassword("suggestionsTitle")}
</Text>
{checks}
</Popover.Dropdown>
</Popover>
@@ -37,7 +46,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
};
const useRequirements = () => {
const t = useScopedI18n("user.field.password.requirement");
const t = useScopedI18n("user.field.password.suggestion");
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
};
+2 -16
View File
@@ -19,24 +19,10 @@ export const passwordRequirements = [
{ check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!\-~`"_/\\[\]{}]/), value: "special" },
] satisfies {
check: (value: string) => boolean;
value: keyof TranslationObject["user"]["field"]["password"]["requirement"];
value: keyof TranslationObject["user"]["field"]["password"]["suggestion"];
}[];
export const userPasswordSchema = z
.string()
.min(8)
.max(255)
.refine(
(value) => {
return passwordRequirements.every((requirement) => requirement.check(value));
},
{
params: createCustomErrorParams({
key: "passwordRequirements",
params: {},
}),
},
);
export const userPasswordSchema = z.string().min(8).max(255);
const addConfirmPasswordRefinement = <
TSchema extends z.ZodObject<{ password: z.core.$ZodString; confirmPassword: z.core.$ZodString }, z.core.$strip>,
+17 -2
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.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
@@ -56,6 +56,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
@@ -67,6 +68,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
@@ -81,6 +83,7 @@ interface FlexLayoutProps {
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
@@ -91,6 +94,7 @@ const FlexLayout = ({
hideIcon,
hideHostname,
openNewTab,
withBorder,
hasIconColor,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
@@ -106,7 +110,15 @@ const FlexLayout = ({
key={app.id}
w="100%"
>
<Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={4} h="100%">
<Card
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
@@ -138,6 +150,7 @@ interface GridLayoutProps {
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
}
@@ -148,6 +161,7 @@ const GridLayout = ({
hideIcon,
hideHostname,
openNewTab,
withBorder,
itemDirection,
hasIconColor,
}: GridLayoutProps) => {
@@ -168,6 +182,7 @@ const GridLayout = ({
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
+1
View File
@@ -24,6 +24,7 @@ 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,20 +1,10 @@
.statItem {
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: var(--mantine-color-gray-0);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: all 0.2s ease;
&:hover {
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);
}
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@@ -1,17 +1,32 @@
"use client";
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import {
ActionIcon,
Anchor,
Avatar,
Badge,
Box,
Card,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
export default function MediaServerWidget({
integrationIds,
@@ -43,7 +58,7 @@ export default function MediaServerWidget({
const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
);
return newData.sort((dataA, dataB) => {
return newData.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
@@ -61,25 +76,46 @@ export default function MediaServerWidget({
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return (
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
{mediaRequests.map((mediaRequest) => (
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={width <= 256}
options={options}
/>
))}
</Stack>
</ScrollArea>
<Box className={classes.searchRoot}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
{mediaRequests.map((mediaRequest) => (
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={width <= 256}
options={options}
/>
))}
</Stack>
</ScrollArea>
</Box>
);
}
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
const t = useScopedI18n("search.mode.media");
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={classes.searchButton}
variant="light"
size="sm"
aria-label={t("action.search.label")}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
<IconSearch size={16} />
</ActionIcon>
</Tooltip>
);
};
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean;
@@ -0,0 +1,24 @@
.searchRoot {
position: relative;
height: 100%;
}
.searchButton {
position: absolute;
top: var(--mantine-spacing-xs);
right: var(--mantine-spacing-xs);
z-index: 2;
opacity: 0;
transition: opacity 120ms ease;
}
.searchRoot:hover .searchButton,
.searchRoot:focus-within .searchButton {
opacity: 1;
}
@media (hover: none) {
.searchButton {
opacity: 1;
}
}
@@ -1,6 +1,6 @@
"use client";
import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { Icon } from "@tabler/icons-react";
import {
IconDeviceTv,
@@ -9,6 +9,7 @@ import {
IconMovie,
IconPlayerPlay,
IconReceipt,
IconSearch,
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
@@ -17,11 +18,13 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
const OVERSEERR_COLOR = "#ECB000";
const JELLYSEERR_COLOR = "#6677CC";
@@ -93,83 +96,104 @@ export default function MediaServerWidget({
const isTiny = width < 256;
return (
<Stack
className="mediaRequests-stats-layout"
h="100%"
gap="xs"
p="sm"
align="center"
justify="space-between"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{data.map((stat) => (
<Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name}
span={isTiny ? 6 : 3}
>
<Tooltip label={t(`titles.stats.${stat.name}`)}>
<Card p={0} radius={board.itemRadius} className={classes.card}>
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
<Text className="mediaRequests-stats-stat-value" size="md">
{stat.number}
<Box className={searchClasses.searchRoot}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
<Stack
className="mediaRequests-stats-layout"
h="100%"
gap="xs"
p="sm"
align="center"
justify="space-between"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{data.map((stat) => (
<Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name}
span={isTiny ? 6 : 3}
>
<Tooltip label={t(`titles.stats.${stat.name}`)}>
<Card p={0} radius={board.itemRadius} className={classes.card}>
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
<Text className="mediaRequests-stats-stat-value" size="md">
{stat.number}
</Text>
</Group>
</Card>
</Tooltip>
</Grid.Col>
))}
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
{requestStats.users.slice(0, 10).map((user) => (
<Card
component="a"
href={user.link}
target="_blank"
rel="noopener noreferrer"
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
)}
key={user.id}
p="xs"
radius={board.itemRadius}
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
<Group gap={4}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm">
{user.displayName}
</Text>
</Group>
</Card>
</Tooltip>
</Grid.Col>
))}
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
{requestStats.users.slice(0, 10).map((user) => (
<Card
component="a"
href={user.link}
target="_blank"
rel="noopener noreferrer"
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
)}
key={user.id}
p="xs"
radius={board.itemRadius}
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
<Group gap={4}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm">
{user.displayName}
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
{user.requestCount}
</Text>
</Group>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
{user.requestCount}
</Text>
</Group>
</Card>
))}
</Card>
))}
</Stack>
</Stack>
</Stack>
</Stack>
</Box>
);
}
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
const t = useScopedI18n("search.mode.media");
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={searchClasses.searchButton}
variant="light"
size="sm"
aria-label={t("action.search.label")}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
<IconSearch size={16} />
</ActionIcon>
</Tooltip>
);
};
@@ -0,0 +1,95 @@
"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>
);
};
+126 -69
View File
@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { Button, Group, Stack } from "@mantine/core";
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -20,6 +22,8 @@ import type { OptionsBuilderResult } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -34,13 +38,16 @@ interface ModalProps<TSort extends WidgetKind> {
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
appId?: string;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Translate the error messages
z.config({
customError: zodErrorMap(t),
});
@@ -77,78 +84,128 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
actions.closeModal();
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
try {
const appSaved = showAppTab ? await (appEditRef.current?.submitIfDirty() ?? true) : true;
if (!appSaved) {
return;
}
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
actions.closeModal();
} finally {
setIsSubmitting(false);
}
});
const onFormSubmit = (event: FormEvent<HTMLFormElement>) => {
handleSubmit(event);
};
const widgetFormContent = (
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
/>
);
})}
>
<FormProvider form={form}>
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
!Input ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
/>
);
})}
<Group justify="space-between">
<Button
variant="subtle"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
<Group justify="space-between">
<Button
variant="subtle"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
</Button>
{!showAppTab && (
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Group>
</Stack>
);
return (
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showAppTab ? (
<Stack>
<Tabs defaultValue="widget">
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
</Tabs>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.saveChanges")}</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
</Stack>
) : (
widgetFormContent
)}
</FormProvider>
</form>
);
+798 -822
View File
File diff suppressed because it is too large. Load diff
+46 -46
View File
@@ -6,7 +6,7 @@ catalog:
"@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2
"@ctrl/deluge": ^8.0.0
"@ctrl/qbittorrent": ^9.12.1
"@ctrl/qbittorrent": ^9.12.2
"@ctrl/transmission": ^7.4.0
"@dnd-kit/core": ^6.3.1
"@dnd-kit/modifiers": ^9.0.0
@@ -20,17 +20,17 @@ catalog:
"@immich/sdk": ^2.7.5
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@mantine/charts": ^9.2.1
"@mantine/colors-generator": ^9.2.1
"@mantine/core": ^9.2.1
"@mantine/dates": ^9.2.1
"@mantine/dropzone": ^9.2.1
"@mantine/form": ^9.2.1
"@mantine/hooks": ^9.2.1
"@mantine/modals": ^9.2.1
"@mantine/notifications": ^9.2.1
"@mantine/spotlight": ^9.2.1
"@mantine/tiptap": ^9.2.1
"@mantine/charts": ^9.2.2
"@mantine/colors-generator": ^9.2.2
"@mantine/core": ^9.2.2
"@mantine/dates": ^9.2.2
"@mantine/dropzone": ^9.2.2
"@mantine/form": ^9.2.2
"@mantine/hooks": ^9.2.2
"@mantine/modals": ^9.2.2
"@mantine/notifications": ^9.2.2
"@mantine/spotlight": ^9.2.2
"@mantine/tiptap": ^9.2.2
"@next/eslint-plugin-next": 16.2.6
"@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0
@@ -43,34 +43,34 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
"@tanstack/react-query": ^5.100.13
"@tanstack/react-query-devtools": ^5.100.13
"@tanstack/react-query-next-experimental": ^5.100.13
"@testcontainers/mysql": ^12.0.0
"@testcontainers/postgresql": ^12.0.0
"@testcontainers/redis": ^12.0.0
"@tiptap/extension-color": 3.23.6
"@tiptap/extension-highlight": 3.23.6
"@tiptap/extension-image": 3.23.6
"@tiptap/extension-link": ^3.23.6
"@tiptap/extension-placeholder": ^3.23.6
"@tiptap/extension-table": 3.23.6
"@tiptap/extension-table-cell": 3.23.6
"@tiptap/extension-table-header": 3.23.6
"@tiptap/extension-table-row": 3.23.6
"@tiptap/extension-task-item": 3.23.6
"@tiptap/extension-task-list": 3.23.6
"@tiptap/extension-text-align": 3.23.6
"@tiptap/extension-text-style": 3.23.6
"@tiptap/extension-underline": 3.23.6
"@tiptap/react": ^3.23.6
"@tiptap/starter-kit": ^3.23.6
"@tanstack/react-query": ^5.100.14
"@tanstack/react-query-devtools": ^5.100.14
"@tanstack/react-query-next-experimental": ^5.100.14
"@testcontainers/mysql": ^12.0.1
"@testcontainers/postgresql": ^12.0.1
"@testcontainers/redis": ^12.0.1
"@tiptap/extension-color": 3.24.0
"@tiptap/extension-highlight": 3.24.0
"@tiptap/extension-image": 3.24.0
"@tiptap/extension-link": ^3.24.0
"@tiptap/extension-placeholder": ^3.24.0
"@tiptap/extension-table": 3.24.0
"@tiptap/extension-table-cell": 3.24.0
"@tiptap/extension-table-header": 3.24.0
"@tiptap/extension-table-row": 3.24.0
"@tiptap/extension-task-item": 3.24.0
"@tiptap/extension-task-list": 3.24.0
"@tiptap/extension-text-align": 3.24.0
"@tiptap/extension-text-style": 3.24.0
"@tiptap/extension-underline": 3.24.0
"@tiptap/react": ^3.24.0
"@tiptap/starter-kit": ^3.24.0
"@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.9.14
"@turbo/gen": ^2.9.16
"@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13
@@ -109,7 +109,7 @@ catalog:
croner: 10.0.1
cross-env: ^10.1.0
crypto-js: ^4.2.0
dayjs: ^1.11.20
dayjs: ^1.11.21
deepmerge: 4.3.1
dns-caching: ^0.2.9
dockerode: ^5.0.0
@@ -124,30 +124,30 @@ catalog:
flag-icons: ^7.5.0
glob: ^13.0.6
ical.js: ^2.2.1
ioredis: 5.10.1
isomorphic-dompurify: ^3.14.0
ioredis: 5.11.0
isomorphic-dompurify: ^3.15.0
jotai: ^2.20.0
jsdom: ^29.1.1
json5: ^2.2.3
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
mantine-react-table: 2.0.0-beta.9
mantine-react-table: npm:mantine-react-table-open@9.0.3
maria2: ^0.4.1
mysql2: 3.22.3
mysql2: 3.22.4
next: 16.2.6
next-auth: 5.0.0-beta.31
next-intl: 4.12.0
next-intl: 4.13.0
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.51.0
oxlint: ^1.66.0
oxfmt: ^0.52.0
oxlint: ^1.67.0
pg: ^8.21.0
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
proxmox-api: 1.1.1
react: 19.2.6
react-dom: 19.2.6
react-error-boundary: ^6.1.1
react-error-boundary: ^6.1.2
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1
@@ -158,12 +158,12 @@ catalog:
superjson: 2.2.6
svgson: ^5.3.1
swagger-ui-react: ^5.32.6
testcontainers: ^12.0.0
testcontainers: ^12.0.1
timezones-ical-library: ^2.2.0
trpc-to-openapi: ^3.3.0
tsdav: ^2.2.2
tsx: 4.20.4
turbo: ^2.9.14
turbo: ^2.9.16
typescript: ^5.9.3
undici: 7.24.6
use-deep-compare-effect: ^1.8.1