Compare commits

...
Author SHA1 Message Date
Thomas 4782e24b79 Merge branch 'dev' into feat/add-demo-integrations 2026-06-05 16:27:22 +02:00
ThomasandMeier Lukas 25d964a197 refactor(spotlight): registry-driven CMDK preferences (#5817)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 16:26:48 +02:00
Thomas 3eb3908d4c Merge branch 'dev' into feat/add-demo-integrations 2026-06-05 16:24:34 +02:00
Thomas 3d3e3059a9 feat: media request search as first-class spotlight mode (#5823) 2026-06-05 16:09:14 +02:00
Thomas Camlong d6f14de6d1 docs: add live demo link to README
Link to demo.homarr.dev in the top navigation links.
2026-06-05 15:39:39 +02:00
Thomas Camlong 0ad7b79187 fix: address PR review feedback on mock services
- Restore `await Promise.resolve()` in all async mock methods to satisfy
  require-await lint rule (download, calendar, media-releases, media-server,
  network-controller-summary)
- Fix Docker mock container ports to include required `IP` field and use
  proper `Port` type from dockerode
- Add app widgets for bookmarked apps to the demo dashboard
- Reposition bookmarks widget (6 wide, after Proxmox) and allow edit mode
  with discard-on-exit in demo mode
2026-06-05 15:38:27 +02:00
Thomas Camlong 35bc50c12a feat: add comprehensive demo mode with mock integrations and read-only UI
Introduce a DEMO_MODE env var that seeds a demo user with a fully populated
board (all widget types linked to a mock integration), blocks all mutations
server-side, shows credentials on the login page, and allows entering edit
mode without persisting changes. Fixes mock data issues (media server crash
from invalid type, calendar empty states, sparse network/media data) and
adds Docker container mocking for a complete out-of-the-box demo experience.
2026-06-05 15:15:01 +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
98 changed files with 3241 additions and 682 deletions

No files matched your search

@@ -17,10 +17,13 @@ import {
import superjson from "superjson"; import superjson from "superjson";
import type { SuperJSONResult } from "superjson"; import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
@@ -56,6 +59,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: { queries: {
staleTime: 5 * 1000, staleTime: 5 * 1000,
}, },
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
}, },
}), }),
); );
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core"; import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form"; import { LoginForm } from "./_login-form";
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
@@ -42,8 +42,9 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess) { if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />; return <SelectBoardsMenu />;
} }
@@ -51,11 +52,13 @@ export const BoardContentHeaderActions = () => {
<> <>
{isEditMode && <AddMenu />} {isEditMode && <AddMenu />}
<EditModeMenu /> <EditModeMenu demoMode={demoMode ?? false} />
<HeaderButton href={`/boards/${board.name}/settings`}> {!demoMode && (
<IconSettings stroke={1.5} /> <HeaderButton href={`/boards/${board.name}/settings`}>
</HeaderButton> <IconSettings stroke={1.5} />
</HeaderButton>
)}
<SelectBoardsMenu /> <SelectBoardsMenu />
</> </>
@@ -141,7 +144,7 @@ const AddMenu = () => {
); );
}; };
const EditModeMenu = () => { const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -164,10 +167,18 @@ const EditModeMenu = () => {
}, },
}); });
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open(); open();
}, [board, isEditMode, saveBoard, open]); }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
+1
View File
@@ -25,6 +25,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -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))}>
@@ -96,7 +96,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
> >
{children} {children}
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} /> <IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION || env.DEMO_MODE} />
</MenuDropdown> </MenuDropdown>
</Menu> </Menu>
); );
@@ -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
View File
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({ export const env = createEnv({
server: { server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
}, },
experimental__runtimeEnv: process.env, experimental__runtimeEnv: process.env,
}); });
+3
View File
@@ -19,6 +19,9 @@
<!-- Links --> <!-- Links -->
<p align="center"> <p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/"> <a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong> <strong>Install 💻</strong>
</a> • </a> •
+3 -1
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createEnv } from "@homarr/core/infrastructure/env"; import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
}, },
}); });
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { publicProcedure } from "../trpc"; import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => { export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => { return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER) { if (env.ENABLE_DOCKER || isDemoMode) {
return await next(); return await next();
} }
throw new TRPCError({ throw new TRPCError({
+2 -1
View File
@@ -1,7 +1,7 @@
import z from "zod/v4"; import z from "zod/v4";
import packageJson from "../../../../package.json"; import packageJson from "../../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure getInfo: protectedProcedure
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version, version: packageJson.version,
}; };
}), }),
isDemoMode: publicProcedure.query(() => isDemoMode),
}); });
@@ -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));
}), }),
}); });
+17 -7
View File
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" }); const logger = createLogger({ module: "trpc" });
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
*/ */
export const createTRPCRouter = t.router; export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/** /**
* Public (unauthed) procedure * Public (unauthed) procedure
* *
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you * tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in * can still access user session data if they are logged in
*/ */
export const publicProcedure = t.procedure; export const publicProcedure = baseProcedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
return next({ return next({
ctx: { ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user }, session: { ...ctx.session, user: ctx.session.user },
}, },
}); });
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* *
* @see https://trpc.io/docs/procedures * @see https://trpc.io/docs/procedures
*/ */
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/** /**
* Procedure that requires a specific permission * Procedure that requires a specific permission
+203 -5
View File
@@ -1,3 +1,5 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common"; import { createId, objectKeys } from "@homarr/common";
import { import {
createDocumentationLink, createDocumentationLink,
@@ -8,6 +10,7 @@ import {
integrationDefs, integrationDefs,
integrationKinds, integrationKinds,
} from "@homarr/definitions"; } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from ".."; import type { Database } from "..";
@@ -18,12 +21,17 @@ import {
insertServerSettingByKeyAsync, insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync, updateServerSettingByKeyAsync,
} from "../queries/server-setting"; } from "../queries/server-setting";
import { import {
apps,
boards, boards,
groupMembers, groupMembers,
groupPermissions, groupPermissions,
groups, groups,
integrationItems,
integrations, integrations,
itemLayouts,
items,
layouts, layouts,
onboarding, onboarding,
searchEngines, searchEngines,
@@ -39,7 +47,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultSearchEnginesAsync(db); await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db); await seedDefaultIntegrationsAsync(db);
if (process.env.DEMO_MODE === "true") { if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
await seedDemoUserAsync(db); await seedDemoUserAsync(db);
} }
}; };
@@ -209,6 +217,134 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`); console.log(`Created ${createdCount} default integrations through seeding process`);
}; };
interface DemoWidget {
kind: WidgetKind;
width: number;
height: number;
needsIntegration: boolean;
options?: Record<string, unknown>;
}
const demoApps = [
{
name: "Sonarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
href: "https://sonarr.tv",
},
{
name: "Radarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
href: "https://radarr.video",
},
{
name: "Plex",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
href: "https://plex.tv",
},
{
name: "Jellyfin",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
href: "https://jellyfin.org",
},
{
name: "Proxmox",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
href: "https://proxmox.com",
},
{
name: "Pi-hole",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
href: "https://pi-hole.net",
},
{
name: "Grafana",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
href: "https://grafana.com",
},
{
name: "Portainer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
href: "https://portainer.io",
},
{
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
href: "https://nextcloud.com",
},
{
name: "qBittorrent",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
href: "https://qbittorrent.org",
},
{
name: "Overseerr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
href: "https://overseerr.dev",
},
] as const;
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: false,
},
},
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId): DemoWidget => ({
kind: "app",
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => { const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"), where: eq(users.name, "demo"),
@@ -225,7 +361,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId, id: userId,
name: "demo", name: "demo",
email: "demo@example.com", email: "demo@example.com",
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2", password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
}); });
const maxPosition = await getMaxGroupPositionAsync(db); const maxPosition = await getMaxGroupPositionAsync(db);
@@ -250,6 +386,27 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings", previousStep: "settings",
}); });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Demo Integration",
url: "https://demo.homarr.dev",
kind: "mock",
appId: null,
});
const appIds: string[] = [];
for (const app of demoApps) {
const appId = createId();
appIds.push(appId);
await db.insert(apps).values({
id: appId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
}
const boardId = createId(); const boardId = createId();
await db.insert(boards).values({ await db.insert(boards).values({
id: boardId, id: boardId,
@@ -257,23 +414,64 @@ const seedDemoUserAsync = async (db: Database) => {
isPublic: false, isPublic: false,
creatorId: userId, creatorId: userId,
}); });
const sectionId = createId();
await db.insert(sections).values({ await db.insert(sections).values({
id: createId(), id: sectionId,
kind: "empty", kind: "empty",
xOffset: 0, xOffset: 0,
yOffset: 0, yOffset: 0,
boardId, boardId,
}); });
const layoutId = createId();
await db.insert(layouts).values({ await db.insert(layouts).values({
id: createId(), id: layoutId,
name: "Base", name: "Base",
columnCount: 12, columnCount: 12,
breakpoint: 0, breakpoint: 0,
boardId, boardId,
}); });
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId,
kind: widget.kind,
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
if (widget.needsIntegration) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log( console.log(
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.", "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
); );
}; };
+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;
+191 -69
View File
@@ -1,24 +1,130 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types"; import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
export class CalendarMockService implements ICalendarIntegration { interface EventTemplate {
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> { title: string;
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)]; subTitle: string;
return await Promise.resolve(result); description: string;
} dayOffset: number;
hours: number;
image: CalendarEvent["image"];
indicatorColor: string;
metadata?: CalendarEvent["metadata"];
location?: string;
links: CalendarEvent["links"];
} }
const homarrMeetup = (start: Date, end: Date): CalendarEvent => { const eventTemplates: EventTemplate[] = [
const startDate = randomDateBetween(start, end); {
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later title: "The Mandalorian",
return { subTitle: "Season 4",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
dayOffset: 0,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S4/E3", color: "red" },
},
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Silo",
subTitle: "Season 2",
description: "In a ruined and toxic future, thousands live in a giant underground silo.",
dayOffset: 3,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E8", color: "teal" },
},
indicatorColor: "teal",
links: [
{
href: "https://www.imdb.com/title/tt14688458/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Dune: Part Two",
subTitle: "Digital Release",
description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
dayOffset: 5,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt15239678/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Severance",
subTitle: "Season 2",
description: "Mark leads a team of office workers whose memories are surgically divided.",
dayOffset: 7,
hours: 22,
image: {
src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S2/E5", color: "violet" },
},
indicatorColor: "violet",
links: [],
},
{
title: "Oppenheimer",
subTitle: "Physical Release",
description: "The story of the development of the atomic bomb.",
dayOffset: 10,
hours: 0,
image: {
src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "physicalRelease" },
indicatorColor: "orange",
links: [
{
href: "https://www.imdb.com/title/tt15398776/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
},
{
title: "Homarr Meetup", title: "Homarr Meetup",
subTitle: "", subTitle: "",
description: "Yearly meetup of the Homarr community", description: "Yearly meetup of the Homarr community",
startDate, dayOffset: 14,
endDate, hours: 14,
image: null,
location: "Mountains", location: "Mountains",
image: null,
indicatorColor: "#fa5252", indicatorColor: "#fa5252",
links: [ links: [
{ {
@@ -29,63 +135,79 @@ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
isDark: true, isDark: true,
}, },
], ],
}; },
{
title: "The Bear",
subTitle: "Season 3",
description: "A young chef fights to transform a sandwich shop.",
dayOffset: 18,
hours: 3,
image: {
src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "yellow" },
},
indicatorColor: "yellow",
links: [],
},
{
title: "Foundation",
subTitle: "Season 3",
description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
dayOffset: 21,
hours: 20,
image: {
src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
aspectRatio: { width: 7, height: 12 },
badge: { content: "S3/E1", color: "indigo" },
},
indicatorColor: "indigo",
links: [],
},
{
title: "Blade Runner 2099",
subTitle: "Premiere",
description: "A new chapter in the Blade Runner universe.",
dayOffset: 25,
hours: 21,
image: {
src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
aspectRatio: { width: 7, height: 12 },
},
metadata: { type: "radarr", releaseType: "digitalRelease" },
indicatorColor: "grape",
links: [],
},
];
const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
const rangeStart = new Date(start);
rangeStart.setDate(rangeStart.getDate() - 3);
return eventTemplates
.map((tpl) => {
const startDate = new Date(rangeStart);
startDate.setDate(startDate.getDate() + tpl.dayOffset);
startDate.setHours(tpl.hours, 0, 0, 0);
return {
title: tpl.title,
subTitle: tpl.subTitle,
description: tpl.description,
startDate,
endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
image: tpl.image,
location: tpl.location ?? null,
indicatorColor: tpl.indicatorColor,
links: tpl.links,
...(tpl.metadata ? { metadata: tpl.metadata } : {}),
};
})
.filter((event) => event.startDate >= start && event.startDate <= end);
}; };
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({ export class CalendarMockService implements ICalendarIntegration {
title: "Titanic", public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
subTitle: "A classic movie", return await Promise.resolve(buildEventsForRange(start, end));
description: "A tragic love story set on the ill-fated RMS Titanic.", }
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
},
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
title: "The Mandalorian",
subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: {
content: "S1/E1",
color: "red",
},
},
location: null,
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
} }
+135 -29
View File
@@ -2,57 +2,163 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
const mockItems: Omit<DownloadClientItem, "id" | "index">[] = [
{
name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
type: "torrent",
size: 4_700_000_000,
progress: 0.47,
state: "leeching",
downSpeed: 12_400_000,
upSpeed: 890_000,
sent: 420_000_000,
received: 2_209_000_000,
time: 201_000,
category: "movies",
},
{
name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
type: "torrent",
size: 5_800_000_000,
progress: 0.82,
state: "leeching",
downSpeed: 34_500_000,
upSpeed: 2_100_000,
sent: 1_200_000_000,
received: 4_756_000_000,
time: 30_000,
category: "iso",
},
{
name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
type: "torrent",
size: 18_200_000_000,
progress: 0.03,
state: "leeching",
downSpeed: 1_200_000,
upSpeed: 50_000,
sent: 30_000_000,
received: 546_000_000,
time: 14_880_000,
category: "movies",
},
{
name: "Arch.Linux.2024.12.01.x86_64.iso",
type: "torrent",
size: 1_100_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 4_500_000,
sent: 3_300_000_000,
received: 1_100_000_000,
time: -7_200_000,
category: "iso",
},
{
name: "Tears.of.Steel.2012.1080p.DTS.mkv",
type: "torrent",
size: 7_340_000_000,
progress: 0,
state: "paused",
downSpeed: 0,
upSpeed: 0,
time: 0,
category: "movies",
},
{
name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
type: "torrent",
size: 12_000_000_000,
progress: 0.65,
state: "stalled",
downSpeed: 0,
upSpeed: 0,
sent: 200_000_000,
received: 7_800_000_000,
time: 0,
category: "movies",
},
{
name: "Spring.2019.4K.HDR.mkv",
type: "usenet",
size: 3_200_000_000,
progress: 0.91,
state: "downloading",
downSpeed: 45_000_000,
time: 6_500,
category: "movies",
},
{
name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
type: "usenet",
size: 1_800_000_000,
progress: 1,
state: "completed",
time: -3_600_000,
category: "movies",
},
{
name: "Caminandes.3.Llamigos.2016.1080p.mkv",
type: "torrent",
size: 890_000_000,
progress: 1,
state: "seeding",
downSpeed: 0,
upSpeed: 1_800_000,
sent: 2_670_000_000,
received: 890_000_000,
time: -86_400_000,
category: "movies",
},
{
name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
type: "usenet",
size: 9_400_000_000,
progress: 0.22,
state: "downloading",
downSpeed: 28_000_000,
time: 244_000,
category: "tv",
},
];
export class DownloadClientMockService implements IDownloadClientIntegration { export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
return await Promise.resolve({ return await Promise.resolve({
status: { status: {
paused: Math.random() < 0.5, paused: false,
rates: { rates: {
down: Math.floor(Math.random() * 5000), down: 121_100_000,
up: Math.floor(Math.random() * 5000), up: 9_340_000,
}, },
types: ["torrent", "usenet"], types: ["torrent", "usenet"] as const,
}, },
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice( items: mockItems.slice(0, input.limit).map((item, index) => ({
0, ...item,
input.limit, id: `mock-${index}`,
), index,
})),
}); });
} }
public async pauseQueueAsync(): Promise<void> { public async pauseQueueAsync(): Promise<void> {
return await Promise.resolve(); await Promise.resolve();
} }
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> { public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
return await Promise.resolve(); await Promise.resolve();
} }
public async resumeQueueAsync(): Promise<void> { public async resumeQueueAsync(): Promise<void> {
return Promise.resolve(); await Promise.resolve();
} }
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> { public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
return await Promise.resolve(); await Promise.resolve();
} }
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> { public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
return await Promise.resolve(); await Promise.resolve();
}
private static createItem(index: number): DownloadClientItem {
const progress = Math.random() < 0.5 ? Math.random() : 1;
return {
id: `item-${index}`,
index,
name: `Item ${index}`,
type: Math.random() > 0.5 ? "torrent" : "usenet",
progress,
size: Math.floor(Math.random() * 10000) + 1,
downSpeed: Math.floor(Math.random() * 5000),
upSpeed: Math.floor(Math.random() * 5000),
state: progress >= 1 ? "completed" : "downloading",
time: 0,
};
} }
} }
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
title: "Inception", title: "Inception",
subtitle: "A mind-bending thriller", subtitle: "A mind-bending thriller",
description: description:
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.", "A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"), releaseDate: new Date("2010-07-16"),
imageUrls: { imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg", poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
backdrop: "https://example.com/inception_backdrop.jpg", backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
}, },
producer: "Warner Bros.", producer: "Warner Bros.",
price: 14.99, price: 14.99,
rating: "8.8/10", rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"], tags: ["Sci-Fi", "Thriller"],
href: "https://example.com/inception", href: "https://www.themoviedb.org/movie/27205-inception",
length: 148, length: 148,
}, },
{ {
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.", description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"), releaseDate: new Date("2013-09-15"),
imageUrls: { imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg", poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
backdrop: "https://example.com/breaking_bad_backdrop.jpg", backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
}, },
producer: "AMC", producer: "AMC",
rating: "9.5/10", rating: "9.5/10",
tags: ["Crime", "Drama"], tags: ["Crime", "Drama"],
href: "https://example.com/breaking_bad", href: "https://www.themoviedb.org/tv/1396-breaking-bad",
}, },
{ {
id: "3", id: "3",
@@ -50,79 +50,86 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.", description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"), releaseDate: new Date("2013-05-17"),
imageUrls: { imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg", poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
backdrop: "https://example.com/ram_backdrop.jpg", backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
}, },
producer: "Columbia Records", producer: "Columbia Records",
price: 9.99, price: 9.99,
rating: "8.5/10", rating: "8.5/10",
tags: ["Electronic", "Dance", "Pop", "Funk"], tags: ["Electronic", "Funk"],
href: "https://example.com/ram", href: "https://www.daftpunk.com",
}, },
{ {
id: "4", id: "4",
type: "book", type: "movie",
title: "The Great Gatsby", title: "The Dark Knight",
subtitle: "F. Scott Fitzgerald", subtitle: "Theatrical Release",
description: "A novel about the American dream and the disillusionment that comes with it.", description:
releaseDate: new Date("1925-04-10"), "Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
releaseDate: new Date("2008-07-18"),
imageUrls: { imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg", poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
backdrop: "https://example.com/gatsby_backdrop.jpg", backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
}, },
producer: "Scribner", producer: "Warner Bros.",
price: 10.99, price: 12.99,
rating: "4.2/5", rating: "9.0/10",
tags: ["Classic", "Fiction"], tags: ["Action", "Crime", "Drama"],
href: "https://example.com/gatsby", href: "https://www.themoviedb.org/movie/155-the-dark-knight",
length: 152,
}, },
{ {
id: "5", id: "5",
type: "game", type: "movie",
title: "The Legend of Zelda: Breath of the Wild", title: "Interstellar",
subtitle: "Nintendo Switch", subtitle: "Digital Release",
description: "An open-world action-adventure game set in the fantasy land of Hyrule.", description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
releaseDate: new Date("2017-03-03"), releaseDate: new Date("2014-11-07"),
imageUrls: { imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg", poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
backdrop: "https://example.com/zelda_backdrop.jpg", backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
}, },
producer: "Nintendo", producer: "Paramount",
price: 59.99, price: 14.99,
rating: "10/10", rating: "8.7/10",
tags: ["Action", "Adventure"], tags: ["Sci-Fi", "Adventure"],
href: "https://example.com/zelda", href: "https://www.themoviedb.org/movie/157336-interstellar",
length: 169,
}, },
{ {
id: "6", id: "6",
type: "article", type: "tv",
title: "The Rise of AI in Healthcare", title: "Severance",
subtitle: "Tech Innovations", subtitle: "S2E1 - Premiere",
description: "Exploring the impact of artificial intelligence on the healthcare industry.", description:
releaseDate: new Date("2023-10-01"), "Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
releaseDate: new Date("2025-01-17"),
imageUrls: { imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg", poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
backdrop: "https://example.com/ai_healthcare_backdrop.jpg", backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
}, },
producer: "Tech Innovations", producer: "Apple TV+",
rating: "4.8/5", rating: "8.7/10",
tags: ["Technology", "Healthcare"], tags: ["Thriller", "Sci-Fi"],
href: "https://example.com/ai_healthcare", href: "https://www.themoviedb.org/tv/95396-severance",
}, },
{ {
id: "7", id: "7",
type: "video", type: "movie",
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas", title: "Dune: Part Two",
releaseDate: new Date("2024-05-18T17:00:00Z"), subtitle: "Theatrical Release",
description:
"Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
releaseDate: new Date("2024-03-01"),
imageUrls: { imageUrls: {
poster: poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw", backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
}, },
producer: "PietSmiet", producer: "Warner Bros.",
rating: "1K", price: 19.99,
tags: [], rating: "8.5/10",
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg", tags: ["Sci-Fi", "Adventure"],
href: "https://www.themoviedb.org/movie/693134-dune-part-two",
length: 166,
}, },
]; ];
@@ -1,36 +1,95 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration"; import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types"; import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
export class MediaServerMockService implements IMediaServerIntegration { const sessions: StreamSession[] = [
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> { {
return await Promise.resolve( sessionId: "s-0",
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter( sessionName: "Living Room TV",
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null, user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
), currentlyPlaying: {
); type: "movie",
} name: "Interstellar",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-1",
sessionName: "Bedroom Chromecast",
user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-2",
sessionName: "Office Desktop",
user: { userId: "u-2", username: "mike", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "The Dark Knight",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-3",
sessionName: "iPad",
user: { userId: "u-3", username: "emily", profilePictureUrl: null },
currentlyPlaying: {
type: "video",
name: "Severance",
seasonName: "Season 2",
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-4",
sessionName: "Mobile",
user: { userId: "u-4", username: "alex", profilePictureUrl: null },
currentlyPlaying: {
type: "audio",
name: "Daft Punk",
seasonName: undefined,
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
metadata: null,
},
},
{
sessionId: "s-5",
sessionName: "Guest Room",
user: { userId: "u-5", username: "guest", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Oppenheimer",
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
},
},
];
private static createSession(index: number): StreamSession { export class MediaServerMockService implements IMediaServerIntegration {
return { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
sessionId: `session-${index}`, return await Promise.resolve(sessions);
sessionName: `Session ${index}`,
user: {
userId: `user-${index}`,
username: `User${index}`,
profilePictureUrl: "/images/mock/avatar.jpg",
},
currentlyPlaying:
Math.random() > 0.9 // 10% chance of being null (not currently playing)
? {
type: "movie",
name: `Movie ${index}`,
seasonName: undefined,
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
}
: null,
};
} }
} }
@@ -4,27 +4,11 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration { export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> { public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
return await Promise.resolve({ return await Promise.resolve({
lan: { lan: { guests: 5, users: 18, status: "enabled" },
guests: 5, vpn: { users: 3, status: "enabled" },
users: 10, wanStatus: "enabled",
status: "enabled", wifi: { status: "enabled", guests: 2, users: 12 },
}, www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
vpn: {
users: 3,
status: "enabled",
},
wanStatus: "disabled",
wifi: {
status: "disabled",
guests: 0,
users: 0,
},
www: {
latency: 22,
status: "enabled",
ping: 32,
uptime: 3600,
},
}); });
} }
} }
@@ -85,7 +85,7 @@ export class MockIntegration
// Health Monitoring Integrations // Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring); getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient); getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
// IndexerManagerIntegration // IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager); getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -104,7 +104,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest); declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration // MediaServerIntegration
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest); getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
// MediaTranscodingIntegration // MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding); getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
@@ -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",
+8 -1
View File
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications"; import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconCheck, IconX } from "@tabler/icons-react"; import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) => export const showSuccessNotification = (props: NotificationData) =>
notifications.show({ notifications.show({
@@ -15,3 +15,10 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red", color: "red",
icon: <IconX size={20} />, icon: <IconX size={20} />,
}); });
export const showWarningNotification = (props: NotificationData) =>
notifications.show({
...props,
color: "yellow",
icon: <IconInfoCircle size={20} />,
});
@@ -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],
+158 -1
View File
@@ -7,17 +7,174 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db"; import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema"; import { icons } from "@homarr/db/schema";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker"; import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" }); const logger = createLogger({ module: "dockerRequestHandler" });
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
PrivatePort: privatePort,
PublicPort: publicPort,
Type: type,
});
const mockContainers: {
id: string;
name: string;
host: string;
state: ContainerState;
image: string;
iconUrl: string;
cpuUsage: number;
memoryUsage: number;
ports: Port[];
}[] = [
{
id: "a1b2c3d4e5f6",
name: "sonarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/sonarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
cpuUsage: 2.3,
memoryUsage: 256 * 1024 * 1024,
ports: [port(8989, 8989, "tcp")],
},
{
id: "b2c3d4e5f6a7",
name: "radarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/radarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
cpuUsage: 1.8,
memoryUsage: 220 * 1024 * 1024,
ports: [port(7878, 7878, "tcp")],
},
{
id: "c3d4e5f6a7b8",
name: "plex",
host: "local",
state: "running",
image: "lscr.io/linuxserver/plex:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
cpuUsage: 12.5,
memoryUsage: 1024 * 1024 * 1024,
ports: [port(32400, 32400, "tcp")],
},
{
id: "d4e5f6a7b8c9",
name: "qbittorrent",
host: "local",
state: "running",
image: "lscr.io/linuxserver/qbittorrent:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
cpuUsage: 5.1,
memoryUsage: 380 * 1024 * 1024,
ports: [port(8080, 8080, "tcp")],
},
{
id: "e5f6a7b8c9d0",
name: "prowlarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/prowlarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
cpuUsage: 0.8,
memoryUsage: 120 * 1024 * 1024,
ports: [port(9696, 9696, "tcp")],
},
{
id: "f6a7b8c9d0e1",
name: "overseerr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/overseerr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
cpuUsage: 1.2,
memoryUsage: 180 * 1024 * 1024,
ports: [port(5055, 5055, "tcp")],
},
{
id: "a7b8c9d0e1f2",
name: "homarr",
host: "local",
state: "running",
image: "ghcr.io/homarr-labs/homarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
cpuUsage: 3.4,
memoryUsage: 290 * 1024 * 1024,
ports: [port(7575, 7575, "tcp")],
},
{
id: "b8c9d0e1f2a3",
name: "pihole",
host: "local",
state: "running",
image: "pihole/pihole:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
cpuUsage: 0.5,
memoryUsage: 95 * 1024 * 1024,
ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
},
{
id: "c9d0e1f2a3b4",
name: "nginx-proxy",
host: "local",
state: "running",
image: "jc21/nginx-proxy-manager:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
cpuUsage: 0.3,
memoryUsage: 65 * 1024 * 1024,
ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
},
{
id: "d0e1f2a3b4c5",
name: "watchtower",
host: "local",
state: "running",
image: "containrrr/watchtower:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
cpuUsage: 0.1,
memoryUsage: 30 * 1024 * 1024,
ports: [],
},
{
id: "e1f2a3b4c5d6",
name: "tdarr",
host: "local",
state: "exited",
image: "ghcr.io/haveagitgat/tdarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
cpuUsage: 0,
memoryUsage: 0,
ports: [],
},
{
id: "f2a3b4c5d6e7",
name: "bazarr",
host: "local",
state: "running",
image: "lscr.io/linuxserver/bazarr:latest",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
cpuUsage: 0.6,
memoryUsage: 110 * 1024 * 1024,
ports: [port(6767, 6767, "tcp")],
},
];
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult", queryKey: "dockerContainersResult",
widgetKind: "dockerContainers", widgetKind: "dockerContainers",
async requestAsync() { async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync(); return await getContainersWithStatsAsync();
}, },
cacheDuration: dayjs.duration(20, "seconds"), cacheDuration: dayjs.duration(20, "seconds"),
+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