Compare commits

..
269 changed files with 2041 additions and 16273 deletions

No files matched your search

@@ -36,8 +36,6 @@ pnpm cli harness setup feature-board
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
```sh
pnpm cli harness port feature-board
```
@@ -55,7 +55,7 @@ The studio keeps related choices together:
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint.
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
+3 -3
View File
@@ -1,5 +1,5 @@
---
description: Configure and use Assistant.
description: Configure and use Homarr Assistant.
tags:
- Assistant
- AI
@@ -8,10 +8,10 @@ tags:
# Assistant
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
approval before making changes.
![Assistant conversation with suggested prompts](/img/assistant/conversation.png)
![Homarr Assistant conversation with suggested prompts](/img/assistant/conversation.png)
## Set it up
+1 -1
View File
@@ -317,7 +317,7 @@ Change the radius of your items and Containers.
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
or Danger zone actions.
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
runtime components.
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
```sh
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
@@ -41,10 +41,6 @@ needs attention.
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Test and preview, inspect Data and the redacted request journal, then save.
While focus is inside a code editor, use `Ctrl+Z`/`Cmd+Z` to undo and `Ctrl+Shift+Z`/`Cmd+Shift+Z` to redo. The toolbar exposes the same actions. This history is local to that editor; builder-wide undo is not yet available.
Changing the definition, instance options, or credentials invalidates the previous preview and its live-action session. Run **Test and preview** again before treating the data or result as current. Automatic load queries are queued with up to four running at once so larger previews stay within the request limit.
<CanonicalCustomWidgetExample
id="named-widget-requests"
label="Named queries and actions"
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
### Design with AI
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
update tool for your approval.
+1 -1
View File
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
The optional [Assistant](./assistant) uses these same tool definitions internally.
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
approval before running mutating procedures.
+2 -4
View File
@@ -38,10 +38,8 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
We would rather become millionaires in a more ethical manner.
-3
View File
@@ -72,9 +72,6 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
-351
View File
@@ -3453,9 +3453,6 @@
"enableRightClickOnWidgets": {
"type": "boolean"
},
"headerPreferences": {
"type": "string"
},
"defaultSearchEngineId": {
"anyOf": [
{
@@ -3491,7 +3488,6 @@
"firstDayOfWeek",
"pingIconsEnabled",
"enableRightClickOnWidgets",
"headerPreferences",
"defaultSearchEngineId",
"openSearchInNewTab",
"ddgBangs",
@@ -4350,353 +4346,6 @@
}
}
},
"/api/users/header-preferences": {
"patch": {
"operationId": "userRouter-changeHeaderPreferences",
"tags": ["users"],
"security": [
{
"apikey": []
}
],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"headerPreferences": {
"oneOf": [
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 3
},
"visible": {
"type": "boolean"
},
"searchDisplay": {
"type": "string",
"enum": ["input", "icon"]
},
"logoDisplay": {
"default": "logoAndText",
"type": "string",
"enum": ["logo", "logoAndText"]
},
"zones": {
"type": "object",
"properties": {
"left": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
},
"center": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
},
"right": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
}
},
"required": ["left", "center", "right"],
"additionalProperties": false
}
},
"required": ["version", "visible", "searchDisplay", "zones"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 1
},
"visible": {
"type": "boolean"
},
"items": {
"minItems": 1,
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
}
},
"required": ["version", "visible", "items"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 2
},
"visible": {
"type": "boolean"
},
"zones": {
"type": "object",
"properties": {
"left": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
},
"center": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
},
"right": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
}
},
"required": ["left", "center", "right"],
"additionalProperties": false
}
},
"required": ["version", "visible", "zones"],
"additionalProperties": false
}
],
"type": "object"
}
},
"required": ["id", "headerPreferences"]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.NOT_FOUND"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/api/users/ddg-bangs": {
"patch": {
"operationId": "userRouter-changeDdgBangs",
@@ -1,7 +1,6 @@
"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";
@@ -11,23 +10,18 @@ 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,
branding,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
const manager = useColorSchemeManager();
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={configuredTheme}
theme={theme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
@@ -1,13 +1,11 @@
import { notFound } from "next/navigation";
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 { OnboardingAuthShell } from "@homarr/onboarding";
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form";
@@ -22,9 +20,11 @@ interface InviteUsagePageProps {
}
export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound();
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
const session = await auth();
if (session) notFound();
const invite = await db.query.invites.findFirst({
@@ -45,33 +45,13 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound();
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;
const t = await getI18n("user.page.invite");
return (
<OnboardingAuthShell
title={title}
description={description}
title={t("title")}
description={t("subtitle")}
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! })}
>
@@ -2,11 +2,9 @@ import { redirect } from "next/navigation";
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 { OnboardingAuthShell } from "@homarr/onboarding";
import { getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
@@ -21,35 +19,17 @@ interface LoginProps {
}
export default async function Login(props: LoginProps) {
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
const searchParams = await props.searchParams;
const session = await auth();
if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
}
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");
const t = await getI18n("user.page.login");
return (
<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}
>
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
{appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}>
@@ -4,7 +4,6 @@ import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -16,11 +15,12 @@ import type { WidgetKind } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client";
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
@@ -36,8 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -104,24 +103,18 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
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;
const board = await getInitialBoard(await params);
const t = await getI18n("board");
return {
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
icons: {
icon: favicon,
apple: favicon,
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: {
url: favicon,
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
},
},
};
@@ -39,9 +39,10 @@ const loadBoardEditorAsync = async () =>
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null;
@@ -49,27 +50,20 @@ export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
</>
);
};
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");
@@ -1,33 +1,30 @@
"use client";
import type { PropsWithChildren } from "react";
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
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: generateColorScale(primaryColor),
secondaryColor: generateColorScale(board.secondaryColor),
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
},
primaryColor: "primaryColor",
autoContrast: true,
defaultRadius: branding.defaultRadius,
defaultRadius: "sm",
fontSizes: {
"2xl": rem(24),
"3xl": rem(28),
@@ -46,3 +43,29 @@ 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;
};
@@ -1,6 +1,5 @@
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";
@@ -8,7 +7,6 @@ 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();
@@ -21,9 +19,8 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const t = await getI18n("board");
if (boardCount === 0) {
const { branding } = await getRscServerSettingsAsync();
return {
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("error.noBoard.title"),
description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
@@ -22,10 +22,9 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { generateColorScale } from "~/theme/branding";
import { generateColors } from "../../(content)/_theme";
import type { FormValues } from "./_settings-form";
interface Props {
@@ -41,19 +40,13 @@ export const ColorSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
return (
<SectionCard title={tBoard("setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<BoardColorInput
label={tBoard("field.primaryColor.label")}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
/>
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
@@ -128,8 +121,7 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors =
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
return (
<Group gap={0} wrap="nowrap">
@@ -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";
@@ -21,26 +21,20 @@ interface Props {
export const GeneralSettingsContent = ({ board, form }: Props) => {
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, defaultMetaTitle);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
return (
<SectionCard title={t("setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("field.pageTitle.label")}
placeholder={branding.appName}
{...form.getInputProps("pageTitle")}
/>
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("field.metaTitle.label")}
placeholder={defaultMetaTitle}
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
@@ -49,7 +43,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("field.logoImageUrl.label")}
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
@@ -57,7 +51,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("field.faviconImageUrl.label")}
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
@@ -78,9 +72,9 @@ const useLogoPreview = (url: string | null) => {
}, [logoDebounced, updateBoard]);
};
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
const useMetaTitlePreview = (title: string | null) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
useDocumentTitle(metaTitleDebounced);
return {
isPending: (title ?? "") !== metaTitleDebounced,
@@ -10,11 +10,10 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
@@ -89,7 +88,6 @@ interface BoardSettingsFormProps {
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n("common");
const tSection = useI18n("board.setting.section");
const { branding } = useSettings();
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
@@ -144,9 +142,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const [, canonicalLayouts] = await Promise.all([
partialSettingsChanged
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
);
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
})
: Promise.resolve(),
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
@@ -12,8 +12,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { appShellLogoHeight } from "~/components/layout/constants";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { 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";
@@ -28,17 +27,13 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions?: JSX.Element;
headerBoardEditAction?: JSX.Element;
headerBoardSettingsAction?: JSX.Element;
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
headerBoardEditAction,
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => {
@@ -110,11 +105,8 @@ export const createBoardLayout = <TParams extends Params>({
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
+38 -52
View File
@@ -27,7 +27,6 @@ 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";
@@ -54,45 +53,35 @@ const fontSans = Inter({
const logger = createLogger({ module: "rootLayout" });
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");
// 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: env.HOMARR_WEBSITE_URL,
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",
},
});
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 const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
export default async function Layout(props: {
@@ -105,7 +94,6 @@ export default async function Layout(props: {
}
const sessionPromise = auth();
const serverSettingsPromise = getRscServerSettingsAsync();
const userPromise = sessionPromise.then((session) =>
session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
@@ -114,17 +102,18 @@ export default async function Layout(props: {
})
: null,
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
if (!session) return "unauthenticated";
return api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error");
});
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
!session
? "unauthenticated"
: api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error"),
);
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
userPromise,
serverSettingsPromise,
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
assistantAvailabilityPromise,
]);
@@ -159,7 +148,6 @@ export default async function Layout(props: {
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
}}
{...innerProps}
/>
@@ -168,9 +156,7 @@ export default async function Layout(props: {
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} />,
(innerProps) => (
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
),
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
@@ -2,13 +2,11 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
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";
export const HeroBanner = async () => {
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
const { appName, logoImageUrl } = serverSettings.branding;
const t = await getI18n("management.page.home");
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -16,17 +14,9 @@ export const HeroBanner = async () => {
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")}
</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 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>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
@@ -37,8 +37,9 @@ import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
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";
@@ -49,7 +50,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getI18n("management.page.settings.section.assistant");
return {
title: t("title"),
title: `${t("title")} • Homarr`,
};
}
@@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
@@ -13,38 +12,39 @@ type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
const messages = useMemo<SharedEditorProps["messages"]>(
() => ({
languageJsx: invariantTechnicalLabels.jsx,
languageJson: invariantTechnicalLabels.json,
undo: tCommon("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: tCommon("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: invariantTechnicalLabels.jsonSchema,
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}),
[t, tCommon],
return (
<CustomWidgetCodeEditor
{...props}
messages={{
languageJsx: invariantTechnicalLabels.jsx,
languageJson: invariantTechnicalLabels.json,
undo: tCommon("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: tCommon("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: invariantTechnicalLabels.jsonSchema,
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}}
/>
);
return <CustomWidgetCodeEditor {...props} messages={messages} />;
}
@@ -3,70 +3,37 @@
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonBaseProps {
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
request?: string | null;
documentationUrl?: string | null;
}
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({
rawResponse,
draft,
getDraft,
diagnostics,
getDiagnostics,
currentConfig,
request,
documentationUrl,
}: CopyAiPromptButtonProps) => {
const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt(
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
};
const handleCopy = async () => {
try {
const value = buildCustomWidgetAiPrompt(
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
const value = prompt();
await navigator.clipboard.writeText(value);
showSuccessNotification({
title: t("action.copyAiPrompt"),
@@ -1,38 +1,27 @@
"use client";
import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core";
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,
name: form.values.name,
...(form.values.description ? { description: form.values.description } : {}),
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
sources: parseJson(form.values.sources),
requests: parseJson(form.values.requests),
options: parseJson(form.values.options),
template: form.values.template,
},
null,
2,
@@ -41,26 +30,33 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
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 parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
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"));
}
}}
/>
<Accordion variant="contained">
<Accordion.Item value="manifest">
<Accordion.Control>{t("advancedManifest")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="raw-widget-manifest"
label={t("manifestFilename")}
language="json"
value={draft}
error={error}
onChange={(next) => {
setDraft(next);
try {
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
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"));
}
}}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -3,14 +3,13 @@
import { Accordion, Button, Group, 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 type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
interface AiCardProps {
getDraft(): CustomWidgetAiDraft;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
candidate: HomarrCustomWidgetV2 | null;
request: string;
onRequestChange(value: string): void;
documentationUrl: string;
@@ -28,15 +27,14 @@ export function CustomWidgetAiCard(props: AiCardProps) {
{t("title")}
</Accordion.Control>
<CopyAiPromptButton
getDraft={props.getDraft}
getDiagnostics={props.getDiagnostics}
currentConfig={props.candidate}
request={props.request}
documentationUrl={props.documentationUrl}
/>
</Group>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
@@ -2,10 +2,7 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(
values: Pick<CustomWidgetFormValues, "options" | "sources">,
requestIds: string[],
) {
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {});
return [
@@ -1,40 +1,14 @@
"use client";
import { useEffect, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { ReactNode } 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 { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
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,
@@ -110,83 +84,3 @@ export function SaveActions({
</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;
}
@@ -1,222 +0,0 @@
"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,
onPaste,
}: {
getDraft(): CustomWidgetAiDraft;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
onPaste(): void;
}) {
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
return (
<CustomWidgetAiCard
getDraft={getDraft}
getDiagnostics={getDiagnostics}
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}
/>
);
}
@@ -1,142 +0,0 @@
"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;
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;
});
});
}, 150);
};
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],
);
}
@@ -28,7 +28,7 @@ describe("Custom Widget workbench preview options", () => {
const setValues = vi.fn();
const result = applyCustomWidgetAiResponse(
{
getValues: () => ({
values: {
name: "",
description: "",
iconUrl: "",
@@ -37,7 +37,7 @@ describe("Custom Widget workbench preview options", () => {
options: "{}",
template: "",
secrets: [],
}),
},
setValues,
} as never,
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
@@ -84,6 +84,13 @@ describe("Custom Widget workbench preview options", () => {
it("normalizes only changed secrets", () => {
expect(
getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "<Text />",
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" },
@@ -12,8 +12,6 @@ import type { UseFormReturnType } from "@mantine/form";
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
const PREVIEW_QUERY_CONCURRENCY = 4;
export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
@@ -27,7 +25,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
});
}
export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) {
export function getChangedSecrets(values: CustomWidgetFormValues) {
return values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
@@ -48,7 +46,7 @@ export function filterSecretsForSourceAuthentication(
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.getValues(),
...form.values,
name: widget.name,
description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "",
@@ -104,43 +102,25 @@ export function isRuntimeParams(
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const requests = Object.entries(definition.requests).filter(
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const [requestId] of 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",
},
};
}
)) {
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
data[requestId] = result.data;
status[requestId] = { loading: false, ...result };
} catch (error) {
data[requestId] = null;
status[requestId] = {
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])),
};
}
return { data, status };
}
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
@@ -1,45 +1,45 @@
"use client";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, FormEventHandler, SetStateAction } from "react";
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
import { useEffect, useMemo, useState } from "react";
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
IconBraces,
IconCode,
IconDatabase,
IconEye,
IconSettings,
} from "@tabler/icons-react";
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
customWidgetFormSchema,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
} from "@homarr/custom-widgets/workbench";
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form";
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 { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
import {
CustomWidgetOptionsSnapshotSync,
CustomWidgetSaveActions,
CustomWidgetSectionNavigation,
CustomWidgetUnsavedChangesGuard,
} from "./_custom-widget-form-layout";
import {
CustomWidgetAiSection,
CustomWidgetGeneralSection,
CustomWidgetOptionsSection,
CustomWidgetPreviewSection,
CustomWidgetRequestsSection,
CustomWidgetSourcesSection,
CustomWidgetTemplateSection,
} from "./_custom-widget-form-sections";
import {
createCustomWidgetFormDocumentStore,
CustomWidgetFormDocumentProvider,
useCustomWidgetFormDocumentBridge,
} from "./_custom-widget-form-state";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { buildDefinition, getDefinitionDefaults } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } 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 { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -47,204 +47,241 @@ interface CustomWidgetFormProps {
initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string;
}
type MobilePane = "configure" | "preview";
interface CustomWidgetFormViewProps {
mode: "create" | "edit";
definitionId?: string;
form: CustomWidgetWorkbenchForm;
formErrors: CustomWidgetWorkbenchForm["errors"];
formRevision: string;
documentStore: CustomWidgetFormDocumentStore;
mobilePane: MobilePane;
setMobilePane: Dispatch<SetStateAction<MobilePane>>;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
previewSize: string;
setPreviewSize: Dispatch<SetStateAction<string>>;
optionsSnapshot: Record<string, unknown>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
saveIssues: CustomWidgetSaveIssue[];
savePending: boolean;
previewPending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
onPreview(): void;
onPasteAiResponse(): void;
}
function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
return {
name: values.name,
description: values.description,
iconUrl: values.iconUrl,
sources: values.sources,
requests: values.requests,
options: values.options,
template: values.template,
};
}
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
const w = useI18n("customWidget.workbench");
const documentStore = props.documentStore;
const form = props.form;
const setMobilePane = props.setMobilePane;
const setPreview = props.setPreview;
const { renameRequest, renameOption } = useMemo(
() =>
createCustomWidgetRenameHandlers({
form,
invalidWidgetMessage: w("invalidWidget"),
}),
[form, w],
);
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
const readAiDiagnostics = useCallback(
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
[documentStore],
);
const handleSectionSelect = useCallback(
(section: string) => {
setMobilePane(section === "preview" ? "preview" : "configure");
},
[setMobilePane],
);
const handleLiveActionsChange = useCallback(
(enabled: boolean) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
})),
[setPreview],
);
return (
<CustomWidgetFormDocumentProvider store={documentStore}>
<CustomWidgetFormAnalysisProvider>
<form onSubmit={props.onSubmit} className={classes.form}>
<CustomWidgetUnsavedChangesGuard />
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => props.setMobilePane(value as MobilePane)}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiSection
getDraft={readAiDraft}
getDiagnostics={readAiDiagnostics}
onPaste={props.onPasteAiResponse}
/>
<CustomWidgetAdvancedManifest form={form} />
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
<CustomWidgetTemplateSection form={form} />
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
<CustomWidgetPreviewSection
preview={props.preview}
size={props.previewSize}
onSizeChange={props.setPreviewSize}
optionsSnapshot={props.optionsSnapshot}
onOptionsChange={props.setOptionsSnapshot}
onLiveActionsChange={handleLiveActionsChange}
/>
</Box>
</div>
</form>
</CustomWidgetFormAnalysisProvider>
</CustomWidgetFormDocumentProvider>
);
});
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 CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
...initialValues,
}));
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
const mantineForm = useZodForm(customWidgetFormSchema, {
initialValues: formInitialValues,
mode: "uncontrolled",
validateDebounce: 300,
validateInputOnChange: false,
onValuesChange: documentStore.setValues,
});
const form = useCustomWidgetFormDocumentBridge(mantineForm, documentStore);
const [mobilePane, setMobilePane] = useState<MobilePane>("configure");
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const w = useI18n("customWidget.workbench");
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
const actions = useCustomWidgetFormActions({
useUnsavedChangesGuard(form.isDirty());
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const parsedOptions = useMemo(
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
[form.values.options],
);
const requestIds = useMemo(
() =>
Object.keys(
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
),
[form.values.requests],
);
const jsxCompletions = useMemo(
() => createCustomWidgetCompletions(form.values, requestIds),
[form.values, requestIds],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(form.values.template, { requestIds }),
[form.values.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
const invalidSections = useMemo(
() =>
getInvalidCustomWidgetSections(
candidate.success ? [] : candidate.error.issues,
requestDiagnostics,
templateDiagnostics,
),
[candidate, requestDiagnostics, templateDiagnostics],
);
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
mode,
definitionId,
form,
documentStore,
candidate,
preview,
setPreview,
setMobilePane,
optionsSnapshot,
setOptionsSnapshot,
});
const latestActions = useRef(actions);
useLayoutEffect(() => {
latestActions.current = actions;
}, [actions]);
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
useEffect(() => {
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions]);
const invalid = hasDiagnostics || !candidate.success;
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
form,
candidate,
invalidWidgetMessage: w("invalidWidget"),
});
return (
<CustomWidgetFormView
mode={mode}
definitionId={definitionId}
form={form}
formErrors={mantineForm.errors}
formRevision={mantineForm.key("name")}
documentStore={documentStore}
mobilePane={mobilePane}
setMobilePane={setMobilePane}
preview={preview}
setPreview={setPreview}
previewSize={previewSize}
setPreviewSize={setPreviewSize}
optionsSnapshot={optionsSnapshot}
setOptionsSnapshot={setOptionsSnapshot}
saveIssues={actions.saveIssues}
savePending={actions.savePending}
previewPending={actions.previewPending}
onSubmit={handleSubmit}
onPreview={handlePreview}
onPasteAiResponse={handlePasteAiResponse}
/>
<form onSubmit={save} className={classes.form}>
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={mobilePane}
onChange={(value) => setMobilePane(value as typeof mobilePane)}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => (
<Button
key={id}
component="a"
href={`#${id}`}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
}
>
{w(key)}
</Button>
))}
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={tCommon("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="options"
label={w("options.title")}
language="json"
value={form.values.options}
onChange={(value) => form.setFieldValue("options", value)}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
</EditorSection>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
<CustomWidgetPreviewPanel
candidate={candidate.success ? candidate.data : null}
validationIssues={
candidate.success
? []
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
}))
}
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={(enabled) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
}))
}
/>
</Box>
</div>
</form>
);
}
@@ -69,7 +69,7 @@ export function CustomWidgetOptionsEditor({
)
: [];
return (
<Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}>
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
@@ -99,7 +99,7 @@ export function CustomWidgetOptionsEditor({
}
/>
<DefaultValueEditor
id={`${id}-default`}
id={`${index}-default`}
control={control}
value={option.default}
choices={choices}
@@ -6,8 +6,6 @@ interface PreviewSnapshot {
session: { id: string; liveActions: boolean } | null;
}
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
export function createPreviewDisplayData({
candidate,
fixture,
@@ -15,7 +13,7 @@ export function createPreviewDisplayData({
options,
fixtureError,
}: {
candidate: PreviewCandidate | null;
candidate: HomarrCustomWidgetV2 | null;
fixture: "live" | "loading" | "empty" | "error";
preview: PreviewSnapshot;
options: Record<string, unknown>;
@@ -1,6 +1,6 @@
"use client";
import { memo, useMemo, useState } from "react";
import { useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
@@ -15,12 +15,9 @@ import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-wi
import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css";
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
export interface PreviewState {
data: Record<string, unknown>;
status: Record<string, unknown>;
@@ -39,7 +36,7 @@ interface PreviewPanelProps {
onLiveActionsChange(enabled: boolean): void;
}
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
@@ -49,55 +46,26 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
{ sessionId: props.preview.session?.id ?? "" },
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
);
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
const candidate = props.candidate;
const candidateTemplate = candidate?.template;
const candidateRequests = candidate?.requests;
const previewCandidate = useMemo(() => {
if (candidateTemplate === undefined || candidateRequests === undefined) return null;
return { template: candidateTemplate, requests: candidateRequests };
}, [candidateRequests, candidateTemplate]);
const previewSnapshot = useMemo(
() => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }),
[props.preview.data, props.preview.session, props.preview.status],
);
const fixtureError = t("fixtureError");
const accessibilityIssues = useMemo(() => {
if (candidateTemplate === undefined) return [];
return analyzeCustomWidgetAccessibility(candidateTemplate);
}, [candidateTemplate]);
const displayData = useMemo(
() =>
createPreviewDisplayData({
candidate: previewCandidate,
fixture,
preview: previewSnapshot,
options: props.optionsSnapshot,
fixtureError,
}),
[fixture, fixtureError, previewCandidate, previewSnapshot, props.optionsSnapshot],
);
const previewSummary = useMemo(() => getPreviewSummary(props.preview.status), [props.preview.status]);
const rendererResetKey = useMemo(
() =>
JSON.stringify({
template: candidateTemplate,
requests: candidateRequests,
fixture,
data: props.preview.data,
status: props.preview.status,
sessionId: props.preview.session?.id,
options: props.optionsSnapshot,
}),
[
candidateRequests,
candidateTemplate,
fixture,
props.optionsSnapshot,
props.preview.data,
props.preview.session?.id,
props.preview.status,
],
);
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
const displayData = createPreviewDisplayData({
candidate,
fixture,
preview: props.preview,
options: props.optionsSnapshot,
fixtureError: t("fixtureError"),
});
const previewSummary = getPreviewSummary(props.preview.status);
const rendererResetKey = JSON.stringify({
template: candidate?.template,
requests: candidate?.requests,
fixture,
data: props.preview.data,
status: props.preview.status,
sessionId: props.preview.session?.id,
options: props.optionsSnapshot,
});
const previewResult =
props.preview.outcome === "success"
? {
@@ -117,7 +85,37 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
<Card withBorder p="md">
<Stack gap="md">
<Group gap="xs" justify="space-between">
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
<SegmentedControl
size="xs"
value={props.size}
onChange={props.onSizeChange}
data={[
{
value: "compact",
label: (
<span aria-label={t("size.compact")} title={t("size.compact")}>
S
</span>
),
},
{
value: "standard",
label: (
<span aria-label={t("size.standard")} title={t("size.standard")}>
M
</span>
),
},
{
value: "wide",
label: (
<span aria-label={t("size.wide")} title={t("size.wide")}>
L
</span>
),
},
]}
/>
<SegmentedControl
size="xs"
value={fixture}
@@ -153,14 +151,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<Paper
withBorder
p="sm"
h={360}
w={previewWidths[props.size] ?? 480}
maw="100%"
style={{ overflow: "auto" }}
>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
@@ -273,5 +264,3 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
</Card>
);
}
export const CustomWidgetPreviewPanel = memo(CustomWidgetPreviewPanelContent);
@@ -1,56 +0,0 @@
"use client";
import { memo } from "react";
import { SegmentedControl, VisuallyHidden } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
interface CustomWidgetPreviewSizeControlProps {
value: string;
onChange(value: string): void;
}
const previewSizeShortLabels = { compact: "S", standard: "M", wide: "L" } as const;
function CustomWidgetPreviewSizeControlContent({ value, onChange }: CustomWidgetPreviewSizeControlProps) {
const t = useI18n("customWidget.workbench.preview");
return (
<SegmentedControl
size="xs"
value={value}
onChange={onChange}
data={[
{
value: "compact",
label: (
<span title={t("size.compact")}>
<span aria-hidden>{previewSizeShortLabels.compact}</span>
<VisuallyHidden>{t("size.compact")}</VisuallyHidden>
</span>
),
},
{
value: "standard",
label: (
<span title={t("size.standard")}>
<span aria-hidden>{previewSizeShortLabels.standard}</span>
<VisuallyHidden>{t("size.standard")}</VisuallyHidden>
</span>
),
},
{
value: "wide",
label: (
<span title={t("size.wide")}>
<span aria-hidden>{previewSizeShortLabels.wide}</span>
<VisuallyHidden>{t("size.wide")}</VisuallyHidden>
</span>
),
},
]}
/>
);
}
export const CustomWidgetPreviewSizeControl = memo(CustomWidgetPreviewSizeControlContent);
@@ -1,35 +1,34 @@
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
import { applyDefinition, buildDefinition } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { applyDefinition } from "./_custom-widget-form-utils";
import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm;
candidate: ReturnType<typeof buildDefinition>;
invalidWidgetMessage: string;
}
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
const renameRequest = (currentId: string, nextId: string) => {
const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
if (!input.candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId));
input.form.clearFieldError("requests");
} catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
}
};
const renameOption = (currentName: string, nextName: string) => {
const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
if (!input.candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
input.form.clearFieldError("options");
} catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -23,7 +23,7 @@ export function QueryValuesEditor({
}) {
const t = useI18n("customWidget.workbench.builder");
const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
@@ -65,23 +65,23 @@ export function QueryValuesEditor({
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
];
return (
<Group key={name} align="end" wrap="wrap">
<Group key={index} align="end" wrap="wrap">
<CustomWidgetIdentifierInput
style={{ flex: "1 1 10rem" }}
label={t("queryKey")}
value={name}
error={nameErrors[name]}
error={nameErrors[index]}
onCommit={(nextName) => {
if (
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
) {
setNameErrors((currentErrors) => ({ ...currentErrors, [name]: t("queryKeyInvalid") }));
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
return;
}
setNameErrors((currentErrors) => {
const nextErrors = { ...currentErrors };
delete nextErrors[name];
delete nextErrors[index];
return nextErrors;
});
commit(nextName, kind, current);
@@ -1,6 +1,6 @@
"use client";
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
@@ -9,7 +9,6 @@ import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -25,8 +24,6 @@ export function CustomWidgetRequestsEditor({
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources);
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
const options = parseJson(form.values.options);
const optionNames = isRecord(options) ? Object.keys(options) : [];
const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
@@ -53,7 +50,7 @@ export function CustomWidgetRequestsEditor({
const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load";
return (
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
@@ -122,61 +119,71 @@ export function CustomWidgetRequestsEditor({
<TextInput
label={t("path")}
description={t("pathDescription")}
descriptionProps={{
style: { color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" },
}}
value={typeof request.path === "string" ? request.path : ""}
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
/>
<LazyOnceAccordion label={t("requestDetails")}>
<Stack gap="sm">
<QueryValuesEditor
value={isRecord(request.query) ? request.query : {}}
optionNames={optionNames}
allowParams={trigger === "manual" || kind === "action"}
onChange={(query) => update(index, id, { ...request, query })}
/>
<RequestBodyEditor
id={id}
value={request.body}
optionNames={optionNames}
allowParams={trigger === "manual" || kind === "action"}
onChange={(body) => update(index, id, { ...request, body })}
/>
{kind === "action" && (
<TextInput
label={t("confirmation")}
value={typeof request.confirmation === "string" ? request.confirmation : ""}
onChange={(event) =>
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
}
/>
)}
{kind === "action" && (
<TextInput
label={t("invalidation")}
description={t("invalidationDescription")}
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
onChange={(event) =>
update(index, id, {
...request,
invalidates: event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
/>
)}
<Switch
label={t("noAuth")}
checked={request.auth === "none"}
onChange={(event) =>
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
}
/>
</Stack>
</LazyOnceAccordion>
<Accordion variant="contained">
<Accordion.Item value="values">
<Accordion.Control>{t("requestDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<QueryValuesEditor
value={isRecord(request.query) ? request.query : {}}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
allowParams={trigger === "manual" || kind === "action"}
onChange={(query) => update(index, id, { ...request, query })}
/>
<RequestBodyEditor
id={id}
value={request.body}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
allowParams={trigger === "manual" || kind === "action"}
onChange={(body) => update(index, id, { ...request, body })}
/>
{kind === "action" && (
<TextInput
label={t("confirmation")}
value={typeof request.confirmation === "string" ? request.confirmation : ""}
onChange={(event) =>
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
}
/>
)}
{kind === "action" && (
<TextInput
label={t("invalidation")}
description={t("invalidationDescription")}
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
onChange={(event) =>
update(index, id, {
...request,
invalidates: event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
/>
)}
<Switch
label={t("noAuth")}
checked={request.auth === "none"}
onChange={(event) =>
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
@@ -1,37 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
export function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -1,18 +0,0 @@
export function areCustomWidgetValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left)) {
if (!Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => areCustomWidgetValuesEqual(value, right[index]));
}
if (!isRecord(left) || !isRecord(right)) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -1,44 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useId, useState } from "react";
import { Accordion } from "@mantine/core";
interface LazyOnceAccordionProps {
label: ReactNode;
children: ReactNode;
}
export function LazyOnceAccordion({ label, children }: LazyOnceAccordionProps) {
const id = useId();
const [value, setValue] = useState<string | null>(null);
const [hasOpened, setHasOpened] = useState(false);
const controlId = `${id}-control`;
const panelId = `${id}-panel`;
const handleChange = (nextValue: string | null) => {
setValue(nextValue);
if (nextValue === "content") setHasOpened(true);
};
return (
<Accordion variant="contained" value={value} onChange={handleChange}>
<Accordion.Item value="content">
<Accordion.Control id={controlId} aria-controls={panelId}>
{label}
</Accordion.Control>
{/* Native hiding keeps editor effects and CodeMirror history alive while the panel is collapsed. */}
<div
id={panelId}
className={Accordion.classes.panel}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- The explicit role matches Mantine's panel contract.
role="region"
aria-labelledby={controlId}
hidden={value !== "content"}
>
<div className={Accordion.classes.content}>{hasOpened && children}</div>
</div>
</Accordion.Item>
</Accordion>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form";
@@ -22,14 +22,13 @@ import {
import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { clearSaveIssues, reportSaveIssues } from "./_custom-widget-save-issue-utils";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
interface FormActionsInput {
mode: "create" | "edit";
definitionId?: string;
form: UseFormReturnType<CustomWidgetFormValues>;
documentStore: CustomWidgetFormDocumentStore;
candidate: ReturnType<typeof buildDefinition>;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
optionsSnapshot: Record<string, unknown>;
@@ -46,34 +45,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false);
const setPreview = input.setPreview;
const previewGeneration = useRef(0);
const activePreviewGeneration = useRef<number | null>(null);
const invalidatePreview = useCallback(() => {
previewGeneration.current += 1;
if (activePreviewGeneration.current !== null) return;
setPreview((preview) => {
if (
preview.outcome === "idle" &&
preview.session === null &&
Object.keys(preview.data).length === 0 &&
Object.keys(preview.status).length === 0
)
return preview;
return { data: {}, status: {}, session: null, outcome: "idle" };
});
}, [setPreview]);
useLayoutEffect(() => {
invalidatePreview();
}, [input.optionsSnapshot, invalidatePreview]);
useEffect(() => input.documentStore.subscribe(invalidatePreview), [input.documentStore, invalidatePreview]);
useEffect(
() => () => {
previewGeneration.current += 1;
activePreviewGeneration.current = null;
},
[],
);
let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create");
@@ -102,7 +73,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
input.form.setInitialValues(values);
input.form.resetDirty();
input.documentStore.markSaved(values);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
window.location.assign(result.managementPath);
} else if (input.definitionId) {
@@ -118,7 +88,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
]);
input.form.setInitialValues(values);
input.form.resetDirty();
input.documentStore.markSaved(values);
showSuccessNotification({
title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }),
@@ -135,18 +104,15 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
const runPreview = async () => {
if (activePreviewGeneration.current !== null) return;
const valuesAtStart = input.documentStore.getValues();
const candidateAtStart = buildDefinition(valuesAtStart);
if (!candidateAtStart.success) {
if (previewPending) return;
if (!input.candidate.success) {
showErrorNotification({
title: w("section.preview"),
message: candidateAtStart.error.issues[0]?.message ?? w("invalidWidget"),
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
});
return;
}
const optionsAtStart = input.optionsSnapshot;
const optionIssues = getCustomWidgetPreviewOptionIssues(candidateAtStart.data, optionsAtStart);
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
showErrorNotification({
@@ -155,24 +121,17 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
return;
}
const secretsAtStart = getChangedSecrets(valuesAtStart);
const generation = previewGeneration.current + 1;
previewGeneration.current = generation;
activePreviewGeneration.current = generation;
const isCurrent = () => previewGeneration.current === generation;
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true);
try {
const created = await previewMutation.mutateAsync({
definition: candidateAtStart.data,
definition: input.candidate.data,
definitionId: input.definitionId,
options: optionsAtStart,
secrets: secretsAtStart,
options: input.optionsSnapshot,
secrets: getChangedSecrets(input.form.values),
});
if (!isCurrent()) return;
const snapshot = await loadPreviewQueries(candidateAtStart.data, created.previewSession.id);
if (!isCurrent()) return;
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({
...snapshot,
@@ -197,15 +156,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
}
} catch {
if (!isCurrent()) return;
input.setPreview((current) => ({ ...current, outcome: "error" }));
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
} finally {
if (activePreviewGeneration.current === generation) {
activePreviewGeneration.current = null;
setPreviewPending(false);
if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
}
setPreviewPending(false);
}
};
@@ -216,6 +170,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
title: w("ai.response"),
message:
@@ -241,3 +196,33 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
previewPending,
};
}
function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -1,188 +0,0 @@
"use client";
import {
createContext,
createElement,
useCallback,
useContext,
useDeferredValue,
useLayoutEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import type { ReactNode } from "react";
import type { CustomWidgetAiDiagnostic } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetOptionsSchema } from "@homarr/custom-widgets/core";
import { analyzeJsxTemplate, analyzeRequestManifest } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { buildDefinition, isRecord, parseJson } from "./_custom-widget-form-utils";
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
const emptyPreviewValidationIssues: Array<{ path?: string; message: string }> = [];
const emptyCandidateIssues: Array<{ path: PropertyKey[] }> = [];
function normalizeEditorDiagnostics(
section: "requests" | "template",
diagnostics: readonly EditorDiagnostic[],
): CustomWidgetAiDiagnostic[] {
return diagnostics.map((diagnostic) => ({
section,
severity: diagnostic.severity,
code: diagnostic.code,
line: diagnostic.line,
column: diagnostic.column,
message: diagnostic.value,
}));
}
function createAiDiagnostics(
candidate: ReturnType<typeof buildDefinition>,
requestDiagnostics: readonly EditorDiagnostic[],
templateDiagnostics: readonly EditorDiagnostic[],
) {
const editorDiagnostics = [
...normalizeEditorDiagnostics("requests", requestDiagnostics),
...normalizeEditorDiagnostics("template", templateDiagnostics),
];
if (candidate.success) return editorDiagnostics;
return [
...candidate.error.issues.map((issue) => ({
section: String(issue.path[0] ?? "definition"),
severity: "error" as const,
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
})),
...editorDiagnostics,
];
}
export function analyzeCustomWidgetAiDiagnostics(values: CustomWidgetFormValues): CustomWidgetAiDiagnostic[] {
const parsedRequests = parseJson(values.requests);
const requestIds = isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
return createAiDiagnostics(
buildDefinition(values),
analyzeRequestManifest(values.requests),
analyzeJsxTemplate(values.template, { requestIds }),
);
}
export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
const analysisValues = useDeferredValue(values);
const candidate = useMemo(() => buildDefinition(analysisValues), [analysisValues]);
const parsedOptions = useMemo(
() => customWidgetOptionsSchema.safeParse(parseJson(analysisValues.options)),
[analysisValues.options],
);
const requestIds = useMemo(() => {
const parsedRequests = parseJson(analysisValues.requests);
return isRecord(parsedRequests) ? Object.keys(parsedRequests) : [];
}, [analysisValues.requests]);
const requestIdsSignature = requestIds.join("\0");
const jsxCompletions = useMemo(
() =>
createCustomWidgetCompletions(
{ options: analysisValues.options, sources: analysisValues.sources },
requestIdsSignature ? requestIdsSignature.split("\0") : [],
),
[analysisValues.options, analysisValues.sources, requestIdsSignature],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(analysisValues.template, { requestIds }),
[analysisValues.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(analysisValues.requests), [analysisValues.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
const candidateIssues = candidate.success ? emptyCandidateIssues : candidate.error.issues;
const invalidSections = useMemo(
() => getInvalidCustomWidgetSections(candidateIssues, requestDiagnostics, templateDiagnostics),
[candidateIssues, requestDiagnostics, templateDiagnostics],
);
const previewCandidate = useMemo(
() => (candidate.success ? candidate.data : null),
// The preview renderer consumes only these definition fields; other fields affect the validity boolean below.
// eslint-disable-next-line react-hooks/exhaustive-deps
[analysisValues.requests, analysisValues.template, candidate.success],
);
const previewValidationIssues = useMemo(
() =>
candidate.success
? emptyPreviewValidationIssues
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
})),
[candidate],
);
return useMemo(
() => ({
candidate,
candidateValid: candidate.success,
parsedOptions,
jsxCompletions,
templateDiagnostics,
requestDiagnostics,
hasDiagnostics,
invalidSections,
previewCandidate,
previewValidationIssues,
}),
[
candidate,
hasDiagnostics,
invalidSections,
jsxCompletions,
parsedOptions,
previewCandidate,
previewValidationIssues,
requestDiagnostics,
templateDiagnostics,
],
);
}
type CustomWidgetFormAnalysis = ReturnType<typeof useCustomWidgetFormAnalysis>;
interface CustomWidgetFormAnalysisStore {
getAnalysis(): CustomWidgetFormAnalysis;
setAnalysis(analysis: CustomWidgetFormAnalysis): void;
subscribe(listener: () => void): () => void;
}
function createCustomWidgetFormAnalysisStore(initialAnalysis: CustomWidgetFormAnalysis): CustomWidgetFormAnalysisStore {
let analysis = initialAnalysis;
const listeners = new Set<() => void>();
return {
getAnalysis: () => analysis,
setAnalysis: (nextAnalysis) => {
if (Object.is(analysis, nextAnalysis)) return;
analysis = nextAnalysis;
listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const CustomWidgetFormAnalysisContext = createContext<CustomWidgetFormAnalysisStore | null>(null);
export function CustomWidgetFormAnalysisProvider({ children }: { children: ReactNode }) {
const values = useDeferredCustomWidgetFormDocumentValues();
const analysis = useCustomWidgetFormAnalysis(values);
const [store] = useState(() => createCustomWidgetFormAnalysisStore(analysis));
useLayoutEffect(() => store.setAnalysis(analysis), [analysis, store]);
return createElement(CustomWidgetFormAnalysisContext.Provider, { value: store }, children);
}
export function useCustomWidgetFormAnalysisField<Key extends keyof CustomWidgetFormAnalysis>(field: Key) {
const store = useContext(CustomWidgetFormAnalysisContext);
if (!store) throw new Error("Custom widget form analysis provider is missing");
const getSnapshot = useCallback(() => store.getAnalysis()[field], [field, store]);
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
@@ -23,7 +23,7 @@ export default async function NewCustomWidgetPage() {
<Stack>
<div>
<Title>{t("page.create.title")}</Title>
<Text c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" size="sm" mt={4}>
<Text c="dimmed" size="sm" mt={4}>
{t("page.create.subtitle")}
</Text>
</div>
@@ -11,6 +11,7 @@ import { useWorkshopQuery } from "@homarr/workshop/backend";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -22,10 +23,9 @@ interface WorkshopBrowseProps {
search: string | undefined;
sort: "top" | "newest";
page: number;
workshopWebUrl: string;
}
export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopBrowseProps) {
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common.action");
const session = useWorkshopSession();
@@ -46,7 +46,7 @@ export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopB
<WorkshopAccountButton session={session} />
<Button
component="a"
href={workshopWebUrl}
href={getWorkshopWebUrl()}
target="_blank"
rel="noopener noreferrer"
variant="default"
@@ -100,7 +100,7 @@ export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopB
<Tooltip label={t("openCommunity")}>
<ActionIcon
component="a"
href={`${workshopWebUrl.replace(/\/+$/u, "")}/${encodeURIComponent(item.id)}`}
href={getWorkshopWebUrl(item.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
@@ -3,9 +3,6 @@ import { z } from "zod/v4";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { env } from "~/env";
import { WorkshopBrowse } from "./_workshop-browse";
@@ -29,13 +26,6 @@ export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps)
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
const { workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
return (
<WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} workshopWebUrl={workshopWebUrl} />
);
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
}
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
import { getI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
@@ -74,7 +74,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
exact: true,
"data-onboarding-tour-id": "manage-welcome",
},
{
+2 -1
View File
@@ -7,13 +7,14 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> {
const t = await getI18n("management");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -1,255 +0,0 @@
"use client";
import {
Avatar,
Badge,
ColorSwatch,
Fieldset,
Group,
Paper,
SimpleGrid,
Slider,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconPicker } from "@homarr/forms-collection";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
interface BrandingSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
const t = useI18n("management.page.settings.section.branding");
const logoInput = form.getInputProps("branding.logoImageUrl");
const faviconInput = form.getInputProps("branding.faviconImageUrl");
const backgroundInput = form.getInputProps("branding.signInBackgroundImageUrl");
const branding = form.values.branding;
return (
<SectionCard title={t("title")}>
<Stack gap="md">
<Fieldset legend={t("groups.identity")} p="sm">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("identity.description")}
</Text>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm" verticalSpacing="sm">
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
<IconPicker
label={t("logo.label")}
description={null}
placeholder={t("logo.placeholder")}
withAsterisk={false}
value={logoInput.value ?? ""}
onChange={(value) => form.setFieldValue("branding.logoImageUrl", value || null)}
error={logoInput.error}
/>
<IconPicker
label={t("favicon.label")}
description={null}
placeholder={t("favicon.placeholder")}
withAsterisk={false}
value={faviconInput.value ?? ""}
onChange={(value) => form.setFieldValue("branding.faviconImageUrl", value || null)}
error={faviconInput.error}
/>
</SimpleGrid>
</Stack>
</Fieldset>
<Fieldset legend={t("groups.appearance")} p="sm">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<BoardColorInput
label={t("primaryColor.label")}
defaultColor="#fa5252"
{...form.getInputProps("branding.primaryColor")}
/>
<BoardColorInput
label={t("secondaryColor.label")}
defaultColor="#fd7e14"
{...form.getInputProps("branding.secondaryColor")}
/>
</SimpleGrid>
<Switch
label={t("lockPrimaryColor.label")}
description={t("lockPrimaryColor.description")}
{...form.getInputProps("branding.lockPrimaryColor", { type: "checkbox" })}
/>
</Stack>
<CornerStylePicker
compact
label={t("defaultRadius.label")}
value={branding.defaultRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]),
) as Record<(typeof cornerStyleValues)[number], string>
}
onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)}
/>
</SimpleGrid>
</Fieldset>
<Fieldset legend={t("groups.authentication")} p="sm">
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
<IconPicker
label={t("signInBackground.label")}
description={null}
placeholder={t("signInBackground.placeholder")}
withAsterisk={false}
value={backgroundInput.value ?? ""}
onChange={(value) => form.setFieldValue("branding.signInBackgroundImageUrl", value || null)}
error={backgroundInput.error}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" verticalSpacing="sm">
<Stack gap={6} justify="center">
<Group justify="space-between" gap="sm">
<Text size="sm" fw={500}>
{t("signInOverlay.label")}
</Text>
<Text size="xs" c="dimmed">
{Math.round(branding.signInBackgroundOverlay * 100)}%
</Text>
</Group>
<Slider
aria-label={t("signInOverlay.label")}
min={0}
max={0.9}
step={0.05}
label={(value) => `${Math.round(value * 100)}%`}
disabled={!branding.signInBackgroundImageUrl}
value={branding.signInBackgroundOverlay}
onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)}
/>
</Stack>
<Stack gap={6}>
<Text size="sm" c="dimmed">
{t("authVisibility.description")}
</Text>
<SimpleGrid cols={3} spacing="xs">
<Switch
size="sm"
label={t("authVisibility.appName")}
{...form.getInputProps("branding.authBranding.showAppName", { type: "checkbox" })}
/>
<Switch
size="sm"
label={t("authVisibility.logo")}
{...form.getInputProps("branding.authBranding.showLogo", { type: "checkbox" })}
/>
<Switch
size="sm"
label={t("authVisibility.greeting")}
{...form.getInputProps("branding.authBranding.showGreeting", { type: "checkbox" })}
/>
</SimpleGrid>
</Stack>
</SimpleGrid>
</Stack>
</Fieldset>
<BrandingSummary form={form} />
</Stack>
</SectionCard>
);
};
const BrandingSummary = ({ form }: BrandingSettingsFormProps) => {
const t = useI18n("management.page.settings.section.branding");
const branding = form.values.branding;
const appName = branding.appName.trim() || "Homarr";
const logoImageUrl = branding.logoImageUrl || "/logo/logo.png";
const faviconImageUrl = branding.faviconImageUrl || logoImageUrl;
const backgroundStatus = branding.signInBackgroundImageUrl
? t("summary.backgroundConfigured", { overlay: Math.round(branding.signInBackgroundOverlay * 100) })
: t("summary.backgroundDefault");
const greetingStatus = branding.greeting.trim() ? t("summary.greetingConfigured") : t("summary.greetingDefault");
return (
<Paper withBorder p="sm" radius={branding.defaultRadius}>
<Stack gap="sm">
<Group justify="space-between" gap="xs" wrap="wrap">
<Text fw={650}>{t("summary.title")}</Text>
<Badge variant="light">
{t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" verticalSpacing="sm">
<Stack gap={6}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("groups.identity")}
</Text>
<Group gap="xs" wrap="nowrap">
<Avatar.Group>
<Avatar src={logoImageUrl} alt={t("logo.label")} />
<Avatar src={faviconImageUrl} alt={t("favicon.label")} />
</Avatar.Group>
<Text size="sm" fw={600} truncate>
{appName}
</Text>
</Group>
</Stack>
<Stack gap={6}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("summary.palette")}
</Text>
<Group gap="md">
<Group gap={6} wrap="nowrap">
<ColorSwatch color={branding.primaryColor} size={20} />
<Text size="xs">{branding.primaryColor}</Text>
</Group>
<Group gap={6} wrap="nowrap">
<ColorSwatch color={branding.secondaryColor} size={20} />
<Text size="xs">{branding.secondaryColor}</Text>
</Group>
</Group>
<Badge color={branding.lockPrimaryColor ? "primaryColor" : "gray"} variant="light" w="fit-content">
{branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")}
</Badge>
</Stack>
<Stack gap={6}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("groups.authentication")}
</Text>
<Text size="sm">
{backgroundStatus} · {greetingStatus}
</Text>
<Group gap={4}>
<VisibilityBadge label={t("summary.appName")} visible={branding.authBranding.showAppName} />
<VisibilityBadge label={t("summary.logo")} visible={branding.authBranding.showLogo} />
<VisibilityBadge label={t("summary.greeting")} visible={branding.authBranding.showGreeting} />
</Group>
</Stack>
</SimpleGrid>
</Stack>
</Paper>
);
};
const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => {
const t = useI18n("management.page.settings.section.branding.summary");
return (
<Badge size="xs" color={visible ? "green" : "gray"} variant="light">
{label}: {visible ? t("on") : t("off")}
</Badge>
);
};
@@ -12,14 +12,12 @@ import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { brandingServerSettingsSchema } from "@homarr/server-settings";
import { useI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings";
import { AppearanceSettingsForm } from "./appearance-settings-form";
import { BoardSettingsForm } from "./board-settings-form";
import { BrandingSettingsForm } from "./branding-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
@@ -39,7 +37,6 @@ const settingsFormSchema = z.object({
defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
branding: brandingServerSettingsSchema,
});
export type FormValues = z.infer<typeof settingsFormSchema>;
@@ -58,7 +55,6 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
branding: initialData.branding,
});
interface SettingsFormProps {
@@ -141,11 +137,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
value: { defaultColorScheme: values.defaultColorScheme },
},
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
{
settingsKey: "branding",
when: changed("branding"),
value: values.branding,
},
];
const promises = groups
@@ -185,7 +176,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<BrandingSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
@@ -9,9 +9,10 @@ import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
const t = await getI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: t("metaTitle"),
title: metaTitle,
};
}
@@ -9,6 +9,7 @@ import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
@@ -62,7 +63,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -5,6 +5,7 @@ import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -18,7 +19,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.tool.backup");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -11,6 +11,7 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection";
@@ -24,7 +25,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -6,6 +6,7 @@ import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { TasksTable } from "./_components/tasks-table";
export async function generateMetadata() {
@@ -16,7 +17,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -1,9 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef } from "react";
import {
Box,
Button,
Card,
Divider,
@@ -24,15 +22,12 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
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 } from "@homarr/translation/client";
import {
headerPreferencesSchema,
parseHeaderPreferences,
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
@@ -41,9 +36,9 @@ import {
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HeaderComposer } from "./header-composer";
dayjs.extend(localeData);
@@ -58,7 +53,6 @@ const userGeneralSettingsSchema = z.object({
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
headerPreferences: headerPreferencesSchema,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -71,10 +65,9 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
interface UserGeneralSettingsFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
searchEnginesData: { value: string; label: string }[];
showLanguageSelector?: boolean;
profileAvatar?: ReactNode;
}
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
@@ -88,7 +81,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
headerPreferences: parseHeaderPreferences(user.headerPreferences),
});
export const UserGeneralSettingsForm = ({
@@ -96,7 +88,6 @@ export const UserGeneralSettingsForm = ({
boardsData,
searchEnginesData,
showLanguageSelector = false,
profileAvatar,
}: UserGeneralSettingsFormProps) => {
const tUser = useI18n("user");
const tCommon = useI18n("common");
@@ -110,7 +101,6 @@ export const UserGeneralSettingsForm = ({
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
@@ -131,7 +121,6 @@ export const UserGeneralSettingsForm = ({
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
changeHeaderPreferencesMutation,
];
const isPending = mutations.some((m) => m.isPending);
@@ -195,14 +184,6 @@ export const UserGeneralSettingsForm = ({
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
{
when: changed("headerPreferences"),
action: () =>
changeHeaderPreferencesMutation.mutateAsync({
id: user.id,
headerPreferences: parsed.data.headerPreferences,
}),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -237,149 +218,135 @@ export const UserGeneralSettingsForm = ({
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
return (
<Stack gap="lg">
<HeaderComposer
value={form.values.headerPreferences}
onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
boards={boardsData}
homeBoardId={form.values.homeBoardId}
mobileHomeBoardId={form.values.mobileHomeBoardId}
/>
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tUser("name")}</Title>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{tUserManagement("fieldsDisabledExternalProvider")}
</Text>
)}
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.email.label")}
{...form.getInputProps("email")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={tUser("field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={tUser("field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={tUser("field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group
{...firstDayOfWeekInputProps}
value={firstDayOfWeekValue}
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
>
<Group mt="xs" wrap="wrap">
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
))}
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={tUser("field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={tUser("field.enableRightClickOnWidgets.label")}
description={tUser("field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{tCommon("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tUser("name")}</Title>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{tUserManagement("fieldsDisabledExternalProvider")}
</Text>
)}
</div>
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.email.label")}
{...form.getInputProps("email")}
/>
</Stack>
</form>
</Box>
{profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
</Group>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={tUser("field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={tUser("field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={tUser("field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group
{...firstDayOfWeekInputProps}
value={firstDayOfWeekValue}
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
>
<Group mt="xs" wrap="wrap">
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
))}
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={tUser("field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={tUser("field.enableRightClickOnWidgets.label")}
description={tUser("field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{tCommon("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</div>
</Stack>
</form>
);
};
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -12,7 +12,6 @@ import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"];
@@ -26,7 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
},
});
const [opened, { toggle }] = useDisclosure(false);
const isMobile = useIsMobile();
const isMobile = useMediaQuery("(max-width: 48em)");
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -1,273 +0,0 @@
.composer {
width: 100%;
overflow: hidden;
background: var(--mantine-color-body);
}
.heading,
.tray {
padding: var(--mantine-spacing-lg);
}
.heading {
border-bottom: 1px solid var(--mantine-color-default-border);
}
.stage {
display: grid;
place-items: center;
min-height: 21rem;
padding: var(--mantine-spacing-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
background-size: 14px 14px;
}
.device {
position: relative;
width: min(100%, 34rem);
min-height: 18rem;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
transition:
width 180ms ease,
min-height 180ms ease;
}
.previewHeader {
position: relative;
z-index: 1;
min-height: 5rem;
border-bottom: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
direction: ltr;
}
.zoneTable {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
min-height: 5rem;
}
.zoneColumn {
min-width: 0;
border-inline-end: 1px solid var(--mantine-color-default-border);
transition: background-color 120ms ease;
}
.zoneColumn:last-child {
border-inline-end: 0;
}
.zoneColumn[data-drop-target="true"] {
background: var(--mantine-primary-color-light);
}
.zoneColumn[data-drop-target="true"] .zoneItems::after {
content: "";
width: 2.25rem;
height: 2.25rem;
border: 1px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
}
.zoneColumnHeader {
display: grid;
place-items: center;
height: 1.75rem;
margin: 0;
color: var(--mantine-color-dimmed);
border-bottom: 1px solid var(--mantine-color-default-border);
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
user-select: none;
}
.zoneItems {
display: flex;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
min-height: 3.25rem;
padding: 0.5rem;
}
.sortableItem {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.sortableItem[data-drop-target="true"]::before {
content: "";
position: absolute;
inset-block: 0.2rem;
inset-inline-start: -0.2rem;
z-index: 1;
width: 2px;
border-radius: var(--mantine-radius-xl);
background: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
}
.sortableItem[data-dragging="true"] {
opacity: 0.2;
}
.itemButton,
.dragOverlay {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
min-width: 2.25rem;
padding: 0;
overflow: hidden;
color: var(--mantine-color-text);
border: 1px solid transparent;
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
}
.itemButton {
cursor: grab;
transition:
border-color 120ms ease,
background-color 120ms ease,
box-shadow 120ms ease;
}
.itemButton[data-wide="true"],
.dragOverlay[data-wide="true"] {
width: 4.75rem;
}
.itemButton:hover,
.itemButton:focus-visible,
.sortableItem[data-drop-target="true"] .itemButton,
.sortableItem[data-selected="true"] .itemButton {
border-color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light-hover);
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
}
.itemButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.dragOverlay {
pointer-events: none;
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-xl);
}
.searchPreview {
display: grid;
place-items: center;
width: 100%;
min-width: 0;
color: var(--mantine-color-text);
}
.logoPreview {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
width: 100%;
min-width: 0;
}
.logoPreview[data-with-title="true"] {
justify-content: flex-start;
padding-inline: 0.375rem;
}
.logoPreview span {
min-width: 0;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchPreview[data-input="true"] {
grid-template-columns: auto minmax(0, 1fr);
gap: 0.25rem;
padding-inline: 0.375rem;
font-size: var(--mantine-font-size-xs);
text-align: start;
}
.searchPreview span {
width: 100%;
min-width: 0;
overflow: hidden;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
}
.hiddenPreview {
position: absolute;
inset: 0 0 auto;
z-index: 1;
display: flex;
justify-content: flex-end;
padding: var(--mantine-spacing-md);
}
.floatingAccount {
box-shadow: var(--mantine-shadow-md);
}
.dashboardPreview {
height: 13rem;
overflow: hidden;
opacity: 0.86;
}
.dashboardLayout {
height: 100%;
aspect-ratio: auto;
}
.tray {
border-top: 1px solid var(--mantine-color-default-border);
}
.configuration {
background: color-mix(in srgb, var(--mantine-primary-color-light) 42%, var(--mantine-color-body));
}
@media (max-width: $mantine-breakpoint-sm) {
.heading,
.tray,
.stage {
padding: var(--mantine-spacing-md);
}
.stage {
min-height: 19rem;
}
}
@media (prefers-reduced-motion: reduce) {
.device,
.zoneColumn,
.itemButton {
transition: none;
}
}
@@ -1,653 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { PointerActivationConstraints } from "@dnd-kit/dom";
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
import type { DragEndEvent } from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react";
import { useSortable } from "@dnd-kit/react/sortable";
import {
ActionIcon,
Avatar,
Box,
Button,
Card,
Group,
Menu,
Paper,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconBrandDocker,
IconEye,
IconEyeOff,
IconHome,
IconLayoutDashboard,
IconPencil,
IconPlus,
IconReplace,
IconRobot,
IconSearch,
IconSettings,
IconSunMoon,
IconUserCircle,
IconX,
} from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type {
HeaderBuiltinItemId,
HeaderItem,
HeaderPreferences,
HeaderZoneId,
HeaderZones,
} from "@homarr/validation/user";
import {
createBoardHeaderItem,
createBuiltinHeaderItem,
getDefaultHeaderZone,
getHeaderItemKey,
getHeaderItems,
getHeaderItemZone,
headerBuiltinItemIds,
isRequiredHeaderItem,
} from "@homarr/validation/user";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { HomarrLogo } from "~/components/layout/logo/homarr-logo";
import classes from "./header-composer.module.css";
interface HeaderComposerProps {
value: HeaderPreferences;
onChange: (value: HeaderPreferences) => void;
boards: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
homeBoardId: string | null;
mobileHomeBoardId: string | null;
}
interface HeaderItemDragData {
kind: "item";
itemKey: string;
zone: HeaderZoneId;
index: number;
}
interface HeaderZoneDropData {
kind: "zone";
zone: HeaderZoneId;
}
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
const headerSortableSensors = [
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })],
}),
KeyboardSensor,
];
const headerSortableGroup = "header-composer";
const headerSortablePlugins = [SortableKeyboardPlugin];
const headerGuideZones = ["left", "center", "right"] as const;
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const zoneTableRef = useRef<HTMLDivElement>(null);
const { branding } = useSettings();
const t = useI18n("management.page.user.setting.general.header");
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
const activeItems = getHeaderItems(value.zones);
useEffect(() => {
const zoneTable = zoneTableRef.current;
if (!zoneTable) return;
const clearSelectionFromEmptyArea = (event: PointerEvent) => {
if (!(event.target instanceof Element)) return;
if (event.target.closest("[data-header-composer-item]")) return;
setSelectedItemKey(null);
};
zoneTable.addEventListener("pointerdown", clearSelectionFromEmptyArea);
return () => zoneTable.removeEventListener("pointerdown", clearSelectionFromEmptyArea);
}, []);
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
const item = createBuiltinHeaderItem(itemId);
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
});
const availableBoards = boards.filter(
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
);
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
const toggleSelectedItem = (itemKey: string) => {
setSelectedItemKey((currentItemKey) => {
if (currentItemKey === itemKey) return null;
return itemKey;
});
};
const itemLabel = (item: HeaderItem) => {
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
return t(`items.${item.id}` as never);
};
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
const removeItem = (item: HeaderItem) => {
if (isRequiredHeaderItem(item)) return;
const zone = getHeaderItemZone(value.zones, item);
if (!zone) return;
const itemKey = getHeaderItemKey(item);
updateZones({
...value.zones,
[zone]: value.zones[zone].filter((candidate) => getHeaderItemKey(candidate) !== itemKey),
});
setSelectedItemKey(null);
};
const addItem = (item: HeaderItem) => {
const itemKey = getHeaderItemKey(item);
if (activeItemKeySet.has(itemKey)) return;
const zone = getDefaultHeaderZone(item);
updateZones({ ...value.zones, [zone]: [...value.zones[zone], item] });
setSelectedItemKey(itemKey);
};
const handleDragEnd = ({ canceled, operation }: DragEndEvent) => {
setDraggedItemKey(null);
const source = operation.source;
const target = operation.target;
if (canceled || !source || !target) return;
const sourceData = source.data as HeaderItemDragData;
const targetData = target.data as HeaderDropData;
if (sourceData.kind !== "item") return;
let targetIndex = value.zones[targetData.zone].length;
if (targetData.kind === "item") {
if (targetData.itemKey === sourceData.itemKey) return;
targetIndex = targetData.index;
}
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
if (nextZones === value.zones) return;
updateZones(nextZones);
};
return (
<Card className={classes.composer} padding={0} radius="lg" withBorder>
<Stack gap={0}>
<Group className={classes.heading} justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" radius="md" size="lg">
<IconLayoutDashboard size={18} />
</ThemeIcon>
<Text fw={700} size="lg">
{t("title")}
</Text>
</Group>
<Text c="dimmed" size="sm">
{t("description")}
</Text>
</Stack>
<Group gap="xs">
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
<ActionIcon
size="lg"
variant={value.visible ? "default" : "filled"}
aria-label={value.visible ? t("action.hide") : t("action.show")}
onClick={() => onChange({ ...value, visible: !value.visible })}
>
{value.visible ? <IconEye size={18} /> : <IconEyeOff size={18} />}
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Box className={classes.stage} data-visible={value.visible}>
<Paper className={classes.device} radius="lg" withBorder>
{value.visible ? (
<DragDropProvider
onDragStart={({ operation }) => setDraggedItemKey(operation.source?.id.toString() ?? null)}
onDragEnd={handleDragEnd}
>
<div className={classes.previewHeader}>
<div ref={zoneTableRef} className={classes.zoneTable}>
{headerGuideZones.map((zone) => (
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
{value.zones[zone].map((item, index) => (
<SortableHeaderItem
key={getHeaderItemKey(item)}
item={item}
zone={zone}
index={index}
label={itemLabel(item)}
searchDisplay={value.searchDisplay}
logoDisplay={value.logoDisplay}
logoTitle={branding.appName}
boardLogo={
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
}
configureLabel={t("action.configure", { item: itemLabel(item) })}
selected={selectedItemKey === getHeaderItemKey(item)}
onSelect={toggleSelectedItem}
/>
))}
</HeaderZoneColumn>
))}
</div>
</div>
<DragOverlay dropAnimation={null}>
{draggedItem ? (
<HeaderItemPreview
item={draggedItem}
label={itemLabel(draggedItem)}
compact={false}
searchDisplay={value.searchDisplay}
logoDisplay={value.logoDisplay}
logoTitle={branding.appName}
boardLogo={
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
}
overlay
/>
) : null}
</DragOverlay>
</DragDropProvider>
) : (
<div className={classes.hiddenPreview}>
<ThemeIcon className={classes.floatingAccount} radius="xl" size="xl" aria-label={t("items.user")}>
<IconUserCircle size={22} />
</ThemeIcon>
</div>
)}
<div className={classes.dashboardPreview}>
<BoardLayoutThumbnail
preview={previewBoard?.preview ?? null}
label={previewBoard ? t("preview.board", { name: previewBoard.name }) : t("preview.empty")}
className={classes.dashboardLayout}
/>
</div>
</Paper>
</Box>
<Stack className={classes.tray} gap="sm">
{selectedItem ? (
<Paper className={classes.configuration} p="sm" radius="md" withBorder>
<Group justify="space-between" gap="sm" wrap="wrap">
<Stack gap={0}>
<Text fw={650} size="sm">
{itemLabel(selectedItem)}
</Text>
<Text c="dimmed" size="xs">
{t("configuration.description")}
</Text>
</Stack>
<Group gap="xs">
{selectedItem.type === "builtin" && selectedItem.id === "search" ? (
<SegmentedControl
size="xs"
value={value.searchDisplay}
onChange={(searchDisplay) =>
onChange({ ...value, searchDisplay: searchDisplay as HeaderPreferences["searchDisplay"] })
}
data={[
{ value: "input", label: t("configuration.searchDisplay.input") },
{ value: "icon", label: t("configuration.searchDisplay.icon") },
]}
/>
) : null}
{selectedItem.type === "builtin" && selectedItem.id === "logo" ? (
<SegmentedControl
size="xs"
value={value.logoDisplay}
onChange={(logoDisplay) =>
onChange({ ...value, logoDisplay: logoDisplay as HeaderPreferences["logoDisplay"] })
}
data={[
{ value: "logo", label: t("configuration.logoDisplay.logo") },
{ value: "logoAndText", label: t("configuration.logoDisplay.logoAndText") },
]}
/>
) : null}
{!isRequiredHeaderItem(selectedItem) ? (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconX size={14} />}
onClick={() => removeItem(selectedItem)}
>
{t("action.remove", { item: itemLabel(selectedItem) })}
</Button>
) : (
<Text c="dimmed" size="xs">
{t("available.requiredAlways")}
</Text>
)}
</Group>
</Group>
</Paper>
) : null}
<Group justify="space-between" gap="xs">
<Stack gap={0}>
<Text fw={650} size="sm">
{t("available.title")}
</Text>
<Text c="dimmed" size="xs">
{t("available.description")}
</Text>
</Stack>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<Button
size="xs"
variant="default"
leftSection={<IconPlus size={14} />}
disabled={availableBoards.length === 0}
>
{t("available.boardShortcut")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("available.chooseBoard")}</Menu.Label>
{availableBoards.map((board) => (
<Menu.Item
key={board.id}
leftSection={
<Avatar src={board.logoImageUrl} size="xs" radius="sm">
<IconLayoutDashboard size={14} />
</Avatar>
}
onClick={() => addItem(createBoardHeaderItem(board.id))}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Group>
<Group gap="xs">
{inactiveBuiltinIds.length === 0 ? (
<Text c="dimmed" size="sm">
{t("available.allAdded")}
</Text>
) : (
inactiveBuiltinIds.map((itemId) => {
const item = createBuiltinHeaderItem(itemId);
const Icon = getBuiltinItemIcon(itemId);
return (
<Tooltip key={itemId} label={t("available.add", { item: itemLabel(item) })}>
<ActionIcon
size="lg"
variant="default"
aria-label={t("available.add", { item: itemLabel(item) })}
onClick={() => addItem(item)}
>
<Icon size={19} stroke={1.7} />
</ActionIcon>
</Tooltip>
);
})
)}
</Group>
</Stack>
</Stack>
</Card>
);
};
interface HeaderZoneColumnProps {
zone: HeaderZoneId;
label: string;
children: ReactNode;
}
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
const { isDropTarget, ref } = useDroppable({
id: `header-zone:${zone}`,
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
});
return (
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
<Text className={classes.zoneColumnHeader} component="h4">
{label}
</Text>
<div className={classes.zoneItems}>{children}</div>
</section>
);
};
interface SortableHeaderItemProps {
item: HeaderItem;
zone: HeaderZoneId;
index: number;
label: string;
searchDisplay: HeaderPreferences["searchDisplay"];
logoDisplay: HeaderPreferences["logoDisplay"];
logoTitle: string;
boardLogo: string | null;
configureLabel: string;
selected: boolean;
onSelect: (itemKey: string) => void;
}
const SortableHeaderItem = ({
item,
zone,
index,
label,
searchDisplay,
logoDisplay,
logoTitle,
boardLogo,
configureLabel,
selected,
onSelect,
}: SortableHeaderItemProps) => {
const itemKey = getHeaderItemKey(item);
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
id: itemKey,
index,
group: `${headerSortableGroup}:${zone}`,
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
sensors: headerSortableSensors,
plugins: headerSortablePlugins,
});
return (
<div
ref={ref}
className={classes.sortableItem}
data-dragging={isDragging || undefined}
data-drop-target={isDropTarget || undefined}
data-entry-id={itemKey}
data-selected={selected || undefined}
>
<button
ref={handleRef}
type="button"
className={classes.itemButton}
data-header-composer-item
data-kind={item.type === "builtin" ? item.id : "board"}
data-wide={
(item.type === "builtin" &&
((item.id === "search" && searchDisplay === "input") ||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
undefined
}
aria-label={configureLabel}
onClick={(event) => {
event.stopPropagation();
onSelect(itemKey);
}}
>
<HeaderItemPreview
item={item}
label={label}
compact={false}
searchDisplay={searchDisplay}
logoDisplay={logoDisplay}
logoTitle={logoTitle}
boardLogo={boardLogo}
/>
</button>
</div>
);
};
const HeaderItemPreview = ({
item,
label,
compact,
searchDisplay,
logoDisplay,
logoTitle,
boardLogo,
overlay = false,
}: {
item: HeaderItem;
label: string;
compact: boolean;
searchDisplay: HeaderPreferences["searchDisplay"];
logoDisplay: HeaderPreferences["logoDisplay"];
logoTitle: string;
boardLogo: string | null | undefined;
overlay?: boolean;
}) => {
const content = (
<PreviewItem
item={item}
label={label}
compact={compact}
searchDisplay={searchDisplay}
logoDisplay={logoDisplay}
logoTitle={logoTitle}
boardLogo={boardLogo}
/>
);
if (!overlay) return content;
return (
<div
className={classes.dragOverlay}
data-kind={item.type === "builtin" ? item.id : "board"}
data-wide={
(item.type === "builtin" &&
!compact &&
((item.id === "search" && searchDisplay === "input") ||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
undefined
}
>
{content}
</div>
);
};
const PreviewItem = ({
item,
label,
compact,
searchDisplay,
logoDisplay,
logoTitle,
boardLogo,
}: {
item: HeaderItem;
label: string;
compact: boolean;
searchDisplay: HeaderPreferences["searchDisplay"];
logoDisplay: HeaderPreferences["logoDisplay"];
logoTitle: string;
boardLogo: string | null | undefined;
}) => {
if (item.type === "board") {
return (
<Avatar src={boardLogo} size={22} radius="sm">
<IconLayoutDashboard size={17} />
</Avatar>
);
}
if (item.id === "logo") {
return (
<div className={classes.logoPreview} data-with-title={logoDisplay === "logoAndText" || undefined}>
<HomarrLogo size={26} />
{logoDisplay === "logoAndText" ? <span>{logoTitle}</span> : null}
</div>
);
}
if (item.id === "search") {
const showInput = searchDisplay === "input" && !compact;
return (
<div className={classes.searchPreview} data-input={showInput || undefined}>
<IconSearch size={18} />
{showInput ? <span>{label}…</span> : null}
</div>
);
}
const Icon = getBuiltinItemIcon(item.id);
return <Icon size={20} stroke={1.7} />;
};
const moveHeaderItem = (
zones: HeaderZones,
itemKey: string,
targetZone: HeaderZoneId,
requestedTargetIndex: number,
): HeaderZones => {
let sourceItem: HeaderItem | undefined;
for (const zone of headerGuideZones) {
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
if (sourceItem) break;
}
if (!sourceItem) return zones;
const nextZones: HeaderZones = {
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
};
const targetItems = nextZones[targetZone];
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
return nextZones;
};
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
if (id === null) return undefined;
return items.find((item) => getHeaderItemKey(item) === id);
};
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
if (itemId === "search") return IconSearch;
if (itemId === "home") return IconHome;
if (itemId === "boardSwitcher") return IconReplace;
if (itemId === "assistant") return IconRobot;
if (itemId === "docker") return IconBrandDocker;
if (itemId === "boardEdit") return IconPencil;
if (itemId === "boardSettings") return IconSettings;
if (itemId === "settings") return IconSettings;
if (itemId === "themeToggle") return IconSunMoon;
if (itemId === "user") return IconUserCircle;
return IconLayoutDashboard;
};
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import { Alert, Stack, Title } from "@mantine/core";
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
@@ -8,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
@@ -37,7 +38,7 @@ export async function generateMetadata(props: Props) {
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
title: t("metaTitle", { username: user.name! }),
title: createMetaTitle(t("metaTitle", { username: user.name! })),
};
}
@@ -57,7 +58,7 @@ export default async function EditUserPage(props: Props) {
notFound();
}
const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials";
@@ -70,18 +71,23 @@ export default async function EditUserPage(props: Props) {
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
preview: board.preview,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
profileAvatar={<UserProfileAvatarForm user={user} />}
/>
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
</Box>
<Box w={{ base: "100%", lg: 260 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
@@ -8,6 +8,7 @@ import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
export async function generateMetadata() {
@@ -21,7 +22,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.user.create");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list";
export async function generateMetadata() {
@@ -19,7 +20,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.user.list");
return {
title: t("metaTitle"),
title: createMetaTitle(t("metaTitle")),
};
}
@@ -89,9 +89,8 @@ const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T
};
};
const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
const isCustomWidgetTool = toolCall.toolName.startsWith("customWidget_");
if (!isCustomWidgetTool && toolCall.toolName !== "configure_board_settings") return null;
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
try {
const input = JSON.parse(toolCall.input) as unknown;
@@ -115,4 +114,4 @@ const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T)
};
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
repairIconSearchInput(toolCall) ?? repairMultilineToolInput(toolCall);
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
@@ -4,7 +4,6 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
import type { ToolSet, UIMessage } from "ai";
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
@@ -25,10 +24,6 @@ import {
assistantProviderRequiresApiKey,
assistantReasoningModes,
} from "@homarr/definitions";
import { localeCookieKey } from "@homarr/definitions/cookie";
import type { SupportedLanguage } from "@homarr/translation";
import { fallbackLocale, isLocaleSupported } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
@@ -151,12 +146,12 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never use ask_user to confirm a board settings or custom CSS change before configure_board_settings, or to confirm another change whose inputs are already sufficient for its native review form or mutating tool. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- Do not retry a denied action.
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then immediately call configure_board_settings with only the requested proposed changes. For a custom CSS request, include the complete resulting stylesheet in changes.customCss so the native form is prefilled and previews the proposed CSS. The native form is the user's review and confirmation step, so never insert ask_user between these calls when the requested change is known. Use the form's returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget.
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
@@ -375,7 +370,7 @@ export async function POST(request: Request) {
const session = await auth();
if (!session) {
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
}
const requestBody = await request.text();
@@ -419,11 +414,7 @@ export async function POST(request: Request) {
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
const requestedLocale = (await cookies()).get(localeCookieKey)?.value;
let locale: SupportedLanguage = fallbackLocale;
if (requestedLocale && isLocaleSupported(requestedLocale)) locale = requestedLocale;
const t = await getI18n({ locale, namespace: "assistant.unavailable" });
return Response.json({ error: t("notConfigured") }, { status: 503 });
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
}
let homarrProviderToken: string | null | undefined;
+23 -43
View File
@@ -1,59 +1,39 @@
import type { MetadataRoute } from "next";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
export const dynamic = "force-dynamic";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const serverSettings = await getRscServerSettingsAsync();
const branding = serverSettings.branding;
const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl;
const icons: NonNullable<MetadataRoute.Manifest["icons"]> = [
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
];
if (appIcon) {
icons.unshift({
src: appIcon,
sizes: "any",
purpose: "any",
});
} else {
icons.unshift(
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Homarr",
short_name: "Homarr",
description: "Your dashboard for managing your server.",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
);
}
return {
name: branding.appName,
short_name: branding.appName,
description: "Your dashboard for managing your server.",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: branding.primaryColor,
icons,
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
};
export const buildAssistantConversationMarkdown = ({
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
const header = [
`# ${title}`,
"",
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"",
`- Conversation ID: \`${thread.id}\``,
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
@@ -25,6 +25,7 @@ const EnabledAssistantRoot = dynamic(() =>
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description);
const value = useMemo(
() => ({
enabled: false,
@@ -54,7 +54,7 @@ export const assistantBoardSettingsChangesSchema = z
export const browserToolContracts = {
ask_user: {
description:
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
parameters: z.object({
question: z.string().trim().min(1).max(240),
description: z.string().trim().max(400).optional(),
@@ -82,7 +82,7 @@ export const browserToolContracts = {
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
@@ -5,10 +5,10 @@
overflow: hidden;
color: var(--mantine-color-dimmed);
background:
linear-gradient(
to top,
radial-gradient(
circle at 15% 10%,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 55%
transparent 35%
),
var(--mantine-color-body);
}
@@ -12,22 +12,15 @@ interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
fitFullLayout?: boolean;
className?: string;
}
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({
preview,
label,
fitFullLayout = false,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
return (
<Center className={canvasClassName} role="img" aria-label={label}>
<Center className={classes.canvas} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
@@ -45,7 +38,7 @@ export const BoardLayoutThumbnail = ({
});
if (lanes.length === 0) {
return (
<Center className={canvasClassName} role="img" aria-label={label}>
<Center className={classes.canvas} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
@@ -62,7 +55,7 @@ export const BoardLayoutThumbnail = ({
};
return (
<Box className={canvasClassName} role="img" aria-label={label}>
<Box className={classes.canvas} role="img" aria-label={label}>
<div
className={classes.lanes}
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
@@ -15,10 +15,6 @@
outline: none;
}
.results {
min-height: 0;
}
.searchInput {
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
@@ -48,19 +44,3 @@
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-md);
}
@media (max-width: $mantine-breakpoint-sm) {
.modalContent {
height: calc(100dvh - var(--modal-y-offset) * 2);
}
.modalBody,
.content {
min-height: 0;
height: 100%;
}
.results {
flex: 1;
}
}
@@ -36,14 +36,8 @@ import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
export interface BoardSwitcherControls {
open: () => void;
preload: () => void;
hotkey: string;
}
interface BoardSwitcherProps {
children: (controls: BoardSwitcherControls) => ReactNode;
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
}
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
@@ -228,13 +222,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
radius="xl"
classNames={{ input: classes.searchInput }}
/>
<ScrollArea.Autosize
mah="min(80vh, 42rem)"
type="never"
scrollbars="y"
overscrollBehavior="contain"
className={classes.results}
>
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
{results}
</ScrollArea.Autosize>
</Stack>
@@ -1,14 +0,0 @@
.mobileDrawerContent {
height: min(80dvh, rem(640px));
max-height: 80dvh;
overscroll-behavior: contain;
}
.mobileDrawerBody {
padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom));
}
.mobileActionList {
display: flex;
flex-direction: column;
}
@@ -2,7 +2,7 @@
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -20,7 +20,6 @@ import { getWidgetName } from "@homarr/definitions";
import { translateIfNecessary } from "@homarr/translation";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
@@ -32,9 +31,6 @@ import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
import classes from "./widget-context-menu.module.css";
type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }];
interface WidgetContextMenuProps {
item: SectionItem;
@@ -138,12 +134,11 @@ export const WidgetContextMenu = ({
[item.id, persistBoard],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, ToggleOption[1]>;
return Object.entries(rawOptions).filter(
([, option]) => option.type === "switch" && !option.skipContextMenu,
) as ToggleOption[];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
}, [canConfigureWidget, definition, settings]);
const widgetContextActions =
@@ -227,21 +222,7 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return;
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}, [item.id, openAdvancedFocus, sourceRef]);
const handleCloseMenu = useCallback(() => {
setMenuOpened(false);
}, []);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -256,10 +237,21 @@ export const WidgetContextMenu = ({
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item closeMenuOnClick leftSection={<IconMaximize size={16} />} onClick={handleOpenAdvancedFocus}>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
@@ -325,39 +317,11 @@ export const WidgetContextMenu = ({
>
{tMenu("settings")}
</Menu.Item>
</WidgetContextMenuDropdown>
</Menu.Dropdown>
</Menu>
);
};
interface WidgetContextMenuDropdownProps {
opened: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => {
const isMobile = useIsMobile();
if (!isMobile) return <Menu.Dropdown>{children}</Menu.Dropdown>;
return (
<Drawer
opened={opened}
onClose={onClose}
position="bottom"
size="min(80dvh, 40rem)"
title={title}
classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }}
>
<div className={classes.mobileActionList} role="menu" aria-orientation="vertical" data-menu-dropdown>
{children}
</div>
</Drawer>
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
matchesQuery: (queryKey: QueryKey) => boolean;
@@ -24,7 +24,6 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
},
});
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
@@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={0}
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
@@ -18,7 +18,7 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -138,56 +138,9 @@ export const SectionGrid = ({
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
// any widget, so it gets that same gap). A widget's content just fills whatever size its
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
// corrected to compensate so icon/text sizing inside the container isn't affected.
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
// be left with a small amount of unfilled slack when width/height need different ratios to
// exactly fit - centered below so it's even on both sides rather than left-aligned.
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
// zero or negative - combinedUiScale divides by this value, and this value is also used as
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const logicalWidth = getLogicalGridSize(columnCount);
const logicalHeight = getLogicalGridSize(rowCount);
const viewportHeight = getLogicalGridSize(viewportRowCount);
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -296,15 +249,7 @@ export const SectionGrid = ({
>
<Box
className={classes.staticGrid}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
} as CSSProperties
}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => {
test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
const runtimeImports = getSourceFiles(gridDirectory)
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
.flatMap(getRuntimeImports)
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
.map(({ filePath, kind, specifier }) => ({
@@ -1,2 +1 @@
export const appShellHeaderHeight = 60;
export const appShellLogoHeight = appShellHeaderHeight - 8;
+37 -34
View File
@@ -1,46 +1,49 @@
import type { ReactNode } from "react";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { appShellLogoHeight } from "./constants";
import { ConfigurableHeader } from "./header/configurable-header";
import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
import { LazySpotlight } from "./header/lazy-spotlight";
interface Props {
logo?: ReactNode;
logoWithTitle?: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation?: boolean;
}
export const MainHeader = async ({
logo,
logoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation = true,
}: Props) => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const resolvedLogoWithTitle = logoWithTitle ?? <HomarrLogoWithTitle size="md" />;
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={resolvedLogoWithTitle}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
hasNavigation={hasNavigation}
avatar={<CurrentUserAvatar size="md" />}
userId={session?.user.id ?? null}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
/>
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
style={{ overflowX: "hidden" }}
data-advanced-focus-background
data-app-shell-header
>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
</AppShellHeader>
);
};
@@ -1,192 +0,0 @@
.header {
overflow: hidden;
}
.header[data-desktop-visible="false"] {
display: none;
}
.desktopContent {
width: 100%;
height: 100%;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: var(--mantine-spacing-sm);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-md);
}
.zone {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
box-sizing: border-box;
min-width: 0;
height: 100%;
padding-inline-end: rem(10px);
overflow-x: auto;
scrollbar-width: none;
}
.zone::-webkit-scrollbar {
display: none;
}
.zone[data-zone="left"] {
justify-content: flex-start;
}
.zone[data-zone="center"] {
justify-content: center;
}
.zone[data-zone="right"] {
justify-content: flex-end;
}
.search {
display: flex;
min-width: 0;
max-width: 100%;
}
.search[data-display="icon"] {
width: 2.25rem;
flex: 0 0 2.25rem;
}
.search > * {
max-width: 100%;
}
.zone [data-tour-target="board-search"] {
min-width: 0;
max-width: 100%;
}
.contextActions {
flex: 0 0 auto;
}
.mobileContent {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-xs);
}
.mobileIdentity,
.mobileActions {
min-width: 0;
}
.mobileIdentity {
flex: 1 1 auto;
overflow: hidden;
}
.mobileIdentity > :last-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.mobileIdentity h2 {
overflow: hidden;
text-overflow: ellipsis;
}
.mobileActions {
flex: 0 0 auto;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
top: max(rem(10px), env(safe-area-inset-top));
right: max(rem(10px), env(safe-area-inset-right));
padding: rem(8px);
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
box-shadow: 0 rem(12px) rem(36px) rgb(0 0 0 / 20%);
backdrop-filter: blur(rem(20px)) saturate(140%);
opacity: 0;
pointer-events: none;
transform: translateY(rem(-8px)) scale(0.98);
transition:
opacity 160ms ease,
transform 180ms ease;
}
.floatingControls[data-visible="true"],
.floatingControls:focus-within {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.floatingControls button {
display: flex;
align-items: center;
justify-content: center;
}
.floatingControlsCorner {
position: fixed;
z-index: calc(var(--homarr-z-index-board-header) - 1);
top: 0;
right: 0;
width: rem(112px);
height: rem(112px);
pointer-events: none;
}
.floatingControlsCorner[data-active="true"] {
pointer-events: auto;
}
@media (max-width: $mantine-breakpoint-sm) {
.header[data-desktop-visible="false"] {
display: block;
}
.desktopContent {
display: none;
}
.mobileContent {
display: flex;
}
.floatingControls,
.floatingControlsCorner {
display: none;
}
}
@media (hover: none), (pointer: coarse) {
.floatingControls {
opacity: 1;
pointer-events: auto;
transform: none;
}
.floatingControlsCorner {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.floatingControls {
transition: none;
}
}
@@ -1,429 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import {
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { HeaderItem } from "@homarr/validation/user";
import { getHeaderItemKey, getHeaderItems } from "@homarr/validation/user";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
import { BoardSwitcher } from "~/components/board/board-switcher";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { ClientBurger } from "./burger";
import { HeaderButton } from "./button";
import { DockerQuickAccessModal } from "./docker-quick-access-modal";
import { HeaderLogo } from "./header-logo";
import { LazySpotlight } from "./lazy-spotlight";
import { DesktopSearchInput, MobileSearchButton } from "./search";
import { TourTarget } from "./tour-target";
import { UserButtonClient } from "./user-client";
import classes from "./configurable-header.module.css";
interface ConfigurableHeaderProps {
logo: ReactNode;
logoWithTitle: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation: boolean;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
}
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
const floatingControlsIntroDurationMs = 5_000;
const floatingControlsDismissDelayMs = 900;
export const ConfigurableHeader = ({
logo,
logoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation,
avatar,
userId,
isAdmin,
isDockerEnabled,
}: ConfigurableHeaderProps) => {
const { headerPreferences } = useSettings();
const assistant = useOptionalHomarrAssistant();
const { toggleColorScheme } = useMantineColorScheme();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const t = useI18n("management.page.user.setting.general.header");
const allHeaderItems = getHeaderItems(headerPreferences.zones);
const hasBoardShortcuts = allHeaderItems.some((item) => item.type === "board");
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, {
enabled: hasBoardShortcuts,
staleTime: 60_000,
});
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
return (
<BoardSwitcher>
{(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) =>
items.map((item) => (
<HeaderItem
key={getHeaderItemKey(item)}
item={item}
logo={logo}
logoWithTitle={logoWithTitle}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
avatar={avatar}
userId={userId}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay}
logoDisplay={headerPreferences.logoDisplay}
boardSwitcher={boardSwitcher}
assistant={assistant}
openDockerModal={openDockerModal}
toggleColorScheme={toggleColorScheme}
label={(key) => t(`items.${key}` as never)}
boardUnavailableLabel={t("items.boardUnavailable")}
/>
));
return (
<>
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-desktop-visible={headerPreferences.visible}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.desktopContent}>
<div className={classes.content}>
<div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left)}
</div>
<div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center)}
</div>
<div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</div>
<div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
</Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
{actions}
{boardEditAction}
{boardSettingsAction}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group>
</div>
</AppShellHeader>
{!headerPreferences.visible ? (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
) : null}
<LazySpotlight />
</>
);
}}
</BoardSwitcher>
);
};
const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
const [isIntroComplete, setIsIntroComplete] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const isInteractingRef = useRef(false);
const dismissTimeoutRef = useRef<number | null>(null);
const clearDismissTimeout = () => {
if (dismissTimeoutRef.current === null) return;
window.clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
};
const dismissSoon = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
dismissTimeoutRef.current = window.setTimeout(() => {
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsDismissDelayMs);
};
const revealFromCorner = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
setIsVisible(true);
};
const startInteraction = () => {
isInteractingRef.current = true;
clearDismissTimeout();
setIsVisible(true);
};
const endInteraction = () => {
isInteractingRef.current = false;
dismissSoon();
};
useEffect(() => {
const introTimeout = window.setTimeout(() => {
setIsIntroComplete(true);
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsIntroDurationMs);
return () => {
window.clearTimeout(introTimeout);
if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
};
}, []);
return (
<>
<div
className={classes.floatingControlsCorner}
data-active={isIntroComplete || undefined}
aria-hidden
onPointerEnter={revealFromCorner}
onPointerMove={revealFromCorner}
onPointerDown={revealFromCorner}
onPointerLeave={dismissSoon}
/>
<Group
className={classes.floatingControls}
data-visible={isVisible || undefined}
gap={4}
wrap="nowrap"
onPointerEnter={startInteraction}
onPointerLeave={endInteraction}
onFocusCapture={startInteraction}
onBlurCapture={(event) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
endInteraction();
}}
>
{children}
</Group>
</>
);
};
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;
logoWithTitle: ReactNode;
boardEditAction: ReactNode;
boardSettingsAction: ReactNode;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
board: HeaderBoard | undefined;
searchDisplay: "input" | "icon";
logoDisplay: "logo" | "logoAndText";
boardSwitcher: BoardSwitcherControls;
assistant: ReturnType<typeof useOptionalHomarrAssistant>;
openDockerModal: () => void;
toggleColorScheme: () => void;
label: (key: string) => string;
boardUnavailableLabel: string;
}
const HeaderItem = ({
item,
logo,
logoWithTitle,
boardEditAction,
boardSettingsAction,
avatar,
userId,
isAdmin,
isDockerEnabled,
board,
searchDisplay,
logoDisplay,
boardSwitcher,
assistant,
openDockerModal,
toggleColorScheme,
label,
boardUnavailableLabel,
}: HeaderItemProps) => {
if (item.type === "board") {
if (!board) {
return (
<Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled>
<IconLayoutDashboard size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
return (
<Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm">
{getBoardInitial(board.name)}
</Avatar>
</HeaderButton>
</Tooltip>
);
}
if (item.id === "logo") {
return <HeaderLogo display={logoDisplay} logo={logo} logoWithTitle={logoWithTitle} label={label("logo")} />;
}
if (item.id === "boardEdit") return boardEditAction;
if (item.id === "boardSettings") return boardSettingsAction;
if (item.id === "search") {
return (
<TourTarget id="board-search">
<div className={classes.search} data-display={searchDisplay}>
{searchDisplay === "input" ? (
<>
<DesktopSearchInput />
<MobileSearchButton />
</>
) : (
<MobileSearchButton alwaysVisible />
)}
</div>
</TourTarget>
);
}
if (item.id === "home") {
return (
<Tooltip label={label("home")}>
<HeaderButton href="/boards" aria-label={label("home")}>
<IconHome size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "boardSwitcher") {
return (
<Tooltip label={label("boardSwitcher")}>
<HeaderButton
aria-label={label("boardSwitcher")}
onClick={boardSwitcher.open}
onFocus={boardSwitcher.preload}
onPointerEnter={boardSwitcher.preload}
>
<IconReplace size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "assistant") {
if (!assistant?.enabled) return null;
return (
<Tooltip label={label("assistant")}>
<Indicator
inline
disabled={!assistant.isRunning && assistant.unreadCount === 0}
color="red"
size={16}
label={assistant.isRunning ? <Loader type="bars" color="white" size={8} /> : assistant.unreadCount}
>
<HeaderButton aria-label={label("assistant")} onClick={assistant.open}>
<IconRobot size={20} stroke={1.5} />
</HeaderButton>
</Indicator>
</Tooltip>
);
}
if (item.id === "docker") {
if (!isDockerEnabled) return null;
return (
<Tooltip label={invariantTechnicalLabels.docker}>
<HeaderButton aria-label={invariantTechnicalLabels.docker} onClick={openDockerModal}>
<IconBrandDocker size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "settings") {
const href = userId ? `/manage/users/${userId}/general` : "/auth/login";
return (
<Tooltip label={label("settings")}>
<HeaderButton href={href} aria-label={label("settings")}>
<IconSettings size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "themeToggle") {
return (
<Tooltip label={label("themeToggle")}>
<HeaderButton aria-label={label("themeToggle")} onClick={toggleColorScheme}>
<IconSunMoon size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
return (
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
);
};
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
@@ -1,22 +0,0 @@
.root {
display: flex;
align-items: center;
flex: 0 0 auto;
border-radius: var(--mantine-radius-md);
}
.root[data-display="logo"] {
justify-content: center;
width: calc(var(--app-shell-header-height) - 0.5rem);
height: calc(var(--app-shell-header-height) - 0.5rem);
overflow: hidden;
}
.root[data-display="logo"] img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: calc(var(--app-shell-header-height) - 0.5rem);
object-fit: contain;
}
@@ -1,30 +0,0 @@
import type { ReactNode } from "react";
import { UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
import classes from "./header-logo.module.css";
interface HeaderLogoProps {
display: "logo" | "logoAndText";
logo: ReactNode;
logoWithTitle: ReactNode;
label: string;
}
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
let content = logo;
if (display === "logoAndText") content = logoWithTitle;
return (
<UnstyledButton
component={Link}
href="/"
className={classes.root}
data-display={display}
aria-label={label}
>
{content}
</UnstyledButton>
);
};
@@ -28,14 +28,12 @@ export const DesktopSearchInput = () => {
);
};
export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
const t = useI18n();
export const MobileSearchButton = () => {
return (
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={() => openSpotlight()}
className={alwaysVisible ? undefined : classes.mobileSearch}
aria-label={t("search.placeholder")}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
@@ -8,22 +8,18 @@ import { useHotkeys } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UpdateIndicator } from "./update";
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
isDockerEnabled: boolean;
boardSwitcher: BoardSwitcherControls;
}
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const t = useI18n("common.userAvatar.menu");
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -49,8 +45,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitch
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
return (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
<UnstyledButton aria-label={t("open")}>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
@@ -2,19 +2,13 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UserButtonClient } from "./user-client";
export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
export const UserButton = async () => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
return (
<UserButtonClient
avatar={<CurrentUserAvatar size="md" />}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
);
};
@@ -1,9 +1,8 @@
"use client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { homarrLogoPath } from "./constants";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
@@ -13,9 +12,8 @@ interface LogoProps {
const useImageOptions = () => {
const board = useRequiredBoard();
const { branding } = useSettings();
return {
src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
src: board.logoImageUrl ?? homarrLogoPath,
alt: "Board logo",
shouldUseNextImage: false,
};
@@ -33,13 +31,12 @@ interface CommonLogoWithTitleProps {
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const board = useRequiredBoard();
const { branding } = useSettings();
const imageOptions = useImageOptions();
return (
<LogoWithTitle
size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? branding.appName}
title={board.pageTitle ?? homarrPageTitle}
image={imageOptions}
/>
);
@@ -1 +0,0 @@
export const homarrLogoPath = "/logo/logo.png";
@@ -1,39 +1,25 @@
"use client";
import { useSettings } from "@homarr/settings";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
import { homarrLogoPath } from "./constants";
interface LogoProps {
size: number;
}
const useBrandLogo = () => {
const { branding } = useSettings();
const configuredLogoUrl = branding.logoImageUrl?.trim();
return {
title: branding.appName,
image: {
src: configuredLogoUrl || homarrLogoPath,
alt: `${branding.appName} logo`,
shouldUseNextImage: !configuredLogoUrl,
},
};
export const homarrLogoPath = "/logo/logo.png";
export const homarrPageTitle = "Homarr";
const imageOptions = {
src: homarrLogoPath,
alt: "Homarr logo",
shouldUseNextImage: true,
};
export const HomarrLogo = ({ size }: LogoProps) => {
const brand = useBrandLogo();
return <Logo size={size} {...brand.image} />;
};
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />;
interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"];
hideTitleOnMobile?: boolean;
}
export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const brand = useBrandLogo();
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />;
};
@@ -9,18 +9,14 @@ interface LogoProps {
shouldUseNextImage?: boolean;
}
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
const normalizedSrc = src.trim();
if (normalizedSrc.length === 0) return null;
return shouldUseNextImage ? (
<Image className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
shouldUseNextImage ? (
<Image className="logo" src={src} alt={alt} width={size} height={size} />
) : (
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
// eslint-disable-next-line @next/next/no-img-element
<img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
<img className="logo" src={src} alt={alt} width={size} height={size} />
);
};
const logoWithTitleSizes = {
lg: { logoSize: 48, titleOrder: 1 },
@@ -15,17 +15,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
id ? <TourTarget id={id}>{children}</TourTarget> : children;
const pathMatches = (pathname: string, href: string, exact = false) => {
if (pathname === href) return true;
if (exact) return false;
return pathname.startsWith(`${href}/`);
};
const getMostSpecificMatchingHref = (pathname: string, items: NavigationLinkHref[]) =>
items
.filter((item) => pathMatches(pathname, item.href, item.exact))
.toSorted((first, second) => second.href.length - first.href.length)
.at(0)?.href;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
const useClientPathname = () => {
const pathname = usePathname();
@@ -41,7 +31,6 @@ const useClientPathname = () => {
const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname();
const tourId = props["data-onboarding-tour-id"];
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : (
@@ -50,7 +39,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={isActive}
active={isClient && pathMatches(pathname, props.href)}
/>
);
return withOptionalTourTarget(tourId, link);
@@ -58,18 +47,11 @@ const NavLinkHref = (props: NavigationLinkHref) => {
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const { pathname, isClient } = useClientPathname();
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
const isActive = isClient && activeItemHref !== undefined;
const [opened, setOpened] = useState(false);
useEffect(() => {
if (isActive) setOpened(true);
}, [isActive]);
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
const nav = (
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
<NavLinkHref key={item.label} {...item} />
))}
</NavLink>
);
@@ -85,8 +67,6 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string;
external?: boolean;
exact?: boolean;
active?: boolean;
}
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[];
@@ -72,7 +72,6 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string;
external?: boolean;
exact?: boolean;
}
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[];
+1 -4
View File
@@ -3,7 +3,6 @@
import type { PropsWithChildren } from "react";
import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useSettings } from "@homarr/settings";
import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants";
@@ -21,13 +20,11 @@ export const ClientShell = ({
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: headerHeight } : undefined}
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
navbar={
hasNavigation
? {
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff. Show more