Compare commits

...
Author SHA1 Message Date
Thomas Camlong 114d9ce321 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.
2026-06-05 16:18:05 +02:00
Thomas 3d3e3059a9 feat: media request search as first-class spotlight mode (#5823) 2026-06-05 16:09:14 +02:00
homarr-crowdin[bot]andMeier Lukas 9ae8cb2768 chore(lang): updated translations from crowdin (#5863)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 13:48:42 +02:00
Thomas 99346f288b feat(widgets): add in-place app editing in widget edit modal (#5809) 2026-06-05 12:31:50 +02:00
ajnart 3a1adab3e5 style: auto-fix lint and format 2026-06-01 08:31:53 +00:00
Thomas Camlong e67d603570 fix(spotlight): remove all non-null assertions to pass oxlint
- Refactor groupDispatchers to accept userId as a parameter instead of closing over nullable userId
- Replace mutationGroup! with a guard clause
- Use optional chaining for preferenceChildrenOptionsByKey lookup with fallback
2026-06-01 10:30:41 +02:00
Thomas Camlong 0449716805 fix(spotlight): address PR review feedback
- Clear optimistic store on user/session change to prevent stale values leaking across logins
- Move dispatcher lookup before setOptimistic to avoid permanent ghost entries when no dispatcher exists
- Inline dayjs.weekdays() calls to reflect runtime locale changes instead of caching at module load
- Remove unused UserPreferenceKey import
2026-06-01 10:26:08 +02:00
Thomas e0a77948e2 Merge branch 'dev' into feat/cnmdk-preferences 2026-05-29 22:01:10 +02:00
ajnart a2c4b29bfd style: auto-fix lint and format 2026-05-29 17:52:12 +00:00
Thomas Camlong 5501ee6a7d refactor(spotlight): registry-driven preferences with data-driven hook
Replace 5 per-preference children files with a single
preference-registry.tsx that auto-generates CMDK children via factory
functions. Rewrite useUserPreferences from ~347 lines to ~160 lines by
deriving mutation dispatch, pending state, and optimistic values from
preference definitions.

Simplify settings-children.tsx and groups.tsx to iterate over
visiblePreferenceDefinitions instead of maintaining manual mappings.

Deleted: board.tsx, color-scheme.tsx, first-day-of-week.tsx,
search-engine.tsx, registry.ts

Net reduction: ~261 lines across 9 files.
2026-05-29 17:26:27 +02:00
Thomas Camlong f6592b27a1 refactor(settings): add mutationGroup to preference definitions
Enrich userPreferenceDefinitions with mutationGroup field to express
which tRPC mutation handles each preference key. Move
visiblePreferenceDefinitions helper from spotlight registry to the
shared settings package.

This enables data-driven mutation dispatch in the CMDK hook and
eliminates manual key-to-group mappings.
2026-05-29 17:26:02 +02:00
Thomas Camlong 970b92cd95 Merge remote-tracking branch 'origin/feat/rework-perferences' into feat/cnmdk-preferences 2026-05-29 17:05:49 +02:00
Thomas Camlong e182d28605 continue working on user prefernces in command palette 2026-05-29 16:23:29 +02:00
Thomas Camlong 19d86daa87 wip: user preferences in command palette 2026-05-29 15:37:00 +02:00
Meier Lukas 1bdfc44ba1 Merge branch 'dev' into feat/rework-perferences 2026-05-29 11:08:03 +02:00
Meier Lukas 0ab0be0aa4 Merge branch 'dev' into feat/rework-perferences 2026-05-29 10:28:31 +02:00
Thomas Camlong cf998b474b Merge branch 'dev' into feat/rework-perferences
Resolve conflicts by keeping the consolidated general settings form,
porting the firstDayOfWeek Radio.Group fix from dev, and retaining
mobile avatar layout improvements from this branch.
2026-05-28 11:11:27 +02:00
Thomas Camlong 737e2f8c42 chore(users): remove unused general settings form components
These were superseded by UserGeneralSettingsForm in the preferences rework.
2026-05-28 11:09:52 +02:00
Thomas Camlong c33b09c5d4 fix(users): fix mobile layout and add missing ddgBangsEnabled field to general settings form
- Make avatar responsive on mobile (120px) vs desktop (200px) and center on mobile
- Add missing ddgBangsEnabled field to form schema, initial values, mutation, and UI
- Fix async function naming to satisfy lint rule
2026-05-18 17:25:35 +02:00
Thomas bc0627ca9c Merge branch 'dev' into feat/rework-perferences 2026-05-18 17:18:28 +02:00
Meier Lukas c4ab2c35ab fix: format issue 2026-05-01 16:38:53 +02:00
Thomas d44e7e9b52 Merge branch 'dev' into feat/rework-perferences 2026-05-01 10:00:32 +02:00
Thomas 1340880fef Merge branch 'dev' into feat/rework-perferences 2026-03-15 16:08:05 +01:00
Thomas Camlong e79efe06bb refactor(manage): update user settings 2026-01-28 12:19:31 +01:00
Thomas Camlong c16974b8db chore: address pr comments 2026-01-28 11:43:38 +01:00
Thomas Camlong b0c5de82f4 chore: address pr comments 2026-01-28 11:43:28 +01:00
Thomas Camlong d99b20d3ef feat(manage): consolidate user general settings form 2026-01-25 16:00:21 +01:00
Thomas Camlong d8eaef859a chore(gitignore): ignore Cursor project files 2026-01-25 16:00:10 +01:00
78 changed files with 2309 additions and 446 deletions

No files matched your search

@@ -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 ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
title(t) { title(t) {
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories, integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema, integrationSavePermissionsSchema,
integrationUpdateSchema, integrationUpdateSchema,
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; 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"; import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
url: input.url, url: input.url,
}); });
if ( // Media request search is a first-class Spotlight action. Do not create a search engine for it.
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),
});
}
}), }),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
}); });
return { return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => { inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
users: userPermissions users: userPermissions
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
user, user,
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}), }),
groups: dbGroupIntegrationPermission groups: dbGroupIntegrationPermission
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
}, },
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
}; };
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query)); 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 { interface UpdateSecretInput {
integrationId: string; integrationId: string;
value: string; value: string;
@@ -640,35 +770,6 @@ interface AddSecretInput {
kind: IntegrationSecretKind; 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) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; 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"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" }); const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, 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 create: permissionRequiredProcedure
.requiresPermission("search-engine-create") .requiresPermission("search-engine-create")
.input(searchEngineManageSchema) .input(searchEngineManageSchema)
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; 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 { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; 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", () => { describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => { test("with full access should return an integration by id", async () => {
const db = createDb(); const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); 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 db = createDb();
const caller = integrationRouter.createCaller({ const caller = integrationRouter.createCaller({
db, db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); expect(dbSearchEngine).toBeUndefined();
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",
);
}); });
test("with create integration access should create a new integration with new linked app", async () => { test("with create integration access should create a new integration with new linked app", async () => {
+2 -2
View File
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
pingIconsEnabled: input.pingIconsEnabled, pingIconsEnabled: input.pingIconsEnabled,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, input.id));
}), }),
changeDdgBangs: protectedProcedure changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
firstDayOfWeek: input.firstDayOfWeek, firstDayOfWeek: input.firstDayOfWeek,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, input.id));
}), }),
}); });
+90 -34
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ChangeEventHandler } from "react"; 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 { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
@@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>; 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 { interface AppFormProps {
showBackToOverview: boolean; showBackToOverview: boolean;
buttonLabels: { buttonLabels: {
@@ -24,8 +37,10 @@ interface AppFormProps {
submitAndCreateAnother?: string; submitAndCreateAnother?: string;
}; };
initialValues?: FormType; initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void; handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise<void>;
isPending: boolean; isPending: boolean;
hideButtons?: boolean;
formRef?: React.Ref<AppFormHandle>;
} }
export const AppForm = ({ export const AppForm = ({
@@ -34,19 +49,31 @@ export const AppForm = ({
handleSubmit: originalHandleSubmit, handleSubmit: originalHandleSubmit,
initialValues, initialValues,
isPending, isPending,
hideButtons,
formRef,
}: AppFormProps) => { }: AppFormProps) => {
const t = useI18n(); const t = useI18n();
const form = useZodForm(appManageSchema, { const form = useZodForm(appManageSchema, {
initialValues: { initialValues: toFormValues(initialValues),
name: initialValues?.name ?? "",
description: initialValues?.description ?? "",
iconUrl: initialValues?.iconUrl ?? "",
href: initialValues?.href ?? "",
pingUrl: initialValues?.pingUrl ?? "",
},
}); });
const initialValuesKey = [
initialValues?.name,
initialValues?.description,
initialValues?.iconUrl,
initialValues?.href,
initialValues?.pingUrl,
].join("\0");
useEffect(() => {
if (!initialValues || form.isDirty()) {
return;
}
form.initialize(toFormValues(initialValues));
}, [initialValuesKey, form, initialValues]);
// Debounce the name value with 200ms delay // Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200); const [debouncedName] = useDebouncedValue(form.values.name, 200);
@@ -62,6 +89,29 @@ export const AppForm = ({
originalHandleSubmit(values, redirect, afterSuccess); 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 [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => { const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => {
@@ -92,32 +142,32 @@ export const AppForm = ({
} }
}, [debouncedName, iconsData]); }, [debouncedName, iconsData]);
return ( const formFields = (
<form onSubmit={form.onSubmit(handleSubmit)}> <Stack>
<Stack> <TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} /> <IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker {...form.getInputProps("iconUrl")} /> <Textarea
<Textarea {...form.getInputProps("description")}
{...form.getInputProps("description")} label={t("app.field.description.label")}
label={t("app.field.description.label")} autosize
autosize minRows={2}
minRows={2} resize="vertical"
resize="vertical" />
/> <TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Checkbox <Checkbox
checked={opened} checked={opened}
onChange={handleClickDifferentUrlPing} onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")} label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")} description={t("app.field.useDifferentUrlForPing.checkbox.description")}
mt="md" mt="md"
/> />
<Collapse expanded={opened}> <Collapse expanded={opened}>
<TextInput {...form.getInputProps("pingUrl")} /> <TextInput {...form.getInputProps("pingUrl")} />
</Collapse> </Collapse>
{!hideButtons && (
<Group justify="end"> <Group justify="end">
{showBackToOverview && ( {showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps"> <Button variant="default" component={Link} href="/manage/apps">
@@ -139,7 +189,13 @@ export const AppForm = ({
{buttonLabels.submit} {buttonLabels.submit}
</Button> </Button>
</Group> </Group>
</Stack> )}
</form> </Stack>
); );
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(handleSubmit)}>{formFields}</form>;
}; };
@@ -12,12 +12,14 @@ interface SeriesInformation {
overview: string; overview: string;
seasons: SerieSeason[]; seasons: SerieSeason[];
posterPath: string; posterPath: string;
requestedSeasons?: number[];
} }
interface MovieInformation { interface MovieInformation {
id: number; id: number;
overview: string; overview: string;
posterPath: string; posterPath: string;
requestedSeasons?: number[];
} }
export type MediaInformation = SeriesInformation | MovieInformation; export type MediaInformation = SeriesInformation | MovieInformation;
@@ -60,6 +60,9 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined, text: "overview" in result ? result.overview : undefined,
type: result.mediaType, type: result.mediaType,
inLibrary: result.mediaInfo !== undefined, 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"), "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; 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([ const mediaInformationSchema = z.union([
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -355,14 +379,22 @@ const mediaInformationSchema = z.union([
), ),
numberOfSeasons: z.number(), numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
overview: z.string(), overview: z.string(),
posterPath: z.string().startsWith("/"), posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}), }),
]); ]);
const searchMediaInfoSchema = z
.object({
status: z.number(),
})
.optional();
const searchSchema = z.object({ const searchSchema = z.object({
results: z results: z
.array( .array(
@@ -373,7 +405,7 @@ const searchSchema = z.object({
name: z.string(), name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: z.object({}).optional(), mediaInfo: searchMediaInfoSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
@@ -381,14 +413,14 @@ const searchSchema = z.object({
title: z.string(), title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(), overview: z.string(),
mediaInfo: z.object({}).optional(), mediaInfo: searchMediaInfoSchema,
}), }),
z.object({ z.object({
id: z.number(), id: z.number(),
mediaType: z.literal("person"), mediaType: z.literal("person"),
name: z.string(), name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(), profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: z.object({}).optional(), mediaInfo: searchMediaInfoSchema,
}), }),
]), ]),
) )
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table"; import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
} }
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => { 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, integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId, mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType, mediaType: innerProps.mediaType,
}); });
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({ const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
onSuccess() { onSuccess() {
actions.closeModal(); actions.closeModal();
showSuccessNotification({ showSuccessNotification({
@@ -33,6 +33,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
const isPending = isPendingQuery || isPendingMutation; const isPending = isPendingQuery || isPendingMutation;
const t = useI18n(); const t = useI18n();
const requestedSeasons = new Set(data?.requestedSeasons ?? []);
const columns = useMemo<MRT_ColumnDef<Season>[]>( const columns = useMemo<MRT_ColumnDef<Season>[]>(
() => [ () => [
@@ -44,8 +45,18 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
accessorKey: "episodeCount", accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"), 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({ const table = useTranslatedMantineReactTable({
@@ -56,7 +67,7 @@ export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions,
enablePagination: false, enablePagination: false,
enableSorting: false, enableSorting: false,
enableSelectAll: true, enableSelectAll: true,
enableRowSelection: true, enableRowSelection: (row: MRT_Row<Season>) => !requestedSeasons.has(row.original.seasonNumber),
mantineTableProps: { mantineTableProps: {
highlightOnHover: false, highlightOnHover: false,
striped: "odd", striped: "odd",
@@ -44,6 +44,7 @@ const optionMapping: OptionMapping = {
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon), hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname), hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab), openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
withBorder: () => undefined,
}, },
calendar: { calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType], releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
+1
View File
@@ -1 +1,2 @@
export * from "./src/context"; export * from "./src/context";
export * from "./src/preferences/definitions";
@@ -0,0 +1,78 @@
export const userPreferenceDefinitions = [
{
key: "colorScheme",
kind: "select",
guest: true,
mutationGroup: "colorScheme",
aliases: ["theme", "dark", "light", "auto"],
},
{ key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] },
{
key: "defaultSearchEngineId",
kind: "searchEngine",
guest: false,
mutationGroup: "searchPreferences",
aliases: ["search", "search engine"],
},
{
key: "openSearchInNewTab",
kind: "boolean",
guest: false,
mutationGroup: "searchPreferences",
aliases: ["search", "new tab", "external"],
},
{
key: "ddgBangs",
kind: "boolean",
guest: false,
mutationGroup: "searchPreferences",
aliases: ["duckduckgo", "bangs", "search"],
},
{
key: "firstDayOfWeek",
kind: "select",
guest: false,
mutationGroup: "firstDayOfWeek",
aliases: ["calendar", "week", "weekday"],
},
{
key: "homeBoardId",
kind: "board",
guest: false,
mutationGroup: "homeBoards",
aliases: ["home", "board", "dashboard"],
},
{
key: "mobileHomeBoardId",
kind: "board",
guest: false,
mutationGroup: "homeBoards",
aliases: ["mobile", "home", "board", "dashboard"],
},
{
key: "pingIconsEnabled",
kind: "boolean",
guest: false,
mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"],
},
{
key: "fullPreferencesPage",
kind: "link",
guest: false,
mutationGroup: null,
aliases: ["settings", "manage", "profile"],
},
] as const;
export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
export type UserPreferenceKey = UserPreferenceDefinition["key"];
export type UserPreferenceKind = UserPreferenceDefinition["kind"];
export type UserPreferenceMutationGroup = Exclude<UserPreferenceDefinition["mutationGroup"], null>;
export const userPreferenceDefinitionByKey = Object.fromEntries(
userPreferenceDefinitions.map((definition) => [definition.key, definition]),
) as Record<UserPreferenceKey, UserPreferenceDefinition>;
export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
+1
View File
@@ -29,6 +29,7 @@
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
@@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
<Spotlight.Action <Spotlight.Action
renderRoot={renderRoot} renderRoot={renderRoot}
onClick={onClick} onClick={onClick}
closeSpotlightOnTrigger={interaction.type !== "children"} closeSpotlightOnTrigger={
interaction.type !== "children" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
className={classes.spotlightAction} className={classes.spotlightAction}
> >
<action.Component {...childrenOptions.option} /> <action.Component {...childrenOptions.option} />
@@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
setQuery(interaction.query); setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") { } else if (interaction.type === "mode") {
if (interaction.query !== undefined) {
setQuery(interaction.query);
}
setMode(interaction.mode); setMode(interaction.mode);
} else if (interaction.type === "children") { } else if (interaction.type === "children") {
setChildrenOptions(interaction); setChildrenOptions(interaction);
@@ -58,7 +61,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
interaction.type !== "mode" && interaction.type !== "mode" &&
interaction.type !== "children" && interaction.type !== "children" &&
interaction.type !== "none" && interaction.type !== "none" &&
interaction.type !== "setQuery" interaction.type !== "setQuery" &&
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
} }
className={classes.spotlightAction} className={classes.spotlightAction}
> >
+100 -33
View File
@@ -1,24 +1,33 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react"; import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { OpenMediaRequestSearchOptions } from "../index";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups"; 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 { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"]; type SearchModeKey = keyof TranslationObject["search"]["mode"];
type ChildrenOptions = inferSearchInteractionOptions<"children">;
const defaultMode = "home"; const defaultMode = "home";
export const Spotlight = () => { export const Spotlight = () => {
@@ -31,7 +40,6 @@ export const Spotlight = () => {
return null; return null;
} }
// We use the "key" below to prevent the 'Different amounts of hooks' error
return ( return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} /> <SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
); );
@@ -46,13 +54,57 @@ interface SpotlightWithActiveModeProps {
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => { const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState; const [query, setQuery] = queryState;
const [mode, setMode] = modeState; const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null); const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n(); const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Works as always the same amount of hooks are executed const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups(); const groups = useGroups();
const homeEmptyGroups = useHomeEmptyGroups(); const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
const clearChildrenStack = useCallback(() => {
setChildrenStack([]);
}, []);
const pushChildrenOptions = useCallback(
(options: ChildrenOptions) => {
setChildrenStack((currentStack) => [...currentStack, options]);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
},
[setQuery],
);
const popChildrenOptions = useCallback(() => {
setChildrenStack((currentStack) => currentStack.slice(0, -1));
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}, [setQuery]);
useEffect(() => {
const handleMediaRequestSearch = (event: Event) => {
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
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 ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
@@ -61,7 +113,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }} overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
onSpotlightClose={() => { onSpotlightClose={() => {
setMode(defaultMode); setMode(defaultMode);
setChildrenOptions(null); clearChildrenStack();
setQuery(""); setQuery("");
}} }}
query={query} query={query}
@@ -69,17 +121,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery; const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
setQuery(sanitizedQuery); setQuery(sanitizedQuery);
// Only switch modes when a single trigger character is typed
if (sanitizedQuery.length !== 1) return; if (sanitizedQuery.length !== 1) return;
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery); const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
if (!modeToActivate) return; if (!modeToActivate) return;
setMode(modeToActivate.modeKey); setMode(modeToActivate.modeKey);
setChildrenOptions(null); clearChildrenStack();
setQuery(""); setQuery("");
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
}} }}
store={spotlightStore} store={spotlightStore}
@@ -87,13 +136,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.Search <MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`} placeholder={`${t("search.placeholder")}...`}
ref={inputRef} ref={inputRef}
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48} leftSectionWidth={hasModeCharacter ? 80 : 48}
leftSection={ leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%"> <Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%"> <Center w={48} h="100%">
<IconSearch stroke={1.5} /> <IconSearch stroke={1.5} />
</Center> </Center>
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null} {hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group> </Group>
} }
styles={{ styles={{
@@ -102,31 +151,53 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
}, },
}} }}
rightSection={ rightSection={
mode === defaultMode ? null : ( mode === defaultMode && childrenStack.length === 0 ? null : (
<ActionIcon <ActionIcon
title={
childrenStack.length > 0
? t("search.mode.command.group.preferences.children.detail.backAction")
: t("common.action.close")
}
onClick={() => { onClick={() => {
if (childrenStack.length > 0) {
popChildrenOptions();
inputRef.current?.focus();
return;
}
setMode(defaultMode); setMode(defaultMode);
setChildrenOptions(null); clearChildrenStack();
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
variant="subtle" variant="subtle"
aria-label={t("common.action.close")} aria-label={
childrenStack.length > 0
? t("search.mode.command.group.preferences.children.detail.backAction")
: t("common.action.close")
}
> >
<IconX stroke={1.5} /> {childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />}
</ActionIcon> </ActionIcon>
) )
} }
value={query} value={query}
onKeyDown={(event) => { onKeyDown={(event) => {
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") { if (query.length !== 0 || event.key !== "Backspace") return;
if (childrenStack.length > 0) {
popChildrenOptions();
return;
}
if (mode !== defaultMode) {
setMode(defaultMode); setMode(defaultMode);
setChildrenOptions(null); clearChildrenStack();
} }
}} }}
/> />
{childrenOptions ? ( {childrenOptions ? (
<Group> <Group key={childrenStack.length}>
<childrenOptions.DetailComponent options={childrenOptions.option as never} /> <childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group> </Group>
) : null} ) : null}
@@ -134,25 +205,21 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
<MantineSpotlight.ActionsList> <MantineSpotlight.ActionsList>
{childrenOptions ? ( {childrenOptions ? (
<SpotlightChildrenActions <SpotlightChildrenActions
key={childrenStack.length}
childrenOptions={childrenOptions} childrenOptions={childrenOptions}
query={query} query={query}
setChildrenOptions={setChildrenOptions} setChildrenOptions={pushChildrenOptions}
/> />
) : ( ) : (
<SpotlightActionGroups <SpotlightActionGroups
setQuery={setQuery} setQuery={setQuery}
setMode={(mode) => { setMode={(nextMode) => {
setMode(mode); setMode(nextMode);
setChildrenOptions(null); clearChildrenStack();
setTimeout(() => selectAction(0, spotlightStore)); setTimeout(() => selectAction(0, spotlightStore));
}} }}
setChildrenOptions={(options) => { setChildrenOptions={(options) => {
setChildrenOptions(options); pushChildrenOptions(options);
setTimeout(() => {
setQuery("");
selectAction(0, spotlightStore);
});
}} }}
query={query} query={query}
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups} groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
+17 -2
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { spotlightActions } from "./spotlight-store"; import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight"; export { Spotlight } from "./components/spotlight";
export { openSpotlight }; export { openSpotlight, openMediaRequestSearch };
export { export {
SpotlightProvider, SpotlightProvider,
useRegisterSpotlightContextResults, useRegisterSpotlightContextResults,
@@ -11,3 +11,18 @@ export {
} from "./modes/home/context"; } from "./modes/home/context";
const openSpotlight = spotlightActions.open; 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 };
+5 -2
View File
@@ -10,9 +10,12 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
// This is used to define search interactions with their options // This is used to define search interactions with their options
const searchInteractions = [ const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(), createSearchInteraction("javaScript").optionsType<{
onSelect: () => MaybePromise<void>;
closeSpotlightOnTrigger?: boolean;
}>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(), 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<{ createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"]; useActions: CreateChildrenOptionsProps<any>["useActions"];
@@ -1,16 +1,17 @@
import { Group, Stack, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { localeConfigurations, supportedLanguages } from "@homarr/translation"; import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; import { useUserPreference } from "../../../preferences/use-user-preference";
import { LanguageIcon } from "@homarr/ui"; import { LanguageIcon } from "@homarr/ui";
import { createChildrenOptions } from "../../../lib/children"; import { createChildrenOptions } from "../../../lib/children";
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({ export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => { useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const currentLocale = useCurrentLocale(); const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale");
return supportedLanguages return supportedLanguages
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] })) .map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
.filter( .filter(
@@ -18,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
configuration.name.toLowerCase().includes(normalizedQuery) || configuration.name.toLowerCase().includes(normalizedQuery) ||
configuration.translatedName.toLowerCase().includes(normalizedQuery), configuration.translatedName.toLowerCase().includes(normalizedQuery),
) )
.sort( .toSorted(
(languageA, languageB) => (languageA, languageB) =>
Math.min( Math.min(
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery), languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
@@ -29,38 +30,25 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery), languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
), ),
) )
.map(({ localeKey, configuration }) => ({ .map(({ localeKey, configuration }) =>
key: localeKey, createCheckmarkPreferenceAction({
Component() { key: localeKey,
return ( isSelected: localeKey === currentLocale,
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%"> onSelect: () => setLocale(localeKey as never),
<Group wrap="nowrap"> isPending,
<LanguageIcon icon={localeConfigurations[localeKey].icon} /> labelContent: (
<Group wrap="nowrap" gap="xs"> <Group wrap="nowrap">
<Text>{configuration.name}</Text> <LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Text size="xs" c="dimmed" inherit> <Group wrap="nowrap" gap="xs">
({configuration.translatedName}) <Text>{configuration.name}</Text>
</Text> <Text size="xs" c="dimmed" inherit>
</Group> ({configuration.translatedName})
</Text>
</Group> </Group>
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
</Group> </Group>
); ),
}, }),
useInteraction() { );
const { changeLocale } = useChangeLocale();
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
},
}));
},
DetailComponent: () => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
</Stack>
);
}, },
DetailComponent: () => <PreferenceDetailHeader titleKey="locale.children.detail.title" />,
}); });
@@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { interaction } from "../../../../lib/interaction";
export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => {
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
const t = useScopedI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{tOption(titleKey as never)}</Text>
<Text size="xs" c="dimmed">
{t("children.detail.backHint")}
</Text>
</Stack>
);
};
export const createCheckmarkPreferenceAction = ({
key,
label,
labelContent,
isSelected,
onSelect,
dimmed,
isPending,
}: {
key: string;
label?: string;
labelContent?: ReactNode;
isSelected: boolean;
onSelect: () => void;
dimmed?: boolean;
isPending?: boolean;
}) => ({
key,
Component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%" opacity={isPending ? 0.5 : 1}>
{labelContent ?? <Text c={dimmed ? "dimmed" : undefined}>{label}</Text>}
{isSelected && <IconCheck color="currentColor" size={24} />}
</Group>
);
},
useInteraction: interaction.javaScript(() => ({
onSelect: isPending ? () => undefined : onSelect,
closeSpotlightOnTrigger: false,
})),
});
export const createLoadingPreferenceAction = () => ({
key: "loading",
Component() {
return (
<Center mx="md" my="sm">
<Loader size="sm" />
</Center>
);
},
useInteraction: interaction.javaScript(() => ({
onSelect: () => undefined,
closeSpotlightOnTrigger: false,
})),
});
@@ -0,0 +1 @@
export { settingsChildrenOptions } from "./settings-children";
@@ -0,0 +1,55 @@
import { createCheckmarkPreferenceAction } from "./action-row";
interface SelectablePreferenceItem {
key: string;
label: string;
value: string;
}
export const createSelectablePreferenceActions = ({
query,
currentValue,
noneLabel,
unavailableLabel,
items,
onSelect,
isPending,
}: {
query: string;
currentValue: string | null;
noneLabel: string;
unavailableLabel: string;
items: SelectablePreferenceItem[];
onSelect: (value: string | null) => void;
isPending?: boolean;
}) => {
const hasCurrentSelection = currentValue !== null && !items.some((item) => item.value === currentValue);
const orphanedItem: SelectablePreferenceItem | null = hasCurrentSelection
? { key: `orphaned-${currentValue}`, label: unavailableLabel, value: currentValue }
: null;
const normalizedQuery = query.trim().toLowerCase();
const filteredItems = items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
const visibleItems = orphanedItem ? [orphanedItem, ...filteredItems] : filteredItems;
const noneAction = createCheckmarkPreferenceAction({
key: "none",
label: noneLabel,
isSelected: currentValue === null,
onSelect: () => onSelect(null),
dimmed: true,
isPending,
});
const itemActions = visibleItems.map((item) =>
createCheckmarkPreferenceAction({
key: item.key,
label: item.label,
isSelected: currentValue === item.value,
onSelect: () => onSelect(item.value),
dimmed: item.key.startsWith("orphaned-"),
isPending,
}),
);
return [noneAction, ...itemActions];
};
@@ -0,0 +1,156 @@
"use client";
import { Group, Stack, Switch, Text } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { localeConfigurations } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../../../../lib/children";
import type { ChildrenAction } from "../../../../lib/children";
import { interaction } from "../../../../lib/interaction";
import { useUserPreferences } from "../../../../preferences/use-user-preference";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
dayjs.extend(localeData);
type SettingsAction = ChildrenAction<Record<string, unknown>>;
const BooleanRow = ({
label,
Icon,
checked,
isPending,
}: {
label: string;
Icon: TablerIcon;
checked: boolean;
isPending: boolean;
}) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Switch checked={checked} readOnly size="md" disabled={isPending} styles={{ track: { cursor: "pointer" } }} />
</Group>
);
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
</Group>
);
const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
<Group mx="md" my="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
);
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const t = useScopedI18n("search.mode.command.group.preferences.option");
const tColorScheme = useScopedI18n("common.colorScheme.options");
const { data: session } = useSession();
const isAuthenticated = Boolean(session?.user);
const normalizedQuery = query.trim().toLowerCase();
const preferences = useUserPreferences();
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
const valueLabelResolvers: Record<string, () => string> = {
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
locale: () => {
const val = preferences.getPreference("locale").value;
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
},
defaultSearchEngineId: () =>
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
?.label ?? t("searchEngine.none" as never),
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
homeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
t("board.none" as never),
mobileHomeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
t("board.none" as never),
};
return visiblePreferenceDefinitions(isAuthenticated)
.map((definition): SettingsAction | null => {
const label = t(`${definition.key}.label` as never);
const Icon = preferenceIcons[definition.key];
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
if (!matchesSearch) return null;
if (definition.kind === "boolean") {
const pref = preferences.getPreference(definition.key);
return {
key: definition.key,
Component: () => (
<BooleanRow label={label} Icon={Icon} checked={pref.value as boolean} isPending={pref.isPending} />
),
useInteraction: interaction.javaScript(() => ({
onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never),
closeSpotlightOnTrigger: false,
})),
};
}
if (definition.kind === "link") {
return {
key: definition.key,
Component: () => <LinkRow label={label} Icon={Icon} />,
useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })),
};
}
if (SELECT_KINDS.has(definition.kind)) {
const children = preferenceChildrenOptionsByKey[definition.key];
if (!children) return null;
return {
key: definition.key,
Component: () => (
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
),
useInteraction: interaction.children(children),
};
}
return null;
})
.filter((action): action is SettingsAction => action !== null);
};
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: useSettingsActions,
DetailComponent: () => {
const t = useScopedI18n("search.mode.command.group.preferences");
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{t("title")}</Text>
<Text size="xs" c="dimmed">
{t("children.detail.backHint")}
</Text>
</Stack>
);
},
});
@@ -1,14 +1,10 @@
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { import {
IconBox, IconBox,
IconCategoryPlus, IconCategoryPlus,
IconDeviceDesktop,
IconFileImport, IconFileImport,
IconLanguage,
IconMailForward, IconMailForward,
IconMoon,
IconPlug, IconPlug,
IconSun,
IconUserPlus, IconUserPlus,
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -22,7 +18,6 @@ import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration"; import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>. // This has to be type so it can be interpreted as Record<string, unknown>.
@@ -52,33 +47,9 @@ export const globalCommandGroup = createGroup<Command>({
}, },
useOptions() { useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession(); const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [ const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
name:
colorScheme === "auto"
? tOption("colorScheme.auto")
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{ {
commandKey: "newBoard", commandKey: "newBoard",
icon: IconCategoryPlus, icon: IconCategoryPlus,
@@ -1,9 +1,10 @@
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { contextSpecificActionsSearchGroups } from "./context-specific-group"; import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { globalCommandGroup } from "./global-group"; import { globalCommandGroup } from "./global-group";
import { preferencesGroup } from "../preferences/groups";
export const commandMode = { export const commandMode = {
modeKey: "command", modeKey: "command",
character: ">", character: ">",
groups: [contextSpecificActionsSearchGroups, globalCommandGroup], groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
} satisfies SearchMode; } satisfies SearchMode;
@@ -64,6 +64,36 @@ type MediaRequestChildrenProps = {
kind: IntegrationKind; kind: IntegrationKind;
url: string; url: string;
id: 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, searchEngine: SearchEngine,
searchResult: FromIntegrationSearchResult, searchResult: FromIntegrationSearchResult,
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
const { openSearchInNewTab } = useSettings();
if (searchEngine.type !== "fromIntegration") { if (searchEngine.type !== "fromIntegration") {
throw new Error("Invalid search engine type"); throw new Error("Invalid search engine type");
} }
@@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = (
) && ) &&
"type" in searchResult "type" in searchResult
) { ) {
const type = searchResult.type; return useMediaRequestSearchInteraction(searchEngine.integration, searchResult);
if (type === "person") {
return {
type: "link",
href: searchResult.link,
newTab: openSearchInNewTab,
};
}
return {
type: "children",
...mediaRequestsChildrenOptions({
result: {
...searchResult,
type,
},
integration: searchEngine.integration,
}),
};
} }
return { return {
@@ -115,13 +125,15 @@ export const useFromIntegrationSearchInteraction = (
}; };
}; };
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
useActions() { useActions() {
const { openModal } = useModalAction(RequestMediaModal); const { openModal } = useModalAction(RequestMediaModal);
return [ return [
{ {
key: "request", key: "request",
hide: (option) => option.result.inLibrary, hide: (option) =>
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) { Component(option) {
const t = useScopedI18n("search.mode.media"); const t = useScopedI18n("search.mode.media");
return ( return (
@@ -19,12 +19,13 @@ import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board"; import { appIntegrationBoardMode } from "../app-integration-board";
import { commandMode } from "../command"; import { commandMode } from "../command";
import { externalMode } from "../external"; import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page"; import { pageMode } from "../page";
import { preferencesGroup } from "../preferences/groups";
import { userGroupMode } from "../user-group"; import { userGroupMode } from "../user-group";
// We need type to allow it as generic type for Record<string, unknown>
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type QuickLinkOption = { type QuickLinkOption = {
key: string;
icon: TablerIcon; icon: TablerIcon;
name: string; name: string;
path: string; path: string;
@@ -33,7 +34,7 @@ type QuickLinkOption = {
export const useHomeEmptyGroups = () => { export const useHomeEmptyGroups = () => {
const { data: session } = useSession(); const { data: session } = useSession();
const tPages = useScopedI18n("search.mode.page.group.page.option"); 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")) { if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode); visibleSearchModes.unshift(userGroupMode);
@@ -41,19 +42,13 @@ export const useHomeEmptyGroups = () => {
return [ return [
createGroup({ createGroup({
keyPath: "path", keyPath: "key",
title: () => "Quick links", title: () => "Quick links",
options: (() => { options: (() => {
const quickLinks: QuickLinkOption[] = []; const quickLinks: QuickLinkOption[] = [];
if (session?.user) {
quickLinks.push({
icon: IconSettings,
path: `/manage/users/${session.user.id}/general`,
name: tPages("preferences.label"),
});
}
quickLinks.push({ quickLinks.push({
key: "manageBoards",
icon: IconLayoutDashboard, icon: IconLayoutDashboard,
path: "/manage/boards", path: "/manage/boards",
name: tPages("manageBoard.label"), name: tPages("manageBoard.label"),
@@ -61,6 +56,7 @@ export const useHomeEmptyGroups = () => {
if (session?.user.permissions.includes("admin")) { if (session?.user.permissions.includes("admin")) {
quickLinks.push({ quickLinks.push({
key: "manageSettings",
icon: IconSettings, icon: IconSettings,
path: "/manage/settings", path: "/manage/settings",
name: tPages("manageSettings.label"), name: tPages("manageSettings.label"),
@@ -69,6 +65,7 @@ export const useHomeEmptyGroups = () => {
if (session) { if (session) {
quickLinks.push({ quickLinks.push({
key: "manageSearchEngines",
icon: IconSearch, icon: IconSearch,
path: "/manage/search-engines", path: "/manage/search-engines",
name: tPages("manageSearchEngine.label"), name: tPages("manageSearchEngine.label"),
@@ -87,7 +84,7 @@ export const useHomeEmptyGroups = () => {
useInteraction: interaction.link(({ path }) => ({ href: path })), useInteraction: interaction.link(({ path }) => ({ href: path })),
}), }),
createGroup({ createGroup({
keyPath: "character", keyPath: "modeKey",
title: (t) => t("search.mode.help.group.mode.title"), title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => { Component: ({ modeKey, character }) => {
@@ -96,7 +93,7 @@ export const useHomeEmptyGroups = () => {
return ( return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between"> <Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text> <Text>{t("help")}</Text>
<Kbd size="sm">{character}</Kbd> {character && <Kbd size="sm">{character}</Kbd>}
</Group> </Group>
); );
}, },
@@ -138,3 +135,5 @@ export const useHomeEmptyGroups = () => {
}), }),
] as const; ] as const;
}; };
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
@@ -20,6 +20,7 @@ type GroupItem = {
name: string; name: string;
description?: string; description?: string;
icon: TablerIcon | string; icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
}; };
@@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
); );
return ( 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} {icon}
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
@@ -91,9 +92,7 @@ const createDefaultSearchEntries = (
query: string, query: string,
t: TranslationFunction, t: TranslationFunction,
): GroupItem[] => { ): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) { if (!session?.user && !defaultSearchEngine) return [];
return [];
}
if (!defaultSearchEngine) { if (!defaultSearchEngine) {
return [ return [
+2 -1
View File
@@ -1,9 +1,10 @@
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { preferencesGroup } from "../preferences/groups";
import { contextSpecificSearchGroups } from "./context-specific-group"; import { contextSpecificSearchGroups } from "./context-specific-group";
import { homeSearchEngineGroup } from "./home-search-engine-group"; import { homeSearchEngineGroup } from "./home-search-engine-group";
export const homeMode = { export const homeMode = {
character: undefined, character: undefined,
modeKey: "home", modeKey: "home",
groups: [homeSearchEngineGroup, contextSpecificSearchGroups], groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
} satisfies SearchMode; } satisfies SearchMode;
+2
View File
@@ -2,6 +2,7 @@ import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home"; import { homeMode } from "./home";
import { mediaMode } from "./media";
import { pageMode } from "./page"; import { pageMode } from "./page";
import { userGroupMode } from "./user-group"; import { userGroupMode } from "./user-group";
@@ -11,5 +12,6 @@ export const searchModes = [
externalMode, externalMode,
commandMode, commandMode,
pageMode, pageMode,
mediaMode,
homeMode, homeMode,
] as const; ] 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,
})),
};
},
});
@@ -0,0 +1,72 @@
import { Group, Text } from "@mantine/core";
import { IconSettings } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { colorSchemes } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string };
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
export const preferencesGroup = createGroup<PreferencesGroupOption>({
keyPath: "key",
title: (t) => t("search.mode.command.group.preferences.title"),
useOptions(query) {
const t = useI18n();
const tColorScheme = useScopedI18n("common.colorScheme.options");
const { data: session } = useSession();
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
const q = query.trim().toLowerCase();
if (q.length === 0) return [settingsOption];
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
if (!preferenceChildrenOptionsByKey[def.key]) return [];
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const searchValues = [
label,
...def.aliases,
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
];
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
return [{ key: def.key, preferenceKey: def.key }];
});
if (matching.length > 0) return matching;
const allSearchValues = [
t("search.mode.command.group.preferences.title"),
...definitions.flatMap((d) => d.aliases),
];
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
},
Component: (option) => {
const t = useScopedI18n("search.mode.command.group.preferences");
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
const Icon = option.preferenceKey
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
: IconSettings;
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
return (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} />
<Text>{label}</Text>
</Group>
);
},
filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
settingsChildrenOptions({}))
: settingsChildrenOptions({})),
}),
});
@@ -0,0 +1,136 @@
"use client";
import {
IconActivity,
IconCalendarWeek,
IconDeviceDesktop,
IconExternalLink,
IconHome,
IconLanguage,
IconLayoutBoard,
IconSearch,
IconSettings,
IconWorldWww,
} from "@tabler/icons-react";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../lib/children";
import {
createCheckmarkPreferenceAction,
createLoadingPreferenceAction,
PreferenceDetailHeader,
} from "../modes/command/children/preferences/action-row";
import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions";
import { languageChildrenOptions } from "../modes/command/children/language";
import { useUserPreference } from "./use-user-preference";
dayjs.extend(localeData);
type ChildrenFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
colorScheme: IconDeviceDesktop,
locale: IconLanguage,
defaultSearchEngineId: IconSearch,
openSearchInNewTab: IconExternalLink,
ddgBangs: IconWorldWww,
firstDayOfWeek: IconCalendarWeek,
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
fullPreferencesPage: IconSettings,
};
const createStaticChildren = (
preferenceKey: UserPreferenceKey,
useOptions: () => Array<{ key: string; label: string; value: unknown }>,
): ChildrenFactory =>
createChildrenOptions<Record<string, unknown>>({
useActions: () => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
return useOptions().map((opt) =>
createCheckmarkPreferenceAction({
key: opt.key,
label: opt.label,
isSelected: value === opt.value,
onSelect: () => setValue(opt.value as never),
isPending,
}),
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
});
const createAsyncSelectableChildren = <TItem,>(
preferenceKey: UserPreferenceKey,
useQuery: () => { data: TItem[] | undefined; isLoading: boolean },
mapItem: (item: TItem) => { key: string; label: string; value: string },
noneKey: string,
unavailableKey: string,
): ChildrenFactory =>
createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
const result = useQuery();
const t = useScopedI18n("search.mode.command.group.preferences.option");
if (result.isLoading) return [createLoadingPreferenceAction()];
return createSelectablePreferenceActions({
query,
currentValue: value as string | null,
noneLabel: t(noneKey as never),
unavailableLabel: t(unavailableKey as never),
items: (result.data ?? []).map(mapItem),
onSelect: (v) => setValue(v as never),
isPending,
});
},
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
});
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
colorScheme: createStaticChildren("colorScheme", () => {
const t = useScopedI18n("common.colorScheme.options");
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
}),
locale: languageChildrenOptions,
defaultSearchEngineId: createAsyncSelectableChildren(
"defaultSearchEngineId",
() => clientApi.searchEngine.getSelectable.useQuery(),
(e) => ({ key: e.value, label: e.label, value: e.value }),
"searchEngine.none",
"searchEngine.unavailable",
),
firstDayOfWeek: createStaticChildren("firstDayOfWeek", () =>
firstDayOfWeekOptions.map((day) => ({
key: day.toString(),
label: dayjs.weekdays(false)[day] ?? day.toString(),
value: day,
})),
),
homeBoardId: createAsyncSelectableChildren(
"homeBoardId",
() => clientApi.board.getAllBoards.useQuery(),
(b) => ({ key: b.id, label: b.name, value: b.id }),
"board.none",
"board.unavailable",
),
mobileHomeBoardId: createAsyncSelectableChildren(
"mobileHomeBoardId",
() => clientApi.board.getAllBoards.useQuery(),
(b) => ({ key: b.id, label: b.name, value: b.id }),
"board.none",
"board.unavailable",
),
};
@@ -0,0 +1,204 @@
"use client";
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { useMantineColorScheme } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { userPreferenceDefinitions, userPreferenceDefinitionByKey } from "@homarr/settings";
import type { UserPreferenceKey } from "@homarr/settings";
import { useSettings } from "@homarr/settings";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
// --- Optimistic store (module-level, shared across all hook instances) ---
const optimisticRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
let optimisticVersion = 0;
const optimisticListeners = new Set<() => void>();
const notifyListeners = () => {
optimisticVersion += 1;
optimisticListeners.forEach((fn) => fn());
};
const subscribeOptimistic = (listener: () => void) => {
optimisticListeners.add(listener);
return () => optimisticListeners.delete(listener);
};
const setOptimistic = (key: UserPreferenceKey, value: unknown) => {
optimisticRef.current[key] = value;
notifyListeners();
};
const clearOptimistic = (keys: readonly UserPreferenceKey[]) => {
let changed = false;
for (const key of keys) {
changed ||= key in optimisticRef.current;
delete optimisticRef.current[key];
}
if (changed) notifyListeners();
};
const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]);
const isDbBacked = (key: UserPreferenceKey) => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
return group !== null && !CLIENT_ONLY_GROUPS.has(group);
};
const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group) return [key];
return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key);
};
// --- Hook ---
export const useUserPreferences = () => {
const router = useRouter();
const settings = useSettings();
const settingsRef = useRef(settings);
settingsRef.current = settings;
const { data: session } = useSession();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const currentLocale = useCurrentLocale();
const { changeLocale } = useChangeLocale();
const t = useI18n();
const userId = session?.user.id;
const pendingRef = useRef<Record<string, boolean>>({});
const prevUserIdRef = useRef(userId);
useEffect(() => {
if (prevUserIdRef.current === userId) return;
prevUserIdRef.current = userId;
clearOptimistic(userPreferenceDefinitions.map((d) => d.key));
}, [userId]);
useSyncExternalStore(
subscribeOptimistic,
() => optimisticVersion,
() => optimisticVersion,
);
const refresh = () => router.refresh();
const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh });
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) =>
field === changedKey ? value : getEffective(field);
const groupDispatchers: Record<string, (uid: string, key: UserPreferenceKey, value: unknown) => Promise<unknown>> = {
searchPreferences: (uid, key, value) =>
searchMutation.mutateAsync({
userId: uid,
defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null,
openInNewTab: pick("openSearchInNewTab", key, value) as boolean,
ddgBangsEnabled: pick("ddgBangs", key, value) as boolean,
}),
homeBoards: (uid, key, value) =>
homeBoardsMutation.mutateAsync({
userId: uid,
homeBoardId: pick("homeBoardId", key, value) as string | null,
mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null,
}),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
};
const groupPending: Record<string, boolean> = {
searchPreferences: searchMutation.isPending,
homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending,
};
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
const values: Record<string, unknown> = {
colorScheme: colorScheme as ColorScheme,
locale: currentLocale,
fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null,
};
for (const def of userPreferenceDefinitions) {
if (def.key in values) continue;
values[def.key] = settings[def.key as keyof SettingsContextProps];
}
return values as Record<UserPreferenceKey, unknown>;
}, [colorScheme, currentLocale, userId, settings]);
useEffect(() => {
const matchedKeys = Object.entries(persistedValues)
.filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value)
.map(([key]) => key as UserPreferenceKey);
clearOptimistic(matchedKeys);
}, [persistedValues]);
const getValue = (key: UserPreferenceKey) =>
key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key];
const isPreferencePending = (key: UserPreferenceKey): boolean => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group || CLIENT_ONLY_GROUPS.has(group)) return false;
return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false);
};
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
colorScheme: (value) => setColorScheme(value as ColorScheme),
locale: (value) => changeLocale(value as typeof currentLocale),
fullPreferencesPage: () => undefined,
};
const setValueAsync = async (key: UserPreferenceKey, value: unknown) => {
if (isDbBacked(key) && !userId) return;
if (isPreferencePending(key)) return;
const clientSetter = clientSetters[key];
if (clientSetter) {
clientSetter(value);
return;
}
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group) return;
const dispatcher = groupDispatchers[group];
if (!dispatcher || !userId) return;
setOptimistic(key, value);
pendingRef.current[group] = true;
try {
await dispatcher(userId, key, value);
} catch {
clearOptimistic(getGroupKeys(key));
showErrorNotification({
message: t("search.mode.command.group.preferences.notification.error.message"),
});
} finally {
pendingRef.current[group] = false;
}
};
return {
getPreference(preferenceKey: UserPreferenceKey) {
return {
value: getValue(preferenceKey),
setValue: (value: never) => setValueAsync(preferenceKey, value),
isPending: isPreferencePending(preferenceKey),
};
},
};
};
export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key);
@@ -3,9 +3,17 @@
import { clamp } from "@mantine/hooks"; import { clamp } from "@mantine/hooks";
import type { SpotlightStore } from "@mantine/spotlight"; import type { SpotlightStore } from "@mantine/spotlight";
import { createSpotlight } from "@mantine/spotlight"; import { createSpotlight } from "@mantine/spotlight";
import { atom } from "jotai";
export const [spotlightStore, spotlightActions] = createSpotlight(); 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) => { export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index })); store.updateState((state) => ({ ...state, selected: index }));
}; };
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Contrasenya", "label": "Contrasenya",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inclou com a mínim 8 caràcters", "length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules", "lowercase": "Inclou minúscules",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "密码", "label": "密码",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "至少包含 8 个字符", "length": "至少包含 8 个字符",
"lowercase": "包含小写字母", "lowercase": "包含小写字母",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "在新标签页中打开" "label": "在新标签页中打开"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "书签", "label": "书签",
"add": "添加书签" "add": "添加书签"
+9 -5
View File
@@ -132,12 +132,13 @@
}, },
"password": { "password": {
"label": "crwdns26:0crwdne26:0", "label": "crwdns26:0crwdne26:0",
"suggestionsTitle": "crwdns4261:0crwdne4261:0",
"suggestion": { "suggestion": {
"length": "crwdns28:0crwdne28:0", "length": "crwdns4263:0crwdne4263:0",
"lowercase": "crwdns30:0crwdne30:0", "lowercase": "crwdns4265:0crwdne4265:0",
"uppercase": "crwdns32:0crwdne32:0", "uppercase": "crwdns4267:0crwdne4267:0",
"number": "crwdns34:0crwdne34:0", "number": "crwdns4269:0crwdne4269:0",
"special": "crwdns36:0crwdne36:0" "special": "crwdns4271:0crwdne4271:0"
} }
}, },
"passwordConfirm": { "passwordConfirm": {
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "crwdns2686:0crwdne2686:0" "label": "crwdns2686:0crwdne2686:0"
}, },
"withBorder": {
"label": "crwdns4273:0crwdne4273:0"
},
"items": { "items": {
"label": "crwdns764:0crwdne764:0", "label": "crwdns764:0crwdne764:0",
"add": "crwdns766:0crwdne766:0" "add": "crwdns766:0crwdne766:0"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Heslo", "label": "Heslo",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Obsahuje alespoň 8 znaků", "length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno", "lowercase": "Obsahuje malé písmeno",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Adgangskode", "label": "Adgangskode",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inkluderer mindst 8 tegn", "length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver", "lowercase": "Inkluderer små bogstaver",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Åbn i nyt faneblad" "label": "Åbn i nyt faneblad"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bogmærker", "label": "Bogmærker",
"add": "Tilføj bogmærke" "add": "Tilføj bogmærke"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Enthält mindestens 8 Zeichen", "length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben", "lowercase": "Enthält Kleinbuchstaben",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "In neuem Tab öffnen" "label": "In neuem Tab öffnen"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Lesezeichen", "label": "Lesezeichen",
"add": "Lesezeichen hinzufügen" "add": "Lesezeichen hinzufügen"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Enthält mindestens 8 Zeichen", "length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben", "lowercase": "Enthält Kleinbuchstaben",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "In neuem Tab öffnen" "label": "In neuem Tab öffnen"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Lesezeichen", "label": "Lesezeichen",
"add": "Lesezeichen hinzufügen" "add": "Lesezeichen hinzufügen"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Κωδικός", "label": "Κωδικός",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα", "lowercase": "Περιλαμβάνει πεζό γράμμα",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+24 -2
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Includes a minimum of 8 characters", "length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter", "lowercase": "Includes a lower-case letter",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Open in new tab" "label": "Open in new tab"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bookmarks", "label": "Bookmarks",
"add": "Add bookmark" "add": "Add bookmark"
@@ -4551,9 +4555,23 @@
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "", "requestMovie": "",
"requestSeries": "", "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": { "external": {
"help": "", "help": "",
@@ -4829,7 +4847,11 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes" "episodes": "Episodes",
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+141 -2
View File
@@ -1303,6 +1303,13 @@
}, },
"edit": { "edit": {
"title": "Edit item", "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": { "advancedOptions": {
"label": "Advanced options", "label": "Advanced options",
"title": "Advanced item options" "title": "Advanced item options"
@@ -4551,13 +4558,141 @@
"label": "Create a new group" "label": "Create a new group"
} }
} }
},
"preferences": {
"title": "Preferences",
"children": {
"detail": {
"title": "Select a preference to change",
"backHint": "Press Backspace or use the back button to go back",
"backAction": "Go back"
}
},
"notification": {
"error": {
"message": "Unable to update preference"
}
},
"option": {
"preferences": {
"label": "Open preferences"
},
"boolean": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"board": {
"none": "None",
"unavailable": "Currently selected (unavailable)"
},
"searchEngine": {
"none": "None",
"unavailable": "Currently selected (unavailable)"
},
"colorScheme": {
"label": "Color scheme",
"children": {
"detail": {
"title": "Select your color scheme"
}
}
},
"locale": {
"label": "Language",
"children": {
"detail": {
"title": "Select your preferred language"
}
}
},
"defaultSearchEngineId": {
"label": "Default search engine",
"children": {
"detail": {
"title": "Select your default search engine"
}
}
},
"openSearchInNewTab": {
"label": "Open search results in new tab",
"children": {
"detail": {
"title": "Open search results in new tab"
}
}
},
"ddgBangs": {
"label": "Enable DuckDuckGo bangs",
"children": {
"detail": {
"title": "Enable DuckDuckGo bangs"
}
}
},
"firstDayOfWeek": {
"label": "First day of the week",
"children": {
"detail": {
"title": "Select the first day of the week"
}
}
},
"homeBoardId": {
"label": "Home board",
"children": {
"detail": {
"title": "Select your home board"
}
}
},
"mobileHomeBoardId": {
"label": "Mobile home board",
"children": {
"detail": {
"title": "Select your mobile home board"
}
}
},
"pingIconsEnabled": {
"label": "Use icons for pings",
"children": {
"detail": {
"title": "Use icons for pings"
}
}
},
"fullPreferencesPage": {
"label": "Open full preferences page"
}
}
} }
} }
}, },
"media": { "media": {
"help": "Search for movies or shows to request",
"requestMovie": "Request movie", "requestMovie": "Request movie",
"requestSeries": "Request series", "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": { "external": {
"help": "Use an external search engine", "help": "Use an external search engine",
@@ -4833,7 +4968,11 @@
"table": { "table": {
"header": { "header": {
"season": "Season", "season": "Season",
"episodes": "Episodes" "episodes": "Episodes",
"status": "Status"
},
"status": {
"requested": "Requested"
} }
}, },
"button": { "button": {
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Contraseña", "label": "Contraseña",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Incluye al menos 8 caracteres", "length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula", "lowercase": "Incluye al menos una letra minúscula",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Abrir en una nueva pestaña" "label": "Abrir en una nueva pestaña"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Marcadores", "label": "Marcadores",
"add": "Añadir marcador" "add": "Añadir marcador"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "", "label": "",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+5 -1
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Mot de passe", "label": "Mot de passe",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Contient au moins 8 caractères", "length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule", "lowercase": "Inclut une lettre minuscule",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Ouvrir dans un nouvel onglet" "label": "Ouvrir dans un nouvel onglet"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Signets", "label": "Signets",
"add": "Ajouter un signet" "add": "Ajouter un signet"
@@ -2088,7 +2092,7 @@
"empty": "", "empty": "",
"user": "Utilisateur", "user": "Utilisateur",
"media": "Média", "media": "Média",
"state": "", "state": "État",
"quality": "Qualité", "quality": "Qualité",
"device": "Appareil", "device": "Appareil",
"progress": "Progression", "progress": "Progression",
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "סיסמה", "label": "סיסמה",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "כולל לפחות 8 תווים", "length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות", "lowercase": "אפשר אותיות קטנות",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "פתיחה בכרטיסיה חדשה" "label": "פתיחה בכרטיסיה חדשה"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "סימניות", "label": "סימניות",
"add": "הוסף סימניה" "add": "הוסף סימניה"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Lozinka", "label": "Lozinka",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Uključuje mala slova", "lowercase": "Uključuje mala slova",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Jelszó", "label": "Jelszó",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Tartalmazzon legalább 8 karaktert", "length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt", "lowercase": "Tartalmazzon kisbetűt",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Megnyitás új lapon" "label": "Megnyitás új lapon"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Könyjelzők", "label": "Könyjelzők",
"add": "Könyvjelző hozzáadása" "add": "Könyvjelző hozzáadása"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestionsTitle": "Suggerimenti password",
"suggestion": { "suggestion": {
"length": "Include almeno 8 caratteri", "length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola", "lowercase": "Include lettera minuscola",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Apri in una nuova scheda" "label": "Apri in una nuova scheda"
}, },
"withBorder": {
"label": "Mostra bordo"
},
"items": { "items": {
"label": "Segnalibri", "label": "Segnalibri",
"add": "Aggiungi segnalibro" "add": "Aggiungi segnalibro"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "パスワード", "label": "パスワード",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "少なくとも 8 文字以上を含む", "length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む", "lowercase": "小文字を含む",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "新しいタブで開く" "label": "新しいタブで開く"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "ブックマーク", "label": "ブックマーク",
"add": "ブックマークに追加" "add": "ブックマークに追加"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "비밀번호", "label": "비밀번호",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "소문자 포함", "lowercase": "소문자 포함",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Slaptažodis", "label": "Slaptažodis",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Parole", "label": "Parole",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Ietver mazo burtu", "lowercase": "Ietver mazo burtu",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Wachtwoord", "label": "Wachtwoord",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Bevat ten minste 8 tekens", "length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter", "lowercase": "Inclusief kleine letter",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "In nieuw tabblad openen" "label": "In nieuw tabblad openen"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bladwijzers", "label": "Bladwijzers",
"add": "Bladwijzer toevoegen" "add": "Bladwijzer toevoegen"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Passord", "label": "Passord",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inkluderer minst 8 tegn", "length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav", "lowercase": "Inkluderer liten bokstav",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Åpne i ny fane" "label": "Åpne i ny fane"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bokmerke", "label": "Bokmerke",
"add": "Legg til bokmerke" "add": "Legg til bokmerke"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Hasło", "label": "Hasło",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Zawiera co najmniej 8 znaków", "length": "Zawiera co najmniej 8 znaków",
"lowercase": "Zawiera małą literę", "lowercase": "Zawiera małą literę",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Otwórz w nowej karcie" "label": "Otwórz w nowej karcie"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Zakładki", "label": "Zakładki",
"add": "Dodaj zakładkę" "add": "Dodaj zakładkę"
+9 -5
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Senha", "label": "Senha",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Inclua pelo menos 8 caracteres", "length": "Inclua pelo menos 8 caracteres",
"lowercase": "Inclui letras minúsculas", "lowercase": "Inclui letras minúsculas",
@@ -1056,7 +1057,7 @@
} }
}, },
"noResults": { "noResults": {
"title": "" "title": "Nenhuma mídia enviada"
} }
}, },
"common": { "common": {
@@ -1088,8 +1089,8 @@
"checkLogs": "Verifique os logs para mais detalhes", "checkLogs": "Verifique os logs para mais detalhes",
"tryAgain": "Tente novamente", "tryAgain": "Tente novamente",
"loading": "Carregando", "loading": "Carregando",
"show": "", "show": "Mostrar",
"hide": "" "hide": "Esconder"
}, },
"here": "aqui", "here": "aqui",
"iconPicker": { "iconPicker": {
@@ -1255,7 +1256,7 @@
"label": { "label": {
"create": "Nova categoria", "create": "Nova categoria",
"changePosition": "Mudar de posição", "changePosition": "Mudar de posição",
"settings": "" "settings": "Configurações"
} }
}, },
"openAllInNewTabs": { "openAllInNewTabs": {
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Abrir em nova guia" "label": "Abrir em nova guia"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Marcadores", "label": "Marcadores",
"add": "Adicionar marcador" "add": "Adicionar marcador"
@@ -3459,7 +3463,7 @@
"private": "Este quadro é privado" "private": "Este quadro é privado"
}, },
"noResults": { "noResults": {
"title": "" "title": "Nenhum painel ainda"
}, },
"modal": { "modal": {
"createBoard": { "createBoard": {
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "", "label": "",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "", "lowercase": "",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Parola", "label": "Parola",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Să includă litere mici", "lowercase": "Să includă litere mici",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Содержит не менее 8 символов", "length": "Содержит не менее 8 символов",
"lowercase": "Содержит строчную букву", "lowercase": "Содержит строчную букву",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Открывать в новой вкладке" "label": "Открывать в новой вкладке"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Закладки", "label": "Закладки",
"add": "Добавить закладку" "add": "Добавить закладку"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Heslo", "label": "Heslo",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Obsahuje aspoň 8 znakov", "length": "Obsahuje aspoň 8 znakov",
"lowercase": "Zahŕňa malé písmeno", "lowercase": "Zahŕňa malé písmeno",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Otvoriť na novej karte" "label": "Otvoriť na novej karte"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Záložky", "label": "Záložky",
"add": "Pridať záložku" "add": "Pridať záložku"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Geslo", "label": "Geslo",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "", "length": "",
"lowercase": "Vključuje male črke", "lowercase": "Vključuje male črke",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Lösenord", "label": "Lösenord",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Använd minst 8 tecken", "length": "Använd minst 8 tecken",
"lowercase": "Använd liten bokstav", "lowercase": "Använd liten bokstav",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Öppna i en ny flik" "label": "Öppna i en ny flik"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Bokmärken", "label": "Bokmärken",
"add": "Addera bokmärke" "add": "Addera bokmärke"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Şifre", "label": "Şifre",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "En az 8 karakter içermeli", "length": "En az 8 karakter içermeli",
"lowercase": "Küçük harf içermeli", "lowercase": "Küçük harf içermeli",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Yeni sekmede aç" "label": "Yeni sekmede aç"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Yer İmleri", "label": "Yer İmleri",
"add": "Yer imi ekle" "add": "Yer imi ekle"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Включає принаймні 8 символів", "length": "Включає принаймні 8 символів",
"lowercase": "Включає малу літеру", "lowercase": "Включає малу літеру",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "Відкрити в новій вкладці" "label": "Відкрити в новій вкладці"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "Закладки", "label": "Закладки",
"add": "Додати закладку" "add": "Додати закладку"
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "Mật khẩu", "label": "Mật khẩu",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Bao gồm ít nhất 8 kí tự", "length": "Bao gồm ít nhất 8 kí tự",
"lowercase": "Bao gồm chữ thường", "lowercase": "Bao gồm chữ thường",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "" "label": ""
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "", "label": "",
"add": "" "add": ""
+4
View File
@@ -132,6 +132,7 @@
}, },
"password": { "password": {
"label": "密碼", "label": "密碼",
"suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "至少包含 8 個字符", "length": "至少包含 8 個字符",
"lowercase": "包含小寫字母", "lowercase": "包含小寫字母",
@@ -1405,6 +1406,9 @@
"openNewTab": { "openNewTab": {
"label": "於新分頁開啟" "label": "於新分頁開啟"
}, },
"withBorder": {
"label": ""
},
"items": { "items": {
"label": "書籤", "label": "書籤",
"add": "新增書籤" "add": "新增書籤"
@@ -1,17 +1,32 @@
"use client"; "use client";
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import {
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; 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 type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { MediaRequestStatus } from "@homarr/integrations/types"; import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
@@ -43,7 +58,7 @@ export default function MediaServerWidget({
const newData = filteredData.concat( const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
); );
return newData.sort((dataA, dataB) => { return newData.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) { if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime(); return dataB.createdAt.getTime() - dataA.createdAt.getTime();
} }
@@ -61,25 +76,46 @@ export default function MediaServerWidget({
if (mediaRequests.length === 0) throw new NoIntegrationDataError(); if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return ( return (
<ScrollArea <Box className={classes.searchRoot}>
className="mediaRequests-list-scrollArea" {!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
scrollbarSize="md" <ScrollArea
style={{ pointerEvents: isEditMode ? "none" : undefined }} className="mediaRequests-list-scrollArea"
> scrollbarSize="md"
<Stack className="mediaRequests-list-list" gap="xs" p="sm"> style={{ pointerEvents: isEditMode ? "none" : undefined }}
{mediaRequests.map((mediaRequest) => ( >
<MediaRequestCard <Stack className="mediaRequests-list-list" gap="xs" p="sm">
key={`${mediaRequest.integrationId}-${mediaRequest.id}`} {mediaRequests.map((mediaRequest) => (
request={mediaRequest} <MediaRequestCard
isTiny={width <= 256} key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
options={options} request={mediaRequest}
/> isTiny={width <= 256}
))} options={options}
</Stack> />
</ScrollArea> ))}
</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 { interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean; 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"; "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 type { Icon } from "@tabler/icons-react";
import { import {
IconDeviceTv, IconDeviceTv,
@@ -9,6 +9,7 @@ import {
IconMovie, IconMovie,
IconPlayerPlay, IconPlayerPlay,
IconReceipt, IconReceipt,
IconSearch,
IconThumbDown, IconThumbDown,
IconThumbUp, IconThumbUp,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -17,11 +18,13 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types"; import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
const OVERSEERR_COLOR = "#ECB000"; const OVERSEERR_COLOR = "#ECB000";
const JELLYSEERR_COLOR = "#6677CC"; const JELLYSEERR_COLOR = "#6677CC";
@@ -93,83 +96,104 @@ export default function MediaServerWidget({
const isTiny = width < 256; const isTiny = width < 256;
return ( return (
<Stack <Box className={searchClasses.searchRoot}>
className="mediaRequests-stats-layout" {!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
h="100%" <Stack
gap="xs" className="mediaRequests-stats-layout"
p="sm" h="100%"
align="center" gap="xs"
justify="space-between" p="sm"
style={{ pointerEvents: isEditMode ? "none" : undefined }} align="center"
> justify="space-between"
<Stack gap={4} w="100%"> style={{ pointerEvents: isEditMode ? "none" : undefined }}
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}> >
{t("titles.stats.main")} <Stack gap={4} w="100%">
</Text> <Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%"> {t("titles.stats.main")}
{data.map((stat) => ( </Text>
<Grid.Col <Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)} {data.map((stat) => (
key={stat.name} <Grid.Col
span={isTiny ? 6 : 3} className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
> key={stat.name}
<Tooltip label={t(`titles.stats.${stat.name}`)}> span={isTiny ? 6 : 3}
<Card p={0} radius={board.itemRadius} className={classes.card}> >
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%"> <Tooltip label={t(`titles.stats.${stat.name}`)}>
<stat.icon className="mediaRequests-stats-stat-icon" size={16} /> <Card p={0} radius={board.itemRadius} className={classes.card}>
<Text className="mediaRequests-stats-stat-value" size="md"> <Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
{stat.number} <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> </Text>
</Group> </Group>
</Card>
</Tooltip> <Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
</Grid.Col> {user.requestCount}
))}
</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> </Text>
</Group> </Group>
</Card>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}> ))}
{user.requestCount} </Stack>
</Text>
</Group>
</Card>
))}
</Stack> </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"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { Button, Group, Stack } from "@mantine/core"; import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs } from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals"; import { createModal, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -20,6 +22,8 @@ import type { OptionsBuilderResult } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select"; import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; 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 { export interface WidgetEditModalState {
options: Record<string, unknown>; options: Record<string, unknown>;
@@ -34,13 +38,16 @@ interface ModalProps<TSort extends WidgetKind> {
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
integrationSupport: boolean; integrationSupport: boolean;
settings: SettingsContextProps; settings: SettingsContextProps;
appId?: string;
} }
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => { export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions); const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Translate the error messages
z.config({ z.config({
customError: zodErrorMap(t), customError: zodErrorMap(t),
}); });
@@ -77,78 +84,128 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
}); });
const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
return ( const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
<form const appId = innerProps.appId;
onSubmit={form.onSubmit((values) => { const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
innerProps.onSuccessfulEdit({
...values, const handleSubmit = form.onSubmit(async (values) => {
advancedOptions, setIsSubmitting(true);
}); try {
actions.closeModal(); 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}
/>
);
})} })}
> <Group justify="space-between">
<FormProvider form={form}> <Button
<Stack> variant="subtle"
{innerProps.integrationSupport && ( onClick={() =>
<WidgetIntegrationSelect openModal({
label={t("item.edit.field.integrations.label")} advancedOptions,
data={innerProps.integrationData} onSuccess(options) {
{...form.getInputProps("integrationIds")} setAdvancedOptions(options);
/> innerProps.onSuccessfulEdit({
)} ...innerProps.value,
{Object.entries(options).map(([key, value]) => { advancedOptions: options,
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 {t("item.edit.advancedOptions.label")}
value.shouldHide?.( </Button>
form.values.options as never, {!showAppTab && (
innerProps.integrationData <Group justify="end" w={{ base: "100%", xs: "auto" }}>
.filter(({ id }) => form.values.integrationIds.includes(id)) <Button onClick={actions.closeModal} variant="subtle" color="gray">
.map(({ kind }) => kind), {t("common.action.cancel")}
)
) {
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")}
</Button> </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"> <Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")} {t("common.action.cancel")}
</Button> </Button>
<Button type="submit">{t("common.action.saveChanges")}</Button> <Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group> </Group>
</Group> </Stack>
</Stack> ) : (
widgetFormContent
)}
</FormProvider> </FormProvider>
</form> </form>
); );
+3
View File
@@ -2144,6 +2144,9 @@ importers:
'@homarr/modals-collection': '@homarr/modals-collection':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../modals-collection version: link:../modals-collection
'@homarr/notifications':
specifier: workspace:^0.1.0
version: link:../notifications
'@homarr/settings': '@homarr/settings':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../settings version: link:../settings