feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -1,32 +0,0 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -1,25 +0,0 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
resource: baseUrl,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -8,6 +8,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
import { useByteFormatter } from "@homarr/settings";
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
const metricStyle = {
background: "color-mix(in srgb, currentColor 4%, transparent)",
@@ -33,11 +35,9 @@ const readDiagnostics = (queryClient: QueryClient) => {
total: queries.length,
readyWidgets,
widgetItems,
scriptKiB: Math.round(
resources
.filter((resource) => resource.initiatorType === "script")
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
),
scriptBytes: resources
.filter((resource) => resource.initiatorType === "script")
.reduce((total, resource) => total + resource.decodedBodySize, 0),
};
};
@@ -51,6 +51,7 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
const pathname = usePathname();
const queryClient = useQueryClient();
const { formatBytes } = useByteFormatter();
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
useEffect(() => {
@@ -95,7 +96,7 @@ const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
<Metric label="Errors" value={diagnostics.errors} />
<Metric label="Mutations" value={diagnostics.pendingMutations} />
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
<Metric label="Script" value={formatBytes(diagnostics.scriptBytes)} />
</div>
</section>
);
@@ -1,6 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs";
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui";
import type { ServerSettings } from "@homarr/server-settings";
import { createBrandTheme } from "~/theme/branding";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
branding,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
const manager = useColorSchemeManager();
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
theme={configuredTheme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
@@ -1,27 +1,30 @@
"use client";
import { createContext, useContext, useEffect } from "react";
import { createContext, useContext, useEffect, useRef } from "react";
import type { PropsWithChildren } from "react";
import dayjs from "dayjs";
import type { Session } from "@homarr/auth";
import { SessionProvider, signIn } from "@homarr/auth/client";
interface AuthProviderProps extends AuthContextProps {
interface AuthProviderProps {
logoutUrl: string | undefined;
session: Session | null;
}
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
useLoginRedirectOnSessionExpiry(session);
const logoutRedirectInProgress = useRef(false);
return (
<SessionProvider session={session}>
<AuthContext.Provider value={{ logoutUrl }}>{children}</AuthContext.Provider>
<AuthContext.Provider value={{ logoutUrl, logoutRedirectInProgress }}>{children}</AuthContext.Provider>
</SessionProvider>
);
};
interface AuthContextProps {
logoutRedirectInProgress: { current: boolean };
logoutUrl: string | undefined;
}
@@ -1,9 +1,9 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defaultShouldDehydrateQuery, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import {
createWSClient,
@@ -24,6 +24,8 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
dashboardSupportingQueryPolicies,
isTrpcForbiddenError,
isWidgetDataQueryKey,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
@@ -34,6 +36,7 @@ import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { useAuthContext } from "./session";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import { createQueryRetry } from "./query-retry";
@@ -65,14 +68,18 @@ const constructWebsocketUrl = () => {
export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const { logoutRedirectInProgress } = useAuthContext();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const handleScopeChange = useCallback(() => {
if (!logoutRedirectInProgress.current) reloadPage();
}, [logoutRedirectInProgress]);
return (
<SessionQueryScopeGuard
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={reloadPage}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
@@ -81,7 +88,22 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const reloadPage = () => window.location.reload();
const clearLegacyDashboardPersistence = () => {
for (const storageName of ["localStorage", "sessionStorage"] as const) {
try {
const storage = window[storageName];
for (let index = storage.length - 1; index >= 0; index--) {
const key = storage.key(index);
if (key?.startsWith("homarr:widget-query-cache:")) storage.removeItem(key);
}
} catch {
// Storage can be unavailable in private or locked-down browsers.
}
}
};
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
useEffect(clearLegacyDashboardPersistence, []);
const wsClient = useMemo(
() =>
createWSClient({
@@ -96,8 +118,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
},
[wsClient],
);
const [queryClient] = useState(() => {
const [{ queryClient, rscStreamedQueryHashes }] = useState(() => {
const streamedHashes = new Set<string>();
const client = new QueryClient({
queryCache: new QueryCache({
onError(error, query) {
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
},
}),
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
@@ -125,15 +154,25 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
for (const queryDefaults of widgetQueryRefetchIntervals) {
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
if (typeof queryDefaults.intervalSeconds === "number") {
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
}
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
client.setQueryDefaults(queryDefaults.queryKey, policy);
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
}
return client;
if (typeof window === "undefined") {
client.getQueryCache().subscribe((event) => {
if (event.query.meta?.rscWidgetPrefetch === true) streamedHashes.add(event.query.queryHash);
if (event.type === "removed") streamedHashes.delete(event.query.queryHash);
});
}
return { queryClient: client, rscStreamedQueryHashes: streamedHashes };
});
useEffect(() => () => queryClient.clear(), [queryClient]);
@@ -187,7 +226,21 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
<ReactQueryStreamedHydration
transformer={superjson}
options={{
dehydrate: {
// These promises already travel through the RSC boundary. Sending
// them again here duplicates large album and chart payloads.
shouldDehydrateQuery: (query) =>
!rscStreamedQueryHashes.has(query.queryHash) &&
query.meta?.rscWidgetPrefetch !== true &&
defaultShouldDehydrateQuery(query),
},
}}
>
{children}
</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</QueryClientProvider>
</clientApi.Provider>
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userRegistrationSchema } from "@homarr/validation/user";
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user");
const t = useI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(userRegistrationSchema, {
@@ -62,13 +62,16 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<form autoComplete="on" onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
id="username"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
{...form.getInputProps("username")}
name="username"
/>
<CustomPasswordInput
withPasswordRequirements
@@ -76,6 +79,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
id="password"
autoComplete="new-password"
{...form.getInputProps("password")}
name="password"
/>
<PasswordInput
@@ -83,6 +87,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
id="password-confirm"
autoComplete="new-password"
{...form.getInputProps("confirmPassword")}
name="password-confirmation"
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.register.label")}
@@ -1,13 +1,15 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { IconUserPlus } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema";
import { getScopedI18n } from "@homarr/translation/server";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
}
export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
if (session) notFound();
const invite = await db.query.invites.findFirst({
@@ -45,28 +45,37 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite");
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
const defaultDescription = showCustomAppName
? t("subtitleWithAppName", { appName: branding.appName })
: t("subtitle");
const description = showCustomGreeting ? branding.greeting : defaultDescription;
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} />
</Card>
<Text size="xs" c="gray.5" ta="center">
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{t("description", { username: invite.creator.name! })}
</Text>
</Stack>
</Center>
<OnboardingAuthShell
title={title}
description={description}
icon={<IconUserPlus size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
footer={t("description", { username: invite.creator.name! })}
>
<RegistrationForm invite={invite} />
</OnboardingAuthShell>
);
}
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user";
@@ -28,7 +28,7 @@ interface LoginFormProps {
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user");
const t = useI18n("user");
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
@@ -115,19 +115,26 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
<Stack gap="lg">
{credentialInputsVisible && (
<>
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
<form
autoComplete="on"
onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}
>
<Stack gap="lg">
<TextInput
label={t("field.username.label")}
id="username"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
{...form.getInputProps("name")}
name="username"
/>
<PasswordInput
label={t("field.password.label")}
id="password"
autoComplete="current-password"
{...form.getInputProps("password")}
name="password"
/>
{providers.includes("credentials") && (
@@ -151,7 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("oidc") && (
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button>
)}
@@ -172,7 +179,8 @@ const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren
return (
<Button
type="submit"
name={provider}
name="provider"
value={provider}
fullWidth
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
@@ -188,7 +196,7 @@ interface PasswordForgottenCollapseProps {
}
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
const tForgotPassword = useI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
@@ -1,61 +1,70 @@
import { redirect } from "next/navigation";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { Alert, Code, Text } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
interface LoginProps {
searchParams: Promise<{
callbackUrl?: string;
redirect?: string;
}>;
}
export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams;
const session = await auth();
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
const redirectUrl = sanitizeRedirectionUrl(searchParams.redirect ?? searchParams.callbackUrl);
if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
redirect(redirectUrl);
}
const t = await getScopedI18n("user.page.login");
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const description = showCustomGreeting ? branding.greeting : t("subtitle");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
<OnboardingAuthShell
title={t("title")}
description={description}
icon={<IconLogin size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
>
{appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
</Card>
</Stack>
</Center>
</Alert>
) : null}
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={redirectUrl}
/>
</OnboardingAuthShell>
);
}
@@ -1,11 +1,11 @@
import { IconLayoutOff } from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() {
const tNotFound = await getScopedI18n("board.error.notFound");
const tNotFound = await getI18n("board.error.notFound");
return (
<BoardNotFound
icon={IconLayoutOff}
@@ -1,84 +0,0 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardAddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: { id: "new", name: "" },
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{ title: (translate) => translate("section.category.create.title") },
),
[addCategoryToEnd, openCategoryEditModal, t],
);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
withCreate: session?.user.permissions.includes("app-create") ?? false,
})
}
>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -0,0 +1,43 @@
.board[data-board-left-rail="true"]:not([data-board-layout-preview]) {
margin-inline-start: calc(-1 * var(--app-shell-padding, 0px));
}
.board[data-board-right-rail="true"]:not([data-board-layout-preview]) {
margin-inline-end: calc(-1 * var(--app-shell-padding, 0px));
}
.columns {
display: grid;
align-items: start;
}
.lane {
min-width: 0;
}
.gutter {
--board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1));
--board-gutter-viewport-height: calc(
(100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1)
);
position: sticky;
top: var(--board-gutter-sticky-top);
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
overflow-x: clip;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.gutter::-webkit-scrollbar {
display: none;
}
.gutter[data-board-editing="true"] {
outline: 0.0625rem solid var(--mantine-color-default-border);
outline-offset: -0.0625rem;
border-radius: var(--mantine-radius-md);
}
@@ -1,33 +1,191 @@
"use client";
import { Box, Stack } from "@mantine/core";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
import classes from "./_client.module.css";
const APP_SHELL_SIDE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useI18n("board.landmark");
const tPreview = useI18n("board.setting.section.layout.preview");
const tTips = useI18n("tips");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
const layoutOverrideId = useLayoutOverride();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
if (!currentLayout) throw new Error("Expected the board to contain a layout");
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const mainSection = getRootSectionForLane(board, "main");
const leftSection = getRootSectionForLane(board, "left");
const rightSection = getRootSectionForLane(board, "right");
if (!mainSection) throw new Error("Expected the board to contain a main canvas section");
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
let inlinePadding = 0;
if (leftColumnCount === 0) inlinePadding += APP_SHELL_SIDE_PADDING;
if (rightColumnCount === 0) inlinePadding += APP_SHELL_SIDE_PADDING;
if (representativeWidth !== null) inlinePadding = APP_SHELL_SIDE_PADDING * 2;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - inlinePadding);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
const content = (
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<Box
h="100%"
pos="relative"
className={classes.board}
data-board-left-rail={leftColumnCount > 0 || undefined}
data-board-right-rail={rightColumnCount > 0 || undefined}
data-board-layout-preview={representativeWidth !== null || undefined}
data-homarr-dev-benchmark-board
>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gridTemplateColumns, gap: LOGICAL_GRID_GAP }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
);
if (representativeWidth === null) return content;
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</Box>
<Stack align="center" gap="xs" p="md" mih="100%">
<Text size="xs" c="dimmed" fw={500}>
{tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
</Text>
<Paper
withBorder
shadow="sm"
radius="md"
w={`min(${representativeWidth}px, calc(100vw - 2rem))`}
mih="calc(100dvh - 8rem)"
style={{ overflow: "hidden" }}
>
{content}
</Paper>
</Stack>
);
};
@@ -1,31 +1,30 @@
import type { Metadata } from "next";
import { cache } from "react";
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { QueryClient } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { makeQueryClient } from "@homarr/api/shared";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { getQueryClient } from "@homarr/api/server";
import { getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { prepareIntegrationResponseCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { prefetchForKind } from "@homarr/widgets/prefetch";
import { getInitiallyVisibleWidgetKinds, prefetchInitialWidgetData } from "@homarr/widgets/server-prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import { createBoardLayout, getInitialViewportWidthAsync } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
import { ClientBoard } from "./_client";
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
import { createBoardQuerySnapshot } from "./_query-snapshot";
import { WidgetResourcePreload } from "./_widget-resource-preload";
export type Params = Record<string, unknown>;
@@ -33,21 +32,96 @@ interface Props<TParams extends Params> {
getInitialBoardAsync: (params: TParams) => Promise<Board>;
}
const BoardWithIntegrations = async ({
integrationsPromise,
}: {
integrationsPromise: ReturnType<typeof getIntegrationsWithPermissionsAsync>;
}) => (
<IntegrationProvider integrations={await integrationsPromise}>
<ClientBoard />
</IntegrationProvider>
);
const DeferredWidgetHydration = async ({
queryClient,
queries,
}: {
queryClient: QueryClient;
queries: Promise<void>[];
}) => {
await Promise.allSettled(queries);
return (
<HydrationBoundary
state={dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
query.meta?.streamedBeszelSelection === true &&
(query.state.status === "pending" || query.state.status === "success"),
})}
/>
);
};
const RefreshedQueryHydration = async ({
queryClient,
queryHashes,
promises,
}: {
queryClient: QueryClient;
queryHashes: Set<string>;
promises: Promise<unknown>[];
}) => {
await Promise.allSettled(promises);
return (
<HydrationBoundary
state={dehydrate(queryClient, {
shouldDehydrateQuery: (query) => queryHashes.has(query.queryHash) && query.state.status === "success",
})}
/>
);
};
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
void prepareIntegrationResponseCacheAsync().catch(() => undefined);
const resolvedParams = await params;
const queryClient = getQueryClient();
const sessionPromise = auth();
const boardPromise = getInitialBoard(resolvedParams);
const viewportWidthPromise = getInitialViewportWidthAsync();
const integrationsPromise = sessionPromise.then(getIntegrationsWithPermissionsAsync);
// The board can fail independently of the permissions read (e.g. a missing board).
void integrationsPromise.catch(() => undefined);
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const board = await boardPromise.catch(async (error: unknown) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
const session = await sessionPromise;
if (!session) {
const requestedBoardName =
typeof resolvedParams.name === "string" ? `/boards/${encodeURIComponent(resolvedParams.name)}` : null;
redirect(
requestedBoardName ? `/auth/login?callbackUrl=${encodeURIComponent(requestedBoardName)}` : "/auth/login",
);
}
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error;
});
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -58,49 +132,93 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}
return acc;
}, new Map<WidgetKind, Item[]>());
const [integrations] = await Promise.all([
getIntegrationsWithPermissionsAsync(session),
...Array.from(itemsMap).map(([kind, items]) =>
prefetchForKindAsync(kind, queryClient, items).catch((error) => {
logger.error(
new ErrorWithMetadata(
"Failed to prefetch widget",
{ widgetKind: kind, itemCount: items.length },
{ cause: error },
),
);
}),
),
]);
for (const [kind, items] of itemsMap) prefetchForKind(kind, queryClient, items);
const layoutId = getLayoutIdForViewportWidth(board.layouts, await viewportWidthPromise);
const [session, integrations] = await Promise.all([sessionPromise, integrationsPromise]);
const snapshot = createBoardQuerySnapshot(board, integrations);
await snapshot.restoreAsync(queryClient).catch(() => undefined);
const restoredQueryHashes = new Set(
queryClient
.getQueryCache()
.getAll()
.filter((query) => query.meta?.rscWidgetPrefetch === true && query.state.status === "success")
.map((query) => query.queryHash),
);
const dependentQueries = prefetchInitialWidgetData(queryClient, board, layoutId, Boolean(session));
const refreshingQueries = queryClient
.getQueryCache()
.getAll()
.flatMap((query) => {
if (query.meta?.rscWidgetPrefetch !== true || !query.promise) return [];
return [{ queryHash: query.queryHash, promise: query.promise }];
});
const restoredRefreshes = refreshingQueries.filter(({ queryHash }) => restoredQueryHashes.has(queryHash));
void Promise.allSettled([...refreshingQueries.map(({ promise }) => promise), ...dependentQueries])
.then(async () => {
const lateQueries = queryClient
.getQueryCache()
.getAll()
.flatMap((query) => {
if (query.meta?.rscWidgetPrefetch !== true || !query.promise) return [];
return [query.promise];
});
await Promise.allSettled(lateQueries);
await snapshot.saveAsync(queryClient);
})
.catch(() => undefined);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
<>
<WidgetResourcePreload kinds={getInitiallyVisibleWidgetKinds(board, layoutId)} />
<HydrationBoundary state={dehydrate(queryClient)}>
{restoredRefreshes.length > 0 && (
<Suspense fallback={null}>
<RefreshedQueryHydration
queryClient={queryClient}
queryHashes={restoredQueryHashes}
promises={restoredRefreshes.map(({ promise }) => promise)}
/>
</Suspense>
)}
{dependentQueries.length > 0 && (
<Suspense fallback={null}>
<DeferredWidgetHydration queryClient={queryClient} queries={dependentQueries} />
</Suspense>
)}
<Suspense fallback={null}>
<BoardWithIntegrations integrationsPromise={integrationsPromise} />
</Suspense>
</HydrationBoundary>
</>
);
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const t = await getI18n();
const [board, t, serverSettings] = await Promise.all([
getInitialBoard(await params),
getI18n("board"),
getRscServerSettingsAsync(),
]);
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
icon: favicon,
apple: favicon,
},
appleWebApp: {
startupImage: {
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
url: favicon,
},
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
// Ignore not found and bad-request errors and return empty metadata
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
return {};
}
@@ -1,24 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
const BoardLoadingShell = () => (
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
<Skeleton
key={index}
data-homarr-dev-benchmark-board-shell
height={rows * 96}
radius="var(--mantine-radius-default)"
/>
))}
</SimpleGrid>
</Stack>
);
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
loading: BoardLoadingShell,
});
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
return <AddMenu />;
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const handledInitialAdd = useRef(false);
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
openItemSelectModal({ boardId: board.id });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
onSelectMany: (apps) => {
for (const app of apps) {
createItem({
kind: "app",
options: { appId: app.id },
});
}
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({ completionBoardId: board.id });
};
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{tBoard("emptyState.addApp")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{tContainer("action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -1,11 +1,11 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useRouter, useSearchParams } from "next/navigation";
import { useHotkeys } from "@mantine/hooks";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -15,69 +15,99 @@ import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
import type * as EditActionsModule from "./_edit-actions";
const loadBoardAddMenu = () => import("./_board-add-menu");
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
loading: () => (
<HeaderButton loading>
<IconPlus stroke={1.5} />
</HeaderButton>
),
});
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
if (!hasChangeAccess) return null;
return (
<>
{isEditMode && <BoardAddMenu />}
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
<SelectBoardsMenu />
</>
);
};
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess || demoReadOnly) return null;
return (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
searchParams.get("layout") !== null &&
searchParams.get("returnTo") === "settings";
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
@@ -88,24 +118,107 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => {
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const toggle = useCallback(async () => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(board);
return saveBoard(latestBoardRef.current);
}
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
open();
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
} finally {
setIsEnteringEditMode(false);
}
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
close();
router.push(`/boards/${board.name}/settings?tab=layout`);
})();
},
});
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
if (returnToLayoutSettings) {
return (
<>
<HeaderButton
onClick={() => saveBoard(board)}
loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")}
>
<IconDeviceFloppy stroke={1.5} />
</HeaderButton>
<HeaderButton
onClick={cancelLayoutEdit}
disabled={isPending}
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
>
<IconX stroke={1.5} />
</HeaderButton>
</>
);
}
return (
<TourTarget id="board-edit-mode">
<HeaderButton
onClick={toggle}
loading={isPending}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
onClick={() => void toggle()}
onFocus={prewarmEditor}
onPointerEnter={prewarmEditor}
loading={isPending || isEnteringEditMode}
data-testid="board-edit-mode-toggle"
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-pressed={isEditMode}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
@@ -113,49 +226,10 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
);
};
const SelectBoardsMenu = () => {
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return (
<TourTarget id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
@@ -171,13 +245,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.stopPropagation();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
children: tCommon("action.discard"),
},
});
};
@@ -203,5 +277,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, t]);
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -0,0 +1,231 @@
import { createHash } from "node:crypto";
import superjson from "superjson";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { dehydrate, hydrate, QueryClient } from "@tanstack/react-query";
import type { QueryKey } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore, persistQueryClientSave } from "@tanstack/react-query-persist-client";
import type { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import {
createGetSetChannel,
getIntegrationCacheGenerationAsync,
prepareIntegrationResponseCacheAsync,
} from "@homarr/redis";
import type { Board } from "../_types";
const snapshotMaxAgeMs = 3 * 24 * 60 * 60 * 1000;
const snapshotRestoreBudgetMs = 50;
const maxQueryBytes = 128 * 1024;
const maxBeszelStatsBytes = 256 * 1024;
const maxSnapshotBytes = 512 * 1024;
const snapshotVersion = "board-widget-queries-v1";
type Integrations = Awaited<ReturnType<typeof getIntegrationsWithPermissionsAsync>>;
type CachedQuery = PersistedClient["clientState"]["queries"][number];
interface BoardSnapshot extends PersistedClient {
generations: Record<string, string>;
}
// Only data shared by viewers of this board enters the snapshot. Browser-specific
// queries stay out; RSS inputs must match the current board on save and restore.
const sharedWidgetPaths = new Set([
"widget.dnsHole.summary",
"widget.beszel.getSystems",
"widget.beszel.getSystemStats",
"widget.downloads.getJobsAndStatuses",
"widget.indexerManager.getIndexersStatus",
"widget.mediaRelease.getMediaReleases",
"widget.mediaServer.getCurrentStreams",
"widget.mediaRequests.getLatestRequests",
"widget.immich.getServerStats",
"widget.immich.getAlbumPreview",
"widget.immich.getAlbum",
"widget.calendar.findAllEvents",
]);
const getQueryIntegrationIds = (queryKey: QueryKey, board: Board) => {
const path = queryKey[0];
if (!Array.isArray(path)) return null;
const details = queryKey[1];
if (!details || typeof details !== "object" || !("input" in details)) return null;
const input = details.input;
if (!input || typeof input !== "object") return null;
if (path.join(".") === "widget.rssFeed.getFeeds") {
if (!("urls" in input) || !Array.isArray(input.urls)) return null;
if (!("maximumAmountPosts" in input) || typeof input.maximumAmountPosts !== "number") return null;
const urls: unknown[] = input.urls;
const matchesBoardItem = board.items.some((item) => {
if (item.kind !== "rssFeed" || !Array.isArray(item.options.feedUrls)) return false;
if (Number(item.options.maximumAmountPosts ?? 100) !== input.maximumAmountPosts) return false;
return (
item.options.feedUrls.length === urls.length && item.options.feedUrls.every((url, index) => url === urls[index])
);
});
return matchesBoardItem ? [] : null;
}
if (!sharedWidgetPaths.has(path.join("."))) return null;
if ("integrationIds" in input && Array.isArray(input.integrationIds)) {
if (input.integrationIds.length === 0 || !input.integrationIds.every((id) => typeof id === "string")) return null;
return input.integrationIds as string[];
}
if ("integrationId" in input && typeof input.integrationId === "string") return [input.integrationId];
return null;
};
const getShareableQueries = (
queries: CachedQuery[],
board: Board,
boardIntegrationIds: Set<string>,
allowedIds: Set<string>,
) => {
let totalBytes = 0;
return queries.filter((query) => {
if (query.meta?.rscWidgetPrefetch !== true || query.state.status !== "success") return false;
const ids = getQueryIntegrationIds(query.queryKey, board);
if (!ids?.every((id) => boardIntegrationIds.has(id) && allowedIds.has(id))) return false;
const serialized = superjson.stringify({ key: query.queryKey, data: query.state.data });
const queryBytes = Buffer.byteLength(serialized);
const path = query.queryKey[0];
let limit = maxQueryBytes;
if (Array.isArray(path) && path.join(".") === "widget.beszel.getSystemStats") limit = maxBeszelStatsBytes;
if (queryBytes > limit || /data:[^,]{0,100};base64,/i.test(serialized)) return false;
if (totalBytes + queryBytes > maxSnapshotBytes) return false;
totalBytes += queryBytes;
return true;
});
};
const getGenerationsAsync = async (ids: Set<string>) => {
const entries = await Promise.all(
[...ids].map(async (id) => [id, await getIntegrationCacheGenerationAsync(id)] as const),
);
if (entries.some(([, generation]) => !generation.isShared)) return null;
return Object.fromEntries(entries.map(([id, generation]) => [id, generation.value]));
};
export const createBoardQuerySnapshot = (board: Board, integrations: Integrations) => {
// A board edit must not restore data from a widget that has since been
// removed or reconfigured, even if another widget uses the same integration.
const boardContentFingerprint = createHash("sha256")
.update(
JSON.stringify(
board.items.map(({ id, kind, integrationIds, options }) => ({ id, kind, integrationIds, options })),
),
)
.digest("hex");
const boardIntegrationIds = new Set(board.items.flatMap((item) => item.integrationIds));
// The board procedure has already granted view access. Integration query
// authorization also grants use of integrations placed on a viewable board.
const existingIds = new Set(integrations.map((entry) => entry.id));
const allowedIds = new Set([...boardIntegrationIds].filter((id) => existingIds.has(id)));
const canSeedBoardSnapshot = [...boardIntegrationIds].every((id) => allowedIds.has(id));
const initialGenerationsPromise = prepareIntegrationResponseCacheAsync()
.then(async () => await getGenerationsAsync(boardIntegrationIds))
.catch(() => null);
const channel = createGetSetChannel<string>(`${snapshotVersion}:${board.id}:${boardContentFingerprint}`, {
useBoundedCacheClient: true,
});
const redisPersister = createAsyncStoragePersister({
key: board.id,
throttleTime: 0,
serialize: superjson.stringify,
deserialize: superjson.parse,
storage: {
getItem: async () => await channel.getAsync(),
setItem: async (_key, value) => {
if (Buffer.byteLength(value) > maxSnapshotBytes) return;
await channel.setAsync(value, { ttlMs: snapshotMaxAgeMs });
},
removeItem: async () => await channel.removeAsync(),
},
});
const persister = {
...redisPersister,
restoreClient: async () => {
const snapshot = (await redisPersister.restoreClient()) as BoardSnapshot | undefined;
if (!snapshot || !snapshot.generations) return undefined;
const generations = await initialGenerationsPromise;
if (!generations) return undefined;
const queries = getShareableQueries(snapshot.clientState.queries, board, boardIntegrationIds, allowedIds).filter(
(query) =>
getQueryIntegrationIds(query.queryKey, board)?.every((id) => generations[id] === snapshot.generations[id]),
);
return {
...snapshot,
clientState: {
mutations: [],
queries: queries.map((query) => ({
...query,
state: { ...query.state, dataUpdatedAt: Date.now(), fetchStatus: "idle" as const },
})),
},
};
},
persistClient: async (client: PersistedClient) => {
if (!canSeedBoardSnapshot) return;
const queries = getShareableQueries(client.clientState.queries, board, boardIntegrationIds, allowedIds);
if (queries.length === 0) return;
const ids = new Set(queries.flatMap((query) => getQueryIntegrationIds(query.queryKey, board) ?? []));
const [initialGenerations, generations] = await Promise.all([
initialGenerationsPromise,
getGenerationsAsync(ids),
]);
if (!initialGenerations || !generations) return;
if ([...ids].some((id) => initialGenerations[id] !== generations[id])) return;
await redisPersister.persistClient({
...client,
generations,
clientState: { mutations: [], queries },
} as BoardSnapshot);
},
};
let restoreInFlight: Promise<void> = Promise.resolve();
return {
restoreAsync: async (queryClient: QueryClient) => {
// A slow Redis read must not hold back the first uncached widget requests.
// Restore into an isolated client so a late result cannot overwrite them.
const restoredClient = new QueryClient();
const restore = persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: snapshotMaxAgeMs,
buster: snapshotVersion,
}).then(() => dehydrate(restoredClient));
restoreInFlight = restore.then(
() => undefined,
() => undefined,
);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const state = await Promise.race([
restore,
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), snapshotRestoreBudgetMs);
}),
]);
if (state) hydrate(queryClient, state);
} finally {
clearTimeout(timeout);
}
},
saveAsync: async (queryClient: QueryClient) => {
// A late invalid/expired restore may remove its key; let it finish before saving.
await restoreInFlight;
await persistQueryClientSave({
queryClient,
persister,
buster: snapshotVersion,
dehydrateOptions: {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: (query) => query.meta?.rscWidgetPrefetch === true && query.state.status === "success",
},
});
},
};
};
@@ -1,30 +1,33 @@
"use client";
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useColorSchemeManager } from "../../_client-providers/mantine";
import { generateColorScale } from "~/theme/branding";
export const BoardMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard();
const { branding } = useSettings();
const colorSchemeManager = useColorSchemeManager();
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
const theme = createTheme({
colors: {
primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
primaryColor: generateColorScale(primaryColor),
secondaryColor: generateColorScale(board.secondaryColor),
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
},
primaryColor: "primaryColor",
autoContrast: true,
defaultRadius: "sm",
defaultRadius: branding.defaultRadius,
fontSizes: {
"2xl": rem(24),
"3xl": rem(28),
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
</MantineProvider>
);
};
export const generateColors = (hex: string) => {
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
}
return darken(hex, lightness);
});
return rgbaColors.map((color) => {
return (
"#" +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color
.split("(")[1]!
.replaceAll(" ", "")
.replace(")", "")
.split(",")
.map((color) => parseInt(color, 10))
.slice(0, 3)
.map((color) => color.toString(16).padStart(2, "0"))
.join("")
);
}) as unknown as MantineColorsTuple;
};
@@ -0,0 +1,13 @@
"use client";
import type { WidgetKind } from "@homarr/definitions";
import { loadWidgetResources } from "@homarr/widgets/manifest";
// This sibling can hydrate before pending widget data. Start only the visible
// board's retryable, deduplicated module imports without awaiting the results.
export const WidgetResourcePreload = ({ kinds }: { kinds: WidgetKind[] }) => {
if (typeof window !== "undefined") {
for (const kind of kinds) void loadWidgetResources(kind).catch(() => undefined);
}
return null;
};
@@ -1,5 +1,6 @@
import { IconHomeOff } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found";
import { homarrLogoPath } from "~/components/layout/logo/constants";
export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync();
@@ -16,15 +18,16 @@ export default async function NotFoundBoardHomePage() {
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards);
const t = await getI18n();
const t = await getI18n("board");
if (boardCount === 0) {
const { branding } = await getRscServerSettingsAsync();
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("board.error.noBoard.title"),
description: t("board.error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"),
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
title: t("error.noBoard.title"),
description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
notice: t("error.noBoard.notice"),
};
}
@@ -39,9 +42,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
return {
icon: IconHomeOff,
title: t(`board.error.homeBoard.title`),
description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`board.error.homeBoard.${type}.notice`),
title: t("error.homeBoard.title"),
description: t(`error.homeBoard.${type}.description` as never),
link: { label: t(`error.homeBoard.${type}.link` as never), href },
notice: t(`error.homeBoard.${type}.notice` as never),
};
};
@@ -1,16 +1,16 @@
"use client";
import {
ActionIcon,
Anchor,
Button,
Collapse,
ColorInput,
ColorSwatch,
Grid,
Fieldset,
Group,
InputWrapper,
isLightColor,
Select,
SimpleGrid,
Slider,
Stack,
Text,
@@ -21,9 +21,10 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme";
import { generateColorScale } from "~/theme/branding";
import type { FormValues } from "./_settings-form";
interface Props {
@@ -36,89 +37,80 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
return (
<SectionCard title={t("board.setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
<Fieldset legend={tBoard("setting.section.appearance.title")} p="sm">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
<Stack gap="xs">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
<BoardColorInput
label={tBoard("field.primaryColor.label")}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.secondaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</SimpleGrid>
<Anchor onClick={toggle} size="sm" w="fit-content">
{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}
</Anchor>
<Collapse expanded={showPreview}>
<Stack>
<Stack gap="xs">
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
type="button"
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid>
</SectionCard>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
<InputWrapper label={tBoard("field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
<Group align="end" gap="xs" wrap="nowrap">
<ColorInput
label={tBoard("field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
style={{ minWidth: 0 }}
{...form.getInputProps("iconColor")}
/>
<ActionIcon
type="button"
variant="subtle"
size={36}
aria-label={tBoard("field.clearColor.label")}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
<IconX size={16} />
</ActionIcon>
</Group>
</SimpleGrid>
</Stack>
<CornerStylePicker
compact
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
value={form.values.itemRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, tBoard(`field.itemRadius.option.${cornerStyle}`)]),
) as Record<(typeof cornerStyleValues)[number], string>
}
onChange={(itemRadius) => form.setFieldValue("itemRadius", itemRadius)}
/>
</SimpleGrid>
</Fieldset>
);
};
@@ -129,7 +121,8 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
const colors =
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
return (
<Group gap={0} wrap="nowrap">
@@ -1,7 +1,7 @@
"use client";
import { startTransition } from "react";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { ActionIcon, Autocomplete, Center, Fieldset, Group, Popover, SimpleGrid, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
@@ -15,7 +15,6 @@ import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
@@ -23,7 +22,7 @@ interface Props {
}
export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n();
const tBoard = useI18n("board");
const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -44,106 +43,103 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return (
<SectionCard title={t("board.setting.section.background.title")}>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
</Popover.Dropdown>
</Popover>
)
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
<Fieldset legend={tBoard("setting.section.background.title")} p="sm">
<Stack gap="sm">
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
</Popover.Dropdown>
</Popover>
)
}
// We filter it on the server
filter={({ options }) => options}
label={tBoard("field.backgroundImageUrl.label")}
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
return (
<Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}>
<Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed">
{option.value}
</Text>
</Stack>
</Group>
);
return (
<Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}>
<Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed">
{option.value}
</Text>
</Stack>
</Group>
);
}}
data={[
{
group: tBoard("field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: tBoard("field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(uploadedMedias) => {
const first = uploadedMedias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(uploadedMedias) => {
const first = uploadedMedias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Grid.Col>
<Grid.Col span={12}>
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm" verticalSpacing="sm">
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
withinPortal
label={tBoard("field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
withinPortal
label={tBoard("field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
withinPortal
label={tBoard("field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
</SectionCard>
</SimpleGrid>
</Stack>
</Fieldset>
);
};
@@ -176,19 +172,20 @@ const useBackgroundOptionData = <
defaultValue: keyof TOptions;
},
) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
return data.values.map(
(value) =>
({
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never),
label: tBoard(`field.${key}.option.${value as string}.label` as never),
description: tBoard(`field.${key}.option.${value as string}.description` as never),
value: value as string,
badge:
data.defaultValue === value
? {
color: "blue",
label: t("common.select.badge.recommended"),
label: tCommon("select.badge.recommended"),
}
: undefined,
}) satisfies SelectItemWithDescriptionBadge,
@@ -13,13 +13,13 @@ interface Props {
}
export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n();
const t = useI18n("board");
return (
<SectionCard title={t("board.setting.section.behavior.title")}>
<SectionCard title={t("setting.section.behavior.title")}>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
label={t("field.disableStatus.label")}
description={t("field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</SectionCard>
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const t = useI18n();
const tBoard = useI18n("board");
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
mutate: userMutation.mutate,
isPending: userMutation.isPending,
}}
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
permission={{
items: boardPermissions,
default: "view",
@@ -1,31 +1,40 @@
"use client";
import { Alert, Input } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
import "~/styles/prismjs.scss";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { FormValues } from "./_settings-form";
import classes from "./customcss.module.css";
interface Props {
form: UseFormReturnType<FormValues>;
}
export const CustomCssSettingsContent = ({ form }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n("board");
const customCssT = useI18n("board.field.customCss");
return (
<SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<SectionCard title={t("setting.section.customCss.title")}>
<CustomCssEditor
id="board-custom-css"
label={customCssT("label")}
description={customCssT("description")}
actions={
isAdmin ? (
<WorkshopCssImportButton size="compact-xs" onImport={(css) => form.setFieldValue("customCss", css)} />
) : undefined
}
{...form.getInputProps("customCss")}
/>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
@@ -33,40 +42,3 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
</SectionCard>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
<Input.Wrapper
label={customCssT("label")}
labelProps={{
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
<Editor
textareaId="custom-css"
onValueChange={onChange}
value={value ?? ""}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
};
@@ -1,85 +1,87 @@
"use client";
import { useCallback } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import { Box, Button, Group, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { boardRenameSchema } from "@homarr/validation/board";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const utils = clientApi.useUtils();
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: board.name,
},
});
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
const onRenameClick = useCallback(
() =>
openModal({
useEffect(() => {
if (isRenaming) renameInputRef.current?.focus();
}, [isRenaming]);
const handleRename = renameForm.onSubmit((values) => {
renameBoard(
{ id: board.id, name: values.name },
{
onSuccess() {
router.push(`/boards/${values.name}/settings`);
},
},
);
});
const cancelRename = () => {
renameForm.reset();
setIsRenaming(false);
};
const handleVisibilityChange = () => {
changeVisibility(
{
id: board.id,
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
visibility: visibility === "public" ? "private" : "public",
},
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
);
};
const handleDelete = () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
};
return (
<DangerZoneRoot>
@@ -87,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
action={
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
isRenaming ? (
<Box component="form" onSubmit={handleRename}>
<Group wrap="wrap" justify="end">
<TextInput
ref={renameInputRef}
aria-label={t("section.dangerZone.action.rename.label")}
size="sm"
{...renameForm.getInputProps("name")}
/>
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
{tCommonAction("cancel")}
</Button>
<Button type="submit" color="red" loading={isRenamePending}>
{tCommonAction("confirm")}
</Button>
</Group>
</Box>
) : (
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
{t("section.dangerZone.action.rename.button")}
</Button>
)
}
/>
{!hideVisibility && (
@@ -97,19 +118,31 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isChangeVisibilityPending}
onConfirm={handleVisibilityChange}
confirmLabel={tCommonAction("confirm")}
>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button>
</InlineConfirmButton>
}
/>
)}
<DangerZoneItem
label={t("section.dangerZone.action.delete.label")}
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{t("section.dangerZone.action.delete.button")}
</Button>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isDeletePending}
onConfirm={handleDelete}
confirmLabel={tCommonAction("confirm")}
>
{tCommonAction("delete")}
</InlineConfirmButton>
}
/>
</DangerZoneRoot>
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form";
@@ -20,25 +20,27 @@ interface Props {
}
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n();
const t = useI18n("board");
const { branding } = useSettings();
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
return (
<SectionCard title={t("board.setting.section.general.title")}>
<SectionCard title={t("setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
label={t("field.pageTitle.label")}
placeholder={branding.appName}
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
label={t("field.metaTitle.label")}
placeholder={defaultMetaTitle}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
@@ -46,16 +48,16 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
label={t("field.logoImageUrl.label")}
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
label={t("field.faviconImageUrl.label")}
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false}
/>
</Grid.Col>
@@ -76,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
}, [logoDebounced, updateBoard]);
};
const useMetaTitlePreview = (title: string | null) => {
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced);
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
return {
isPending: (title ?? "") !== metaTitleDebounced,
@@ -0,0 +1,11 @@
.canvas {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
padding: var(--mantine-spacing-xs);
min-height: 7rem;
max-height: min(18rem, 38dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
@@ -0,0 +1,59 @@
"use client";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useI18n } from "@homarr/translation/client";
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
import type { Board } from "../../_types";
import classes from "./_layout-preview.module.css";
interface Props {
board: Board;
layout: BoardPreviewLayout;
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
apps: RouterOutputs["app"]["byIds"];
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const tBoard = useI18n("board");
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
const largestRepresentativeWidth = Math.max(
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
);
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
const appsById = new Map(apps.map((app) => [app.id, app]));
const preview = {
...board,
items: board.items.map((item) => {
if (item.kind !== "app" || typeof item.options.appId !== "string") return item;
return { ...item, iconUrl: appsById.get(item.options.appId)?.iconUrl };
}),
};
return (
<Stack gap={6} align="center" w="100%">
<Group gap="xs" justify="center">
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
{representativeWidth}px
</Badge>
<Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
</Text>
</Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
<BoardLayoutThumbnail
preview={preview}
layout={layout}
sourceLayout={sourceLayout}
label={tBoard("setting.section.layout.title")}
previewRowLimit={maxBoardLayoutThumbnailRows}
/>
</Box>
</Stack>
);
};
@@ -0,0 +1,18 @@
.gutterSettings {
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
}
.gutterControls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: var(--mantine-spacing-md);
}
.gutterControls > div {
min-width: 0;
}
.gutterWidth {
padding-inline: 4px;
padding-bottom: var(--mantine-spacing-lg);
}
@@ -1,80 +1,385 @@
"use client";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
Box,
Button,
Fieldset,
Grid,
Group,
Input,
NumberInput,
Paper,
Slider,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
import { LayoutPreview } from "./_layout-preview";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
isSaving: boolean;
saveSettingsAsync: () => Promise<FormValues | null>;
}
export const LayoutSettingsContent = ({ form }: Props) => {
const t = useI18n();
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
message: tCommon("notification.update.error"),
});
},
});
const appIds = useMemo(
() =>
Array.from(
new Set(
board.items.flatMap((item) =>
item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [],
),
),
),
[board.items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
setEditingLayoutId(layout.id);
try {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const savedValues = form.isDirty() || !persistedLayout ? await saveSettingsAsync() : form.values;
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
if (!canonicalLayout) return;
router.push(`/boards/${board.name}?layout=${encodeURIComponent(canonicalLayout.id)}&edit=true&returnTo=settings`);
} finally {
setEditingLayoutId(null);
}
};
const handleReset = async (layout: FormValues["layouts"][number]) => {
setResettingLayoutId(layout.id);
try {
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
await utils.board.getBoardByName.invalidate({ name: board.name });
router.refresh();
} catch {
// The mutation callback displays the error notification.
} finally {
setResettingLayoutId(null);
}
};
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return (
<SectionCard title={t("board.setting.section.layout.title")}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<SectionCard title={tBoard("setting.section.layout.title")}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
<Text size="sm">{tBoard("setting.section.layout.responsive.description")}</Text>
</Stack>
<Button
type="button"
variant="subtle"
variant="light"
disabled={nextBreakpoint === null || !baseLayout}
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
}));
if (nextBreakpoint === null || !baseLayout) return;
const newLayout: FormValues["layouts"][number] = {
id: createId(),
name: tBoard("setting.section.layout.custom.defaultName"),
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
breakpoint: nextBreakpoint,
role: "custom",
};
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
form.insertListItem("layouts", newLayout, insertionIndex);
}}
>
{t("board.setting.section.layout.responsive.action.add")}
{tBoard("setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
{displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
return (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
bg="transparent"
>
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
min={1}
max={24}
step={1}
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
styles={{
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
}}
value={layout.columnCount}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
/>
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")}
description={
layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
{sourceLayout && (
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
/>
)}
</Grid.Col>
{layout.role !== "mobile" && (
<Grid.Col span={{ base: 12, lg: 4 }}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
</Grid.Col>
)}
</Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</Button>
)}
</Group>
<Button
type="button"
variant="subtle"
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}));
}}
variant="default"
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
loading={editingLayoutId === layout.id}
disabled={isSaving || !form.isValid()}
onClick={() => void openLayoutEditorAsync(layout)}
>
{t("common.action.remove")}
{form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
</Button>
</Group>
)}
</Fieldset>
))}
</Fieldset>
);
})}
</Stack>
</SectionCard>
);
};
const findCanonicalLayout = (layouts: FormValues["layouts"], layout: FormValues["layouts"][number]) =>
layouts.find((candidate) => candidate.id === layout.id) ??
layouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role);
const getNextCustomBreakpoint = (layouts: Array<{ breakpoint: number }>) => {
const sortedBreakpoints = layouts
.map((layout) => layout.breakpoint)
.toSorted((breakpointA, breakpointB) => breakpointA - breakpointB);
let largestGap: { start: number; end: number } | null = null;
for (let index = 0; index < sortedBreakpoints.length - 1; index++) {
const start = sortedBreakpoints[index];
const end = sortedBreakpoints[index + 1];
if (start === undefined || end === undefined || end - start <= 1) continue;
if (!largestGap || end - start > largestGap.end - largestGap.start) largestGap = { start, end };
}
return largestGap ? Math.floor((largestGap.start + largestGap.end) / 2) : null;
};
interface GutterSettingsProps {
left: number;
right: number;
columnCount: number;
onLeftChange: (value: number) => void;
onRightChange: (value: number) => void;
}
const gutterMarks = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n("layout");
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return (
<Paper className={classes.gutterSettings} p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("field.gutters.label")}</Text>
<Text size="sm" c="dimmed">
{t("field.gutters.description")}
</Text>
</Box>
<div className={classes.gutterControls}>
<div>
<GutterControl
side="left"
value={left}
max={Math.min(maxGutterWidth, columnCount - right - 1)}
onChange={onLeftChange}
/>
</div>
<div>
<GutterControl
side="right"
value={right}
max={Math.min(maxGutterWidth, columnCount - left - 1)}
onChange={onRightChange}
/>
</div>
</div>
</Stack>
</Paper>
);
};
const GutterControl = ({
side,
value,
max,
onChange,
}: {
side: "left" | "right";
value: number;
max: number;
onChange: (value: number) => void;
}) => {
const t = useI18n("layout");
const enabled = value > 0;
return (
<Stack gap="sm">
<Switch
checked={enabled}
disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`field.gutters.${side}.label` as never)}
/>
{enabled && (
<Input.Wrapper className={classes.gutterWidth} label={t("field.gutters.width.label")}>
<Slider
mt="xs"
min={1}
max={max}
value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks
thumbLabel={t(`field.gutters.${side}.thumbLabel` as never)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/>
</Input.Wrapper>
)}
</Stack>
);
};
@@ -10,12 +10,14 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
@@ -36,6 +38,11 @@ const boardSettingsFormSchema = boardSavePartialSettingsSchema
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
const normalizeMobileLayoutGutters = (layouts: readonly FormValues["layouts"][number][]) =>
layouts.map((layout) =>
layout.role === "mobile" ? { ...layout, leftGutterColumnCount: 0, rightGutterColumnCount: 0 } : layout,
);
const PARTIAL_FORM_KEYS = [
"pageTitle",
"metaTitle",
@@ -70,7 +77,7 @@ const buildInitialValues = (board: Board): FormValues => ({
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
layouts: board.layouts,
layouts: normalizeMobileLayoutGutters(board.layouts),
});
interface BoardSettingsFormProps {
@@ -81,8 +88,9 @@ interface BoardSettingsFormProps {
}
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n();
const tSection = useScopedI18n("board.setting.section");
const t = useI18n("common");
const tSection = useI18n("board.setting.section");
const { branding } = useSettings();
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
@@ -100,8 +108,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
useEffect(() => {
return () => {
@@ -113,55 +120,47 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
}, [updateBoard]);
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const saveSettingsAsync = async (values: FormValues): Promise<FormValues | null> => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const { layouts, ...partialSettings } = values;
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
{
when: changed(...PARTIAL_FORM_KEYS),
action: () =>
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
}),
},
{
when: changed("layouts"),
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
},
];
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
if (promises.length === 0) return;
const { layouts: submittedLayouts, ...partialSettings } = values;
const layouts = normalizeMobileLayoutGutters(submittedLayouts);
const partialSettingsChanged = changed(...PARTIAL_FORM_KEYS);
const layoutsChanged = changed("layouts");
if (!partialSettingsChanged && !layoutsChanged) return values;
try {
await Promise.all(promises);
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
const [, canonicalLayouts] = await Promise.all([
partialSettingsChanged
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
);
})
: Promise.resolve(),
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
]);
const canonicalValues = canonicalLayouts ? { ...values, layouts: canonicalLayouts } : values;
lastSavedRef.current = { pageTitle: canonicalValues.pageTitle, logoImageUrl: canonicalValues.logoImageUrl };
initialValuesRef.current = canonicalValues;
form.setValues(canonicalValues);
form.setInitialValues(canonicalValues);
form.resetDirty(canonicalValues);
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
title: t("notification.update.success"),
message: t("notification.update.success"),
});
return canonicalValues;
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
title: t("notification.update.error"),
message: t("notification.update.error"),
});
return null;
}
};
@@ -179,10 +178,15 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<LayoutSettingsContent form={form} />
<LayoutSettingsContent
board={board}
form={form}
isSaving={isPending}
saveSettingsAsync={() => saveSettingsAsync(form.values)}
/>
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
<CustomCssSettingsContent form={form} />
@@ -191,10 +195,10 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
@@ -1,22 +0,0 @@
.codeEditorFooter {
border-bottom-left-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.codeEditorRoot {
margin-top: 4px;
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-width: 1px;
border-style: solid;
border-radius: var(--mantine-radius-sm);
}
.codeEditor {
background-color: light-dark(white, var(--mantine-color-dark-6));
font-size: var(--mantine-font-size-xs);
}
.codeEditor ::placeholder {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardSettingsForm } from "./_settings-form";
@@ -46,14 +46,14 @@ export default async function BoardSettingsPage(props: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting");
const t = await getI18n("board.setting");
if (!hasChangeAccess) {
notFound();
}
return (
<Container>
<Container size="xl" px={{ base: "md", md: "xl" }}>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<BoardSettingsForm
@@ -1,21 +1,26 @@
import { cache } from "react";
import type { JSX, PropsWithChildren } from "react";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { appShellLogoHeight } from "~/components/layout/constants";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProviders } from "./_providers";
import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
@@ -24,13 +29,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
headerActions?: JSX.Element;
headerBoardEditAction?: JSX.Element;
headerBoardSettingsAction?: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
headerBoardEditAction,
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => {
@@ -47,6 +56,7 @@ export const createBoardLayout = <TParams extends Params>({
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const initialViewportWidthPromise = getInitialViewportWidthAsync();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
@@ -58,11 +68,12 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, initialViewportWidth] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
initialViewportWidthPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
@@ -83,29 +94,58 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const { hasChangeAccess } = constructBoardPermissions(initialBoard, session);
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
return (
<BoardProvider initialBoard={initialBoard}>
<BoardProviders
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
canModify={hasChangeAccess}
>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<ModalProvider>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</BoardMantineProvider>
</EditModeProvider>
</ModalProvider>
</BoardMantineProvider>
</BoardReadyProvider>
</BoardProvider>
</BoardProviders>
);
};
return Layout;
};
export const getInitialViewportWidthAsync = cache(async () => {
const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value;
const cookieWidth = Number(cookieValue);
if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) {
return cookieWidth;
}
const requestHeaders = await headers();
const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width"));
if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) {
return clientHintWidth;
}
const userAgent = requestHeaders.get("user-agent") ?? "";
if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024;
if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390;
return 1440;
});
@@ -0,0 +1,107 @@
import { createElement, act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardProviders } from "./_providers";
const mocks = vi.hoisted(() => ({
searchParams: new URLSearchParams(),
layoutOverrideId: undefined as string | null | undefined,
initialOpen: undefined as boolean | undefined,
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => mocks.searchParams,
}));
vi.mock("@homarr/boards/context", async () => {
const React = await import("react");
return {
BoardProvider: ({ children, layoutOverrideId }: { children: React.ReactNode; layoutOverrideId: string | null }) => {
mocks.layoutOverrideId = layoutOverrideId;
return React.createElement(React.Fragment, null, children);
},
};
});
vi.mock("@homarr/boards/edit-mode", async () => {
const React = await import("react");
return {
EditModeProvider: ({ children, initialOpen }: { children: React.ReactNode; initialOpen: boolean }) => {
mocks.initialOpen = initialOpen;
return React.createElement(React.Fragment, null, children);
},
};
});
describe("board providers", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
mocks.layoutOverrideId = undefined;
mocks.initialOpen = undefined;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test.each([
["base", "base"],
["custom", "custom"],
])("uses the requested %s layout for an explicit edit request", (_name, requestedLayoutId) => {
mocks.searchParams = new URLSearchParams(`layout=${requestedLayoutId}&edit=true&returnTo=settings`);
act(() => {
root.render(
createElement(
BoardProviders,
{
initialBoard: { layouts: [{ id: "mobile" }, { id: "custom" }, { id: "base" }] } as never,
initialLayoutId: "mobile",
initialViewportWidth: 390,
canModify: true,
},
createElement("span", null, "board"),
),
);
});
expect(mocks.layoutOverrideId).toBe(requestedLayoutId);
expect(mocks.initialOpen).toBe(true);
});
test.each([
["without edit mode", "layout=base", true],
["for an unknown layout", "layout=unknown&edit=true", true],
["without modify access", "layout=base&edit=true", false],
])("keeps viewport layout selection %s", (_name, query, canModify) => {
mocks.searchParams = new URLSearchParams(query);
act(() => {
root.render(
createElement(
BoardProviders,
{
initialBoard: { layouts: [{ id: "mobile" }, { id: "base" }] } as never,
initialLayoutId: "mobile",
initialViewportWidth: 390,
canModify,
},
createElement("span", null, "board"),
),
);
});
expect(mocks.layoutOverrideId).toBeNull();
expect(mocks.initialOpen).toBe(false);
});
});
@@ -0,0 +1,41 @@
"use client";
import type { PropsWithChildren } from "react";
import { useSearchParams } from "next/navigation";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import type { Board } from "./_types";
interface Props {
initialBoard: Board;
initialLayoutId: string;
initialViewportWidth: number;
canModify: boolean;
}
export const BoardProviders = ({
children,
initialBoard,
initialLayoutId,
initialViewportWidth,
canModify,
}: PropsWithChildren<Props>) => {
const searchParams = useSearchParams();
const requestedLayoutId = searchParams.get("layout");
const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId);
const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid;
const layoutOverrideId = isLayoutEditRequest ? requestedLayoutId : null;
return (
<BoardProvider
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
layoutOverrideId={layoutOverrideId}
>
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
</BoardProvider>
);
};
@@ -7,10 +7,9 @@ export type Item = Board["items"][number];
export type ItemLayout = Item["layouts"][number];
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type DynamicSectionLayout = DynamicSection["layouts"][number];
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
export type ContainerSection = Extract<Section, { kind: "container" }>;
export type ContainerSectionLayout = ContainerSection["layouts"][number];
export type ContainerSectionItem = Omit<ContainerSection, "layouts"> & ContainerSectionLayout & { type: "section" };
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
@@ -0,0 +1,176 @@
"use client";
import { useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Center,
PasswordInput,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import { isCustomWidgetSourceUrlPlaceholder } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import { useI18n } from "@homarr/translation/client";
interface RequestDetails {
widgetName: string;
sourceName: string;
kinds: Array<"apiKey" | "username" | "password">;
expiresAt: number;
status: "pending" | "applying" | "completed";
source: CustomWidgetSource;
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const t = useI18n("customWidget.secretEntry");
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [integrationId, setIntegrationId] = useState<string>();
const [baseUrl, setBaseUrl] = useState("");
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
void fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`)
.then(async (response) => {
const body = (await response.json()) as RequestDetails | { error: string };
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
setDetails(body);
const sourceBaseUrl = body.source.baseUrl ?? "";
setBaseUrl(isCustomWidgetSourceUrlPlaceholder(sourceBaseUrl) ? "" : sourceBaseUrl);
setNetworkScope(body.source.networkScope ?? "public");
setIntegrationId(body.source.integrationId);
})
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
.finally(() => setLoading(false));
}, [t, token]);
const submit = async () => {
setSaving(true);
setError(null);
try {
let configuration: unknown = { baseUrl, networkScope, secrets: values };
if (details?.source.type === "integration") configuration = { integrationId, secrets: {} };
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(configuration),
});
const body = (await response.json()) as { status?: string; error?: string };
if (!response.ok) throw new Error(body.error ?? t("saveError"));
setDetails((current) => (current ? { ...current, status: "completed" } : current));
setValues({});
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("saveError"));
} finally {
setSaving(false);
}
};
return (
<Center mih="100dvh" p="md">
<Card withBorder shadow="md" radius="lg" p="xl" w="100%" maw={480}>
<Stack gap="lg">
<ThemeIcon size={48} radius="xl" variant="light">
<IconLock size={24} />
</ThemeIcon>
<Stack gap={4}>
<Title order={1} size="h2">
{t("title")}
</Title>
<Text c="dimmed">{t("description")}</Text>
</Stack>
{loading && <Text c="dimmed">{t("loading")}</Text>}
{error && <Alert color="red">{error}</Alert>}
{details?.status === "completed" && (
<Alert color="green" icon={<IconCheck size={18} />}>
{t("saved")}
</Alert>
)}
{details?.status === "applying" && <Alert color="blue">{t("applying")}</Alert>}
{details?.status === "pending" && (
<Stack gap="md">
<Card withBorder bg="var(--mantine-color-default-hover)">
<Text fw={600}>{details.widgetName}</Text>
<Text size="sm" c="dimmed">
{t("source", { name: details.sourceName })}
</Text>
</Card>
{details.source.type === "integration" && (
<IntegrationSourceSelect
kind={details.source.integrationKind}
integrationId={integrationId}
onChange={setIntegrationId}
/>
)}
{details.source.type !== "integration" && (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
placeholder={details.source.baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
</>
)}
{details.kinds.map((kind) => {
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Input
key={kind}
label={tSecret(kind)}
leftSection={<IconKey size={16} />}
value={values[kind] ?? ""}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [kind]: value }));
}}
autoComplete="off"
required
/>
);
})}
<Button
loading={saving}
disabled={
(details.source.type === "integration"
? !integrationId
: !URL.canParse(baseUrl) || isCustomWidgetSourceUrlPlaceholder(baseUrl)) ||
details.kinds.some((kind) => !values[kind])
}
onClick={() => void submit()}
>
{t("save")}
</Button>
<Text size="xs" c="dimmed">
{t("expires", { time: new Date(details.expiresAt).toLocaleTimeString() })}
</Text>
</Stack>
)}
</Stack>
</Card>
</Center>
);
}
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { createLoginUrl } from "@homarr/auth/shared";
import { CustomWidgetConfigurationEntry } from "./configuration-entry";
export const metadata: Metadata = {
title: "Configure custom widget API source",
robots: { index: false, follow: false },
};
export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) {
const [session, { token }] = await Promise.all([auth(), params]);
if (!session) redirect(createLoginUrl(`/custom-widget-configuration/${token}`));
if (!session.user.permissions.includes("admin")) redirect("/");
return <CustomWidgetConfigurationEntry token={token} />;
}
@@ -1,12 +0,0 @@
import type { PropsWithChildren } from "react";
import { Card } from "@mantine/core";
interface InitStepCardProps {
withBorder?: boolean;
}
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
<Card w="100%" withBorder={withBorder}>
{children}
</Card>
);
@@ -1,2 +0,0 @@
export const onboardingContentWidth = 64 * 16;
export const onboardingMaxWidth = "90vw";
@@ -1,23 +0,0 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};
@@ -1,75 +0,0 @@
import type { MantineColor } from "@mantine/core";
import { Button, Stack, Text } from "@mantine/core";
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { InitStepCard } from "../../_components/init-step-card";
export const InitFinish = async () => {
const tFinish = await getScopedI18n("init.step.finish");
return (
<InitStepCard>
<Stack>
<Text>{tFinish("description")}</Text>
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
{tFinish("action.goToBoard", { name: "dashboard" })}
</InternalLinkButton>
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</InitStepCard>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
@@ -1,55 +0,0 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
import { InitStepCard } from "../../_components/init-step-card";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
await mutateAsync(values, {
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<InitStepCard>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</InitStepCard>
);
};
@@ -1,43 +0,0 @@
import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<InitStepCard withBorder={false}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</InitStepCard>
);
};
@@ -1,63 +0,0 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -1,54 +0,0 @@
"use client";
import { startTransition, useState } from "react";
import { Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<InitStepCard>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</InitStepCard>
);
}
return (
<Stack w="100%" mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack>
);
};
@@ -1,209 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Avatar,
Badge,
Card,
Collapse,
Group,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
export interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}
export interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}
interface DockerDiscoveryIndicatorProps {
integrations: DiscoveredIntegration[];
apps: DiscoveredApp[];
selectedAppIds: Set<string>;
onToggleApp: (containerId: string) => void;
isLoading: boolean;
}
const APP_CARD_HEIGHT = 80;
export const DockerDiscoveryIndicator = ({
integrations,
apps,
selectedAppIds,
onToggleApp,
isLoading,
}: DockerDiscoveryIndicatorProps) => {
const hasApps = apps.length > 0;
const [expanded, setExpanded] = useState(false);
const expandedOnce = useRef(false);
useEffect(() => {
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
expandedOnce.current = true;
setExpanded(true);
}
}, [hasApps, integrations.length]);
const t = useScopedI18n("init.step.integrations.docker");
const totalCount = integrations.length + apps.length;
if (!isLoading && totalCount === 0) return null;
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
if (isLoading) {
return (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{t("scanning")}
</Text>
<Text size="xs" c="dimmed">
{t("scanningDescription")}
</Text>
</Stack>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
);
}
return (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{t("detected", { count: String(totalCount) })}
</Text>
<Text size="xs" c="dimmed">
{t("detectedBreakdown", {
integrations: String(integrations.length),
apps: String(apps.length),
})}
</Text>
</Stack>
</Group>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: expanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
</UnstyledButton>
<Collapse expanded={expanded}>
<Stack gap="xs" mt="sm">
{integrations.length > 0 && (
<Stack gap={4}>
{integrations.map((integration) => (
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
<IntegrationAvatar kind={integration.kind} size="xs" />
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{integration.containerName}
</Text>
<Badge size="xs" variant="light" color="blue">
{getIntegrationName(integration.kind)}
</Badge>
</Group>
))}
</Stack>
)}
{hasApps && (
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<ScrollArea.Autosize mah="30vh">
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{apps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={APP_CARD_HEIGHT}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => onToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
</Collapse>
</Paper>
);
};
@@ -1,483 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
ActionIcon,
Alert,
Avatar,
Button,
Card,
Center,
Collapse,
Group,
Loader,
Paper,
Progress,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowRight,
IconBrandDocker,
IconCheck,
IconChevronDown,
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
import { InitStepCard } from "../../_components/init-step-card";
type Phase = "select" | "configure" | "done";
interface IntegrationDraft {
kind: IntegrationKind;
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker";
}
export const InitIntegrations = () => {
const [phase, setPhase] = useState<Phase>("select");
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [baseHost, setBaseHost] = useState("");
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
const t = useScopedI18n("init.step.integrations");
const router = useRouter();
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
{
retry: false,
refetchOnWindowFocus: false,
},
);
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
const totalDetected = dockerIntegrations.length + dockerApps.length;
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const [appsExpanded, setAppsExpanded] = useState(false);
const dockerApplied = useRef(false);
useEffect(() => {
if (dockerData?.status !== "success" || dockerApplied.current) return;
dockerApplied.current = true;
const newDrafts = new Map(drafts);
const newKinds = new Set(selectedKinds);
for (const item of dockerData.integrations) {
if (!newDrafts.has(item.kind)) {
newDrafts.set(item.kind, {
kind: item.kind,
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: "docker",
});
}
newKinds.add(item.kind);
}
setDrafts(newDrafts);
setSelectedKinds(Array.from(newKinds));
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
if (dockerData.apps.length > 0) {
setAppsExpanded(true);
}
}, [dockerData]);
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
setSelectedKinds(kinds);
}, []);
const handleToggleApp = useCallback((containerId: string) => {
setSelectedAppIds((prev) => {
const next = new Set(prev);
if (next.has(containerId)) {
next.delete(containerId);
} else {
next.add(containerId);
}
return next;
});
}, []);
const handleContinueToConfig = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
if (baseHost) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
initialName: existing?.initialName,
dockerPort: existing?.dockerPort,
source: existing?.source ?? "manual",
});
} else if (!existing) {
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
}
}
setDrafts(newDrafts);
setCurrentIndex(0);
setPhase("configure");
};
const finishSetupAsync = async () => {
setPhase("done");
try {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
await createApps(
appsToCreate.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
return {
name: app.containerName,
href: href || null,
iconUrl: app.iconUrl,
};
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
showErrorNotification({
title: t("configure.error.title"),
message: t("configure.error.message"),
});
setPhase("select");
}
};
const advanceOrFinish = () => {
if (currentIndex + 1 >= selectedKinds.length) {
void finishSetupAsync();
} else {
setCurrentIndex((i) => i + 1);
}
};
const backToSelect = () => {
setPhase("select");
setCurrentIndex(0);
};
if (phase === "done") {
return (
<InitStepCard>
<Center py="xl">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t("configure.finishing")}</Text>
</Stack>
</Center>
</InitStepCard>
);
}
if (phase === "configure") {
return (
<ConfigurePhase
selectedKinds={selectedKinds}
currentIndex={currentIndex}
drafts={drafts}
baseHost={baseHost}
setBaseHost={setBaseHost}
urlMode={urlMode}
setUrlMode={setUrlMode}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
onSkip={advanceOrFinish}
/>
);
}
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
return (
<InitStepCard>
<Stack>
<Text>{t("description")}</Text>
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("urlTemplate.warning")}
</Text>
<Group gap="sm" grow>
<TextInput
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
data-autofocus
/>
<SegmentedControl
value={urlMode}
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
data={[
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
]}
style={{ flex: "0 0 auto" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
</Text>
)}
</Stack>
</Alert>
{isDockerLoading && (
<Paper withBorder p="sm" radius="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm" fw={500}>
{t("docker.scanning")}
</Text>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
)}
{!isDockerLoading && totalDetected > 0 && (
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm">
{t("docker.summary", {
apps: String(totalDetected),
integrations: String(dockerIntegrations.length),
})}
</Text>
</Group>
</Paper>
)}
<IntegrationMultiSelectGrid
selectedKinds={selectedKinds}
onSelectionChange={handleSelectionChange}
detectedKinds={detectedKinds}
onboarding
/>
{dockerApps.length > 0 && (
<Paper withBorder p="sm" radius="md">
<Stack gap="xs">
<Group
justify="space-between"
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => setAppsExpanded((prev) => !prev)}
>
<Text size="sm" fw={500}>
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: appsExpanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
<Collapse expanded={appsExpanded}>
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{dockerApps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={80}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => handleToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Collapse>
</Stack>
</Paper>
)}
<Group justify="space-between">
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
{t("action.skip")}
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
</Button>
</Group>
</Stack>
</InitStepCard>
);
};
interface ConfigurePhaseProps {
selectedKinds: IntegrationKind[];
currentIndex: number;
drafts: Map<IntegrationKind, IntegrationDraft>;
baseHost: string;
setBaseHost: (value: string) => void;
urlMode: UrlTemplateMode;
setUrlMode: (value: UrlTemplateMode) => void;
onSuccess: () => void;
onCancel: () => void;
onSkip: () => void;
}
const ConfigurePhase = ({
selectedKinds,
currentIndex,
drafts,
baseHost,
setBaseHost,
urlMode,
setUrlMode,
onSuccess,
onCancel,
onSkip,
}: ConfigurePhaseProps) => {
const t = useScopedI18n("init.step.integrations");
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const computedUrl = baseHost
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
return (
<InitStepCard>
<Stack>
<Group justify="space-between" align="center">
<Group gap="xs">
<IntegrationAvatar kind={currentKind} size="sm" />
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
</Group>
<Tooltip label={t("configure.helpers.tooltip")}>
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
<IconSettings size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
<Stack gap={4}>
<Progress value={progress} size="sm" />
<Text size="xs" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
</Stack>
<Collapse expanded={helpersOpen}>
<Stack
gap="xs"
p="xs"
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
>
<Text size="xs" c="dimmed">
{t("configure.helpers.description")}
</Text>
<Group gap="sm" grow>
<TextInput
size="xs"
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
/>
<SegmentedControl
size="xs"
value={urlMode}
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
data={[
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
]}
style={{ flex: "0 0 auto" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", {
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
})}
</Text>
)}
</Stack>
</Collapse>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
initialUrl={computedUrl}
initialName={draft?.initialName}
onSuccess={onSuccess}
onCancel={onCancel}
onSkip={onSkip}
isOnboarding
/>
</Stack>
</InitStepCard>
);
};
@@ -1,129 +0,0 @@
"use client";
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { settingsInitSchema } from "@homarr/validation/settings";
import { InitStepCard } from "../../_components/init-step-card";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
await mutateAsync(values);
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack w="100%">
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
</Stack>
</Stack>
</InitStepCard>
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</InitStepCard>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};
@@ -1,37 +0,0 @@
import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
);
};
@@ -1,28 +0,0 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};
@@ -1,39 +0,0 @@
"use client";
import { useState } from "react";
import { Button, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const InitSqliteRestore = () => {
const t = useScopedI18n("init.step.start.action");
const [expanded, setExpanded] = useState(false);
if (expanded) {
return (
<Stack gap="sm">
<Button variant="subtle" size="xs" onClick={() => setExpanded(false)}>
{t("backToOptions")}
</Button>
<Text size="sm" c="dimmed">
{t("restoreSqliteDescription")}
</Text>
<DatabaseRestoreFlow variant="standalone" />
</Stack>
);
}
return (
<Button
onClick={() => setExpanded(true)}
variant="default"
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
>
{t("restoreSqlite")}
</Button>
);
};
@@ -1,75 +0,0 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => {
const t = useScopedI18n("user");
const tUser = useScopedI18n("init.step.user");
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(userInitSchema, {
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
onSuccess() {
showSuccessNotification({
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
void signIn("credentials", {
name: values.username,
password: values.password,
redirect: false,
}).then(async () => {
await revalidatePathActionAsync("/init");
});
},
onError: (error) => {
showErrorNotification({
title: tUser("notification.error.title"),
message: error.message,
});
},
});
};
return (
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(values) => void handleSubmitAsync(values),
(err) => console.log(err),
)}
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
</Stack>
</form>
</Stack>
);
};
type FormType = z.infer<typeof userInitSchema>;
@@ -1,10 +0,0 @@
import { InitStepCard } from "../../_components/init-step-card";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<InitStepCard>
<InitUserForm />
</InitStepCard>
);
};
+69 -58
View File
@@ -1,66 +1,77 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { headers } from "next/headers";
import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db, eq } from "@homarr/db";
import { groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { env as dockerEnv } from "@homarr/docker/env";
import { OnboardingStudio } from "@homarr/onboarding";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitIntegrations } from "./_steps/integrations/init-integrations";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish,
};
const getCurrentOnboardingStepAsync = async () => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
import { DatabaseRestoreFlow } from "~/components/backup";
import { env } from "~/env";
import { getPackageVersion } from "~/versions/package-reader";
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await getCurrentOnboardingStepAsync();
const CurrentComponent = stepComponents[currentStep.current];
const [state, session, requestHeaders, firstUser] = await Promise.all([
db.query.onboarding.findFirst(),
auth(),
headers(),
db.query.users.findFirst({ columns: { id: true } }),
]);
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
const canReadSetupContext =
canConfigurePrivileged || (!firstUser && (await isOnboardingClaimValidAsync(db, onboardingClaim)));
const [defaultGroup, availableBoards] = canReadSetupContext
? await Promise.all([
db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
columns: { homeBoardId: true },
}),
db.query.boards.findMany({ columns: { id: true, name: true, primaryColor: true, secondaryColor: true } }),
])
: [undefined, []];
const homeBoardId = defaultGroup?.homeBoardId;
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
const currentStep = normalizeOnboardingStep(state?.step);
const databaseDriver = dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : "postgresql";
const { workshopApiUrl, workshopWebUrl } = canReadSetupContext
? resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
})
: { workshopApiUrl: "", workshopWebUrl: "" };
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
<OnboardingStudio
environment={{
version: getPackageVersion(),
currentStep,
databaseDriver,
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
dockerConfigured: canReadSetupContext && dockerEnv.ENABLE_DOCKER,
kubernetesConfigured: canReadSetupContext && dockerEnv.ENABLE_KUBERNETES,
workshopApiUrl,
workshopUrl: workshopWebUrl,
serverOrigin: baseUrl,
mcpEndpoint: `${baseUrl}/api/mcp`,
canConfigurePrivileged,
hasUsers: Boolean(firstUser),
initialBoard: initialBoard ?? null,
availableBoards: availableBoards.map(({ id, name }) => ({ id, name })),
}}
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
assistantConfiguration={canConfigurePrivileged ? <AssistantConfiguration /> : undefined}
/>
);
}
+85 -37
View File
@@ -3,19 +3,23 @@ import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@mantine/lightbox/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "flag-icons/css/flag-icons.min.css";
import "mantine-datatable/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { ColorSchemeScript } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { env as authEnv } from "@homarr/auth/env";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { getRscAssistantAvailabilityAsync } from "@homarr/api/assistant-availability-server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
@@ -24,12 +28,16 @@ import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics";
import { AssistantGate } from "~/components/assistant/assistant-gate";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { env } from "~/env";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { ViewportHint } from "~/components/layout/viewport-hint";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai";
@@ -45,35 +53,45 @@ const fontSans = Inter({
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
openGraph: {
title: "Homarr Dashboard",
description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
url: "https://homarr.dev",
siteName: "Homarr",
},
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
},
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
},
});
export const generateMetadata = async (): Promise<Metadata> => {
const [serverSettings, colorScheme, t] = await Promise.all([
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
getI18n("metadata"),
]);
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
const logo = logoImageUrl ?? "/logo/logo.png";
const favicon = faviconImageUrl ?? logo;
const description = t("description");
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
return {
title: {
default: appName,
template: `%s • ${appName}`,
},
description,
openGraph: {
title: t("dashboardTitle", { appName }),
description,
url: env.HOMARR_WEBSITE_URL,
siteName: appName,
},
icons: {
icon: favicon,
apple: logo,
},
appleWebApp: {
title: appName,
capable: true,
startupImage: { url: logo },
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
},
};
};
export const generateViewport = async (): Promise<Viewport> => {
const serverSettings = await getRscServerSettingsAsync();
return { themeColor: serverSettings.branding.primaryColor };
};
export default async function Layout(props: {
@@ -86,6 +104,17 @@ export default async function Layout(props: {
}
const sessionPromise = auth();
const serverSettingsPromise = getRscServerSettingsAsync();
const assistantAvailabilityPromise = sessionPromise.then(async (session) => {
if (!session) return "unauthenticated" as const;
try {
const enabled = await getRscAssistantAvailabilityAsync();
return enabled ? ("enabled" as const) : ("unconfigured" as const);
} catch {
return "error" as const;
}
});
const userPromise = sessionPromise.then((session) =>
session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
@@ -94,17 +123,23 @@ export default async function Layout(props: {
})
: null,
);
const [session, user, serverSettings, colorScheme] = await Promise.all([
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
serverSettingsPromise,
getCurrentColorSchemeAsync(),
assistantAvailabilityPromise,
]);
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const publicUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const StackedProvider = composeWrappers([
(innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
return <AuthProvider session={session} logoutUrl={authEnv.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
},
(innerProps) => (
<SettingsProvider
@@ -126,6 +161,7 @@ export default async function Layout(props: {
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
}}
{...innerProps}
/>
@@ -134,28 +170,40 @@ export default async function Layout(props: {
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => (
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
),
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => (
<AssistantGate availability={session ? assistantAvailability : "unauthenticated"} {...innerProps} />
),
]);
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang={locale}
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
}}
suppressHydrationWarning
>
<head>
<ColorSchemeScript defaultColorScheme={colorScheme} />
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
{session ? (
<>
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
</>
) : null}
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
<style data-homarr-global-custom-css>{serverSettings.branding.customCss}</style>
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<ViewportHint />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
@@ -0,0 +1,35 @@
import type { Session } from "@homarr/auth";
import { api } from "@homarr/api/server";
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
/**
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
* navigation and the pages resolve access exactly the same way the API does.
*/
export const getAppsSectionAccess = (session: Session | null) =>
getAppManagementAccess(session?.user.permissions ?? []);
/**
* Pure variant for callers that already fetched the integrations the user can see, so the list is
* only queried once per request.
*/
export const getIntegrationsSectionAccess = (
session: Session | null,
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
) =>
getIntegrationManagementAccess(
session?.user.permissions ?? [],
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
);
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
const permissions = session?.user.permissions ?? [];
// Only pay for the lookup when the answer is not already known from the global permissions.
const needsDelegatedLookup =
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
};
@@ -21,26 +21,4 @@
.scrollContainer {
height: 100%;
transform: rotateZ(10deg);
}
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -1,22 +1,14 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import classes from "./hero-banner.module.css";
const icons = Object.values(integrationDefs)
.filter((int) => int.name !== "Mock")
.map((int) => int.iconUrl);
const countIconGroups = 3;
const animationDurationInSeconds = icons.length;
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
export const HeroBanner = async () => {
const t = await getScopedI18n("management.page.home");
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
const { appName, logoImageUrl } = serverSettings.branding;
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -24,47 +16,21 @@ export const HeroBanner = async () => {
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")}
</Title>
<Group gap="xs" wrap="nowrap">
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
<Group gap="sm" wrap="nowrap">
<Image
src={logoImageUrl ?? "/logo/logo.png"}
alt={`${appName} logo`}
w="auto"
h={{ base: 48, md: 64 }}
maw={{ base: 128, md: 192 }}
fit="contain"
style={{ flexShrink: 0 }}
/>
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
</Group>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
<Stack
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))}
</Stack>
</GridCol>
))}
</Grid>
<IntegrationMarquee />
</Box>
</Box>
);
@@ -35,11 +35,10 @@ import {
import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
@@ -47,15 +46,15 @@ import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
export default async function AboutPage() {
const t = await getScopedI18n("management.page.about");
const t = await getI18n("management.page.about");
const version = getPackageVersion();
const dependencies = await getDependenciesAsync();
@@ -1,57 +1,59 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const { openConfirmModal } = useConfirmModal();
const t = useI18n("app.page.delete");
const tCommon = useI18n("common");
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", {
name: app.name,
}),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
const onConfirm = useCallback(() => {
mutate(
{ id: app.id },
{
onSuccess: () => {
void utils.app.invalidate();
showSuccessNotification({
title: tCommon("notification.delete.success"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/apps");
},
onError: () => {
showErrorNotification({
title: tCommon("notification.delete.error"),
message: t("notification.error.message"),
});
},
},
});
}, [app, mutate, t, openConfirmModal]);
);
}, [app.id, mutate, t, tCommon, utils]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<InlineConfirmActionIcon
confirmLabel={tCommon("action.confirm")}
confirmationAriaLabel={tCommon("action.confirm")}
onConfirm={onConfirm}
loading={isPending}
variant="subtle"
color="red"
size={44}
aria-label={tCommon("action.deleteNamed", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
</InlineConfirmActionIcon>
);
};
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
interface AppEditFormProps {
@@ -17,14 +17,14 @@ interface AppEditFormProps {
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const tScoped = useI18n("app.page.edit.notification");
const tCommon = useI18n("common");
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: tScoped("success.title"),
title: tCommon("notification.update.success"),
message: tScoped("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
@@ -33,7 +33,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
},
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
title: tCommon("notification.update.error"),
message: tScoped("error.message"),
});
},
@@ -52,7 +52,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
return (
<AppForm
buttonLabels={{
submit: t("common.action.save"),
submit: tCommon("action.save"),
}}
initialValues={app}
handleSubmit={handleSubmit}
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
+154 -109
View File
@@ -1,26 +1,38 @@
import { Fragment } from "react";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { getAppsSectionAccess } from "../_access";
import { AppDeleteButton } from "./_app-delete-button";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
pageSize: z
.string()
.regex(/^[1-9]\d*$/u)
.transform(Number)
.pipe(z.number().int().positive().max(100))
.catch(10),
page: z
.string()
.regex(/^[1-9]\d*$/u)
.transform(Number)
.pipe(z.number().int().positive())
.catch(1),
});
interface AppsPageProps {
@@ -29,24 +41,57 @@ interface AppsPageProps {
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
if (!session) redirect("/auth/login");
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
if (!canAccess) notFound();
const canDelete = session.user.permissions.includes("app-full-all");
const searchParams = searchParamsSchema.parse(await props.searchParams);
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
// so the list stays empty instead of leaking every app name and URL.
const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 };
const totalPages = Math.ceil(totalCount / searchParams.pageSize);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app");
if (totalPages > 0 && searchParams.page > totalPages) {
const params = createPaginationSearchParams(searchParams.search, searchParams.pageSize, totalPages);
redirect(`/manage/apps?${params.toString()}`);
}
const canCreate = session.user.permissions.includes("app-create");
const t = await getI18n("app");
const tCommon = await getI18n("common");
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
return (
<ManagePageLayout
title={t("page.list.title")}
const emptyState = !canManageAll ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.createOnlyTitle")}
description={t("page.list.noResults.createOnlyDescription")}
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
/>
) : hasSearch ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: tCommon("action.clearSearch"), href: "/manage/apps" }}
/>
) : (
<NoResults
icon={IconBox}
title={t("page.list.noResults.title")}
description={t("page.list.noResults.description")}
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new", hidden: !canCreate }}
/>
);
const page = (
<ManageCollectionPage
title={tCommon("entity.apps")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length}
emptyState={emptyState}
primaryAction={
canCreate ? (
<TourTarget id="manage-apps-create">
@@ -56,101 +101,101 @@ export default async function AppsPage(props: AppsPageProps) {
</TourTarget>
) : undefined
}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
toolbar={
canManageAll ? (
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
) : undefined
}
footer={totalPages > 1 ? <TablePagination total={totalPages} /> : undefined}
floatingPrimaryAction={canCreate}
>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<TourTarget id="manage-apps-list">
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
{apps.map((app) => (
<AppItem
key={app.id}
app={app}
canDelete={canDelete}
editLabel={tCommon("action.editNamed", { name: app.name })}
/>
))}
</ManageCollectionPage>
);
return apps.length > 0 ? <TourTarget id="manage-apps-list">{page}</TourTarget> : page;
}
interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number];
canDelete: boolean;
editLabel: string;
}
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href);
return (
<ManageCollectionItem
leading={<Avatar size={36} src={app.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />}
title={
<Text component="span" fw={600} lineClamp={1}>
{app.name}
</Text>
}
description={
descriptionLines ? (
<Text size="sm" c="dimmed" lineClamp={2}>
{descriptionLines.map((line, index) => (
<Fragment key={index}>
{line}
{index < descriptionLines.length - 1 && <br />}
</Fragment>
))}
</Stack>
</TourTarget>
)}
</ManagePageLayout>
);
}
interface AppCardProps {
app: RouterOutputs["app"]["all"][number];
}
const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const session = await auth();
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Avatar
</Text>
) : undefined
}
metadata={
safeHref ? (
<Anchor
href={safeHref}
target="_blank"
rel="noreferrer"
lineClamp={1}
size="sm"
src={app.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0} style={{ flex: "1" }}>
<Text fw={500} lineClamp={1}>
{app.name}
</Text>
{app.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{app.description.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
{app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
{app.href}
</Anchor>
)}
</Stack>
</Group>
<Group>
<ActionIconGroup>
{session?.user.permissions.includes("app-modify-all") && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const AppNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconBox}
title={t("app.page.list.noResults.title")}
action={{
label: t("app.page.list.noResults.action"),
href: "/manage/apps/new",
hidden: !session?.user.permissions.includes("app-create"),
}}
style={{ wordBreak: "break-all" }}
>
{app.href}
</Anchor>
) : undefined
}
actions={
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={editLabel}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
}
/>
);
};
const createPaginationSearchParams = (search: string | undefined, pageSize: number, page: number) => {
const params = new URLSearchParams({ page: page.toString() });
if (search) params.set("search", search);
if (pageSize !== 10) params.set("pageSize", pageSize.toString());
return params;
};
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
describe("getAssistantConnectionState", () => {
it("marks a saved required-key provider as ready", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: true,
apiKeyConfigured: true,
}),
).toEqual({
hasStoredApiKey: true,
connectionPending: false,
connectionReady: true,
});
});
it("requires the endpoint to be saved before exposing model controls", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: true,
providerRequiresApiKey: true,
apiKeyConfigured: true,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: true,
connectionReady: false,
});
});
it("does not call a required-key connection ready after its key is removed", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: true,
apiKeyConfigured: false,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: true,
connectionReady: false,
});
});
it("allows a saved local endpoint without an API key", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: false,
apiKeyConfigured: false,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: false,
connectionReady: true,
});
});
});
describe("formatAssistantContextWindow", () => {
it("uses Homarr's compact number format for large context windows", () => {
expect(formatAssistantContextWindow(1_048_576)).toBe("1M");
expect(formatAssistantContextWindow(131_072)).toBe("131k");
});
});
@@ -0,0 +1,27 @@
import { formatNumber } from "@homarr/common";
interface AssistantConnectionStateInput {
connectionConfigured: boolean;
destinationChanged: boolean;
providerRequiresApiKey: boolean;
apiKeyConfigured: boolean;
}
export const getAssistantConnectionState = ({
connectionConfigured,
destinationChanged,
providerRequiresApiKey,
apiKeyConfigured,
}: AssistantConnectionStateInput) => {
const hasStoredApiKey = apiKeyConfigured && !destinationChanged;
const credentialsMissing = providerRequiresApiKey && !hasStoredApiKey;
const connectionPending = !connectionConfigured || destinationChanged || credentialsMissing;
return {
hasStoredApiKey,
connectionPending,
connectionReady: !connectionPending,
};
};
export const formatAssistantContextWindow = (tokens: number) => formatNumber(tokens, 0);
@@ -0,0 +1,158 @@
.configuration {
width: 100%;
max-width: 74rem;
}
.summary {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.summaryProvider {
min-width: 0;
}
.summaryProviderText {
min-width: 0;
}
.summaryEndpoint {
max-width: min(46rem, 70vw);
}
.section {
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background-color: var(--mantine-color-body);
}
.sectionHeader {
padding: var(--mantine-spacing-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.sectionHeading {
min-width: 0;
}
.stepNumber {
width: 1.75rem;
height: 1.75rem;
flex: 0 0 1.75rem;
border-radius: 50%;
background-color: var(--mantine-primary-color-light);
color: var(--mantine-primary-color-light-color);
font-size: var(--mantine-font-size-sm);
font-weight: 700;
line-height: 1.75rem;
text-align: center;
}
.sectionBody {
padding: var(--mantine-spacing-md);
}
.sectionActions {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.credentialSummary {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.credentialSummaryContent {
min-width: 0;
}
.advancedControl {
min-height: 3.25rem;
}
.selectedModel {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.selectedModelText {
min-width: 0;
}
.selectedModelBadges {
flex-shrink: 0;
}
.capabilityRow,
.enableRow {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.securityNote {
min-width: 0;
flex: 1;
}
.flowActions {
display: flex;
justify-content: flex-end;
gap: var(--mantine-spacing-xs);
}
@media (max-width: 48em) {
.summaryEndpoint {
max-width: 78vw;
}
.summaryActions,
.credentialActions,
.sectionActions > :global(.mantine-Group-root) {
width: 100%;
}
.summaryActions > *,
.credentialActions > *,
.flowActions > *,
.sectionActions :global(.mantine-Button-root) {
flex: 1 1 auto;
}
.credentialSummary {
align-items: stretch;
}
.flowActions {
display: grid;
grid-template-columns: 1fr;
}
.selectedModel {
align-items: stretch;
flex-direction: column;
}
.selectedModelBadges {
justify-content: flex-start;
flex-shrink: 1;
}
.capabilityRow {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.configuration *,
.configuration *::before,
.configuration *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@@ -0,0 +1,807 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Divider,
Group,
Image,
LoadingOverlay,
Paper,
PasswordInput,
Select,
Skeleton,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrandGithub,
IconCheck,
IconCircleCheck,
IconDatabaseSearch,
IconKey,
IconPlus,
IconSearch,
IconShieldCheck,
IconShieldLock,
IconTool,
IconTrash,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
assistantProviderPresets,
getAssistantModelOptionLabel,
resolveAssistantModelId,
} from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
import classes from "./assistant-configuration.module.css";
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
type HeaderEntry = { id: number; name: string; value: string };
type CredentialFlow = "idle" | "remove";
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
if (providerId === "homarr") {
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
}
const preset = assistantProviderPresets[providerId];
if (!preset.iconUrl) return <IconWorld size={size} aria-hidden />;
const darkIconUrl = "darkIconUrl" in preset ? preset.darkIconUrl : null;
if (!darkIconUrl) {
return <Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />;
}
return (
<>
<Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" darkHidden />
<Image src={darkIconUrl} alt="" aria-hidden w={size} h={size} fit="contain" lightHidden />
</>
);
};
interface ConfigurationSectionProps {
number: number;
title: string;
description: string;
status?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}
const ConfigurationSection = ({ number, title, description, status, actions, children }: ConfigurationSectionProps) => (
<section className={classes.section}>
<Group className={classes.sectionHeader} justify="space-between" align="flex-start">
<Group className={classes.sectionHeading} gap="sm" align="flex-start" wrap="nowrap">
<Box className={classes.stepNumber} aria-hidden>
{number}
</Box>
<Box>
<Text fw={650}>{title}</Text>
<Text size="sm" c="dimmed" maw="66ch">
{description}
</Text>
</Box>
</Group>
{status}
</Group>
<Stack className={classes.sectionBody} gap="md">
{children}
</Stack>
{actions && <Box className={classes.sectionActions}>{actions}</Box>}
</section>
);
const ConfigurationSkeleton = ({ label }: { label: string }) => (
<Stack className={classes.configuration} gap="md" aria-label={label}>
<Paper withBorder p="md">
<Group justify="space-between">
<Group>
<Skeleton circle h={36} />
<Stack gap={5}>
<Skeleton h={14} w={180} />
<Skeleton h={10} w={280} />
</Stack>
</Group>
<Skeleton h={24} w={110} radius="xl" />
</Group>
</Paper>
{[1, 2, 3].map((number) => (
<Paper key={number} withBorder p="md">
<Stack gap="sm">
<Skeleton h={18} w={220} />
<Skeleton h={12} w="65%" />
<Skeleton h={42} />
</Stack>
</Paper>
))}
</Stack>
);
export const AssistantConfiguration = () => {
const t = useI18n("management.page.settings.section.assistant");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
const [provider, setProvider] = useState<AssistantProvider>("homarr");
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.homarr.baseUrl);
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
const [apiKey, setApiKey] = useState("");
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
const [clearHeaders, setClearHeaders] = useState(false);
const [nextHeaderId, setNextHeaderId] = useState(1);
const [modelId, setModelId] = useState("");
const [enabled, setEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
useEffect(() => {
const configuredProvider = configuration?.provider ?? "homarr";
setProvider(configuredProvider);
setBaseUrl(
configuredProvider === "homarr"
? getWorkshopAssistantProviderUrl()
: (configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl),
);
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
setModelId(configuration?.modelId ?? "");
setEnabled(configuration?.enabled ?? false);
setWebSearchEnabled(configuration?.webSearchEnabled ?? false);
setApiKey("");
setCredentialFlow("idle");
}, [configuration]);
const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, "");
const normalizedModelDiscoveryPath = modelDiscoveryPath.trim() || null;
const destinationChanged =
configuration?.connectionConfigured === true &&
(configuration.provider !== provider || configuration.baseUrl !== normalizedBaseUrl);
const connectionChanged =
configuration?.connectionConfigured === true &&
(destinationChanged || configuration.modelDiscoveryPath !== normalizedModelDiscoveryPath);
const {
data: models,
error: discoveryError,
isFetching: isDiscovering,
refetch: discoverModels,
} = clientApi.assistant.discoverModels.useQuery(undefined, {
enabled:
configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null && !connectionChanged,
staleTime: 5 * 60_000,
retry: false,
});
const modelOptions = useMemo(
() =>
(models ?? []).map((model) => ({
value: model.id,
label: getAssistantModelOptionLabel(model),
})),
[models],
);
const providerOptions = useMemo(
() =>
(["free", "hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
const items = assistantProviderIds
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: assistantProviderPresets[providerId].label ?? t("provider.options.custom.label"),
}));
return { group: t(`provider.groups.${category}`), items };
}),
[t],
);
const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId);
const hasDiscoveredModels = !connectionChanged && (models?.length ?? 0) > 0;
useEffect(() => {
if (!models || models.length === 0) return;
const resolvedModelId = resolveAssistantModelId(models, modelId);
if (resolvedModelId && resolvedModelId !== modelId) {
setModelId(resolvedModelId);
}
}, [modelId, models]);
const preset = assistantProviderPresets[provider];
const isHomarrProvider = provider === "homarr";
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
connectionConfigured: configuration?.connectionConfigured === true,
destinationChanged,
providerRequiresApiKey: preset.requiresApiKey,
apiKeyConfigured: configuration?.apiKeyConfigured === true,
});
const hasEffectiveApiKey = apiKey.trim().length > 0 || hasStoredApiKey;
const connectionValid =
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
const modelControlsDisabled = connectionPending || isDiscovering;
const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0;
const canUseOpenRouterServerTools = assistantProviderCanUseOpenRouterServerTools(provider);
const updateConnection = clientApi.assistant.updateConnection.useMutation({
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
setApiKey("");
setCredentialFlow("idle");
setHeaders([]);
setClearHeaders(false);
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.discoverModels.invalidate(),
utils.assistant.getAvailability.invalidate(),
]);
showSuccessNotification({
title: t("notification.connectionSaved.title"),
message: credentialsClearedForDestinationChange
? t("notification.connectionSaved.credentialsCleared")
: t("notification.connectionSaved.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({
onSuccess: async () => {
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.getAvailability.invalidate(),
utils.assistant.getRuntimeOptions.invalidate(),
]);
router.refresh();
showSuccessNotification({
title: t("notification.saved.title"),
message: t("notification.saved.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const pending = updateConnection.isPending || saveConfiguration.isPending;
const resetCredentialFlow = () => {
setApiKey("");
setCredentialFlow("idle");
};
const clearDraftConnectionState = () => {
resetCredentialFlow();
setHeaders([]);
setClearHeaders(false);
setModelId("");
setEnabled(false);
setWebSearchEnabled(false);
};
const changeProvider = (value: string | null) => {
if (!value || !(value in assistantProviderPresets)) return;
const nextProvider = value as AssistantProvider;
const nextPreset = assistantProviderPresets[nextProvider];
clearDraftConnectionState();
setProvider(nextProvider);
setBaseUrl(nextProvider === "homarr" ? getWorkshopAssistantProviderUrl() : nextPreset.baseUrl);
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
};
const changeBaseUrl = (value: string) => {
if (value !== baseUrl) {
clearDraftConnectionState();
}
setBaseUrl(value);
};
const changeModelDiscoveryPath = (value: string) => {
if (value !== modelDiscoveryPath) {
setModelId("");
setEnabled(false);
}
setModelDiscoveryPath(value);
};
const addHeader = () => {
setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]);
setNextHeaderId((current) => current + 1);
setClearHeaders(false);
};
const updateHeader = (id: number, field: "name" | "value", value: string) => {
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
};
const saveConnection = (clearApiKey = false) => {
const customHeaders = isHomarrProvider
? undefined
: headers.length > 0
? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value]))
: undefined;
updateConnection.mutate({
provider,
baseUrl,
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
apiKey: clearApiKey || isHomarrProvider ? undefined : apiKey.trim() || undefined,
clearApiKey: clearApiKey || isHomarrProvider,
customHeaders,
clearCustomHeaders: clearHeaders || isHomarrProvider,
});
};
const saveAssistantConfiguration = () => {
if (!canSaveConfiguration) return;
saveConfiguration.mutate({
enabled,
modelId: modelId.trim(),
webSearchEnabled: canUseOpenRouterServerTools && webSearchEnabled,
});
};
if (isLoading) {
return <ConfigurationSkeleton label={t("loading")} />;
}
return (
<>
<Stack className={classes.configuration} gap="md">
<Paper className={classes.summary} withBorder p="md" radius="md" pos="relative">
<LoadingOverlay visible={pending} />
<Group justify="space-between" align="center">
<Group className={classes.summaryProvider} gap="sm" wrap="nowrap">
<ThemeIcon variant="default" size="lg" radius="md">
<ProviderIcon providerId={provider} size={22} />
</ThemeIcon>
<Box className={classes.summaryProviderText}>
<Group gap="xs">
<Text fw={650}>{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}</Text>
<Badge
size="sm"
variant="light"
color={connectionChanged ? "yellow" : connectionReady ? "green" : "gray"}
>
{connectionChanged
? t("overview.unsavedChanges")
: connectionReady
? t("overview.connectionReady")
: t("overview.setupRequired")}
</Badge>
</Group>
<Text className={classes.summaryEndpoint} size="sm" c="dimmed" truncate>
{configuration?.modelId ?? t("overview.noModel")} · {normalizedBaseUrl}
</Text>
</Box>
</Group>
<Group className={classes.summaryActions} gap="xs">
{configuration?.webSearchEnabled && (
<Badge variant="light" leftSection={<IconSearch size={12} />}>
{t("serverTools.webSearch.label")}
</Badge>
)}
{configuration?.enabled === false && (
<Badge variant="filled" color="gray">
{t("overview.disabled")}
</Badge>
)}
</Group>
</Group>
</Paper>
<Group gap="xs" align="flex-start" wrap="nowrap">
<IconShieldCheck size={18} color="var(--mantine-color-blue-6)" aria-hidden />
<Text className={classes.securityNote} size="sm" c="dimmed">
{t("security.description")}
</Text>
</Group>
<ConfigurationSection
number={1}
title={t("connection.endpointTitle")}
description={t("connection.description")}
status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{assistantProviderPresets[provider].label ?? t("provider.options.custom.label")}
</Badge>
}
>
<Stack gap="md">
<Select
label={t("provider.title")}
description={t(`provider.options.${provider}.description`)}
value={provider}
onChange={changeProvider}
data={providerOptions}
searchable
allowDeselect={false}
leftSection={<ProviderIcon providerId={provider} />}
renderOption={({ option }) => (
<Group gap="sm" wrap="nowrap" align="center" w="100%">
<ProviderIcon providerId={option.value as AssistantProvider} />
<Box flex={1} miw={0}>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{option.label}
</Text>
</Group>
{option.value === "homarr" && (
<Text size="xs" c="dimmed" lineClamp={2}>
{t("provider.options.homarr.description")}
</Text>
)}
</Box>
</Group>
)}
/>
<TextInput
label={t("baseUrl.title")}
description={t("baseUrl.description")}
leftSection={<IconWorld size={16} />}
value={baseUrl}
disabled={isHomarrProvider}
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
placeholder="https://provider.example/v1"
/>
<TextInput
label={t("model.discoveryPath")}
description={t("model.discoveryPathDescription")}
value={modelDiscoveryPath}
disabled={isHomarrProvider}
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
placeholder="/models"
/>
</Stack>
</ConfigurationSection>
<ConfigurationSection
number={2}
title={t("credentials.title")}
description={t("credentials.description")}
status={
isHomarrProvider ? (
<Badge variant="light" color="blue">
{t("credentials.workshop")}
</Badge>
) : hasStoredApiKey ? undefined : (
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
</Badge>
)
}
actions={
<Group justify="flex-end">
{credentialFlow === "idle" && (
<Button
leftSection={<IconCheck size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("connection.save")}
</Button>
)}
</Group>
}
>
{isHomarrProvider ? (
<Alert color="blue" icon={<IconBrandGithub size={18} />} title={t("provider.homarrAuth.title")}>
{t("provider.homarrAuth.description")}
</Alert>
) : hasStoredApiKey && credentialFlow === "idle" ? (
<Group className={classes.credentialSummary} justify="space-between" align="center">
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
<ThemeIcon color="teal" variant="light" radius="xl">
<IconShieldLock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("apiKey.savedTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("apiKey.savedDescription")}
</Text>
</Box>
</Group>
<Group className={classes.credentialActions} gap="xs">
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
{t("apiKey.remove")}
</Button>
</Group>
</Group>
) : credentialFlow === "remove" ? (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
<Stack gap="md">
<Text size="sm">
{preset.requiresApiKey
? t("apiKey.removeFlow.description")
: t("apiKey.removeFlow.optionalDescription")}
</Text>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.removeFlow.cancel")}
</Button>
<Button color="red" loading={updateConnection.isPending} onClick={() => saveConnection(true)}>
{t("apiKey.removeFlow.confirm")}
</Button>
</Box>
</Stack>
</Alert>
) : (
<PasswordInput
label={t("apiKey.title")}
description={preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.placeholder")}
autoComplete="new-password"
/>
)}
{!isHomarrProvider && (
<Accordion variant="contained" radius="md">
<Accordion.Item value="headers">
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
<Group justify="space-between" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("headers.title")}
</Text>
<Text size="xs" c="dimmed">
{t("headers.description")}
</Text>
</Box>
{configuration?.customHeadersConfigured && (
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
</Text>
<Button
variant="light"
size="compact-sm"
leftSection={<IconPlus size={14} />}
onClick={addHeader}
>
{t("headers.add")}
</Button>
</Group>
{headers.map((header) => (
<Group key={header.id} align="flex-end" wrap="wrap">
<TextInput
flex={1}
miw="12rem"
label={t("headers.name")}
value={header.name}
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
placeholder="X-Provider-Header"
/>
<PasswordInput
flex={2}
miw="12rem"
label={t("headers.value")}
value={header.value}
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
placeholder={t("headers.valuePlaceholder")}
autoComplete="off"
/>
<Tooltip label={t("headers.remove")}>
<ActionIcon
size="lg"
variant="subtle"
color="red"
aria-label={t("headers.remove")}
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
>
<IconTrash size={17} />
</ActionIcon>
</Tooltip>
</Group>
))}
{configuration?.customHeadersConfigured && headers.length === 0 && (
<Button
variant="light"
color={clearHeaders ? "gray" : "red"}
onClick={() => setClearHeaders(!clearHeaders)}
>
{clearHeaders ? t("headers.keep") : t("headers.clear")}
</Button>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</ConfigurationSection>
<ConfigurationSection
number={3}
title={t("model.title")}
description={t("model.description")}
status={
!configuration?.modelId ? (
<Badge variant="light" color="gray">
{t("overview.noModel")}
</Badge>
) : configuration.enabled ? undefined : (
<Badge variant="light" color="gray">
{t("overview.disabled")}
</Badge>
)
}
actions={
<Group justify="flex-end">
<Button
leftSection={<IconCircleCheck size={16} />}
onClick={saveAssistantConfiguration}
disabled={!canSaveConfiguration}
loading={saveConfiguration.isPending}
>
{t("save")}
</Button>
</Group>
}
>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")}
</Text>
<Button
variant="light"
size="compact-sm"
leftSection={<IconDatabaseSearch size={14} />}
loading={isDiscovering}
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
onClick={() => void discoverModels()}
>
{t("model.refresh")}
</Button>
</Group>
{hasDiscoveredModels ? (
<Select
label={t("model.title")}
value={modelId || null}
onChange={(value) => setModelId(value ?? "")}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={t("model.discovered", { count: models?.length ?? 0 })}
searchable
allowDeselect={false}
limit={100}
/>
) : (
<TextInput
label={t("model.title")}
value={modelId}
onChange={(event) => setModelId(event.currentTarget.value)}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={
connectionPending
? t("model.saveConnectionFirst")
: isDiscovering
? t("model.discovering")
: discoveryError || models?.length === 0
? t("model.manualFallback")
: t("model.manual")
}
/>
)}
{discoveryError && !connectionPending && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
</Alert>
)}
{selectedModel && (
<Group className={classes.selectedModel} justify="space-between" align="flex-start" wrap="nowrap">
<Box className={classes.selectedModelText}>
<Text fw={600}>{selectedModel.name}</Text>
{selectedModel.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{selectedModel.description}
</Text>
)}
</Box>
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">
{t("model.context", { count: formatAssistantContextWindow(selectedModel.contextLength) })}
</Badge>
)}
<Badge
variant="light"
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
leftSection={<IconTool size={12} />}
>
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
</Badge>
</Group>
</Group>
)}
{canUseOpenRouterServerTools && (
<Stack gap="xs">
<Divider label={t("serverTools.title")} labelPosition="left" />
<Group className={classes.capabilityRow} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" radius="xl" size="md">
<IconSearch size={17} />
</ThemeIcon>
<Box>
<Group gap="xs">
<Text size="sm" fw={600}>
{t("serverTools.webSearch.label")}
</Text>
<Badge size="xs" variant="light">
{tCommon("beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed" maw="66ch">
{provider === "custom"
? t("serverTools.webSearch.proxyDescription")
: t("serverTools.webSearch.description")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("serverTools.webSearch.limit")}
</Text>
</Box>
</Group>
<Switch
checked={webSearchEnabled}
onChange={(event) => setWebSearchEnabled(event.currentTarget.checked)}
disabled={modelControlsDisabled}
aria-label={t("serverTools.webSearch.label")}
size="md"
/>
</Group>
</Stack>
)}
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("enabled.label")}
</Text>
<Text size="sm" c="dimmed">
{t("enabled.description")}
</Text>
</Box>
<Switch
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
disabled={!canSaveConfiguration}
aria-label={t("enabled.label")}
size="md"
/>
</Group>
</ConfigurationSection>
</Stack>
</>
);
};
@@ -0,0 +1,33 @@
import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getI18n("management.page.settings.section.assistant");
return {
title: t("title"),
};
}
export default async function AssistantPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getI18n("management.page.settings.section.assistant");
return (
<ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch">
{t("description")}
</Text>
<AssistantConfiguration />
</ManagePageLayout>
);
}
@@ -8,10 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -22,21 +20,20 @@ const iconProps = {
interface BoardCardMenuDropdownProps {
board: Pick<
RouterOutputs["board"]["getAllBoards"][number],
RouterOutputs["board"]["getManageOverview"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
onDuplicate: () => void;
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
const t = useI18n("management.page.board.action");
const tRoot = useI18n();
const tCommon = useI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
@@ -55,20 +52,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
},
});
const handleDeletion = useCallback(() => {
openConfirmModal({
title: t("delete.confirm.title"),
children: t("delete.confirm.description", {
name: board.name,
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await deleteBoardMutation.mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
const handleDeletion = useCallback(
() => deleteBoardMutation.mutateAsync({ id: board.id }),
[board.id, deleteBoardMutation],
);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
@@ -78,15 +65,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -96,8 +74,8 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
{tRoot("board.action.duplicate.title")}
</Menu.Item>
)}
{hasChangeAccess && (
@@ -116,14 +94,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<>
<Menu.Divider />
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<Menu.Item
<InlineConfirmMenuItem
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion}
onConfirm={handleDeletion}
confirmLabel={tCommon("action.confirm")}
disabled={deleteBoardMutation.isPending}
>
{t("delete.label")}
</Menu.Item>
{tCommon("action.delete")}
</InlineConfirmMenuItem>
</>
)}
</Menu.Dropdown>
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Collapse,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
import { DuplicateBoardForm } from "./duplicate-board-form";
interface BoardCardProps {
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<>
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
<UserAvatar user={board.creator} size="sm" />
</Tooltip>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown
board={board}
onDuplicate={() => {
setDuplicateFormKey((value) => value + 1);
setIsDuplicateOpen(true);
}}
/>
</Menu>
)}
</Group>
</CardSection>
</Card>
<Collapse expanded={isDuplicateOpen}>
<Card mt="sm" withBorder>
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
</Card>
</Collapse>
</>
);
};
@@ -1,38 +1,55 @@
"use client";
import { Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button, Card, Collapse, Group, Stack } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { BoardCreateForm } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const t = useI18n("management.page.board.action");
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true");
const [formKey, setFormKey] = useState(0);
useEffect(() => {
if (searchParams.get("create") !== "true") return;
setFormKey((value) => value + 1);
setIsOpen(true);
}, [searchParams]);
const close = () => {
setIsOpen(false);
setFormKey((value) => value + 1);
if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false });
};
const toggle = () => {
if (isOpen) {
close();
return;
}
setFormKey((value) => value + 1);
setIsOpen(true);
};
return (
<ManageMobilePrimaryAction>
<Button.Group>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
<Stack>
<Group justify="end">
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
{t("new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
</ManageMobilePrimaryAction>
</Group>
<Collapse expanded={isOpen}>
<Card withBorder>
<BoardCreateForm key={formKey} onCancel={close} />
</Card>
</Collapse>
</Stack>
);
};
@@ -0,0 +1,90 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardDuplicateSchema } from "@homarr/validation/board";
interface DuplicateBoardFormProps {
board: {
id: string;
name: string;
};
onCancel: () => void;
}
export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardDuplicateSchema.omit({ id: true }), {
mode: "controlled",
initialValues: {
name: board.name,
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
const { mutateAsync, isPending } = clientApi.board.duplicateBoard.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
if (!boardNameStatus.canSubmit) return;
await mutateAsync(
{
...values,
id: board.id,
},
{
onSuccess() {
showSuccessNotification({
title: tBoard("action.duplicate.notification.success.title"),
message: tBoard("action.duplicate.notification.success.message"),
});
onCancel();
router.refresh();
},
onError() {
showErrorNotification({
title: tBoard("action.duplicate.notification.error.title"),
message: tBoard("action.duplicate.notification.error.message"),
});
},
},
);
})}
>
<Stack maw={720}>
<Text size="sm" c="gray.6">
{tBoard("action.duplicate.message", { name: board.name })}
</Text>
<TextInput
label={tCommon("field.name")}
autoFocus
{...form.getInputProps("name")}
description={<BoardNameAvailability status={boardNameStatus} />}
withAsterisk
/>
<Group justify="end" wrap="wrap">
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tBoard("action.duplicate.title")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,64 +1,56 @@
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
Grid,
GridCol,
Group,
Menu,
MenuTarget,
Text,
Tooltip,
} from "@mantine/core";
import {
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import { Grid, GridCol } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getI18n } from "@homarr/translation/server";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { BoardCard } from "./_components/board-card";
import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board");
const t = await getI18n("management.page.board");
const session = await auth();
const boards = await api.board.getAllBoards();
const boards = await api.board.getManageOverview();
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
<ManagePageLayout
title={t("title")}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
<ManagePageLayout title={t("title")}>
{canCreateBoards && (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
)}
{boards.length === 0 && (
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
<BoardCard
board={board}
isMenuVisible={constructBoardPermissions(board, session).hasChangeAccess}
labels={{
visibility: t(`visibility.${board.isPublic ? "public" : "private"}`),
preview: t("preview.label", {
name: board.name,
count: String(board.preview?.items.length ?? 0),
}),
unknownCreator: t("preview.unknownCreator"),
home: t("action.setHomeBoard.badge.label"),
homeTooltip: t("action.setHomeBoard.badge.tooltip"),
mobileHome: t("action.setMobileHomeBoard.badge.label"),
mobileHomeTooltip: t("action.setMobileHomeBoard.badge.tooltip"),
open: t("action.open.label"),
settings: t("action.settings.label"),
}}
/>
</GridCol>
))}
</Grid>
@@ -67,83 +59,3 @@ export default async function ManageBoardsPage() {
</ManagePageLayout>
);
}
interface BoardCardProps {
board: RouterOutputs["board"]["getAllBoards"][number];
}
const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
<Tooltip label={t(`visibility.${visibility}`)}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700}>{board.name}</Text>
</Group>
<Group>
{board.isHome && (
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{t("action.setHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{t("action.setMobileHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
<Text>{board.creator.name}</Text>
</Group>
)}
</Group>
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size="lg"
aria-label={t("action.settings.label")}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
<BoardCardMenuDropdown board={board} />
</Menu>
)}
</Group>
</CardSection>
</Card>
);
};
@@ -1,19 +0,0 @@
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
return (
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
<Text size="sm">
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
official discussion thread
</Anchor>{" "}
will be taken into account. Happy Hacking!
</Text>
</Alert>
);
}
@@ -0,0 +1 @@
export { CodeEditor } from "~/components/custom-widgets/code-editor";
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
});
@@ -1,343 +1,115 @@
"use client";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
import { useRef, useState } from "react";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import { IconCopy, IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
## Your Task
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
## Output Format
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
- If your environment supports structured/JSON output mode, use it.
## JSON Schema
The output must conform to this JSON Schema:
`;
const PROMPT_RULES = `
## Key Rules
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
- For arrays of items, inspect the API response to find the correct paths
## Display Type Guide
- \`singleValue\`: Show one prominent number/text (e.g. total count, status)
- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array
- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers
- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63")
- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc.
- \`statusIndicator\`: Green/red dots based on value matching — for service health checks
- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors
- \`raw\`: Raw JSON display — for debugging or complex data
- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions
- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`).
## Custom JSX (\`customJsx\`) — Available Mantine Components
When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below.
### Layout Components
- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\`
- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean)
- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\`
- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\`
- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\`
- **Center** — Centers content. Props: \`inline\` (boolean)
- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width)
- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean)
- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9)
### Typography Components
- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span)
- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\`
- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block)
- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[])
- **Mark** — Highlighted text background. Props: \`color\`
- **Kbd** — Keyboard key display
- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\`
- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never")
- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number)
### Data Display Components
- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\`
- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\`
- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\`
- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\`
- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\`
- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string)
- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\`
- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\`
- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\`
- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content.
- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean)
- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\`
- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\`
### Feedback Components
- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\`
- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode)
- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean)
- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots)
### Media Components
- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill)
- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\`
- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\`
- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\`
- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\`
- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover)
### Charts (@mantine/charts)
- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line)
- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\`
- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\`
- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\`
- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\`
### Common Prop Patterns
- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers
- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4"
- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\`
- **Conditional rendering:** Use ternaries: \`{condition ? <Text>Yes</Text> : <Text>No</Text>}\`
- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\`
### Interactive Components (built-in, state-managed)
These components manage their own internal state — they provide interactivity without event handlers:
**PaginatedList** — Paginates its children with prev/next buttons.
Props: \`pageSize\` (number, default 6)
Usage: Wrap a \`.map()\` expression to paginate results.
\`\`\`jsx
<PaginatedList pageSize={8}>
{data.results.map(item =>
<Card withBorder p="xs" mb="xs">
<Text>{item.name}</Text>
</Card>
)}
</PaginatedList>
\`\`\`
**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching.
TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab)
TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text)
\`\`\`jsx
<TabsContainer defaultTab="overview">
<TabPanel value="overview" label="Overview">
<Text>{data.description}</Text>
</TabPanel>
<TabPanel value="stats" label="Statistics">
<Text>Total: {data.count}</Text>
</TabPanel>
</TabsContainer>
\`\`\`
**Collapsible** — Expandable/collapsible section with a title.
Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false)
\`\`\`jsx
<Collapsible title="Details" defaultOpen={true}>
<Text>{data.details}</Text>
</Collapsible>
\`\`\`
**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress).
Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color)
\`\`\`jsx
<StatBar label="HP" value={45} max={100} color="red" />
<StatBar label="ATK" value={80} max={255} color="orange" />
\`\`\`
**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy).
Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm)
\`\`\`jsx
<Group gap="xs">
<TypeBadge type="fire" />
<TypeBadge type="flying" />
</Group>
\`\`\`
**Available bindings in templates:**
- \`data\` — the full API response JSON object
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers
- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\`
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\`
- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => <Text>{item.name}</Text>)}\`
- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\`
**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch
**Chart data formats:**
- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row
- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series
- Sparkline: \`data={[1,2,3]}\` — flat number array
- NumberFormatter: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible)
API: \`https://pokeapi.co/api/v2/pokemon/charizard\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group wrap="nowrap">
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
<Stack gap={4} style={{flex: 1}}>
<Title order={3} tt="capitalize">{data.name}</Title>
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
<Group gap="xs">
{data.types.map(t => <TypeBadge type={t.type.name} />)}
</Group>
</Stack>
</Group>
<TabsContainer defaultTab="stats">
<TabPanel value="stats" label="Stats">
<Stack gap="xs" pt="xs">
{data.stats.map(s =>
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
)}
</Stack>
</TabPanel>
<TabPanel value="abilities" label="Abilities">
<Stack gap="xs" pt="xs">
{data.abilities.map(a =>
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
</Badge>
)}
</Stack>
</TabPanel>
<TabPanel value="moves" label="Moves">
<Collapsible title="Move list">
<Group gap="xs" pt="xs">
{data.moves.slice(0, 20).map(m =>
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
)}
</Group>
</Collapsible>
</TabPanel>
</TabsContainer>
</Stack>
\`\`\`
### Paginated List Example
API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group justify="space-between">
<Title order={3}>Pokédex</Title>
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
</Group>
<PaginatedList pageSize={10}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
size="lg"
radius="sm"
/>
<Stack gap={0} style={{ flex: 1 }}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
</Stack>
</Group>
</Card>
)}
</PaginatedList>
</Stack>
\`\`\`
## API Response
`;
const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below:
\`\`\`json
PASTE_YOUR_API_RESPONSE_HERE
\`\`\`
`;
const PROMPT_FOOTER = `
## Your Request
Describe what you want the widget to show:
`;
function buildAiPrompt(
jsonSchema: unknown,
rawResponse?: string | null,
currentConfig?: Record<string, unknown> | null,
) {
const schemaStr = JSON.stringify(jsonSchema, null, 2);
const responseSection = rawResponse
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
: PROMPT_NO_RESPONSE;
const configSection = currentConfig
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
: "";
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
}
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
getSampleResponse: () => string | null;
request?: string | null;
documentationUrl?: string | null;
getDraft: () => CustomWidgetAiDraft | null;
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
}
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const [opened, { open, close }] = useDisclosure(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
export const CopyAiPromptButton = ({
getSampleResponse,
getDraft,
getDiagnostics,
request,
documentationUrl,
}: CopyAiPromptButtonProps) => {
const t = useI18n("customWidget");
const ai = useI18n("customWidget.workbench.ai");
const assistant = useOptionalHomarrAssistant();
const [manualPrompt, setManualPrompt] = useState<string | null>(null);
const promptRef = useRef<HTMLTextAreaElement>(null);
const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt(
undefined,
getSampleResponse(),
getDraft(),
request,
documentationUrl,
getDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
};
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
const value = buildCustomWidgetAiPrompt(
undefined,
getSampleResponse(),
getDraft(),
request,
documentationUrl,
getDiagnostics(),
);
try {
await navigator.clipboard.writeText(prompt);
close();
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
await navigator.clipboard.writeText(value);
showSuccessNotification({
title: t("action.copyAiPrompt"),
message: t("notification.aiPromptCopiedWithCount", { count: value.length }),
});
} catch {
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
setManualPrompt(value);
}
};
return (
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
<Popover.Target>
<>
{assistant?.enabled && (
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
loading={isLoading}
disabled={!schema}
fullWidth
size="sm"
leftSection={<IconRobot size={16} />}
onClick={handleAssistant}
disabled={!request?.trim()}
>
{t("action.copyAiPrompt")}
{t("action.aiPrompt")}
</Button>
</Popover.Target>
<Popover.Dropdown>
)}
<Button type="button" variant="light" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()}>
{t("action.copyAiPrompt")}
</Button>
<Modal
opened={manualPrompt !== null}
onClose={() => setManualPrompt(null)}
title={t("action.copyAiPrompt")}
size="lg"
>
<Stack gap="sm">
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
{!rawResponse && (
<Text size="xs" c="dimmed" fs="italic">
{t("notification.aiPromptNoResponse")}
</Text>
)}
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
{t("notification.aiPromptCopy")}
<Text size="sm">{ai("manualCopyDescription")}</Text>
<Textarea
ref={promptRef}
label={t("action.copyAiPrompt")}
value={manualPrompt ?? ""}
readOnly
autosize
minRows={8}
maxRows={16}
onFocus={(event) => event.currentTarget.select()}
/>
<Button
type="button"
onClick={() => {
promptRef.current?.focus();
promptRef.current?.select();
}}
>
{ai("selectAll")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
</Modal>
</>
);
};
@@ -1,48 +1,147 @@
"use client";
import { useRef } from "react";
import { useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBuildingStore,
IconClipboard,
IconCopy,
IconDots,
IconDownload,
IconToggleLeft,
IconToggleRight,
IconPlayerPause,
IconPlayerPlay,
IconPlugConnected,
IconSparkles,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { downloadJson } from "~/components/custom-widgets/download";
const iconProps = { size: 16, stroke: 1.5 };
interface WidgetRef {
id: string;
name: string;
enabled: boolean;
type WidgetListItem = RouterOutputs["customWidget"]["list"][number];
type WidgetRef = Pick<WidgetListItem, "id" | "name" | "enabled" | "valid" | "migrationRequired">;
interface CustomWidgetRowActionsProps {
widget: WidgetRef;
onConfigureSources(): void;
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
export const CustomWidgetRowActions = ({ widget, onConfigureSources }: CustomWidgetRowActionsProps) => {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const [migrationOpened, migrationControls] = useDisclosure(false);
const migrationFileInputRef = useRef<HTMLInputElement>(null);
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const copyMigrationPrompt = async () => {
try {
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
await navigator.clipboard.writeText(result.prompt);
showSuccessNotification({
title: t("action.copyMigrationPrompt"),
message: t("notification.migrationPromptCopied"),
});
} catch (error) {
showErrorNotification({
title: t("action.copyMigrationPrompt"),
message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"),
});
}
};
const copyLegacyDefinition = async () => {
try {
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
showSuccessNotification({
title: t("action.copyLegacyDefinition"),
message: t("notification.legacyDefinitionCopied"),
});
} catch (error) {
showErrorNotification({
title: t("action.copyLegacyDefinition"),
message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"),
});
}
};
const queueMigratedWidget = (text: string) => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) });
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
};
const notifyMigrationError = () =>
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
const pasteMigratedWidget = async () => {
try {
queueMigratedWidget(await navigator.clipboard.readText());
} catch {
notifyMigrationError();
}
};
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (loadEvent) => {
try {
queueMigratedWidget(loadEvent.target?.result as string);
} catch {
notifyMigrationError();
}
});
reader.addEventListener("error", notifyMigrationError);
reader.readAsText(file);
event.target.value = "";
};
const handleExport = async () => {
try {
const data = await utils.customWidget.export.fetch({ id: widget.id });
downloadJson(data, `${widget.name}.json`);
} catch {
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
}
};
const exportLegacyDefinition = async () => {
try {
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
downloadJson(data, `${widget.name}.legacy.json`);
} catch {
showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") });
}
};
const handleToggleEnabled = () => {
toggleEnabledMutation.mutate(
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
onSuccess: async () => {
await utils.widget.customApi.getData.cancel();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
@@ -55,21 +154,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
);
};
const handleExport = async () => {
try {
const data = await utils.customWidget.export.fetch({ id: widget.id });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${widget.name}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
}
};
const handleDuplicate = () => {
duplicateMutation.mutate(
{ id: widget.id },
@@ -79,7 +163,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.duplicate"),
message: t("notification.duplicated", { name: result.name }),
});
void utils.customWidget.all.invalidate();
void utils.customWidget.list.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
@@ -90,124 +174,126 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
const handleDelete = () => {
openConfirmModal({
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
);
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
});
};
return (
<Menu withinPortal position="bottom-end" shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
<IconDots {...iconProps} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleToggleEnabled}
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
disabled={toggleEnabledMutation.isPending}
>
{widget.enabled ? t("action.disable") : t("action.enable")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
disabled={duplicateMutation.isPending}
>
{t("action.duplicate")}
</Menu.Item>
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
},
});
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result as string);
importMutation.mutate(json);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
}
};
reader.onerror = () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
};
reader.readAsText(file);
event.target.value = "";
);
};
return (
<>
<MobileAffixButton
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json"
hidden
onChange={handleImport}
aria-label="Import custom widget"
/>
<Menu withinPortal position="bottom-end" shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
<IconDots {...iconProps} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{widget.valid && (
<>
<Menu.Item
onClick={handleToggleEnabled}
leftSection={widget.enabled ? <IconPlayerPause {...iconProps} /> : <IconPlayerPlay {...iconProps} />}
disabled={toggleMutation.isPending}
>
{widget.enabled ? t("action.disable") : t("action.enable")}
</Menu.Item>
<Menu.Item onClick={onConfigureSources} leftSection={<IconPlugConnected {...iconProps} />}>
{t("action.configureSources")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
disabled={duplicateMutation.isPending}
>
{t("action.duplicate")}
</Menu.Item>
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Item
component={Link}
href={`/manage/custom-widgets/publish/${widget.id}`}
leftSection={<IconBuildingStore {...iconProps} />}
>
{t("action.publishWorkshop")}
</Menu.Item>
<Menu.Divider />
</>
)}
{widget.migrationRequired && (
<>
<Menu.Item onClick={() => void copyLegacyDefinition()} leftSection={<IconCopy {...iconProps} />}>
{t("action.copyLegacyDefinition")}
</Menu.Item>
<Menu.Item onClick={() => void exportLegacyDefinition()} leftSection={<IconDownload {...iconProps} />}>
{t("action.exportLegacyDefinition")}
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
{t("action.copyMigrationPrompt")}
</Menu.Item>
<Menu.Item onClick={() => void pasteMigratedWidget()} leftSection={<IconClipboard {...iconProps} />}>
{t("action.pasteMigration")}
</Menu.Item>
<Menu.Item
onClick={() => migrationFileInputRef.current?.click()}
leftSection={<IconUpload {...iconProps} />}
>
{t("action.importMigration")}
</Menu.Item>
<Menu.Divider />
</>
)}
{!widget.migrationRequired && (
<InlineConfirmMenuItem
color="red"
leftSection={<IconTrash {...iconProps} />}
onConfirm={handleDelete}
confirmLabel={tCommon("action.confirm")}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</InlineConfirmMenuItem>
)}
</Menu.Dropdown>
</Menu>
{widget.migrationRequired && (
<>
<input
ref={migrationFileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleMigrationFile}
aria-label={t("action.importMigration")}
/>
<CustomWidgetImportDialog
opened={migrationOpened}
widget={migratedWidget}
legacyId={widget.id}
onClose={() => {
migrationControls.close();
setMigratedWidget(null);
}}
/>
</>
)}
</>
);
};
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useI18n("customWidget.workbench.builder");
return (
<LazyOnceAccordion label={t("advancedManifest")}>
<CustomWidgetManifestEditor form={form} />
</LazyOnceAccordion>
);
}
function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useI18n("customWidget.workbench.builder");
const values = useDeferredCustomWidgetFormDocumentValues();
const serialized = JSON.stringify(
{
$schema: "homarr-custom-widget-v2",
name: values.name,
...(values.description ? { description: values.description } : {}),
...(values.iconUrl ? { iconUrl: values.iconUrl } : {}),
sources: parseJson(values.sources),
requests: parseJson(values.requests),
options: parseJson(values.options),
template: values.template,
},
null,
2,
);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id="raw-widget-manifest"
label={t("manifestFilename")}
language="json"
value={draft}
error={error}
onChange={(next) => {
setDraft(next);
try {
const value: unknown = JSON.parse(next);
const parsed = customWidgetDefinitionSchema.safeParse(value);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
return;
}
setError(undefined);
applyDefinition(form, parsed.data);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
);
}
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Accordion, Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { useI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
interface AiCardProps {
getDraft(): CustomWidgetAiDraft | null;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
getSampleResponse(): string | null;
request: string;
onRequestChange(value: string): void;
documentationUrl: string;
onDocumentationUrlChange(value: string): void;
onPaste(response: string): string | null;
}
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useI18n("customWidget.workbench.ai");
const [manualResponse, setManualResponse] = useState<string | null>(null);
const [manualError, setManualError] = useState<string | null>(null);
const handlePaste = async () => {
try {
const response = await navigator.clipboard.readText();
const error = props.onPaste(response);
if (!error) return;
setManualResponse(response);
setManualError(error);
} catch {
setManualResponse("");
setManualError(null);
}
};
const submitManualResponse = () => {
const error = props.onPaste(manualResponse ?? "");
if (error) {
setManualError(error);
return;
}
setManualResponse(null);
setManualError(null);
};
return (
<>
<Accordion variant="contained">
<Accordion.Item value="ai">
<Accordion.Control icon={<IconSparkles size={18} />}>{t("title")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
autosize
minRows={2}
/>
<TextInput
label={t("documentationUrl")}
value={props.documentationUrl}
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
/>
<Group gap="xs">
<CopyAiPromptButton
getDraft={props.getDraft}
getDiagnostics={props.getDiagnostics}
getSampleResponse={props.getSampleResponse}
request={props.request}
documentationUrl={props.documentationUrl}
/>
<Button
type="button"
variant="light"
leftSection={<IconClipboard size={16} />}
onClick={() => void handlePaste()}
>
{t("paste")}
</Button>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Modal opened={manualResponse !== null} onClose={() => setManualResponse(null)} title={t("response")} size="lg">
<Stack gap="sm">
<Text size="sm">{t("manualPasteDescription")}</Text>
<Textarea
label={t("response")}
value={manualResponse ?? ""}
onChange={(event) => {
setManualResponse(event.currentTarget.value);
setManualError(null);
}}
error={manualError ? t("invalidResponseDetail", { message: manualError }) : undefined}
autosize
minRows={8}
maxRows={16}
/>
<Button type="button" onClick={submitManualResponse}>
{t("loadResponse")}
</Button>
</Stack>
</Modal>
</>
);
}
@@ -0,0 +1,41 @@
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(
values: Pick<CustomWidgetFormValues, "options" | "sources">,
requestIds: string[],
) {
const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {});
return [
...requestIds.flatMap((id) => [
{ label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` },
{ label: `status["${id}"]`, type: "variable", detail: `Status for ${id}` },
{ label: `requestId="${id}"`, type: "property", detail: "Named request" },
]),
...parseSources(values.sources).map(({ id }) => ({ label: id, type: "constant", detail: "Source ID" })),
...optionKeys.map((key) => ({ label: `options.${key}`, type: "variable", detail: "Widget option" })),
{ label: "inputs", type: "variable", detail: "Temporary bound input values" },
];
}
export function getInvalidCustomWidgetSections(
issues: readonly { path: PropertyKey[] }[],
requestDiagnostics: EditorDiagnostic[],
templateDiagnostics: EditorDiagnostic[],
) {
const result = new Set<string>();
for (const issue of issues) {
const field = String(issue.path[0] ?? "");
if (["name", "description", "iconUrl"].includes(field)) result.add("general");
else if (field === "sources") result.add("sources");
else if (field === "requests") result.add("requests");
else if (field === "options") result.add("options");
else if (field === "template") result.add("jsx");
}
if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests");
if (templateDiagnostics.some((entry) => entry.severity === "error")) result.add("jsx");
if (result.size > 0) result.add("preview");
return result;
}
@@ -0,0 +1,192 @@
"use client";
import { useEffect, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import {
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconBraces,
IconCheck,
IconCode,
IconDatabase,
IconEye,
IconPlayerPlay,
IconSettings,
} from "@tabler/icons-react";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
import classes from "./_custom-widget-form.module.css";
const sectionLinks = [
["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
["requests", "section.requests", IconDatabase],
["options", "section.options", IconBraces],
["jsx", "section.jsx", IconCode],
["preview", "section.preview", IconEye],
] as const;
export function EditorSection({
id,
title,
icon: SectionIcon,
children,
}: {
id: string;
title: string;
icon: Icon;
children: ReactNode;
}) {
return (
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
<Stack gap="md">
<Group gap="sm">
<ThemeIcon variant="light" size="lg">
<SectionIcon size={18} />
</ThemeIcon>
<Text fw={700} size="lg">
{title}
</Text>
</Group>
{children}
</Stack>
</Card>
);
}
export function SaveActions({
dirty,
savePending,
previewPending,
invalid,
mode,
onPreview,
}: {
dirty: boolean;
savePending: boolean;
previewPending: boolean;
invalid: boolean;
mode: "create" | "edit";
onPreview(): void;
}) {
const t = useI18n("customWidget.workbench");
const tCommon = useI18n("common");
return (
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
{invalid ? (
<IconAlertTriangle size={16} color="var(--mantine-color-red-6)" />
) : (
<IconCheck size={16} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" fw={600}>
{invalid ? t("status.invalid") : dirty ? t("status.unsaved") : t("status.ready")}
</Text>
</Group>
<Group gap="xs" wrap="wrap">
<Button
type="button"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={onPreview}
loading={previewPending}
disabled={invalid || savePending}
>
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
</Button>
</Group>
</Group>
);
}
export function CustomWidgetSectionNavigation({
onSelect,
}: {
onSelect(section: (typeof sectionLinks)[number][0]): void;
}) {
const t = useI18n("customWidget.workbench");
const invalidSections = useCustomWidgetFormAnalysisField("invalidSections");
return (
<nav className={classes.sectionNav} aria-label={t("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => {
const invalid = invalidSections.has(id);
return (
<Button
key={id}
component="a"
href={`#${id}`}
onClick={() => onSelect(id)}
size="compact-sm"
variant="subtle"
color={invalid ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={invalid ? <IconAlertCircle size={13} aria-label={t("status.invalid")} /> : undefined}
>
{t(key)}
</Button>
);
})}
</nav>
);
}
export function CustomWidgetSaveActions({
mode,
savePending,
previewPending,
onPreview,
}: {
mode: "create" | "edit";
savePending: boolean;
previewPending: boolean;
onPreview(): void;
}) {
const dirty = useCustomWidgetFormDocumentDirty();
const candidateValid = useCustomWidgetFormAnalysisField("candidateValid");
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
return (
<SaveActions
dirty={dirty}
savePending={savePending}
previewPending={previewPending}
invalid={hasDiagnostics || !candidateValid}
mode={mode}
onPreview={onPreview}
/>
);
}
export function CustomWidgetUnsavedChangesGuard() {
const dirty = useCustomWidgetFormDocumentDirty();
useUnsavedChangesGuard(dirty);
return null;
}
export function CustomWidgetOptionsSnapshotSync({
setOptionsSnapshot,
}: {
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
}) {
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
const lastDefaults = useRef<Record<string, unknown> | null>(null);
useEffect(() => {
if (!parsedOptions.success) return;
const defaults = getCustomWidgetDefaultOptions(parsedOptions.data);
if (lastDefaults.current && areCustomWidgetValuesEqual(lastDefaults.current, defaults)) return;
lastDefaults.current = defaults;
setOptionsSnapshot(defaults);
}, [parsedOptions, setOptionsSnapshot]);
return null;
}
@@ -0,0 +1,18 @@
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
export const customWidgetRequestReference = {
schema: getCustomWidgetRequestsJsonSchema(),
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
};
export const customWidgetOptionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full,
};
@@ -0,0 +1,225 @@
"use client";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { TextInput, Textarea } from "@mantine/core";
import { IconApi, IconBraces, IconCode, IconDatabase, IconSettings } from "@tabler/icons-react";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { EditorSection } from "./_custom-widget-form-layout";
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
import { useCustomWidgetFormDocumentField } from "./_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
export function CustomWidgetAiSection({
getDraft,
getDiagnostics,
getSampleResponse,
onPaste,
}: {
getDraft(): CustomWidgetAiDraft | null;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
getSampleResponse(): string | null;
onPaste(response: string): string | null;
}) {
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
return (
<CustomWidgetAiCard
getDraft={getDraft}
getDiagnostics={getDiagnostics}
getSampleResponse={getSampleResponse}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={onPaste}
/>
);
}
export function CustomWidgetGeneralSection({
form,
formRevision,
mode,
}: {
form: CustomWidgetWorkbenchForm;
formRevision: string;
mode: "create" | "edit";
}) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const [suggestedIconSearch, setSuggestedIconSearch] = useState(() => form.getValues().name);
const iconUrl = useCustomWidgetFormDocumentField("iconUrl");
const nameInputProps = form.getInputProps("name");
useEffect(() => setSuggestedIconSearch(form.getValues().name), [form, formRevision]);
return (
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput
key={form.key("name")}
label={tCommon("field.name")}
required
{...nameInputProps}
onBlur={(event) => {
nameInputProps.onBlur?.(event);
setSuggestedIconSearch(form.getValues().name);
}}
/>
<Textarea
key={form.key("description")}
label={t("field.description")}
autosize
minRows={2}
{...form.getInputProps("description")}
/>
<IconPicker
key={form.key("iconUrl")}
withAsterisk={false}
suggestedSearch={mode === "create" ? suggestedIconSearch : undefined}
{...form.getInputProps("iconUrl")}
value={iconUrl}
/>
</EditorSection>
);
}
export function CustomWidgetSourcesSection({
form,
definitionId,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const w = useI18n("customWidget.workbench");
useCustomWidgetFormDocumentField("sources");
useCustomWidgetFormDocumentField("requests");
useCustomWidgetFormDocumentField("secrets");
return (
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
);
}
export function CustomWidgetRequestsSection({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const w = useI18n("customWidget.workbench");
const requests = useCustomWidgetFormDocumentField("requests");
useCustomWidgetFormDocumentField("sources");
useCustomWidgetFormDocumentField("options");
const requestDiagnostics = useCustomWidgetFormAnalysisField("requestDiagnostics");
return (
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={onRename} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={requests}
onChange={(value) => form.setFieldValue("requests", value, { forceUpdate: false })}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</LazyOnceAccordion>
</EditorSection>
);
}
export function CustomWidgetOptionsSection({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const w = useI18n("customWidget.workbench");
const options = useCustomWidgetFormDocumentField("options");
useCustomWidgetFormDocumentField("requests");
return (
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={onRename} />
<LazyOnceAccordion label={w("builder.advancedJson")}>
<CodeEditor
id="options-editor"
label={w("options.title")}
language="json"
value={options}
onChange={(value) => form.setFieldValue("options", value, { forceUpdate: false })}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</LazyOnceAccordion>
</EditorSection>
);
}
export function CustomWidgetTemplateSection({ form }: { form: CustomWidgetWorkbenchForm }) {
const w = useI18n("customWidget.workbench");
const template = useCustomWidgetFormDocumentField("template");
const jsxCompletions = useCustomWidgetFormAnalysisField("jsxCompletions");
const templateDiagnostics = useCustomWidgetFormAnalysisField("templateDiagnostics");
return (
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={template}
onChange={(value) => form.setFieldValue("template", value, { forceUpdate: false })}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
);
}
interface CustomWidgetPreviewSectionProps {
preview: PreviewState;
size: string;
onSizeChange(size: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange: Dispatch<SetStateAction<Record<string, unknown>>>;
onLiveActionsChange(enabled: boolean): void;
}
export function CustomWidgetPreviewSection(props: CustomWidgetPreviewSectionProps) {
const previewCandidate = useCustomWidgetFormAnalysisField("previewCandidate");
const previewValidationIssues = useCustomWidgetFormAnalysisField("previewValidationIssues");
return (
<CustomWidgetPreviewPanel
candidate={previewCandidate}
validationIssues={previewValidationIssues}
preview={props.preview}
size={props.size}
onSizeChange={props.onSizeChange}
optionsSnapshot={props.optionsSnapshot}
onOptionsChange={props.onOptionsChange}
onLiveActionsChange={props.onLiveActionsChange}
/>
);
}
@@ -0,0 +1,143 @@
"use client";
import {
createContext,
startTransition,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ReactNode } from "react";
import type { UseFormReturnType } from "@mantine/form";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
export interface CustomWidgetFormDocumentStore {
getValues(): CustomWidgetFormValues;
setValues(values: CustomWidgetFormValues): void;
getDirty(): boolean;
subscribe(listener: () => void): () => void;
markSaved(values: CustomWidgetFormValues): void;
}
const documentValueKeys = customWidgetFormSchema.keyof().options;
export const CUSTOM_WIDGET_PREVIEW_DEBOUNCE_MS = 1_000;
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
if (Object.keys(left).length !== documentValueKeys.length) return false;
if (Object.keys(right).length !== documentValueKeys.length) return false;
return documentValueKeys.every(
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
);
}
export function createCustomWidgetFormDocumentStore(
initialValues: CustomWidgetFormValues,
): CustomWidgetFormDocumentStore {
let values = initialValues;
let persistedValues = initialValues;
let dirty = false;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
return {
getValues: () => values,
setValues: (nextValues) => {
if (Object.is(values, nextValues)) return;
values = nextValues;
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
notify();
},
getDirty: () => dirty,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
markSaved: (savedValues) => {
const wasDirty = dirty;
persistedValues = savedValues;
dirty = !areDocumentValuesEqual(values, persistedValues);
if (wasDirty !== dirty) notify();
},
};
}
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
export function CustomWidgetFormDocumentProvider({
store,
children,
}: {
store: CustomWidgetFormDocumentStore;
children: ReactNode;
}) {
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
}
export function useCustomWidgetFormDocumentStore() {
const store = useContext(CustomWidgetFormDocumentContext);
if (!store) throw new Error("Custom widget form document provider is missing");
return store;
}
export function useDeferredCustomWidgetFormDocumentValues() {
const store = useCustomWidgetFormDocumentStore();
const [values, setValues] = useState(() => store.getValues());
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const nextValues = store.getValues();
clearTimeout(timeout);
timeout = setTimeout(() => {
startTransition(() => {
setValues((currentValues) => {
if (!Object.is(store.getValues(), nextValues)) return currentValues;
return nextValues;
});
});
}, CUSTOM_WIDGET_PREVIEW_DEBOUNCE_MS);
};
const unsubscribe = store.subscribe(scheduleUpdate);
return () => {
clearTimeout(timeout);
unsubscribe();
};
}, [store]);
return values;
}
export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetFormValues>(field: Key) {
const store = useCustomWidgetFormDocumentStore();
const getSnapshot = () => store.getValues()[field];
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
export function useCustomWidgetFormDocumentDirty() {
const store = useCustomWidgetFormDocumentStore();
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
}
export function useCustomWidgetFormDocumentBridge(
form: UseFormReturnType<CustomWidgetFormValues>,
store: CustomWidgetFormDocumentStore,
) {
const formRef = useRef(form);
formRef.current = form;
return useMemo(
() =>
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
get: (_target, property) => {
if (property === "values") return store.getValues();
return Reflect.get(formRef.current, property);
},
}),
[store],
);
}
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
applyCustomWidgetAiResponse,
filterSecretsForSourceAuthentication,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
loadPreviewQueries,
parseSources,
} from "./_custom-widget-form-utils";
const previewQuery = vi.hoisted(() => vi.fn());
vi.mock("@homarr/api/client", () => ({ fetchApi: { customWidget: { previewQuery: { query: previewQuery } } } }));
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Preview options",
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { containers: { path: "/endpoints/{option:environmentId}/containers" } },
options: { environmentId: { label: "Environment", control: "number", default: 1 } },
template: "<Text>Preview</Text>",
});
describe("Custom Widget workbench preview options", () => {
it("loads one complete AI response into every editor field, including JSX", () => {
const setValues = vi.fn();
const result = applyCustomWidgetAiResponse(
{
getValues: () => ({
name: "",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "",
secrets: [],
}),
setValues,
} as never,
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
);
if (!result.success) throw new Error("Expected the complete AI response to be accepted");
expect(setValues).toHaveBeenCalledOnce();
const values = setValues.mock.calls[0]?.[0];
expect(values).toMatchObject({
name: "Preview options",
description: "",
iconUrl: "",
template: "<Text>Preview</Text>",
secrets: [],
});
expect(JSON.parse(values?.sources as string)).toEqual(result.widget.sources);
expect(JSON.parse(values?.requests as string)).toEqual(result.widget.requests);
expect(JSON.parse(values?.options as string)).toEqual(result.widget.options);
});
it("shows the default source first regardless of manifest key order", () => {
expect(
parseSources(
JSON.stringify({
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "none" },
default: { baseUrl: "https://default.example.com", networkScope: "public", auth: "none" },
}),
).map(({ id }) => id),
).toEqual(["default", "secondary"]);
});
it("keeps load-query option resolution on the server", async () => {
previewQuery.mockResolvedValueOnce({ ok: true, status: 200, data: [] });
await loadPreviewQueries(definition, "preview-1");
expect(previewQuery).toHaveBeenCalledWith({ sessionId: "preview-1", requestId: "containers", params: {} });
});
it("rejects invalid selected options", () => {
expect(getCustomWidgetPreviewOptionIssues(definition, { environmentId: "wrong" })).toEqual([
{ path: "configuration.environmentId", message: "Expected a number" },
]);
});
it("normalizes only changed secrets", () => {
expect(
getChangedSecrets({
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" },
],
}),
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
});
it("drops credentials that do not apply after an authentication transition", () => {
expect(
filterSecretsForSourceAuthentication(
[
{ sourceId: "default", kind: "username", value: "admin", hasValue: true },
{ sourceId: "default", kind: "password", value: "secret", hasValue: true },
{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true },
],
"default",
"bearer",
),
).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]);
});
});
@@ -0,0 +1,148 @@
import { fetchApi } from "@homarr/api/client";
import { isRecord } from "@homarr/common";
import {
customWidgetDefinitionSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetRequiredSecretKinds,
parseCustomWidgetAiResponse,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { UseFormReturnType } from "@mantine/form";
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
export { isRecord };
const PREVIEW_QUERY_CONCURRENCY = 4;
export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
name: values.name,
description: values.description || undefined,
iconUrl: values.iconUrl || undefined,
sources: parseJson(values.sources),
requests: parseJson(values.requests),
options: parseJson(values.options),
template: values.template,
});
}
export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) {
return values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
}
export function filterSecretsForSourceAuthentication(
secrets: CustomWidgetFormValues["secrets"],
sourceId: string,
authType: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0],
) {
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
}
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.getValues(),
name: widget.name,
description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "",
sources: JSON.stringify(widget.sources, null, 2),
requests: JSON.stringify(widget.requests, null, 2),
options: JSON.stringify(widget.options, null, 2),
template: widget.template,
});
}
export function applyCustomWidgetAiResponse(form: CustomWidgetWorkbenchForm, response: string) {
const result = parseCustomWidgetAiResponse(response);
if (result.success) applyDefinition(form, result.widget);
return result;
}
export function parseSources(value: string): Array<CustomWidgetSource & { id: string }> {
const parsed = parseJson(value);
return isRecord(parsed)
? Object.entries(parsed)
.flatMap(([id, entry]) => (isRecord(entry) ? [{ id, ...(entry as CustomWidgetSource) }] : []))
.toSorted((left, right) => Number(right.id === "default") - Number(left.id === "default"))
: [];
}
export function parseJson(value: string): unknown {
try {
const parsed: unknown = JSON.parse(value);
return parsed;
} catch {
return null;
}
}
export function getCustomWidgetPreviewOptionIssues(definition: HomarrCustomWidgetV2, options: Record<string, unknown>) {
return validateCustomWidgetOptions(definition.options, options);
}
export function isRuntimeParams(
value: unknown,
expectedNames: readonly string[],
): value is Record<string, string | number | boolean> {
if (!isRecord(value)) return false;
const names = Object.keys(value).toSorted();
return (
names.length === expectedNames.length &&
names.every((name, index) => name === expectedNames[index]) &&
Object.values(value).every((entry) => ["string", "number", "boolean"].includes(typeof entry))
);
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const requests = Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
);
const results: Array<{
requestId: string;
data: unknown;
status: Record<string, unknown>;
}> = [];
let nextIndex = 0;
const worker = async () => {
while (true) {
const index = nextIndex;
nextIndex += 1;
const request = requests[index];
if (!request) return;
const [requestId] = request;
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
} catch (error) {
results[index] = {
requestId,
data: null,
status: {
loading: false,
ok: false,
error: error instanceof Error ? error.message : "Request failed",
},
};
}
}
};
await Promise.all(Array.from({ length: Math.min(PREVIEW_QUERY_CONCURRENCY, requests.length) }, () => worker()));
return {
data: Object.fromEntries(results.map((result) => [result.requestId, result.data])),
status: Object.fromEntries(results.map((result) => [result.requestId, result.status])),
};
}
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
return getCustomWidgetDefaultOptions(definition.options);
}
@@ -0,0 +1,115 @@
.form {
min-width: 0;
}
.sectionNav {
position: sticky;
top: 0.5rem;
z-index: 20;
display: flex;
gap: 0.25rem;
margin-bottom: var(--mantine-spacing-md);
padding: 0.35rem;
overflow-x: auto;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px);
}
.sectionNav > a {
flex: 0 0 auto;
}
.editorSection {
scroll-margin-top: 5rem;
}
.previewCanvas {
display: flex;
justify-content: center;
overflow-x: auto;
padding: var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default-hover);
}
.workbench {
display: grid;
gap: var(--mantine-spacing-xl);
align-items: start;
}
.paneSwitcher {
position: sticky;
top: var(--mantine-spacing-xs);
z-index: 10;
margin-bottom: var(--mantine-spacing-lg);
background: var(--mantine-color-body);
}
@media (max-width: 74.9375em) {
.sectionNav {
top: 3.5rem;
}
.workbench[data-mobile-pane="configure"] .previewPane,
.workbench[data-mobile-pane="preview"] .configuration {
display: none;
}
}
.configuration,
.previewPane {
min-width: 0;
}
.previewPane {
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-md);
}
.actionBar {
position: sticky;
bottom: var(--mantine-spacing-sm);
z-index: 10;
background: var(--mantine-color-body);
}
.mobileSaveBar {
position: sticky;
bottom: var(--mantine-spacing-sm);
z-index: 10;
background: var(--mantine-color-body);
}
@media (min-width: 75em) {
.paneSwitcher {
display: none;
}
.mobileSaveBar {
display: none;
}
.workbench {
grid-template-columns: 1fr 1fr;
}
.previewPane {
position: sticky;
top: 5rem;
}
.actionBar {
top: 5rem;
bottom: auto;
}
}
@media (min-width: 100em) {
.workbench {
grid-template-columns: 0.9fr 1.1fr;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { TextInput } from "@mantine/core";
export function CustomWidgetIdentifierInput({
label,
value,
error,
disabled,
style,
onCommit,
}: {
label: string;
value: string;
error?: ReactNode;
disabled?: boolean;
style?: CSSProperties;
onCommit(value: string): void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const commit = () => {
if (draft !== value) onCommit(draft);
};
return (
<TextInput
label={label}
value={draft}
error={error}
disabled={disabled}
style={style}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setDraft(value);
}
}}
/>
);
}
@@ -1,101 +1,192 @@
"use client";
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
const displayTypeBadgeColors: Record<string, string> = {
singleValue: "blue",
keyValue: "green",
table: "orange",
statGrid: "violet",
progressBars: "teal",
statusIndicator: "cyan",
countGrid: "indigo",
raw: "gray",
actionButton: "red",
customJsx: "pink",
};
const iconProps = { size: 16, stroke: 1.5 };
interface CustomWidgetListProps {
definitions: RouterOutputs["customWidget"]["all"];
definitions: RouterOutputs["customWidget"]["list"];
}
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const tEntities = useI18n("common.entity");
if (definitions.length === 0) {
return (
<NoResults
icon={IconApi}
title={t("page.list.noResults")}
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
action={{ href: "/manage/custom-widgets/new", label: tCommon("action.create") }}
/>
);
}
return (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</Table.Th>
<Table.Th>{t("table.url")}</Table.Th>
<Table.Th>{t("table.display")}</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
{def.url}
</Text>
</Table.Td>
<Table.Td>
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${def.id}`}
variant="filled"
color="red"
aria-label={t("action.edit")}
>
<IconPencil {...iconProps} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
<ManageCollectionList ariaLabel={tEntities("customWidgets")}>
{definitions.map((definition) => (
<CustomWidgetRow key={definition.id} widget={definition} />
))}
</ManageCollectionList>
);
};
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))];
return (
<>
<ManageCollectionItem
leading={
<Avatar
size={40}
radius="sm"
src={widget.iconUrl}
color={widget.valid ? "pink" : "red"}
styles={{ image: { objectFit: "contain" } }}
alt=""
>
<IconApi size={20} stroke={1.5} />
</Avatar>
}
title={
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
{widget.name}
</Text>
}
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
</Text>
{origins.map((origin) => (
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
· {origin}
</Text>
))}
</Group>
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{tCommon("action.edit")}
</Button>
)}
<CustomWidgetRowActions
onConfigureSources={sourceSetupControls.open}
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
}
actionsAlignment="center"
/>
{widget.valid && (
<CustomWidgetSourceSetupDialog
definitionId={widget.id}
opened={sourceSetupOpened}
onClose={sourceSetupControls.close}
/>
)}
</>
);
}
/** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ widget, onConfigureSources }: { widget: WidgetDef; onConfigureSources(): void }) {
const t = useI18n("customWidget");
if (widget.migrationRequired) {
return (
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
{t("page.list.migrationRequired")}
</Badge>
</Tooltip>
);
}
if (!widget.valid) {
return (
<Tooltip
label={widget.validationIssues
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
.join("\n")}
multiline
maw={520}
>
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
{t("page.list.invalidDefinition")}
</Badge>
</Tooltip>
);
}
return (
<Group gap={6} wrap="nowrap">
{widget.enabled ? (
<Badge color="green" size="sm" variant="light">
{t("page.list.enabled")}
</Badge>
) : (
<Tooltip label={t("page.list.disabledDescription")}>
<Badge color="gray" size="sm" variant="light">
{t("page.list.disabled")}
</Badge>
</Tooltip>
)}
{widget.missingSecrets.length > 0 && (
<Tooltip
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
multiline
maw={320}
>
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ cursor: "pointer" }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</UnstyledButton>
</Tooltip>
)}
</Group>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
export function DefaultValueEditor({
id,
control,
value,
choices = [],
onChange,
}: {
id: string;
control: string;
value: unknown;
choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void;
}) {
const t = useI18n("customWidget.workbench.builder");
if (control === "switch")
return (
<Switch
label={t("default")}
checked={value === true}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
if (["number", "slider", "duration"].includes(control))
return <NumberInput label={t("default")} value={typeof value === "number" ? value : 0} onChange={onChange} />;
const choiceData = choices.map((choice) => ({ label: choice.label, value: String(choice.value) }));
if (control === "select" && choices.length > 0)
return (
<Select
label={t("default")}
data={choiceData}
value={value === undefined ? null : String(value)}
onChange={(next) => onChange(coerceChoice(next, choices))}
/>
);
if (control === "multiSelect" && choices.length > 0)
return (
<MultiSelect
label={t("default")}
data={choiceData}
value={Array.isArray(value) ? value.map(String) : []}
onChange={(next) => onChange(next.map((entry) => coerceChoice(entry, choices)))}
/>
);
if (control === "json" || control === "multiSelect")
return <JsonValueEditor id={id} label={t("default")} value={value} onChange={onChange} />;
return (
<TextInput
label={t("default")}
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.currentTarget.value)}
/>
);
}
export function optionForControl(option: Record<string, unknown>, control: string | null): Record<string, unknown> {
const nextControl = control ?? "text";
const next: Record<string, unknown> = { ...option, control: nextControl, default: defaultForControl(nextControl) };
if (!["select", "multiSelect"].includes(nextControl)) {
next.choices = undefined;
next.choicesFrom = undefined;
}
if (!["number", "slider", "duration"].includes(nextControl)) {
next.min = undefined;
next.max = undefined;
next.step = undefined;
}
return next;
}
export function OptionChoicesEditor({
value,
onChange,
}: {
value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void;
}) {
const t = useI18n("customWidget.workbench.builder");
return (
<Stack gap="xs">
{value.map((choice, index) => (
<Group key={index} align="end" wrap="wrap">
<TextInput
style={{ flex: "1 1 10rem" }}
label={t("choiceLabel")}
value={choice.label}
onChange={(event) =>
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, label: event.currentTarget.value } : entry,
),
)
}
/>
<TextInput
style={{ flex: "1 1 10rem" }}
label={t("choiceValue")}
value={String(choice.value)}
onChange={(event) =>
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, value: parseChoiceValue(event.currentTarget.value) } : entry,
),
)
}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
aria-label={t("removeChoice")}
onClick={() => onChange(value.filter((_, entryIndex) => entryIndex !== index))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconPlus size={14} />}
onClick={() => onChange([...value, { label: t("newChoice"), value: `choice-${value.length + 1}` }])}
>
{t("addChoice")}
</Button>
</Stack>
);
}
export function JsonValueEditor({
id,
label,
description,
value,
onChange,
}: {
id: string;
label: string;
description?: string;
value: unknown;
onChange(value: unknown): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={`option-${id}`}
label={label}
description={description}
language="json"
value={draft}
height="140px"
error={error}
onChange={(next) => {
setDraft(next);
try {
onChange(JSON.parse(next) as unknown);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
);
}
function defaultForControl(control: string | null) {
if (control === "switch") return false;
if (["number", "slider", "duration"].includes(control ?? "")) return 0;
if (control === "multiSelect") return [];
if (control === "json") return {};
return "";
}
function parseChoiceValue(value: string): string | number {
return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim()) ? Number(value) : value;
}
function coerceChoice(value: string | null, choices: Array<{ value: string | number }>) {
if (value === null) return "";
const numeric = choices.find((choice) => typeof choice.value === "number" && String(choice.value) === value);
return numeric?.value ?? value;
}
@@ -0,0 +1,271 @@
"use client";
import {
Accordion,
ActionIcon,
Button,
Fieldset,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { DefaultValueEditor, OptionChoicesEditor, optionForControl } from "./_custom-widget-option-fields";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
export function CustomWidgetOptionsEditor({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests);
const requestIds = isRecord(requests) ? Object.keys(requests) : [];
const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("options", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, option: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, option] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
if (!next[index] || !next[target]) return;
[next[index], next[target]] = [next[target], next[index]];
commit(next);
};
const add = () => {
let suffix = entries.length + 1;
while (entries.some(([id]) => id === `option-${suffix}`)) suffix += 1;
commit([...entries, [`option-${suffix}`, { label: `Option ${suffix}`, control: "text", default: "" }]]);
};
return (
<Stack gap="sm">
{entries.map(([id, rawOption], index) => {
const option = isRecord(rawOption) ? rawOption : {};
const control = typeof option.control === "string" ? option.control : "text";
const choicesFrom = isRecord(option.choicesFrom) ? option.choicesFrom : null;
const choices = Array.isArray(option.choices)
? option.choices.flatMap((choice) =>
isRecord(choice) &&
typeof choice.label === "string" &&
(typeof choice.value === "string" || typeof choice.value === "number")
? [{ label: choice.label, value: choice.value }]
: [],
)
: [];
return (
<Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("optionName")}
value={id}
error={form.errors.options}
onCommit={(value) => onRename(id, value)}
/>
<TextInput
label={t("label")}
value={typeof option.label === "string" ? option.label : ""}
onChange={(event) => update(index, id, { ...option, label: event.currentTarget.value })}
/>
<Select
label={t("control")}
data={[...customWidgetOptionControls]}
value={control}
allowDeselect={false}
onChange={(value) => update(index, id, optionForControl(option, value))}
/>
</Group>
<TextInput
label={t("description")}
value={typeof option.description === "string" ? option.description : ""}
onChange={(event) =>
update(index, id, { ...option, description: event.currentTarget.value || undefined })
}
/>
<DefaultValueEditor
id={`${id}-default`}
control={control}
value={option.default}
choices={choices}
onChange={(value) => update(index, id, { ...option, default: value })}
/>
<Accordion variant="contained">
<Accordion.Item value="advanced">
<Accordion.Control>{t("optionDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{["number", "slider", "duration"].includes(control) && (
<Group grow>
<NumberInput
label={t("minimum")}
value={typeof option.min === "number" ? option.min : ""}
onChange={(value) =>
update(index, id, { ...option, min: typeof value === "number" ? value : undefined })
}
/>
<NumberInput
label={t("maximum")}
value={typeof option.max === "number" ? option.max : ""}
onChange={(value) =>
update(index, id, { ...option, max: typeof value === "number" ? value : undefined })
}
/>
<NumberInput
label={t("step")}
value={typeof option.step === "number" ? option.step : ""}
onChange={(value) =>
update(index, id, { ...option, step: typeof value === "number" ? value : undefined })
}
/>
</Group>
)}
{["select", "multiSelect"].includes(control) && (
<OptionChoicesEditor
value={choices}
onChange={(nextChoices) =>
update(index, id, {
...option,
choices: nextChoices.length > 0 ? nextChoices : undefined,
choicesFrom: undefined,
default:
control === "select" &&
!nextChoices.some((choice) => Object.is(choice.value, option.default))
? (nextChoices[0]?.value ?? "")
: option.default,
})
}
/>
)}
{["select", "multiSelect"].includes(control) && (
<Group grow align="start">
<Select
label={t("choicesRequest")}
data={requestIds}
value={typeof choicesFrom?.request === "string" ? choicesFrom.request : null}
clearable
onChange={(value) =>
update(index, id, {
...option,
choices: value ? undefined : option.choices,
choicesFrom: value ? { request: value, valuePath: "id", labelPath: "name" } : undefined,
})
}
/>
{choicesFrom && (
<>
<TextInput
label={t("itemsPath")}
placeholder={t("itemsPathPlaceholder")}
value={String(choicesFrom.itemsPath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, itemsPath: event.currentTarget.value || undefined },
})
}
/>
<TextInput
label={t("valuePath")}
value={String(choicesFrom.valuePath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, valuePath: event.currentTarget.value },
})
}
/>
<TextInput
label={t("labelPath")}
value={String(choicesFrom.labelPath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, labelPath: event.currentTarget.value },
})
}
/>
</>
)}
</Group>
)}
<Group grow>
<TextInput
label={t("group")}
value={typeof option.group === "string" ? option.group : ""}
onChange={(event) =>
update(index, id, { ...option, group: event.currentTarget.value || undefined })
}
/>
<Switch
mt="xl"
label={t("advancedOption")}
checked={option.advanced === true}
onChange={(event) => update(index, id, { ...option, advanced: event.currentTarget.checked })}
/>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveOptionUp")}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveOptionDown")}
disabled={index === entries.length - 1}
onClick={() => move(index, 1)}
>
<IconArrowDown size={16} />
</ActionIcon>
</Group>
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => remove(index)}
>
{t("removeOption")}
</Button>
</Group>
</Stack>
</Fieldset>
);
})}
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
{t("addOption")}
</Button>
{form.errors.options && (
<Text c="red" size="xs">
{form.errors.options}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { Button, Fieldset, Stack, Textarea } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
export function PreviewActionControl({
request,
sessionId,
}: {
request: CustomJsxRequest & { id: string };
sessionId?: string;
}) {
const t = useI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
const initialParams = Object.fromEntries(expectedParams.map((name) => [name, ""]));
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
const parsed = parseJson(params);
const validParams = isRuntimeParams(parsed, expectedParams) ? parsed : null;
const confirmation = getCustomWidgetConfirmation(request);
const run = async () => {
if (!sessionId || !validParams) return;
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
if (needsConfirmation) {
const confirmed = await new Promise<boolean>((resolve) => {
openConfirmModal({
title: confirmation?.title ?? request.id,
children: confirmation?.message ?? t("deleteConfirmation"),
labels: { confirm: confirmation?.confirmLabel },
confirmProps: { color: confirmation?.destructive || request.method === "DELETE" ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) return;
}
actionMutation.mutate(
{ sessionId, requestId: request.id, params: validParams, confirmed: needsConfirmation },
{
onSuccess: (result) => {
if (!result.ok) {
showErrorNotification({ title: request.id, message: result.error ?? result.statusText });
return;
}
showSuccessNotification({
title: request.id,
message: result.simulated ? t("simulated") : t("httpStatus", { status: result.status }),
});
},
onError: (error) => showErrorNotification({ title: request.id, message: error.message }),
},
);
};
return (
<Fieldset legend={`${request.id} · ${request.method}`}>
<Stack gap="xs">
{expectedParams.length > 0 && (
<Textarea
label={t("actionParameters")}
autosize
minRows={2}
value={params}
error={validParams ? undefined : t("invalidParameters")}
onChange={(event) => setParams(event.currentTarget.value)}
/>
)}
<Button
type="button"
variant="light"
color={confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
disabled={!sessionId || !validParams}
loading={actionMutation.isPending}
onClick={() => void run()}
>
{confirmation?.confirmLabel ??
(confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
</Button>
</Stack>
</Fieldset>
);
}
Loaded 100 of 3286 files, more files were not shown because too many files have changed in this diff. Show more