Compare commits

...
Author SHA1 Message Date
ajnart 39a714420f fix: refine configurable header defaults 2026-08-26 14:13:44 +02:00
Thomas a56ec5d06a feat: support every widget in the demo dashboard (#6696) 2026-08-26 08:44:55 +02:00
Bergasha 40e34181e7 fix(board-v2): icon and tiny text fix (#6704) 2026-08-26 08:44:36 +02:00
Thomas 6548da3772 fix: stabilize e2e login hydration (#6706) 2026-08-25 22:08:52 +02:00
Thomas 3ee9b4f0c4 feat(custom-widgets): harden AI authoring (credential redaction, JSX policy) (#6700) 2026-08-25 17:11:24 +02:00
Thomas 9303dbd284 feat: add configurable headers and instance branding (#6690) 2026-08-25 17:09:47 +02:00
Thomas 97d8390b20 feat: rebuild command menu (#6692) 2026-08-24 15:40:09 +02:00
Thomas c9a48fa23f fix: polish login backdrop and wordmark (#6687) 2026-08-24 12:26:19 +02:00
Thomas e7c8e2e01e refactor(custom-widgets): simplify authoring prompt (#6695) 2026-08-24 09:24:53 +02:00
Thomas fcca57ddf7 feat(custom-widgets): improve authoring workbench (#6689)
* feat(custom-widgets): improve authoring workbench

* fix(custom-widgets): address review and CI

* refactor(custom-widgets): satisfy architecture checks

* fix(custom-widgets): isolate workbench typing updates

* fix(custom-widgets): harden workbench state
2026-08-24 08:55:00 +02:00
Bergasha 8b6387f321 Merge pull request #6693 from homarr-labs/fix/v2-media-server
fix(mediaServer): use proportional column widths
2026-08-24 11:42:49 +10:00
Bergasha b8bc304c5c fix(mediaServer): use proportional column widths, populate Emby playback/transcoding metadata
The User and Status columns used hardcoded pixel widths with table-layout:
fixed, so they stayed narrow regardless of how wide the widget actually
rendered - text truncated even with plenty of unused space in the
"currently playing" column. Switch both to percentages so they scale with
the widget.

Emby's integration also hardcoded playback/metadata to null for every
session, so the widget's existing progress bar, resolution, and bitrate
display never had anything to show. Port the same TranscodingInfo/
MediaStreams parsing Jellyfin already does (same underlying /Sessions API
shape) so Emby sessions get real position/duration, resolution, bitrate,
and transcode-status data. Also fix ticksToMs treating a position of
exactly 0 as missing, and share the one implementation between the two
integrations instead of duplicating it.
2026-08-24 11:16:20 +10:00
303 changed files with 17494 additions and 2850 deletions

No files matched your search

@@ -36,6 +36,8 @@ 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 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 ```sh
pnpm cli harness port feature-board pnpm cli harness port feature-board
``` ```
@@ -8,13 +8,28 @@ Homarr has the following keyboard shortcuts:
| Shortcut | Description | | Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar | | `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences |
| `MOD` + `P` | Opens app search |
| `MOD` + `/` | Opens Homarr Assistant inside search |
| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search |
| `Shift` + `A` | Opens the full Homarr Assistant |
| `MOD` + `J` | Toggles Dark Mode | | `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode | | `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | | Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed | | Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | | `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Search and commands
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow
the same query when needed. Media request search is available only when a supported Seerr integration is configured.
## Widget advanced view ## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl` Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
@@ -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. 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. 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. 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 Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint. 5. **Extend** explains and optionally configures 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. 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 Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
+4 -4
View File
@@ -1,5 +1,5 @@
--- ---
description: Configure and use Homarr Assistant. description: Configure and use Assistant.
tags: tags:
- Assistant - Assistant
- AI - AI
@@ -8,10 +8,10 @@ tags:
# Assistant # Assistant
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
approval before making changes. approval before making changes.
![Homarr Assistant conversation with suggested prompts](/img/assistant/conversation.png) ![Assistant conversation with suggested prompts](/img/assistant/conversation.png)
## Set it up ## Set it up
@@ -38,7 +38,7 @@ host gateway address.
## Use it ## Use it
- Select **Assistant** in the user menu or press `Shift + A`. - Select **Assistant** in the user menu or press `Shift + A`.
- Ask from `Cmd/Ctrl + K`. - Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
- Add the [Assistant widget](../widgets/assistant) to a board. - Add the [Assistant widget](../widgets/assistant) to a board.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
+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. This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page. The styling will only be applied on the respective board page.
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background, When 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 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, editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
or Danger zone actions. 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 checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
runtime components. runtime components.
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run: To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
```sh ```sh
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
@@ -41,6 +41,10 @@ needs attention.
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`. 4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Test and preview, inspect Data and the redacted request journal, then save. 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 <CanonicalCustomWidgetExample
id="named-widget-requests" id="named-widget-requests"
label="Named queries and actions" label="Named queries and actions"
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
### Design with AI ### Design with AI
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and, The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the when [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 widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
update tool for your approval. 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. 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 [Homarr Assistant](./assistant) uses these same tool definitions internally. The optional [Assistant](./assistant) uses these same tool definitions internally.
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
approval before running mutating procedures. approval before running mutating procedures.
@@ -7,9 +7,13 @@ tags:
# Search Engines # Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`. Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine. The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
ordinary search term never expands into similarly named bang providers.
You can add any search engine that has a search URL that accepts a search term as a parameter. You can add any search engine that has a search URL that accepts a search term as a parameter.
## Create search engine ## Create search engine
@@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover.
Prefix that is used to choose the search engine in the popover using the keyboard. Prefix that is used to choose the search engine in the popover using the keyboard.
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia). It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
additional bang providers.
### Configuration ### Configuration
+351
View File
@@ -3453,6 +3453,9 @@
"enableRightClickOnWidgets": { "enableRightClickOnWidgets": {
"type": "boolean" "type": "boolean"
}, },
"headerPreferences": {
"type": "string"
},
"defaultSearchEngineId": { "defaultSearchEngineId": {
"anyOf": [ "anyOf": [
{ {
@@ -3488,6 +3491,7 @@
"firstDayOfWeek", "firstDayOfWeek",
"pingIconsEnabled", "pingIconsEnabled",
"enableRightClickOnWidgets", "enableRightClickOnWidgets",
"headerPreferences",
"defaultSearchEngineId", "defaultSearchEngineId",
"openSearchInNewTab", "openSearchInNewTab",
"ddgBangs", "ddgBangs",
@@ -4346,6 +4350,353 @@
} }
} }
}, },
"/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": { "/api/users/ddg-bangs": {
"patch": { "patch": {
"operationId": "userRouter-changeDdgBangs", "operationId": "userRouter-changeDdgBangs",
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common"; import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions"; import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui"; import { theme } from "@homarr/ui";
import type { ServerSettings } from "@homarr/server-settings";
import { createBrandTheme } from "~/theme/branding";
export const CustomMantineProvider = ({ export const CustomMantineProvider = ({
children, children,
defaultColorScheme, defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => { branding,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
const manager = useColorSchemeManager(); const manager = useColorSchemeManager();
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
return ( return (
<DirectionProvider> <DirectionProvider>
<MantineProvider <MantineProvider
defaultColorScheme={defaultColorScheme} defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager} colorSchemeManager={manager}
theme={theme} theme={configuredTheme}
cssVariablesResolver={v8CssVariablesResolver} cssVariablesResolver={v8CssVariablesResolver}
> >
{children} {children}
@@ -1,11 +1,13 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { IconUserPlus } from "@tabler/icons-react"; import { IconUserPlus } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db"; import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema"; import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding"; import { OnboardingAuthShell } from "@homarr/onboarding";
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form"; import { RegistrationForm } from "./_registration-form";
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
} }
export default async function InviteUsagePage(props: InviteUsagePageProps) { export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound(); if (!isProviderEnabled("credentials")) notFound();
const session = await auth(); const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
if (session) notFound(); if (session) notFound();
const invite = await db.query.invites.findFirst({ const invite = await db.query.invites.findFirst({
@@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound(); if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getI18n("user.page.invite"); const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
const defaultDescription = showCustomAppName
? t("subtitleWithAppName", { appName: branding.appName })
: t("subtitle");
const description = showCustomGreeting ? branding.greeting : defaultDescription;
return ( return (
<OnboardingAuthShell <OnboardingAuthShell
title={t("title")} title={title}
description={t("subtitle")} description={description}
icon={<IconUserPlus size={24} />} 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 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
footer={t("description", { username: invite.creator.name! })} footer={t("description", { username: invite.creator.name! })}
> >
@@ -2,9 +2,11 @@ import { redirect } from "next/navigation";
import { Alert, Code, Text } from "@mantine/core"; import { Alert, Code, Text } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react"; import { IconLogin } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding"; import { OnboardingAuthShell } from "@homarr/onboarding";
import { getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
@@ -19,17 +21,35 @@ interface LoginProps {
} }
export default async function Login(props: LoginProps) { export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams; const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
const session = await auth();
if (session) { if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl)); redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
} }
const t = await getI18n("user.page.login"); const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const description = showCustomGreeting ? branding.greeting : t("subtitle");
return ( return (
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}> <OnboardingAuthShell
title={t("title")}
description={description}
icon={<IconLogin size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
>
{appEnv.DEMO_MODE ? ( {appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light"> <Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui"; import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state"; import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import { import {
@@ -79,6 +80,7 @@ export const ClientBoard = () => {
<BoardSelectionGridProvider> <BoardSelectionGridProvider>
<BoardAdvancedFocusProvider> <BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<BoardEmptyState /> <BoardEmptyState />
<BoardSetupChecklist /> <BoardSetupChecklist />
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { makeQueryClient } from "@homarr/api/shared"; import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
@@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env"; import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types"; import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client"; import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions"; import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" }); const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient); const getQueryClient = cache(makeQueryClient);
@@ -36,7 +36,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => { }: Props<TParams>) => {
return { return {
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />, headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour: true, withTour: true,
}), }),
@@ -103,18 +104,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try { try {
const board = await getInitialBoard(await params); const [board, t, serverSettings] = await Promise.all([
const t = await getI18n("board"); getInitialBoard(await params),
getI18n("board"),
getRscServerSettingsAsync(),
]);
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
return { return {
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })), title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
icons: { icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, icon: favicon,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, apple: favicon,
}, },
appleWebApp: { appleWebApp: {
startupImage: { startupImage: {
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png", url: favicon,
}, },
}, },
}; };
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
]); ]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false }); const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null; if (!hasChangeAccess) return null;
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return ( return (
<> <>
{isEditMode && <BoardEditActions />} {isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <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 EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled"); const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
@@ -1,30 +1,33 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useColorSchemeManager } from "../../_client-providers/mantine"; import { useColorSchemeManager } from "../../_client-providers/mantine";
import { generateColorScale } from "~/theme/branding";
export const BoardMantineProvider = ({ export const BoardMantineProvider = ({
children, children,
defaultColorScheme, defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { }: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const { branding } = useSettings();
const colorSchemeManager = useColorSchemeManager(); const colorSchemeManager = useColorSchemeManager();
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
const theme = createTheme({ const theme = createTheme({
colors: { colors: {
primaryColor: generateColors(board.primaryColor), primaryColor: generateColorScale(primaryColor),
secondaryColor: generateColors(board.secondaryColor), secondaryColor: generateColorScale(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"), iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
defaultRadius: "sm", defaultRadius: branding.defaultRadius,
fontSizes: { fontSizes: {
"2xl": rem(24), "2xl": rem(24),
"3xl": rem(28), "3xl": rem(28),
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
</MantineProvider> </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,5 +1,6 @@
import { IconHomeOff } from "@tabler/icons-react"; import { IconHomeOff } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema"; import { boards } from "@homarr/db/schema";
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found"; import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found"; import { BoardNotFound } from "~/components/board/not-found";
import { homarrLogoPath } from "~/components/layout/logo/constants";
export default async function NotFoundBoardHomePage() { export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync(); const boardNotFoundProps = await getPropsAsync();
@@ -19,8 +21,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const t = await getI18n("board"); const t = await getI18n("board");
if (boardCount === 0) { if (boardCount === 0) {
const { branding } = await getRscServerSettingsAsync();
return { return {
icon: { src: "/favicon.ico", alt: "Homarr logo" }, icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
title: t("error.noBoard.title"), title: t("error.noBoard.title"),
description: t("error.noBoard.description"), description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" }, link: { label: t("error.noBoard.link"), href: "/manage/boards" },
@@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui"; import { BoardColorInput } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme"; import { generateColorScale } from "~/theme/branding";
import type { FormValues } from "./_settings-form"; import type { FormValues } from "./_settings-form";
interface Props { interface Props {
@@ -40,13 +41,19 @@ export const ColorSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const theme = useMantineTheme(); const theme = useMantineTheme();
const { branding } = useSettings();
return ( return (
<SectionCard title={tBoard("setting.section.appearance.title")}> <SectionCard title={tBoard("setting.section.appearance.title")}>
<Grid> <Grid>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs"> <Stack gap="xs">
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} /> <BoardColorInput
label={tBoard("field.primaryColor.label")}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
/>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
@@ -121,7 +128,8 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000"); const colors =
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
return ( return (
<Group gap={0} wrap="nowrap"> <Group gap={0} wrap="nowrap">
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection"; import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import type { FormValues } from "./_settings-form";
@@ -21,20 +21,26 @@ interface Props {
export const GeneralSettingsContent = ({ board, form }: Props) => { export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n("board"); const t = useI18n("board");
const { branding } = useSettings();
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
useLogoPreview(form.values.logoImageUrl); useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
return ( return (
<SectionCard title={t("setting.section.general.title")}> <SectionCard title={t("setting.section.general.title")}>
<Grid> <Grid>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} /> <TextInput
label={t("field.pageTitle.label")}
placeholder={branding.appName}
{...form.getInputProps("pageTitle")}
/>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("field.metaTitle.label")} label={t("field.metaTitle.label")}
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))} placeholder={defaultMetaTitle}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />} rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
@@ -43,7 +49,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<IconPicker <IconPicker
{...form.getInputProps("logoImageUrl")} {...form.getInputProps("logoImageUrl")}
label={t("field.logoImageUrl.label")} label={t("field.logoImageUrl.label")}
placeholder="/logo/logo.png" placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false} withAsterisk={false}
/> />
</Grid.Col> </Grid.Col>
@@ -51,7 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<IconPicker <IconPicker
{...form.getInputProps("faviconImageUrl")} {...form.getInputProps("faviconImageUrl")}
label={t("field.faviconImageUrl.label")} label={t("field.faviconImageUrl.label")}
placeholder="/logo/logo.png" placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false} withAsterisk={false}
/> />
</Grid.Col> </Grid.Col>
@@ -72,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
}, [logoDebounced, updateBoard]); }, [logoDebounced, updateBoard]);
}; };
const useMetaTitlePreview = (title: string | null) => { const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced); useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
return { return {
isPending: (title ?? "") !== metaTitleDebounced, isPending: (title ?? "") !== metaTitleDebounced,
@@ -10,10 +10,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board"; import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/constants";
import { SectionCard } from "~/components/manage/section-card"; import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
@@ -88,6 +89,7 @@ interface BoardSettingsFormProps {
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => { export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n("common"); const t = useI18n("common");
const tSection = useI18n("board.setting.section"); const tSection = useI18n("board.setting.section");
const { branding } = useSettings();
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board); const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board); const saveLayouts = useSaveLayoutsMutation(board);
@@ -142,7 +144,9 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const [, canonicalLayouts] = await Promise.all([ const [, canonicalLayouts] = await Promise.all([
partialSettingsChanged partialSettingsChanged
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => { ? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(values.faviconImageUrl ?? homarrLogoPath); updateFavicon(
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
);
}) })
: Promise.resolve(), : Promise.resolve(),
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null), layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
@@ -12,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { appShellLogoHeight } from "~/components/layout/constants";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env"; import { env } from "~/env";
@@ -27,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" }); const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions?: JSX.Element;
headerBoardEditAction?: JSX.Element;
headerBoardSettingsAction?: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean; withTour?: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
headerBoardEditAction,
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour = false, withTour = false,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
@@ -105,8 +110,11 @@ export const createBoardLayout = <TParams extends Params>({
<BoardTourGate enabled={shouldRunBoardTour}> <BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions} actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false} hasNavigation={false}
/> />
<AppShellMain data-advanced-focus-background>{children}</AppShellMain> <AppShellMain data-advanced-focus-background>{children}</AppShellMain>
+52 -38
View File
@@ -27,6 +27,7 @@ import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight"; import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation"; import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
@@ -53,35 +54,45 @@ const fontSans = Inter({
const logger = createLogger({ module: "rootLayout" }); const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax export const generateMetadata = async (): Promise<Metadata> => {
export const generateMetadata = async (): Promise<Metadata> => ({ const [serverSettings, colorScheme, t] = await Promise.all([
title: "Homarr", getRscServerSettingsAsync(),
description: getCurrentColorSchemeAsync(),
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", getI18n("metadata"),
openGraph: { ]);
title: "Homarr Dashboard", const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
description: const logo = logoImageUrl ?? "/logo/logo.png";
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", const favicon = faviconImageUrl ?? logo;
url: env.HOMARR_WEBSITE_URL, const description = t("description");
siteName: "Homarr",
},
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
},
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
},
});
export const viewport: Viewport = { return {
themeColor: [ title: {
{ media: "(prefers-color-scheme: light)", color: "white" }, default: appName,
{ media: "(prefers-color-scheme: dark)", color: "black" }, template: `%s • ${appName}`,
], },
description,
openGraph: {
title: t("dashboardTitle", { appName }),
description,
url: env.HOMARR_WEBSITE_URL,
siteName: appName,
},
icons: {
icon: favicon,
apple: logo,
},
appleWebApp: {
title: appName,
capable: true,
startupImage: { url: logo },
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
},
};
};
export const generateViewport = async (): Promise<Viewport> => {
const serverSettings = await getRscServerSettingsAsync();
return { themeColor: serverSettings.branding.primaryColor };
}; };
export default async function Layout(props: { export default async function Layout(props: {
@@ -94,6 +105,7 @@ export default async function Layout(props: {
} }
const sessionPromise = auth(); const sessionPromise = auth();
const serverSettingsPromise = getRscServerSettingsAsync();
const userPromise = sessionPromise.then((session) => const userPromise = sessionPromise.then((session) =>
session session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
@@ -102,18 +114,17 @@ export default async function Layout(props: {
}) })
: null, : null,
); );
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
!session if (!session) return "unauthenticated";
? "unauthenticated" return api.assistant
: api.assistant .getAvailability()
.getAvailability() .then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.then((availability) => (availability.enabled ? "enabled" : "unconfigured")) .catch(() => "error");
.catch(() => "error"), });
);
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([ const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise, sessionPromise,
userPromise, userPromise,
getRscServerSettingsAsync(), serverSettingsPromise,
getCurrentColorSchemeAsync(), getCurrentColorSchemeAsync(),
assistantAvailabilityPromise, assistantAvailabilityPromise,
]); ]);
@@ -148,6 +159,7 @@ export default async function Layout(props: {
}, },
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId }, search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar }, user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
}} }}
{...innerProps} {...innerProps}
/> />
@@ -156,7 +168,9 @@ export default async function Layout(props: {
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />, (innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} />, (innerProps) => <NextIntlClientProvider {...innerProps} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />, (innerProps) => (
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
),
(innerProps) => <ModalProvider {...innerProps} />, (innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />, (innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />, (innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
@@ -2,11 +2,13 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui"; import { IntegrationMarquee } from "@homarr/ui";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import classes from "./hero-banner.module.css"; import classes from "./hero-banner.module.css";
export const HeroBanner = async () => { export const HeroBanner = async () => {
const t = await getI18n("management.page.home"); const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
const { appName, logoImageUrl } = serverSettings.branding;
return ( return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative"> <Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -14,9 +16,17 @@ export const HeroBanner = async () => {
<Title fz={{ base: "h4", md: "h2" }} c="dimmed"> <Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")} {t("heroBanner.title")}
</Title> </Title>
<Group gap="xs" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} /> <Image
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title> 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> </Group>
</Stack> </Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute"> <Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
@@ -37,9 +37,8 @@ import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/constants";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader"; import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json"; import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json"; import openCollectiveContributors from "@static-data/opencollective-contributors.json";
@@ -50,7 +49,7 @@ export async function generateMetadata() {
const t = await getI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getI18n("management.page.settings.section.assistant"); const t = await getI18n("management.page.settings.section.assistant");
return { return {
title: `${t("title")} • Homarr`, title: t("title"),
}; };
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useMemo } from "react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
@@ -12,39 +13,38 @@ type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) { export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor"); const t = useI18n("customWidget.editor");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
return ( const messages = useMemo<SharedEditorProps["messages"]>(
<CustomWidgetCodeEditor () => ({
{...props} languageJsx: invariantTechnicalLabels.jsx,
messages={{ languageJson: invariantTechnicalLabels.json,
languageJsx: invariantTechnicalLabels.jsx, undo: tCommon("action.undo"),
languageJson: invariantTechnicalLabels.json, redo: t("action.redo"),
undo: tCommon("action.undo"), components: t("action.components"),
redo: t("action.redo"), componentSearch: t("componentReference.search"),
components: t("action.components"), componentEmpty: t("componentReference.empty"),
componentSearch: t("componentReference.search"), componentCount: (count) => t("componentReference.count", { count }),
componentEmpty: t("componentReference.empty"), insertStarter: t("action.insertStarter"),
componentCount: (count) => t("componentReference.count", { count }), format: t("action.format"),
insertStarter: t("action.insertStarter"), copy: tCommon("action.copy"),
format: t("action.format"), copied: t("action.copied"),
copy: tCommon("action.copy"), schema: t("action.schema"),
copied: t("action.copied"), schemaTab: invariantTechnicalLabels.jsonSchema,
schema: t("action.schema"), minimalTab: t("reference.minimal"),
schemaTab: invariantTechnicalLabels.jsonSchema, fullTab: t("reference.full"),
minimalTab: t("reference.minimal"), errors: (count) => t("status.errors", { count }),
fullTab: t("reference.full"), warnings: (count) => t("status.warnings", { count }),
errors: (count) => t("status.errors", { count }), ready: t("status.ready"),
warnings: (count) => t("status.warnings", { count }), position: (cursor) => t("status.position", cursor),
ready: t("status.ready"), characters: (count, limit) =>
position: (cursor) => t("status.position", cursor), limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
characters: (count, limit) => diagnosticsTitle: t("diagnostics.title"),
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), diagnostic: (diagnostic) =>
diagnosticsTitle: t("diagnostics.title"), `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
diagnostic: (diagnostic) => `diagnostics.${diagnostic.code}` as never,
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( { value: diagnostic.value ?? "" } as never,
`diagnostics.${diagnostic.code}` as never, )}`,
{ value: diagnostic.value ?? "" } as never, }),
)}`, [t, tCommon],
}}
/>
); );
return <CustomWidgetCodeEditor {...props} messages={messages} />;
} }
@@ -3,37 +3,70 @@
import { ActionIcon, Group, Tooltip } from "@mantine/core"; import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconCopy, IconRobot } from "@tabler/icons-react"; import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { buildCustomWidgetAiPrompt, buildCustomWidgetAssistantPrompt } from "@homarr/custom-widgets/authoring-prompt";
import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonProps { interface CopyAiPromptButtonBaseProps {
rawResponse?: string | null; rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
request?: string | null; request?: string | null;
documentationUrl?: 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 = ({ export const CopyAiPromptButton = ({
rawResponse, rawResponse,
currentConfig, draft,
getDraft,
diagnostics,
getDiagnostics,
request, request,
documentationUrl, documentationUrl,
}: CopyAiPromptButtonProps) => { }: CopyAiPromptButtonProps) => {
const t = useI18n("customWidget"); const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant(); const assistant = useOptionalHomarrAssistant();
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const handleAssistant = () => { const handleAssistant = () => {
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return; const prompt = buildCustomWidgetAssistantPrompt(
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
}; };
const handleCopy = async () => { const handleCopy = async () => {
try { try {
const value = prompt(); const value = buildCustomWidgetAiPrompt(
undefined,
rawResponse,
readDraft(),
request,
documentationUrl,
readDiagnostics(),
);
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
showSuccessNotification({ showSuccessNotification({
title: t("action.copyAiPrompt"), title: t("action.copyAiPrompt"),
@@ -1,27 +1,38 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor"; import { CodeEditor } from "./_code-editor";
import { useDeferredCustomWidgetFormDocumentValues } from "./_custom-widget-form-state";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils"; import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } 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 }) { export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useI18n("customWidget.workbench.builder"); 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( const serialized = JSON.stringify(
{ {
$schema: "homarr-custom-widget-v2", $schema: "homarr-custom-widget-v2",
name: form.values.name, name: values.name,
...(form.values.description ? { description: form.values.description } : {}), ...(values.description ? { description: values.description } : {}),
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}), ...(values.iconUrl ? { iconUrl: values.iconUrl } : {}),
sources: parseJson(form.values.sources), sources: parseJson(values.sources),
requests: parseJson(form.values.requests), requests: parseJson(values.requests),
options: parseJson(form.values.options), options: parseJson(values.options),
template: form.values.template, template: values.template,
}, },
null, null,
2, 2,
@@ -30,33 +41,26 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]); useEffect(() => setDraft(serialized), [serialized]);
return ( return (
<Accordion variant="contained"> <CodeEditor
<Accordion.Item value="manifest"> id="raw-widget-manifest"
<Accordion.Control>{t("advancedManifest")}</Accordion.Control> label={t("manifestFilename")}
<Accordion.Panel> language="json"
<CodeEditor value={draft}
id="raw-widget-manifest" error={error}
label={t("manifestFilename")} onChange={(next) => {
language="json" setDraft(next);
value={draft} try {
error={error} const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
onChange={(next) => { if (!parsed.success) {
setDraft(next); setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
try { return;
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown); }
if (!parsed.success) { setError(undefined);
setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); applyDefinition(form, parsed.data);
return; } catch (cause) {
} setError(cause instanceof Error ? cause.message : t("invalidJson"));
setError(undefined); }
applyDefinition(form, parsed.data); }}
} catch (cause) { />
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
); );
} }
@@ -3,13 +3,14 @@
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core"; import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react"; import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { CustomWidgetAiDiagnostic, CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button"; import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
interface AiCardProps { interface AiCardProps {
candidate: HomarrCustomWidgetV2 | null; getDraft(): CustomWidgetAiDraft;
getDiagnostics(): readonly CustomWidgetAiDiagnostic[];
request: string; request: string;
onRequestChange(value: string): void; onRequestChange(value: string): void;
documentationUrl: string; documentationUrl: string;
@@ -27,14 +28,15 @@ export function CustomWidgetAiCard(props: AiCardProps) {
{t("title")} {t("title")}
</Accordion.Control> </Accordion.Control>
<CopyAiPromptButton <CopyAiPromptButton
currentConfig={props.candidate} getDraft={props.getDraft}
getDiagnostics={props.getDiagnostics}
request={props.request} request={props.request}
documentationUrl={props.documentationUrl} documentationUrl={props.documentationUrl}
/> />
</Group> </Group>
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="dimmed"> <Text size="sm" c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
{t("description")} {t("description")}
</Text> </Text>
<Textarea <Textarea
@@ -2,7 +2,10 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) { export function createCustomWidgetCompletions(
values: Pick<CustomWidgetFormValues, "options" | "sources">,
requestIds: string[],
) {
const options = parseJson(values.options); const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {}); const optionKeys = Object.keys(isRecord(options) ? options : {});
return [ return [
@@ -1,14 +1,40 @@
"use client"; "use client";
import type { ReactNode } from "react"; import { useEffect, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { Icon } from "@tabler/icons-react"; import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react"; import {
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconBraces,
IconCheck,
IconCode,
IconDatabase,
IconEye,
IconPlayerPlay,
IconSettings,
} from "@tabler/icons-react";
import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client"; import { 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"; 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({ export function EditorSection({
id, id,
title, title,
@@ -84,3 +110,83 @@ export function SaveActions({
</Group> </Group>
); );
} }
export function CustomWidgetSectionNavigation({
onSelect,
}: {
onSelect(section: (typeof sectionLinks)[number][0]): void;
}) {
const t = useI18n("customWidget.workbench");
const invalidSections = useCustomWidgetFormAnalysisField("invalidSections");
return (
<nav className={classes.sectionNav} aria-label={t("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => {
const invalid = invalidSections.has(id);
return (
<Button
key={id}
component="a"
href={`#${id}`}
onClick={() => onSelect(id)}
size="compact-sm"
variant="subtle"
color={invalid ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={invalid ? <IconAlertCircle size={13} aria-label={t("status.invalid")} /> : undefined}
>
{t(key)}
</Button>
);
})}
</nav>
);
}
export function CustomWidgetSaveActions({
mode,
savePending,
previewPending,
onPreview,
}: {
mode: "create" | "edit";
savePending: boolean;
previewPending: boolean;
onPreview(): void;
}) {
const dirty = useCustomWidgetFormDocumentDirty();
const candidateValid = useCustomWidgetFormAnalysisField("candidateValid");
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
return (
<SaveActions
dirty={dirty}
savePending={savePending}
previewPending={previewPending}
invalid={hasDiagnostics || !candidateValid}
mode={mode}
onPreview={onPreview}
/>
);
}
export function CustomWidgetUnsavedChangesGuard() {
const dirty = useCustomWidgetFormDocumentDirty();
useUnsavedChangesGuard(dirty);
return null;
}
export function CustomWidgetOptionsSnapshotSync({
setOptionsSnapshot,
}: {
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
}) {
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
const lastDefaults = useRef<Record<string, unknown> | null>(null);
useEffect(() => {
if (!parsedOptions.success) return;
const defaults = getCustomWidgetDefaultOptions(parsedOptions.data);
if (lastDefaults.current && areCustomWidgetValuesEqual(lastDefaults.current, defaults)) return;
lastDefaults.current = defaults;
setOptionsSnapshot(defaults);
}, [parsedOptions, setOptionsSnapshot]);
return null;
}
@@ -0,0 +1,222 @@
"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}
/>
);
}
@@ -0,0 +1,142 @@
"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 setValues = vi.fn();
const result = applyCustomWidgetAiResponse( const result = applyCustomWidgetAiResponse(
{ {
values: { getValues: () => ({
name: "", name: "",
description: "", description: "",
iconUrl: "", iconUrl: "",
@@ -37,7 +37,7 @@ describe("Custom Widget workbench preview options", () => {
options: "{}", options: "{}",
template: "", template: "",
secrets: [], secrets: [],
}, }),
setValues, setValues,
} as never, } as never,
`\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``, `\`\`\`json\n${JSON.stringify(definition)}\n\`\`\``,
@@ -84,13 +84,6 @@ describe("Custom Widget workbench preview options", () => {
it("normalizes only changed secrets", () => { it("normalizes only changed secrets", () => {
expect( expect(
getChangedSecrets({ getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "<Text />",
secrets: [ secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true }, { sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" }, { sourceId: "other", kind: "password", value: "replacement" },
@@ -12,6 +12,8 @@ import type { UseFormReturnType } from "@mantine/form";
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>; export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
const PREVIEW_QUERY_CONCURRENCY = 4;
export function buildDefinition(values: CustomWidgetFormValues) { export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({ return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2", $schema: "homarr-custom-widget-v2",
@@ -25,7 +27,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
}); });
} }
export function getChangedSecrets(values: CustomWidgetFormValues) { export function getChangedSecrets(values: Pick<CustomWidgetFormValues, "secrets">) {
return values.secrets return values.secrets
.filter((secret) => secret.value.trim()) .filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({ .map(({ sourceId, kind, value }) => ({
@@ -46,7 +48,7 @@ export function filterSecretsForSourceAuthentication(
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) { export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({ form.setValues({
...form.values, ...form.getValues(),
name: widget.name, name: widget.name,
description: widget.description ?? "", description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "", iconUrl: widget.iconUrl ?? "",
@@ -102,25 +104,43 @@ export function isRuntimeParams(
} }
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) { export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const data: Record<string, unknown> = {}; const requests = Object.entries(definition.requests).filter(
const status: Record<string, unknown> = {};
for (const [requestId] of Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load", ([, entry]) => entry.kind === "query" && entry.trigger === "load",
)) { );
try { const results: Array<{
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} }); requestId: string;
data[requestId] = result.data; data: unknown;
status[requestId] = { loading: false, ...result }; status: Record<string, unknown>;
} catch (error) { }> = [];
data[requestId] = null; let nextIndex = 0;
status[requestId] = { const worker = async () => {
loading: false, while (true) {
ok: false, const index = nextIndex;
error: error instanceof Error ? error.message : "Request failed", 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",
},
};
}
} }
} };
return { data, status }; await Promise.all(Array.from({ length: Math.min(PREVIEW_QUERY_CONCURRENCY, requests.length) }, () => worker()));
return {
data: Object.fromEntries(results.map((result) => [result.requestId, result.data])),
status: Object.fromEntries(results.map((result) => [result.requestId, result.status])),
};
} }
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) { export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
@@ -1,45 +1,45 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core"; import type { Dispatch, FormEventHandler, SetStateAction } from "react";
import { import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
IconAlertCircle,
IconApi,
IconBraces,
IconCode,
IconDatabase,
IconEye,
IconSettings,
} from "@tabler/icons-react";
import { import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
analyzeJsxTemplate, import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
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 type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client"; 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 { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis"; import {
import { EditorSection, SaveActions } from "./_custom-widget-form-layout"; CustomWidgetOptionsSnapshotSync,
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references"; CustomWidgetSaveActions,
import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } from "./_custom-widget-form-utils"; CustomWidgetSectionNavigation,
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor"; CustomWidgetUnsavedChangesGuard,
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel"; } 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 type { PreviewState } 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 { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert"; import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions"; import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard"; import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
import classes from "./_custom-widget-form.module.css"; import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps { interface CustomWidgetFormProps {
@@ -47,241 +47,204 @@ interface CustomWidgetFormProps {
initialValues?: Partial<CustomWidgetFormValues>; initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string; definitionId?: string;
} }
const sectionLinks = [
["general", "section.general", IconSettings], type MobilePane = "configure" | "preview";
["sources", "section.sources", IconApi],
["requests", "section.requests", IconDatabase], interface CustomWidgetFormViewProps {
["options", "section.options", IconBraces], mode: "create" | "edit";
["jsx", "section.jsx", IconCode], definitionId?: string;
["preview", "section.preview", IconEye], form: CustomWidgetWorkbenchForm;
] as const; 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>
);
});
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) { export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const t = useI18n("customWidget"); const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
const tCommon = useI18n("common"); ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
const w = useI18n("customWidget.workbench"); ...initialValues,
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues }; }));
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues }); const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure"); const mantineForm = useZodForm(customWidgetFormSchema, {
const [request, setRequest] = useState(""); initialValues: formInitialValues,
const [documentationUrl, setDocumentationUrl] = useState(""); mode: "uncontrolled",
validateDebounce: 300,
validateInputOnChange: false,
onValuesChange: documentStore.setValues,
});
const form = useCustomWidgetFormDocumentBridge(mantineForm, documentStore);
const [mobilePane, setMobilePane] = useState<MobilePane>("configure");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" }); const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard"); const [previewSize, setPreviewSize] = useState("standard");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => { const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues); const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {}; return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
}); });
useUnsavedChangesGuard(form.isDirty()); const actions = useCustomWidgetFormActions({
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, mode,
definitionId, definitionId,
form, form,
candidate, documentStore,
preview,
setPreview, setPreview,
setMobilePane, setMobilePane,
optionsSnapshot, optionsSnapshot,
setOptionsSnapshot, setOptionsSnapshot,
}); });
useEffect(() => { const latestActions = useRef(actions);
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data)); useLayoutEffect(() => {
}, [parsedOptions]); 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(), []);
const invalid = hasDiagnostics || !candidate.success;
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
form,
candidate,
invalidWidgetMessage: w("invalidWidget"),
});
return ( return (
<form onSubmit={save} className={classes.form}> <CustomWidgetFormView
<SegmentedControl mode={mode}
className={classes.paneSwitcher} definitionId={definitionId}
fullWidth form={form}
value={mobilePane} formErrors={mantineForm.errors}
onChange={(value) => setMobilePane(value as typeof mobilePane)} formRevision={mantineForm.key("name")}
data={[ documentStore={documentStore}
{ value: "configure", label: w("configure") }, mobilePane={mobilePane}
{ value: "preview", label: w("section.preview") }, setMobilePane={setMobilePane}
]} preview={preview}
/> setPreview={setPreview}
<CustomWidgetSaveIssuesAlert issues={saveIssues} /> previewSize={previewSize}
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}> setPreviewSize={setPreviewSize}
{sectionLinks.map(([id, key, SectionIcon]) => ( optionsSnapshot={optionsSnapshot}
<Button setOptionsSnapshot={setOptionsSnapshot}
key={id} saveIssues={actions.saveIssues}
component="a" savePending={actions.savePending}
href={`#${id}`} previewPending={actions.previewPending}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")} onSubmit={handleSubmit}
size="compact-sm" onPreview={handlePreview}
variant="subtle" onPasteAiResponse={handlePasteAiResponse}
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 ( return (
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}> <Fieldset key={id} legend={typeof option.label === "string" ? option.label : id}>
<Stack gap="sm"> <Stack gap="sm">
<Group grow align="start"> <Group grow align="start">
<CustomWidgetIdentifierInput <CustomWidgetIdentifierInput
@@ -99,7 +99,7 @@ export function CustomWidgetOptionsEditor({
} }
/> />
<DefaultValueEditor <DefaultValueEditor
id={`${index}-default`} id={`${id}-default`}
control={control} control={control}
value={option.default} value={option.default}
choices={choices} choices={choices}
@@ -6,6 +6,8 @@ interface PreviewSnapshot {
session: { id: string; liveActions: boolean } | null; session: { id: string; liveActions: boolean } | null;
} }
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
export function createPreviewDisplayData({ export function createPreviewDisplayData({
candidate, candidate,
fixture, fixture,
@@ -13,7 +15,7 @@ export function createPreviewDisplayData({
options, options,
fixtureError, fixtureError,
}: { }: {
candidate: HomarrCustomWidgetV2 | null; candidate: PreviewCandidate | null;
fixture: "live" | "loading" | "empty" | "error"; fixture: "live" | "loading" | "empty" | "error";
preview: PreviewSnapshot; preview: PreviewSnapshot;
options: Record<string, unknown>; options: Record<string, unknown>;
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { memo, useMemo, useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core"; import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -15,9 +15,12 @@ import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-wi
import type { PreviewOutcome } from "./_custom-widget-preview-status"; import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action"; import { PreviewActionControl } from "./_custom-widget-preview-action";
import { createPreviewDisplayData } from "./_custom-widget-preview-data"; import { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
import { JsonPreviewEditor } from "./_json-preview-editor"; import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css"; import classes from "./_custom-widget-form.module.css";
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
export interface PreviewState { export interface PreviewState {
data: Record<string, unknown>; data: Record<string, unknown>;
status: Record<string, unknown>; status: Record<string, unknown>;
@@ -36,7 +39,7 @@ interface PreviewPanelProps {
onLiveActionsChange(enabled: boolean): void; onLiveActionsChange(enabled: boolean): void;
} }
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview"); const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary"); const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action"); const actionT = useI18n("common.action");
@@ -46,26 +49,55 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
{ sessionId: props.preview.session?.id ?? "" }, { sessionId: props.preview.session?.id ?? "" },
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false }, { 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 candidate = props.candidate;
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : []; const candidateTemplate = candidate?.template;
const displayData = createPreviewDisplayData({ const candidateRequests = candidate?.requests;
candidate, const previewCandidate = useMemo(() => {
fixture, if (candidateTemplate === undefined || candidateRequests === undefined) return null;
preview: props.preview, return { template: candidateTemplate, requests: candidateRequests };
options: props.optionsSnapshot, }, [candidateRequests, candidateTemplate]);
fixtureError: t("fixtureError"), const previewSnapshot = useMemo(
}); () => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }),
const previewSummary = getPreviewSummary(props.preview.status); [props.preview.data, props.preview.session, props.preview.status],
const rendererResetKey = JSON.stringify({ );
template: candidate?.template, const fixtureError = t("fixtureError");
requests: candidate?.requests, const accessibilityIssues = useMemo(() => {
fixture, if (candidateTemplate === undefined) return [];
data: props.preview.data, return analyzeCustomWidgetAccessibility(candidateTemplate);
status: props.preview.status, }, [candidateTemplate]);
sessionId: props.preview.session?.id, const displayData = useMemo(
options: props.optionsSnapshot, () =>
}); 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 previewResult = const previewResult =
props.preview.outcome === "success" props.preview.outcome === "success"
? { ? {
@@ -85,37 +117,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
<Card withBorder p="md"> <Card withBorder p="md">
<Stack gap="md"> <Stack gap="md">
<Group gap="xs" justify="space-between"> <Group gap="xs" justify="space-between">
<SegmentedControl <CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
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 <SegmentedControl
size="xs" size="xs"
value={fixture} value={fixture}
@@ -151,7 +153,14 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="widget" pt="sm"> <Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}> <Box className={classes.previewCanvas}>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}> <Paper
withBorder
p="sm"
h={360}
w={previewWidths[props.size] ?? 480}
maw="100%"
style={{ overflow: "auto" }}
>
{displayData ? ( {displayData ? (
<PreviewErrorBoundary <PreviewErrorBoundary
title={errorBoundaryMessages("title")} title={errorBoundaryMessages("title")}
@@ -264,3 +273,5 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Card> </Card>
); );
} }
export const CustomWidgetPreviewPanel = memo(CustomWidgetPreviewPanelContent);
@@ -0,0 +1,56 @@
"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,34 +1,35 @@
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench"; import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
import { applyDefinition } from "./_custom-widget-form-utils"; import { applyDefinition, buildDefinition } from "./_custom-widget-form-utils";
import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput { interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm; form: CustomWidgetWorkbenchForm;
candidate: ReturnType<typeof buildDefinition>;
invalidWidgetMessage: string; invalidWidgetMessage: string;
} }
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) { export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
const renameRequest = (currentId: string, nextId: string) => { const renameRequest = (currentId: string, nextId: string) => {
if (!input.candidate.success) { const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage); input.form.setFieldError("requests", input.invalidWidgetMessage);
return; return;
} }
try { try {
applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId)); applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
input.form.clearFieldError("requests"); input.form.clearFieldError("requests");
} catch (error) { } catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage); input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
} }
}; };
const renameOption = (currentName: string, nextName: string) => { const renameOption = (currentName: string, nextName: string) => {
if (!input.candidate.success) { const candidate = buildDefinition(input.form.getValues());
if (!candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage); input.form.setFieldError("options", input.invalidWidgetMessage);
return; return;
} }
try { try {
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName)); applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
input.form.clearFieldError("options"); input.form.clearFieldError("options");
} catch (error) { } catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage); 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 t = useI18n("customWidget.workbench.builder");
const entries = Object.entries(value); const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<number, string>>({}); const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -65,23 +65,23 @@ export function QueryValuesEditor({
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []), ...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
]; ];
return ( return (
<Group key={index} align="end" wrap="wrap"> <Group key={name} align="end" wrap="wrap">
<CustomWidgetIdentifierInput <CustomWidgetIdentifierInput
style={{ flex: "1 1 10rem" }} style={{ flex: "1 1 10rem" }}
label={t("queryKey")} label={t("queryKey")}
value={name} value={name}
error={nameErrors[index]} error={nameErrors[name]}
onCommit={(nextName) => { onCommit={(nextName) => {
if ( if (
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) || !/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName) entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
) { ) {
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") })); setNameErrors((currentErrors) => ({ ...currentErrors, [name]: t("queryKeyInvalid") }));
return; return;
} }
setNameErrors((currentErrors) => { setNameErrors((currentErrors) => {
const nextErrors = { ...currentErrors }; const nextErrors = { ...currentErrors };
delete nextErrors[index]; delete nextErrors[name];
return nextErrors; return nextErrors;
}); });
commit(nextName, kind, current); commit(nextName, kind, current);
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core"; import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -9,6 +9,7 @@ import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors"; import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input"; import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
import { LazyOnceAccordion } from "./_lazy-once-accordion";
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -24,6 +25,8 @@ export function CustomWidgetRequestsEditor({
const entries = isRecord(parsed) ? Object.entries(parsed) : []; const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources); const sources = parseJson(form.values.sources);
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"]; 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]>) => const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2)); form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
@@ -50,7 +53,7 @@ export function CustomWidgetRequestsEditor({
const kind = request.kind === "action" ? "action" : "query"; const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load"; const trigger = request.trigger === "manual" ? "manual" : "load";
return ( return (
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}> <Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
<Stack gap="sm"> <Stack gap="sm">
<Group grow align="start"> <Group grow align="start">
<CustomWidgetIdentifierInput <CustomWidgetIdentifierInput
@@ -119,71 +122,61 @@ export function CustomWidgetRequestsEditor({
<TextInput <TextInput
label={t("path")} label={t("path")}
description={t("pathDescription")} 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 : ""} value={typeof request.path === "string" ? request.path : ""}
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })} onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
/> />
<Accordion variant="contained"> <LazyOnceAccordion label={t("requestDetails")}>
<Accordion.Item value="values"> <Stack gap="sm">
<Accordion.Control>{t("requestDetails")}</Accordion.Control> <QueryValuesEditor
<Accordion.Panel> value={isRecord(request.query) ? request.query : {}}
<Stack gap="sm"> optionNames={optionNames}
<QueryValuesEditor allowParams={trigger === "manual" || kind === "action"}
value={isRecord(request.query) ? request.query : {}} onChange={(query) => update(index, id, { ...request, query })}
optionNames={Object.keys( />
isRecord(parseJson(form.values.options)) <RequestBodyEditor
? (parseJson(form.values.options) as Record<string, unknown>) id={id}
: {}, value={request.body}
)} optionNames={optionNames}
allowParams={trigger === "manual" || kind === "action"} allowParams={trigger === "manual" || kind === "action"}
onChange={(query) => update(index, id, { ...request, query })} onChange={(body) => update(index, id, { ...request, body })}
/> />
<RequestBodyEditor {kind === "action" && (
id={id} <TextInput
value={request.body} label={t("confirmation")}
optionNames={Object.keys( value={typeof request.confirmation === "string" ? request.confirmation : ""}
isRecord(parseJson(form.values.options)) onChange={(event) =>
? (parseJson(form.values.options) as Record<string, unknown>) update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
: {}, }
)} />
allowParams={trigger === "manual" || kind === "action"} )}
onChange={(body) => update(index, id, { ...request, body })} {kind === "action" && (
/> <TextInput
{kind === "action" && ( label={t("invalidation")}
<TextInput description={t("invalidationDescription")}
label={t("confirmation")} value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
value={typeof request.confirmation === "string" ? request.confirmation : ""} onChange={(event) =>
onChange={(event) => update(index, id, {
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined }) ...request,
} invalidates: event.currentTarget.value
/> .split(",")
)} .map((value) => value.trim())
{kind === "action" && ( .filter(Boolean),
<TextInput })
label={t("invalidation")} }
description={t("invalidationDescription")} />
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""} )}
onChange={(event) => <Switch
update(index, id, { label={t("noAuth")}
...request, checked={request.auth === "none"}
invalidates: event.currentTarget.value onChange={(event) =>
.split(",") update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
.map((value) => value.trim()) }
.filter(Boolean), />
}) </Stack>
} </LazyOnceAccordion>
/>
)}
<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 justify="space-between">
<Group gap="xs"> <Group gap="xs">
<ActionIcon <ActionIcon
@@ -0,0 +1,37 @@
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);
}
}
@@ -0,0 +1,18 @@
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);
}
@@ -0,0 +1,44 @@
"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"; "use client";
import { useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import type { UseFormReturnType } from "@mantine/form"; import type { UseFormReturnType } from "@mantine/form";
@@ -22,13 +22,14 @@ import {
import type { PreviewState } from "./_custom-widget-preview-panel"; import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors"; import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } 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 { interface FormActionsInput {
mode: "create" | "edit"; mode: "create" | "edit";
definitionId?: string; definitionId?: string;
form: UseFormReturnType<CustomWidgetFormValues>; form: UseFormReturnType<CustomWidgetFormValues>;
candidate: ReturnType<typeof buildDefinition>; documentStore: CustomWidgetFormDocumentStore;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>; setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>; setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
optionsSnapshot: Record<string, unknown>; optionsSnapshot: Record<string, unknown>;
@@ -45,6 +46,34 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const previewMutation = clientApi.customWidget.previewCreate.useMutation(); const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]); const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false); 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"); let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create"); if (input.mode === "create") actionLabel = tCommon("action.create");
@@ -73,6 +102,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}); });
input.form.setInitialValues(values); input.form.setInitialValues(values);
input.form.resetDirty(); input.form.resetDirty();
input.documentStore.markSaved(values);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
window.location.assign(result.managementPath); window.location.assign(result.managementPath);
} else if (input.definitionId) { } else if (input.definitionId) {
@@ -88,6 +118,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
]); ]);
input.form.setInitialValues(values); input.form.setInitialValues(values);
input.form.resetDirty(); input.form.resetDirty();
input.documentStore.markSaved(values);
showSuccessNotification({ showSuccessNotification({
title: tCommon("action.save"), title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }), message: t("notification.updated", { name: values.name }),
@@ -104,15 +135,18 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}); });
const runPreview = async () => { const runPreview = async () => {
if (previewPending) return; if (activePreviewGeneration.current !== null) return;
if (!input.candidate.success) { const valuesAtStart = input.documentStore.getValues();
const candidateAtStart = buildDefinition(valuesAtStart);
if (!candidateAtStart.success) {
showErrorNotification({ showErrorNotification({
title: w("section.preview"), title: w("section.preview"),
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"), message: candidateAtStart.error.issues[0]?.message ?? w("invalidWidget"),
}); });
return; return;
} }
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot); const optionsAtStart = input.optionsSnapshot;
const optionIssues = getCustomWidgetPreviewOptionIssues(candidateAtStart.data, optionsAtStart);
if (optionIssues.length > 0) { if (optionIssues.length > 0) {
const issue = optionIssues[0]; const issue = optionIssues[0];
showErrorNotification({ showErrorNotification({
@@ -121,17 +155,24 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}); });
return; 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.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" }); input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true); setPreviewPending(true);
try { try {
const created = await previewMutation.mutateAsync({ const created = await previewMutation.mutateAsync({
definition: input.candidate.data, definition: candidateAtStart.data,
definitionId: input.definitionId, definitionId: input.definitionId,
options: input.optionsSnapshot, options: optionsAtStart,
secrets: getChangedSecrets(input.form.values), secrets: secretsAtStart,
}); });
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id); if (!isCurrent()) return;
const snapshot = await loadPreviewQueries(candidateAtStart.data, created.previewSession.id);
if (!isCurrent()) return;
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length; const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({ input.setPreview({
...snapshot, ...snapshot,
@@ -156,10 +197,15 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
}); });
} }
} catch { } catch {
if (!isCurrent()) return;
input.setPreview((current) => ({ ...current, outcome: "error" })); input.setPreview((current) => ({ ...current, outcome: "error" }));
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") }); showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
} finally { } finally {
setPreviewPending(false); if (activePreviewGeneration.current === generation) {
activePreviewGeneration.current = null;
setPreviewPending(false);
if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
}
} }
}; };
@@ -170,7 +216,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
throw new Error(formatCustomWidgetImportIssues(result.issues)); throw new Error(formatCustomWidgetImportIssues(result.issues));
} }
input.setOptionsSnapshot(getDefinitionDefaults(result.widget)); input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({ showSuccessNotification({
title: w("ai.response"), title: w("ai.response"),
message: message:
@@ -196,33 +241,3 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
previewPending, 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);
}
}
@@ -0,0 +1,188 @@
"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> <Stack>
<div> <div>
<Title>{t("page.create.title")}</Title> <Title>{t("page.create.title")}</Title>
<Text c="dimmed" size="sm" mt={4}> <Text c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" size="sm" mt={4}>
{t("page.create.subtitle")} {t("page.create.subtitle")}
</Text> </Text>
</div> </div>
@@ -11,7 +11,6 @@ import { useWorkshopQuery } from "@homarr/workshop/backend";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session"; import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid"; import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control"; import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
@@ -23,9 +22,10 @@ interface WorkshopBrowseProps {
search: string | undefined; search: string | undefined;
sort: "top" | "newest"; sort: "top" | "newest";
page: number; page: number;
workshopWebUrl: string;
} }
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) { export function WorkshopBrowse({ search, sort, page, workshopWebUrl }: WorkshopBrowseProps) {
const t = useI18n("workshop"); const t = useI18n("workshop");
const tCommon = useI18n("common.action"); const tCommon = useI18n("common.action");
const session = useWorkshopSession(); const session = useWorkshopSession();
@@ -46,7 +46,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
<WorkshopAccountButton session={session} /> <WorkshopAccountButton session={session} />
<Button <Button
component="a" component="a"
href={getWorkshopWebUrl()} href={workshopWebUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
variant="default" variant="default"
@@ -100,7 +100,7 @@ export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
<Tooltip label={t("openCommunity")}> <Tooltip label={t("openCommunity")}>
<ActionIcon <ActionIcon
component="a" component="a"
href={getWorkshopWebUrl(item.id)} href={`${workshopWebUrl.replace(/\/+$/u, "")}/${encodeURIComponent(item.id)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
variant="subtle" variant="subtle"
@@ -3,6 +3,9 @@ import { z } from "zod/v4";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { env } from "~/env";
import { WorkshopBrowse } from "./_workshop-browse"; import { WorkshopBrowse } from "./_workshop-browse";
@@ -26,6 +29,13 @@ export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps)
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login"); if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams); 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} />; return (
<WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} workshopWebUrl={workshopWebUrl} />
);
} }
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { homarrLogoPath } from "~/components/layout/logo/constants";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
@@ -74,6 +74,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"), label: t("items.home"),
icon: IconHomeFilled, icon: IconHomeFilled,
href: "/manage", href: "/manage",
exact: true,
"data-onboarding-tour-id": "manage-welcome", "data-onboarding-tour-id": "manage-welcome",
}, },
{ {
+1 -2
View File
@@ -7,14 +7,13 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner"; import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -0,0 +1,255 @@
"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,12 +12,14 @@ import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { brandingServerSettingsSchema } from "@homarr/server-settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings"; import { AnalyticsSettings } from "./analytics.settings";
import { AppearanceSettingsForm } from "./appearance-settings-form"; import { AppearanceSettingsForm } from "./appearance-settings-form";
import { BoardSettingsForm } from "./board-settings-form"; import { BoardSettingsForm } from "./board-settings-form";
import { BrandingSettingsForm } from "./branding-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings"; import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form"; import { CultureSettingsForm } from "./culture-settings-form";
import { SearchSettingsForm } from "./search-settings-form"; import { SearchSettingsForm } from "./search-settings-form";
@@ -37,6 +39,7 @@ const settingsFormSchema = z.object({
defaultSearchEngineId: z.string().nullable(), defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes), defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(), defaultLocale: z.string(),
branding: brandingServerSettingsSchema,
}); });
export type FormValues = z.infer<typeof settingsFormSchema>; export type FormValues = z.infer<typeof settingsFormSchema>;
@@ -55,6 +58,7 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
defaultSearchEngineId: initialData.search.defaultSearchEngineId, defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme, defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale, defaultLocale: initialData.culture.defaultLocale,
branding: initialData.branding,
}); });
interface SettingsFormProps { interface SettingsFormProps {
@@ -137,6 +141,11 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
value: { defaultColorScheme: values.defaultColorScheme }, value: { defaultColorScheme: values.defaultColorScheme },
}, },
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } }, { settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
{
settingsKey: "branding",
when: changed("branding"),
value: values.branding,
},
]; ];
const promises = groups const promises = groups
@@ -176,6 +185,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
<UserSettingsForm form={form} /> <UserSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} /> <SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} /> <AppearanceSettingsForm form={form} />
<BrandingSettingsForm form={form} />
<CultureSettingsForm form={form} /> <CultureSettingsForm form={form} />
<AnalyticsSettings form={form} /> <AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} /> <CrawlingAndIndexingSettings form={form} />
@@ -9,10 +9,9 @@ import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getI18n("management"); const t = await getI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: metaTitle, title: t("metaTitle"),
}; };
} }
@@ -9,7 +9,6 @@ import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common"; import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys"; import { ApiKeysManagement } from "./components/api-keys";
@@ -63,7 +62,7 @@ export async function generateMetadata() {
const t = await getI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -5,7 +5,6 @@ import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card"; import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card"; import { BackupImportCard } from "./_components/backup-import-card";
@@ -19,7 +18,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.tool.backup"); const t = await getI18n("management.page.tool.backup");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -11,7 +11,6 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client"; import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider"; import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection"; import { LogLevelSelection } from "./level-selection";
@@ -25,7 +24,7 @@ export async function generateMetadata() {
const t = await getI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -6,7 +6,6 @@ import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { TasksTable } from "./_components/tasks-table"; import { TasksTable } from "./_components/tasks-table";
export async function generateMetadata() { export async function generateMetadata() {
@@ -17,7 +16,7 @@ export async function generateMetadata() {
const t = await getI18n("management"); const t = await getI18n("management");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -1,7 +1,9 @@
"use client"; "use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { import {
Box,
Button, Button,
Card, Card,
Divider, Divider,
@@ -22,12 +24,15 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { import {
headerPreferencesSchema,
parseHeaderPreferences,
userChangeHomeBoardsSchema, userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema, userChangeSearchPreferencesSchema,
userEditProfileSchema, userEditProfileSchema,
@@ -36,9 +41,9 @@ import {
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
} from "@homarr/validation/user"; } from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select"; import { BoardSelect } from "~/components/board/board-select";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HeaderComposer } from "./header-composer";
dayjs.extend(localeData); dayjs.extend(localeData);
@@ -53,6 +58,7 @@ const userGeneralSettingsSchema = z.object({
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets, enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
headerPreferences: headerPreferencesSchema,
}); });
type FormValues = z.infer<typeof userGeneralSettingsSchema>; type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -65,9 +71,10 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
interface UserGeneralSettingsFormProps { interface UserGeneralSettingsFormProps {
user: RouterOutputs["user"]["getById"]; user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[]; boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
searchEnginesData: { value: string; label: string }[]; searchEnginesData: { value: string; label: string }[];
showLanguageSelector?: boolean; showLanguageSelector?: boolean;
profileAvatar?: ReactNode;
} }
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
@@ -81,6 +88,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled, pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets, enableRightClickOnWidgets: user.enableRightClickOnWidgets,
headerPreferences: parseHeaderPreferences(user.headerPreferences),
}); });
export const UserGeneralSettingsForm = ({ export const UserGeneralSettingsForm = ({
@@ -88,6 +96,7 @@ export const UserGeneralSettingsForm = ({
boardsData, boardsData,
searchEnginesData, searchEnginesData,
showLanguageSelector = false, showLanguageSelector = false,
profileAvatar,
}: UserGeneralSettingsFormProps) => { }: UserGeneralSettingsFormProps) => {
const tUser = useI18n("user"); const tUser = useI18n("user");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -101,6 +110,7 @@ export const UserGeneralSettingsForm = ({
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation(); const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
const initialValues = buildInitialValues(user); const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues); const initialValuesRef = useRef(initialValues);
@@ -121,6 +131,7 @@ export const UserGeneralSettingsForm = ({
changeFirstDayOfWeekMutation, changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation, changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation, changeEnableRightClickOnWidgetsMutation,
changeHeaderPreferencesMutation,
]; ];
const isPending = mutations.some((m) => m.isPending); const isPending = mutations.some((m) => m.isPending);
@@ -184,6 +195,14 @@ export const UserGeneralSettingsForm = ({
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets, 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()); const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -218,135 +237,149 @@ export const UserGeneralSettingsForm = ({
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString(); const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
return ( return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <Stack gap="lg">
<Stack gap="lg"> <HeaderComposer
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg"> value={form.values.headerPreferences}
<Card withBorder bg="transparent"> onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
<Stack gap="md"> boards={boardsData}
<Stack gap={2}> homeBoardId={form.values.homeBoardId}
<Title order={3}>{tUser("name")}</Title> mobileHomeBoardId={form.values.mobileHomeBoardId}
{!isCredentialsUser && ( />
<Text c="dimmed" size="sm"> <Group gap="xl" align="flex-start" wrap="wrap">
{tUserManagement("fieldsDisabledExternalProvider")} <Box flex={1} miw={{ base: "100%", md: 540 }}>
</Text> <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>
)} )}
</Stack> </div>
<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> </Stack>
</Card> </form>
</Box>
<Card withBorder bg="transparent"> {profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
<Stack gap="md"> </Group>
<Stack gap={2}> </Stack>
<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>
); );
}; };
@@ -0,0 +1,273 @@
.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;
}
}
@@ -0,0 +1,653 @@
"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 { notFound } from "next/navigation";
import { Alert, Box, Group, Stack, Title } from "@mantine/core"; import { Alert, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react"; import { IconExclamationCircle } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -8,7 +8,6 @@ import { getI18n } from "@homarr/translation/server";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access"; import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button"; import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
@@ -38,7 +37,7 @@ export async function generateMetadata(props: Props) {
return { return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
title: createMetaTitle(t("metaTitle", { username: user.name! })), title: t("metaTitle", { username: user.name! }),
}; };
} }
@@ -58,7 +57,7 @@ export default async function EditUserPage(props: Props) {
notFound(); notFound();
} }
const boards = await api.board.getAllBoards(); const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
const searchEngines = await api.searchEngine.getSelectable(); const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id; const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials"; const isCredentialsUser = user.provider === "credentials";
@@ -71,23 +70,18 @@ export default async function EditUserPage(props: Props) {
</Alert> </Alert>
)} )}
<Title>{tGeneral("title")}</Title> <Title>{tGeneral("title")}</Title>
<Group gap="xl" align="flex-start" wrap="wrap"> <UserGeneralSettingsForm
<Box flex={1} miw={{ base: "100%", md: 540 }}> user={user}
<UserGeneralSettingsForm boardsData={boards.map((board) => ({
user={user} id: board.id,
boardsData={boards.map((board) => ({ name: board.name,
id: board.id, logoImageUrl: board.logoImageUrl,
name: board.name, preview: board.preview,
logoImageUrl: board.logoImageUrl, }))}
}))} searchEnginesData={searchEngines}
searchEnginesData={searchEngines} showLanguageSelector={isSelf}
showLanguageSelector={isSelf} profileAvatar={<UserProfileAvatarForm user={user} />}
/> />
</Box>
<Box w={{ base: "100%", lg: 260 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
{session?.user.id === user.id && ( {session?.user.id === user.id && (
<Stack mb="lg"> <Stack mb="lg">
@@ -8,7 +8,6 @@ import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { UserCreateStepperComponent } from "./_components/create-user-stepper"; import { UserCreateStepperComponent } from "./_components/create-user-stepper";
export async function generateMetadata() { export async function generateMetadata() {
@@ -22,7 +21,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.user.create"); const t = await getI18n("management.page.user.create");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list"; import { UserListComponent } from "./_components/user-list";
export async function generateMetadata() { export async function generateMetadata() {
@@ -20,7 +19,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.user.list"); const t = await getI18n("management.page.user.list");
return { return {
title: createMetaTitle(t("metaTitle")), title: t("metaTitle"),
}; };
} }
@@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { MetadataExtractor } from "@ai-sdk/openai-compatible"; import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
import type { ToolSet, UIMessage } from "ai"; import type { ToolSet, UIMessage } from "ai";
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai"; import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { cookies } from "next/headers";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
@@ -24,6 +25,10 @@ import {
assistantProviderRequiresApiKey, assistantProviderRequiresApiKey,
assistantReasoningModes, assistantReasoningModes,
} from "@homarr/definitions"; } 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 { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts"; import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
@@ -370,7 +375,7 @@ export async function POST(request: Request) {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 }); return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
} }
const requestBody = await request.text(); const requestBody = await request.text();
@@ -414,7 +419,11 @@ export async function POST(request: Request) {
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false; const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) { if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 }); 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 });
} }
let homarrProviderToken: string | null | undefined; let homarrProviderToken: string | null | undefined;
+50 -30
View File
@@ -1,39 +1,59 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest { 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(
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
);
}
return { return {
name: "Homarr", name: branding.appName,
short_name: "Homarr", short_name: branding.appName,
description: "Your dashboard for managing your server.", description: "Your dashboard for managing your server.",
start_url: "/", start_url: "/",
display: "standalone", display: "standalone",
background_color: "#fff", background_color: "#fff",
theme_color: "#fff", theme_color: branding.primaryColor,
icons: [ 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",
},
{
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()) => { export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`; const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
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`; 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`;
}; };
export const buildAssistantConversationMarkdown = ({ export const buildAssistantConversationMarkdown = ({
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
const header = [ const header = [
`# ${title}`, `# ${title}`,
"", "",
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.", "> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"", "",
`- Conversation ID: \`${thread.id}\``, `- Conversation ID: \`${thread.id}\``,
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`, `- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
@@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() =>
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => { const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description); useRegisterAssistantSpotlightPlaceholder(description);
const value = useMemo( const value = useMemo(
() => ({ () => ({
enabled: false, enabled: false,
@@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
: t("busy.description"), : t("busy.description"),
unavailable: !canSend, unavailable: !canSend,
alwaysVisible: true, alwaysVisible: true,
placement: "fallback" as const,
interaction: (query: string) => interaction: (query: string) =>
createAssistantPromptInteraction({ createAssistantPromptInteraction({
sendPrompt, sendPrompt,
@@ -19,6 +19,7 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
description, description,
unavailable: true, unavailable: true,
alwaysVisible: true, alwaysVisible: true,
placement: "fallback" as const,
interaction: (_query: string) => ({ type: "none" as const }), interaction: (_query: string) => ({ type: "none" as const }),
}), }),
[description, t], [description, t],
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { getSafeAppHref } from "@homarr/common";
import type { ContextSpecificItem } from "@homarr/spotlight";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open";
export const BoardAppsSpotlightRegistrar = () => {
const board = useRequiredBoard();
const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen());
useEffect(() => {
if (isSpotlightActivated) return;
const activateSpotlightQueries = () => setIsSpotlightActivated(true);
window.addEventListener(spotlightOpenEvent, activateSpotlightQueries);
if (hasPendingSpotlightOpen()) activateSpotlightQueries();
return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries);
}, [isSpotlightActivated]);
const openInNewTabByAppId = useMemo(() => {
const result = new Map<string, boolean>();
for (const item of board.items) {
if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue;
const existingOpenInNewTab = result.get(item.options.appId) ?? false;
result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true);
}
return result;
}, [board.items]);
const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]);
const activeAppIds = isSpotlightActivated ? appIds : [];
const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id })));
const apps = appQueries.flatMap((query) => query.data ?? []);
const spotlightItems = useMemo<ContextSpecificItem[]>(() => {
return apps.flatMap((app) => {
const href = getSafeAppHref(app.href);
if (!href) return [];
return [
{
id: app.id,
dedupeKey: `app:${app.id}`,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link" as const,
href,
// Opening a new tab wins if any widget instance requests it.
newTab: openInNewTabByAppId.get(app.id) ?? false,
};
},
},
];
});
}, [apps, openInNewTabByAppId]);
useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]);
return null;
};
@@ -5,10 +5,10 @@
overflow: hidden; overflow: hidden;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
background: background:
radial-gradient( linear-gradient(
circle at 15% 10%, to top,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 35% transparent 55%
), ),
var(--mantine-color-body); var(--mantine-color-body);
} }
@@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null; preview: BoardPreviewData | null;
label: string; label: string;
fitFullLayout?: boolean; fitFullLayout?: boolean;
className?: string;
} }
const maxThumbnailRows = 12; const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => { export const BoardLayoutThumbnail = ({
preview,
label,
fitFullLayout = false,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0); const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) { if (!preview || !layout) {
return ( return (
<Center className={classes.canvas} role="img" aria-label={label}> <Center className={canvasClassName} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden /> <IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center> </Center>
); );
@@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
}); });
if (lanes.length === 0) { if (lanes.length === 0) {
return ( return (
<Center className={classes.canvas} role="img" aria-label={label}> <Center className={canvasClassName} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden /> <IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center> </Center>
); );
@@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
}; };
return ( return (
<Box className={classes.canvas} role="img" aria-label={label}> <Box className={canvasClassName} role="img" aria-label={label}>
<div <div
className={classes.lanes} className={classes.lanes}
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }} style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
@@ -26,6 +26,7 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
@@ -35,12 +36,19 @@ import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c"; export const boardSwitcherHotkey = "shift+c";
export interface BoardSwitcherControls {
open: () => void;
preload: () => void;
hotkey: string;
}
interface BoardSwitcherProps { interface BoardSwitcherProps {
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode; children: (controls: BoardSwitcherControls) => ReactNode;
} }
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher"); const t = useI18n("board.action.switcher");
const tBoard = useI18n("board");
const manageBoardsT = useI18n("management.page.board"); const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard(); const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -85,6 +93,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, []); }, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const spotlightAction = useMemo(
() => ({
id: "open-board-switcher",
name: tBoard("action.switch"),
icon: IconLayoutBoard,
interaction: () => ({
type: "javaScript" as const,
onSelect: openSwitcher,
}),
}),
[openSwitcher, tBoard],
);
useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]);
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => { useEffect(() => {
@@ -222,7 +222,9 @@ export const WidgetContextMenu = ({
[setItemOptions], [setItemOptions],
); );
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); const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -24,6 +24,7 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
name: innerProps.previousName, name: innerProps.previousName,
}); });
void utils.board.getHomeBoard.invalidate(); void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
}, },
}); });
const form = useZodForm(boardRenameSchema.omit({ id: true }), { const form = useZodForm(boardRenameSchema.omit({ id: true }), {
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts"); const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => { describe("board grid import boundary", () => {
test("keeps every current dnd-kit runtime import inside the edit-only module", () => { test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory) const runtimeImports = getSourceFiles(gridDirectory)
.flatMap(getRuntimeImports) .flatMap(getRuntimeImports)
.filter(({ specifier }) => isCurrentDndKitImport(specifier)) .filter(({ specifier }) => isCurrentDndKitImport(specifier))
.map(({ filePath, kind, specifier }) => ({ .map(({ filePath, kind, specifier }) => ({
@@ -1 +1,2 @@
export const appShellHeaderHeight = 60; export const appShellHeaderHeight = 60;
export const appShellLogoHeight = appShellHeaderHeight - 8;
+33 -37
View File
@@ -1,49 +1,45 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { Link } from "@homarr/ui"; import { CurrentUserAvatar } from "~/components/user-avatar";
import { appShellLogoHeight } from "./constants";
import { ClientBurger } from "./header/burger"; import { ConfigurableHeader } from "./header/configurable-header";
import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
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 { interface Props {
logo?: ReactNode; logo?: ReactNode;
logoWithTitle?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation?: boolean; hasNavigation?: boolean;
} }
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { 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;
return ( return (
<AppShellHeader <ConfigurableHeader
maw="100vw" logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
zIndex="var(--homarr-z-index-board-header)" logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
style={{ overflowX: "hidden" }} actions={actions}
data-advanced-focus-background boardEditAction={boardEditAction}
data-app-shell-header boardSettingsAction={boardSettingsAction}
> hasNavigation={hasNavigation}
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap"> avatar={<CurrentUserAvatar size="md" />}
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap"> userId={session?.user.id ?? null}
{hasNavigation && <ClientBurger />} isAdmin={isAdmin}
<UnstyledButton component={Link} href="/"> isDockerEnabled={isDockerEnabled}
{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>
); );
}; };
@@ -0,0 +1,145 @@
.header {
overflow: hidden;
}
.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;
}
.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) {
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
}
.zone {
gap: var(--mantine-spacing-xs);
}
.search {
width: auto;
flex: 0 0 auto;
}
}
@media (hover: none), (pointer: coarse) {
.floatingControls {
opacity: 1;
pointer-events: auto;
transform: none;
}
.floatingControlsCorner {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.floatingControls {
transition: none;
}
}
@@ -0,0 +1,418 @@
"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 (
<>
{headerPreferences.visible ? (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-advanced-focus-background
data-app-shell-header
>
<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>
</AppShellHeader>
) : (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
)}
<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">
<IconLayoutDashboard size={17} stroke={1.5} />
</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>
);
};
@@ -0,0 +1,22 @@
.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;
}
@@ -0,0 +1,30 @@
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>
);
};
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { HotkeyItem } from "@mantine/hooks";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
@@ -13,23 +14,18 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
loading: () => null, loading: () => null,
}); });
const spotlightHotkeys: HotkeyItem[] = [
[hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }],
[hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }],
[hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }],
[hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }],
];
export const LazySpotlight = () => { export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false); const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys( useHotkeys(spotlightHotkeys, [], true);
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
@@ -61,12 +57,8 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? ( return isMounted || isPreloaded ? (
<> <>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />} {isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && ( {isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<> <Spotlight />
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</> </>
) : null; ) : null;
}; };
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
w={400} w={400}
size="sm" size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />} leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight} onClick={() => openSpotlight()}
radius="xl" radius="xl"
> >
{`${t("search.placeholder")}...`} {`${t("search.placeholder")}...`}
@@ -28,12 +28,14 @@ export const DesktopSearchInput = () => {
); );
}; };
export const MobileSearchButton = () => { export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
const t = useI18n();
return ( return (
<HeaderButton <HeaderButton
data-homarr-dev-benchmark-interaction="search" data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight} onClick={() => openSpotlight()}
className={classes.mobileSearch} className={alwaysVisible ? undefined : classes.mobileSearch}
aria-label={t("search.placeholder")}
> >
<IconSearch size={20} stroke={1.5} /> <IconSearch size={20} stroke={1.5} />
</HeaderButton> </HeaderButton>
@@ -8,18 +8,22 @@ import { useHotkeys } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatarMenu } from "~/components/user-avatar-menu"; import { UserAvatarMenu } from "~/components/user-avatar-menu";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UpdateIndicator } from "./update"; import { UpdateIndicator } from "./update";
interface UserButtonClientProps { interface UserButtonClientProps {
avatar: ReactNode; avatar: ReactNode;
isAdmin: boolean; isAdmin: boolean;
isDockerEnabled: boolean; isDockerEnabled: boolean;
boardSwitcher: BoardSwitcherControls;
} }
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => { export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false); const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const t = useI18n("common.userAvatar.menu");
const session = useSession(); const session = useSession();
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -45,8 +49,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined; const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
return ( return (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}> <UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
<UnstyledButton> <UnstyledButton aria-label={t("open")}>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}> <UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar} {avatar}
</UpdateIndicator> </UpdateIndicator>
@@ -2,13 +2,19 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar"; import { CurrentUserAvatar } from "~/components/user-avatar";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UserButtonClient } from "./user-client"; import { UserButtonClient } from "./user-client";
export const UserButton = async () => { export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
const session = await auth(); const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
return ( return (
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} /> <UserButtonClient
avatar={<CurrentUserAvatar size="md" />}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
); );
}; };
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo"; import { homarrLogoPath } from "./constants";
import type { LogoWithTitleProps } from "./logo"; import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo"; import { Logo, LogoWithTitle } from "./logo";
@@ -12,8 +13,9 @@ interface LogoProps {
const useImageOptions = () => { const useImageOptions = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const { branding } = useSettings();
return { return {
src: board.logoImageUrl ?? homarrLogoPath, src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
alt: "Board logo", alt: "Board logo",
shouldUseNextImage: false, shouldUseNextImage: false,
}; };
@@ -31,12 +33,13 @@ interface CommonLogoWithTitleProps {
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => { export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const { branding } = useSettings();
const imageOptions = useImageOptions(); const imageOptions = useImageOptions();
return ( return (
<LogoWithTitle <LogoWithTitle
size={size} size={size}
hideTitleOnMobile={hideTitleOnMobile} hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle} title={board.pageTitle ?? branding.appName}
image={imageOptions} image={imageOptions}
/> />
); );
@@ -0,0 +1 @@
export const homarrLogoPath = "/logo/logo.png";
@@ -1,25 +1,39 @@
"use client";
import { useSettings } from "@homarr/settings";
import type { LogoWithTitleProps } from "./logo"; import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo"; import { Logo, LogoWithTitle } from "./logo";
import { homarrLogoPath } from "./constants";
interface LogoProps { interface LogoProps {
size: number; size: number;
} }
export const homarrLogoPath = "/logo/logo.png"; const useBrandLogo = () => {
export const homarrPageTitle = "Homarr"; const { branding } = useSettings();
const configuredLogoUrl = branding.logoImageUrl?.trim();
const imageOptions = { return {
src: homarrLogoPath, title: branding.appName,
alt: "Homarr logo", image: {
shouldUseNextImage: true, src: configuredLogoUrl || homarrLogoPath,
alt: `${branding.appName} logo`,
shouldUseNextImage: !configuredLogoUrl,
},
};
}; };
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />; export const HomarrLogo = ({ size }: LogoProps) => {
const brand = useBrandLogo();
return <Logo size={size} {...brand.image} />;
};
interface CommonLogoWithTitleProps { interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"]; size: LogoWithTitleProps["size"];
hideTitleOnMobile?: boolean;
} }
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => { export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />; const brand = useBrandLogo();
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
}; };
@@ -9,14 +9,18 @@ interface LogoProps {
shouldUseNextImage?: boolean; shouldUseNextImage?: boolean;
} }
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
shouldUseNextImage ? ( const normalizedSrc = src.trim();
<Image className="logo" src={src} alt={alt} width={size} height={size} /> if (normalizedSrc.length === 0) return null;
return shouldUseNextImage ? (
<Image className="logo" src={normalizedSrc} 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 // 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 // eslint-disable-next-line @next/next/no-img-element
<img className="logo" src={src} alt={alt} width={size} height={size} /> <img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
); );
};
const logoWithTitleSizes = { const logoWithTitleSizes = {
lg: { logoSize: 48, titleOrder: 1 }, lg: { logoSize: 48, titleOrder: 1 },
@@ -15,7 +15,17 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) => const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
id ? <TourTarget id={id}>{children}</TourTarget> : children; id ? <TourTarget id={id}>{children}</TourTarget> : children;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`); 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 useClientPathname = () => { const useClientPathname = () => {
const pathname = usePathname(); const pathname = usePathname();
@@ -31,6 +41,7 @@ const useClientPathname = () => {
const NavLinkHref = (props: NavigationLinkHref) => { const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname(); const { pathname, isClient } = useClientPathname();
const tourId = props["data-onboarding-tour-id"]; const tourId = props["data-onboarding-tour-id"];
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
const link = props.external ? ( const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" /> <NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : ( ) : (
@@ -39,7 +50,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label} label={props.label}
leftSection={props.icon} leftSection={props.icon}
href={props.href} href={props.href}
active={isClient && pathMatches(pathname, props.href)} active={isActive}
/> />
); );
return withOptionalTourTarget(tourId, link); return withOptionalTourTarget(tourId, link);
@@ -47,11 +58,18 @@ const NavLinkHref = (props: NavigationLinkHref) => {
const NavLinkWithItems = (props: NavigationLinkWithItems) => { const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const { pathname, isClient } = useClientPathname(); const { pathname, isClient } = useClientPathname();
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href)); const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
const isActive = isClient && activeItemHref !== undefined;
const [opened, setOpened] = useState(false);
useEffect(() => {
if (isActive) setOpened(true);
}, [isActive]);
const nav = ( const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}> <NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
{props.items.map((item) => ( {props.items.map((item) => (
<NavLinkHref key={item.label} {...item} /> <NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
))} ))}
</NavLink> </NavLink>
); );
@@ -67,6 +85,8 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps { interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string; href: string;
external?: boolean; external?: boolean;
exact?: boolean;
active?: boolean;
} }
interface NavigationLinkWithItems extends CommonNavigationLinkProps { interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[]; items: NavigationLinkHref[];
@@ -72,6 +72,7 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps { interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string; href: string;
external?: boolean; external?: boolean;
exact?: boolean;
} }
interface NavigationLinkWithItems extends CommonNavigationLinkProps { interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[]; items: NavigationLinkHref[];
+4 -1
View File
@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { AppShell } from "@mantine/core"; import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useSettings } from "@homarr/settings";
import { useOptionalBackgroundProps } from "./background"; import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants"; import { appShellHeaderHeight } from "./constants";
@@ -20,11 +21,13 @@ export const ClientShell = ({
}: PropsWithChildren<ClientShellProps>) => { }: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom); const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps(); const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const isHeaderVisible = hasHeader && headerPreferences.visible;
return ( return (
<AppShell <AppShell
{...backgroundProps} {...backgroundProps}
header={hasHeader ? { height: appShellHeaderHeight } : undefined} header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
navbar={ navbar={
hasNavigation hasNavigation
? { ? {
@@ -8,10 +8,9 @@ import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core"; import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core"; import type { FloatingPosition } from "@mantine/core";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren { interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[]; steps: OnboardingTourStep[];
started: boolean; started: boolean;
@@ -79,6 +78,7 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => { export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const tCommon = useI18n("common.action"); const tCommon = useI18n("common.action");
const tTour = useI18n("onboardingTour"); const tTour = useI18n("onboardingTour");
const { branding } = useSettings();
const router = useRouter(); const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null); const forwardActionRef = useRef<(() => void) | null>(null);
@@ -145,7 +145,13 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
</Group> </Group>
{stepIndexFromController(controller) === 0 && ( {stepIndexFromController(controller) === 0 && (
<Center py="xs"> <Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" /> <Image
src={branding.logoImageUrl ?? "/logo/logo.png"}
alt={`${branding.appName} logo`}
w={64}
h={64}
fit="contain"
/>
</Center> </Center>
)} )}
</> </>
+101 -104
View File
@@ -23,7 +23,7 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session"; import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context"; import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { BoardSwitcher } from "./board/board-switcher"; import type { BoardSwitcherControls } from "./board/board-switcher";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox"; import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox"; import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal"; import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
@@ -33,6 +33,7 @@ interface UserAvatarMenuProps {
children: ReactNode; children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean; isDockerEnabled?: boolean;
boardSwitcher: BoardSwitcherControls;
} }
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) => const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
@@ -41,7 +42,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`)) .map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
.join(" + "); .join(" + ");
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => { export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => {
const t = useI18n("common.userAvatar.menu"); const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const session = useSession(); const session = useSession();
@@ -59,111 +60,107 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
}, [logoutUrl]); }, [logoutUrl]);
return ( return (
<BoardSwitcher> // We use keepMounted so we can add event listeners to prevent navigating away without saving the board
{({ open, preload, hotkey }) => ( <Menu width={300} withinPortal keepMounted>
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board <Menu.Dropdown>
<Menu width={300} withinPortal keepMounted> <AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Dropdown> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} /> {t("homeBoard")}
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> </Menu.Item>
{t("homeBoard")} <Menu.Item
</Menu.Item> leftSection={<IconReplace size="1rem" />}
<Menu.Item rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
leftSection={<IconReplace size="1rem" />} onClick={boardSwitcher.open}
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>} onFocus={boardSwitcher.preload}
onClick={open} onPointerEnter={boardSwitcher.preload}
onFocus={preload} >
onPointerEnter={preload} {tBoard("action.switch")}
> </Menu.Item>
{tBoard("action.switch")} <Menu.Divider />
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div"> <Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox /> <CurrentColorSchemeCombobox />
</Menu.Item> </Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div"> <Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox /> <CurrentLanguageCombobox />
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
{Boolean(session.data) && ( {Boolean(session.data) && (
<> <>
{assistant?.enabled && ( {assistant?.enabled && (
<Menu.Item <Menu.Item
leftSection={ leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" /> assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
} }
rightSection={ rightSection={
assistant.unreadCount > 0 ? ( assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle> <Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount} {assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge> </Badge>
) : assistant.isRunning ? ( ) : assistant.isRunning ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("assistantThinking")} {t("assistantThinking")}
</Text> </Text>
) : (
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : ( ) : (
assistant.unreadCount <Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
) )
) : undefined }
} onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
> >
{children} {t("preferences")}
</Indicator> </Menu.Item>
</Menu.Target>
</Menu> <Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
)} {t("management")}
</BoardSwitcher> </Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : (
assistant.unreadCount
)
) : undefined
}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
); );
}; };
Loaded 100 of 303 files, more files were not shown because too many files have changed in this diff. Show more