Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4782e24b79 | ||
|
|
25d964a197 | ||
|
|
3eb3908d4c | ||
|
|
3d3e3059a9 | ||
|
|
d6f14de6d1 | ||
|
|
0ad7b79187 | ||
|
|
35bc50c12a | ||
|
|
9ae8cb2768 | ||
|
|
99346f288b |
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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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> •
|
||||||
|
|||||||
@@ -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,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({
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 +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);
|
||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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,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 }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": "添加书签"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "הוסף סימניה"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "ブックマークに追加"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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ę"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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": "Добавить закладку"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "Додати закладку"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user