Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60fcac9f42 |
No files matched your search
@@ -8,28 +8,13 @@ Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| 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 |
|
||||
| `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
|
||||
|
||||
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.
|
||||
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
||||
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
||||
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
||||
|
||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Configure and use Assistant.
|
||||
description: Configure and use Homarr Assistant.
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
@@ -8,10 +8,10 @@ tags:
|
||||
|
||||
# Assistant
|
||||
|
||||
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||
approval before making changes.
|
||||
|
||||

|
||||

|
||||
|
||||
## Set it up
|
||||
|
||||
@@ -38,7 +38,7 @@ host gateway address.
|
||||
## Use it
|
||||
|
||||
- Select **Assistant** in the user menu or press `Shift + A`.
|
||||
- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
|
||||
- Ask from `Cmd/Ctrl + K`.
|
||||
- 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
|
||||
|
||||
@@ -317,7 +317,7 @@ Change the radius of your items and Containers.
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
|
||||
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
|
||||
or Danger zone actions.
|
||||
|
||||
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
|
||||
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
||||
runtime components.
|
||||
|
||||
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
|
||||
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
|
||||
### Design with AI
|
||||
|
||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
|
||||
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
|
||||
update tool for your approval.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
The optional [Assistant](./assistant) uses these same tool definitions internally.
|
||||
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
|
||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||
approval before running mutating procedures.
|
||||
|
||||
|
||||
@@ -7,13 +7,9 @@ tags:
|
||||
|
||||
# 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 `MOD + K`.
|
||||
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`.
|
||||
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.
|
||||
|
||||
## Create search engine
|
||||
@@ -30,8 +26,6 @@ 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.
|
||||
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
|
||||
|
||||
|
||||
-351
@@ -3453,9 +3453,6 @@
|
||||
"enableRightClickOnWidgets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"headerPreferences": {
|
||||
"type": "string"
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3491,7 +3488,6 @@
|
||||
"firstDayOfWeek",
|
||||
"pingIconsEnabled",
|
||||
"enableRightClickOnWidgets",
|
||||
"headerPreferences",
|
||||
"defaultSearchEngineId",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
@@ -4350,353 +4346,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/header-preferences": {
|
||||
"patch": {
|
||||
"operationId": "userRouter-changeHeaderPreferences",
|
||||
"tags": ["users"],
|
||||
"security": [
|
||||
{
|
||||
"apikey": []
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"headerPreferences": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 3
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"searchDisplay": {
|
||||
"type": "string",
|
||||
"enum": ["input", "icon"]
|
||||
},
|
||||
"logoDisplay": {
|
||||
"default": "logo",
|
||||
"type": "string",
|
||||
"enum": ["logo", "logoAndText"]
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"center": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "builtin"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "board"
|
||||
},
|
||||
"boardId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"required": ["type", "boardId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["left", "center", "right"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "searchDisplay", "zones"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 1
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"minItems": 1,
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "items"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"const": 2
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"zones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
},
|
||||
"center": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"maxItems": 6,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["left", "center", "right"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["version", "visible", "zones"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "headerPreferences"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.NOT_FOUND"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/ddg-bangs": {
|
||||
"patch": {
|
||||
"operationId": "userRouter-changeDdgBangs",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
@@ -11,23 +10,18 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
import { theme } from "@homarr/ui";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
|
||||
import { createBrandTheme } from "~/theme/branding";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
branding,
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
|
||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
||||
const manager = useColorSchemeManager();
|
||||
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={configuredTheme}
|
||||
theme={theme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { IconUserPlus } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
@@ -22,9 +20,11 @@ interface InviteUsagePageProps {
|
||||
}
|
||||
|
||||
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
if (!isProviderEnabled("credentials")) notFound();
|
||||
|
||||
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
|
||||
const session = await auth();
|
||||
if (session) notFound();
|
||||
|
||||
const invite = await db.query.invites.findFirst({
|
||||
@@ -45,33 +45,13 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
|
||||
const branding = serverSettings.branding;
|
||||
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
|
||||
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
|
||||
const defaultDescription = showCustomAppName
|
||||
? t("subtitleWithAppName", { appName: branding.appName })
|
||||
: t("subtitle");
|
||||
const description = showCustomGreeting ? branding.greeting : defaultDescription;
|
||||
const t = await getI18n("user.page.invite");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
title={title}
|
||||
description={description}
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
icon={<IconUserPlus size={24} />}
|
||||
appName={branding.appName}
|
||||
showAppName={branding.authBranding.showAppName}
|
||||
showAppLogo={branding.authBranding.showLogo}
|
||||
primaryColor={branding.primaryColor}
|
||||
secondaryColor={branding.secondaryColor}
|
||||
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||
radius={branding.defaultRadius}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
footer={t("description", { username: invite.creator.name! })}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,9 @@ import { redirect } from "next/navigation";
|
||||
import { Alert, Code, Text } from "@mantine/core";
|
||||
import { IconLogin } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getBrandingColorOverrides } from "@homarr/server-settings";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
@@ -21,35 +19,17 @@ interface LoginProps {
|
||||
}
|
||||
|
||||
export default async function Login(props: LoginProps) {
|
||||
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
if (session) {
|
||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||
}
|
||||
|
||||
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
|
||||
const branding = serverSettings.branding;
|
||||
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||
const description = showCustomGreeting ? branding.greeting : t("subtitle");
|
||||
const t = await getI18n("user.page.login");
|
||||
|
||||
return (
|
||||
<OnboardingAuthShell
|
||||
title={t("title")}
|
||||
description={description}
|
||||
icon={<IconLogin size={24} />}
|
||||
appName={branding.appName}
|
||||
showAppName={branding.authBranding.showAppName}
|
||||
showAppLogo={branding.authBranding.showLogo}
|
||||
primaryColor={branding.primaryColor}
|
||||
secondaryColor={branding.secondaryColor}
|
||||
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||
radius={branding.defaultRadius}
|
||||
>
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
{appEnv.DEMO_MODE ? (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
|
||||
<Text size="sm" fw={500}>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { FloatingTip } from "@homarr/ui";
|
||||
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
|
||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
@@ -80,7 +79,6 @@ export const ClientBoard = () => {
|
||||
<BoardSelectionGridProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardAppsSpotlightRegistrar />
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -16,11 +15,12 @@ import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
@@ -36,8 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
@@ -104,24 +103,18 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const [board, t, serverSettings] = await Promise.all([
|
||||
getInitialBoard(await params),
|
||||
getI18n("board"),
|
||||
getRscServerSettingsAsync(),
|
||||
]);
|
||||
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
|
||||
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
|
||||
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n("board");
|
||||
|
||||
return {
|
||||
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
|
||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
||||
icons: {
|
||||
icon: favicon,
|
||||
apple: favicon,
|
||||
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||
},
|
||||
appleWebApp: {
|
||||
startupImage: {
|
||||
url: favicon,
|
||||
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,9 +39,10 @@ const loadBoardEditorAsync = async () =>
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) return null;
|
||||
@@ -49,27 +50,20 @@ export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess || demoReadOnly) return null;
|
||||
|
||||
return (
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||
import { generateColorScale } from "~/theme/branding";
|
||||
|
||||
export const BoardMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
const colorSchemeManager = useColorSchemeManager();
|
||||
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
|
||||
|
||||
const theme = createTheme({
|
||||
colors: {
|
||||
primaryColor: generateColorScale(primaryColor),
|
||||
secondaryColor: generateColorScale(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
defaultRadius: branding.defaultRadius,
|
||||
defaultRadius: "sm",
|
||||
fontSizes: {
|
||||
"2xl": rem(24),
|
||||
"3xl": rem(28),
|
||||
@@ -46,3 +43,29 @@ export const BoardMantineProvider = ({
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateColors = (hex: string) => {
|
||||
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
|
||||
const rgbaColors = lightnessForColors.map((lightness) => {
|
||||
if (lightness < 0) {
|
||||
return lighten(hex, -lightness);
|
||||
}
|
||||
return darken(hex, lightness);
|
||||
});
|
||||
|
||||
return rgbaColors.map((color) => {
|
||||
return (
|
||||
"#" +
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
color
|
||||
.split("(")[1]!
|
||||
.replaceAll(" ", "")
|
||||
.replace(")", "")
|
||||
.split(",")
|
||||
.map((color) => parseInt(color, 10))
|
||||
.slice(0, 3)
|
||||
.map((color) => color.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
);
|
||||
}) as unknown as MantineColorsTuple;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IconHomeOff } from "@tabler/icons-react";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
@@ -8,7 +7,6 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
||||
import { BoardNotFound } from "~/components/board/not-found";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
|
||||
export default async function NotFoundBoardHomePage() {
|
||||
const boardNotFoundProps = await getPropsAsync();
|
||||
@@ -21,9 +19,8 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
||||
const t = await getI18n("board");
|
||||
|
||||
if (boardCount === 0) {
|
||||
const { branding } = await getRscServerSettingsAsync();
|
||||
return {
|
||||
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
|
||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
||||
title: t("error.noBoard.title"),
|
||||
description: t("error.noBoard.description"),
|
||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||
|
||||
@@ -22,10 +22,9 @@ import { IconX } from "@tabler/icons-react";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput } from "@homarr/ui";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { generateColorScale } from "~/theme/branding";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
interface Props {
|
||||
@@ -41,19 +40,13 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
||||
const tBoard = useI18n("board");
|
||||
const tCommon = useI18n("common");
|
||||
const theme = useMantineTheme();
|
||||
const { branding } = useSettings();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<BoardColorInput
|
||||
label={tBoard("field.primaryColor.label")}
|
||||
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
|
||||
disabled={branding.lockPrimaryColor}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
@@ -128,8 +121,7 @@ interface ColorsPreviewProps {
|
||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const colors =
|
||||
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
|
||||
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
|
||||
@@ -21,26 +21,20 @@ interface Props {
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
const t = useI18n("board");
|
||||
const { branding } = useSettings();
|
||||
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("field.pageTitle.label")}
|
||||
placeholder={branding.appName}
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("field.metaTitle.label")}
|
||||
placeholder={defaultMetaTitle}
|
||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
@@ -49,7 +43,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("field.logoImageUrl.label")}
|
||||
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -57,7 +51,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("field.faviconImageUrl.label")}
|
||||
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -78,9 +72,9 @@ const useLogoPreview = (url: string | null) => {
|
||||
}, [logoDebounced, updateBoard]);
|
||||
};
|
||||
|
||||
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
|
||||
const useMetaTitlePreview = (title: string | null) => {
|
||||
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
|
||||
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
|
||||
useDocumentTitle(metaTitleDebounced);
|
||||
|
||||
return {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
|
||||
@@ -10,11 +10,10 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
@@ -89,7 +88,6 @@ interface BoardSettingsFormProps {
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n("common");
|
||||
const tSection = useI18n("board.setting.section");
|
||||
const { branding } = useSettings();
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
@@ -144,9 +142,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const [, canonicalLayouts] = await Promise.all([
|
||||
partialSettingsChanged
|
||||
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(
|
||||
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
|
||||
);
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
})
|
||||
: Promise.resolve(),
|
||||
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
|
||||
|
||||
@@ -12,8 +12,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { appShellLogoHeight } from "~/components/layout/constants";
|
||||
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
@@ -28,17 +27,13 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions?: JSX.Element;
|
||||
headerBoardEditAction?: JSX.Element;
|
||||
headerBoardSettingsAction?: JSX.Element;
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
headerBoardEditAction,
|
||||
headerBoardSettingsAction,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
@@ -110,11 +105,8 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogo size={appShellLogoHeight} />}
|
||||
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
boardEditAction={headerBoardEditAction}
|
||||
boardSettingsAction={headerBoardSettingsAction}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { SettingsProvider } from "@homarr/settings";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
@@ -54,45 +53,35 @@ const fontSans = Inter({
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
export const generateMetadata = async (): Promise<Metadata> => {
|
||||
const [serverSettings, colorScheme, t] = await Promise.all([
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
getI18n("metadata"),
|
||||
]);
|
||||
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
|
||||
const logo = logoImageUrl ?? "/logo/logo.png";
|
||||
const favicon = faviconImageUrl ?? logo;
|
||||
const description = t("description");
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
openGraph: {
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo/logo.png",
|
||||
apple: "/logo/logo.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
title: "Homarr",
|
||||
capable: true,
|
||||
startupImage: { url: "/logo/logo.png" },
|
||||
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: appName,
|
||||
template: `%s • ${appName}`,
|
||||
},
|
||||
description,
|
||||
openGraph: {
|
||||
title: t("dashboardTitle", { appName }),
|
||||
description,
|
||||
url: env.HOMARR_WEBSITE_URL,
|
||||
siteName: appName,
|
||||
},
|
||||
icons: {
|
||||
icon: favicon,
|
||||
apple: logo,
|
||||
},
|
||||
appleWebApp: {
|
||||
title: appName,
|
||||
capable: true,
|
||||
startupImage: { url: logo },
|
||||
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const generateViewport = async (): Promise<Viewport> => {
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
return { themeColor: serverSettings.branding.primaryColor };
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function Layout(props: {
|
||||
@@ -105,7 +94,6 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
const sessionPromise = auth();
|
||||
const serverSettingsPromise = getRscServerSettingsAsync();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
@@ -114,17 +102,18 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||
if (!session) return "unauthenticated";
|
||||
return api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error");
|
||||
});
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
||||
!session
|
||||
? "unauthenticated"
|
||||
: api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error"),
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
serverSettingsPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
assistantAvailabilityPromise,
|
||||
]);
|
||||
@@ -159,7 +148,6 @@ export default async function Layout(props: {
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||
branding: serverSettings.branding,
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
@@ -168,9 +156,7 @@ export default async function Layout(props: {
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
||||
(innerProps) => (
|
||||
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
|
||||
),
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||
|
||||
@@ -2,13 +2,11 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { IntegrationMarquee } from "@homarr/ui";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
export const HeroBanner = async () => {
|
||||
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
|
||||
const { appName, logoImageUrl } = serverSettings.branding;
|
||||
const t = await getI18n("management.page.home");
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
@@ -16,17 +14,9 @@ export const HeroBanner = async () => {
|
||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||
{t("heroBanner.title")}
|
||||
</Title>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Image
|
||||
src={logoImageUrl ?? "/logo/logo.png"}
|
||||
alt={`${appName} logo`}
|
||||
w="auto"
|
||||
h={{ base: 48, md: 64 }}
|
||||
maw={{ base: 128, md: 192 }}
|
||||
fit="contain"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
|
||||
@@ -37,8 +37,9 @@ import { capitalize, objectEntries } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
@@ -49,7 +50,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.settings.section.assistant");
|
||||
return {
|
||||
title: t("title"),
|
||||
title: `${t("title")} • Homarr`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
@@ -74,7 +74,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.home"),
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
exact: true,
|
||||
"data-onboarding-tour-id": "manage-welcome",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,13 +7,14 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
ColorSwatch,
|
||||
Fieldset,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
|
||||
interface BrandingSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.branding");
|
||||
const logoInput = form.getInputProps("branding.logoImageUrl");
|
||||
const faviconInput = form.getInputProps("branding.faviconImageUrl");
|
||||
const backgroundInput = form.getInputProps("branding.signInBackgroundImageUrl");
|
||||
const branding = form.values.branding;
|
||||
|
||||
return (
|
||||
<SectionCard title={t("title")}>
|
||||
<Stack gap="md">
|
||||
<Fieldset legend={t("groups.identity")} p="sm">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("identity.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm" verticalSpacing="sm">
|
||||
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
|
||||
<IconPicker
|
||||
label={t("logo.label")}
|
||||
description={null}
|
||||
placeholder={t("logo.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={logoInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.logoImageUrl", value || null)}
|
||||
error={logoInput.error}
|
||||
/>
|
||||
<IconPicker
|
||||
label={t("favicon.label")}
|
||||
description={null}
|
||||
placeholder={t("favicon.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={faviconInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.faviconImageUrl", value || null)}
|
||||
error={faviconInput.error}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend={t("groups.appearance")} p="sm">
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<BoardColorInput
|
||||
label={t("primaryColor.label")}
|
||||
defaultColor="#fa5252"
|
||||
{...form.getInputProps("branding.primaryColor")}
|
||||
/>
|
||||
<BoardColorInput
|
||||
label={t("secondaryColor.label")}
|
||||
defaultColor="#fd7e14"
|
||||
{...form.getInputProps("branding.secondaryColor")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Switch
|
||||
label={t("lockPrimaryColor.label")}
|
||||
description={t("lockPrimaryColor.description")}
|
||||
{...form.getInputProps("branding.lockPrimaryColor", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
<CornerStylePicker
|
||||
compact
|
||||
label={t("defaultRadius.label")}
|
||||
value={branding.defaultRadius}
|
||||
labels={
|
||||
Object.fromEntries(
|
||||
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]),
|
||||
) as Record<(typeof cornerStyleValues)[number], string>
|
||||
}
|
||||
onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend={t("groups.authentication")} p="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
|
||||
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
|
||||
<IconPicker
|
||||
label={t("signInBackground.label")}
|
||||
description={null}
|
||||
placeholder={t("signInBackground.placeholder")}
|
||||
withAsterisk={false}
|
||||
value={backgroundInput.value ?? ""}
|
||||
onChange={(value) => form.setFieldValue("branding.signInBackgroundImageUrl", value || null)}
|
||||
error={backgroundInput.error}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap={6} justify="center">
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("signInOverlay.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(branding.signInBackgroundOverlay * 100)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Slider
|
||||
aria-label={t("signInOverlay.label")}
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
label={(value) => `${Math.round(value * 100)}%`}
|
||||
disabled={!branding.signInBackgroundImageUrl}
|
||||
value={branding.signInBackgroundOverlay}
|
||||
onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("authVisibility.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.appName")}
|
||||
{...form.getInputProps("branding.authBranding.showAppName", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.logo")}
|
||||
{...form.getInputProps("branding.authBranding.showLogo", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
label={t("authVisibility.greeting")}
|
||||
{...form.getInputProps("branding.authBranding.showGreeting", { type: "checkbox" })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<BrandingSummary form={form} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const BrandingSummary = ({ form }: BrandingSettingsFormProps) => {
|
||||
const t = useI18n("management.page.settings.section.branding");
|
||||
const branding = form.values.branding;
|
||||
const appName = branding.appName.trim() || "Homarr";
|
||||
const logoImageUrl = branding.logoImageUrl || "/logo/logo.png";
|
||||
const faviconImageUrl = branding.faviconImageUrl || logoImageUrl;
|
||||
const backgroundStatus = branding.signInBackgroundImageUrl
|
||||
? t("summary.backgroundConfigured", { overlay: Math.round(branding.signInBackgroundOverlay * 100) })
|
||||
: t("summary.backgroundDefault");
|
||||
const greetingStatus = branding.greeting.trim() ? t("summary.greetingConfigured") : t("summary.greetingDefault");
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius={branding.defaultRadius}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="xs" wrap="wrap">
|
||||
<Text fw={650}>{t("summary.title")}</Text>
|
||||
<Badge variant="light">
|
||||
{t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" verticalSpacing="sm">
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("groups.identity")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar.Group>
|
||||
<Avatar src={logoImageUrl} alt={t("logo.label")} />
|
||||
<Avatar src={faviconImageUrl} alt={t("favicon.label")} />
|
||||
</Avatar.Group>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{appName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("summary.palette")}
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ColorSwatch color={branding.primaryColor} size={20} />
|
||||
<Text size="xs">{branding.primaryColor}</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ColorSwatch color={branding.secondaryColor} size={20} />
|
||||
<Text size="xs">{branding.secondaryColor}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Badge color={branding.lockPrimaryColor ? "primaryColor" : "gray"} variant="light" w="fit-content">
|
||||
{branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||
{t("groups.authentication")}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{backgroundStatus} · {greetingStatus}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<VisibilityBadge label={t("summary.appName")} visible={branding.authBranding.showAppName} />
|
||||
<VisibilityBadge label={t("summary.logo")} visible={branding.authBranding.showLogo} />
|
||||
<VisibilityBadge label={t("summary.greeting")} visible={branding.authBranding.showGreeting} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => {
|
||||
const t = useI18n("management.page.settings.section.branding.summary");
|
||||
|
||||
return (
|
||||
<Badge size="xs" color={visible ? "green" : "gray"} variant="light">
|
||||
{label}: {visible ? t("on") : t("off")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -12,14 +12,12 @@ import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { brandingServerSettingsSchema } from "@homarr/server-settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
import { AnalyticsSettings } from "./analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { BrandingSettingsForm } from "./branding-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
@@ -39,7 +37,6 @@ const settingsFormSchema = z.object({
|
||||
defaultSearchEngineId: z.string().nullable(),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
branding: brandingServerSettingsSchema,
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
@@ -58,7 +55,6 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
branding: initialData.branding,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
@@ -141,11 +137,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
{
|
||||
settingsKey: "branding",
|
||||
when: changed("branding"),
|
||||
value: values.branding,
|
||||
},
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
@@ -185,7 +176,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<BrandingSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
|
||||
@@ -9,9 +9,10 @@ import { SettingsForm } from "./_components/settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: metaTitle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||
import { ApiKeysManagement } from "./components/api-keys";
|
||||
@@ -62,7 +63,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from "@homarr/auth/next";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { BackupExportCard } from "./_components/backup-export-card";
|
||||
import { BackupImportCard } from "./_components/backup-import-card";
|
||||
@@ -18,7 +19,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.tool.backup");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { ClientSideTerminalComponent } from "./client";
|
||||
import { LogFontSizeSlider } from "./font-size-slider";
|
||||
import { LogLevelSelection } from "./level-selection";
|
||||
@@ -24,7 +25,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { TasksTable } from "./_components/tasks-table";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -16,7 +17,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+130
-163
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
@@ -24,15 +22,12 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
headerPreferencesSchema,
|
||||
parseHeaderPreferences,
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
@@ -41,9 +36,9 @@ import {
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HeaderComposer } from "./header-composer";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
@@ -58,7 +53,6 @@ const userGeneralSettingsSchema = z.object({
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
headerPreferences: headerPreferencesSchema,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -71,10 +65,9 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
||||
|
||||
interface UserGeneralSettingsFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
showLanguageSelector?: boolean;
|
||||
profileAvatar?: ReactNode;
|
||||
}
|
||||
|
||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||
@@ -88,7 +81,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
headerPreferences: parseHeaderPreferences(user.headerPreferences),
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -96,7 +88,6 @@ export const UserGeneralSettingsForm = ({
|
||||
boardsData,
|
||||
searchEnginesData,
|
||||
showLanguageSelector = false,
|
||||
profileAvatar,
|
||||
}: UserGeneralSettingsFormProps) => {
|
||||
const tUser = useI18n("user");
|
||||
const tCommon = useI18n("common");
|
||||
@@ -110,7 +101,6 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -131,7 +121,6 @@ export const UserGeneralSettingsForm = ({
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
changeHeaderPreferencesMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -195,14 +184,6 @@ export const UserGeneralSettingsForm = ({
|
||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("headerPreferences"),
|
||||
action: () =>
|
||||
changeHeaderPreferencesMutation.mutateAsync({
|
||||
id: user.id,
|
||||
headerPreferences: parsed.data.headerPreferences,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
@@ -237,149 +218,135 @@ export const UserGeneralSettingsForm = ({
|
||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<HeaderComposer
|
||||
value={form.values.headerPreferences}
|
||||
onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
|
||||
boards={boardsData}
|
||||
homeBoardId={form.values.homeBoardId}
|
||||
mobileHomeBoardId={form.values.mobileHomeBoardId}
|
||||
/>
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tUser("name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={tUser("field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={tUser("field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{tCommon("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tUser("name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={tUser("field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
{profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={tUser("field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={tUser("field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{tCommon("action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
-273
@@ -1,273 +0,0 @@
|
||||
.composer {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.tray {
|
||||
padding: var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.heading {
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 21rem;
|
||||
padding: var(--mantine-spacing-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
.device {
|
||||
position: relative;
|
||||
width: min(100%, 34rem);
|
||||
min-height: 18rem;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
transition:
|
||||
width 180ms ease,
|
||||
min-height 180ms ease;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 5rem;
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.zoneTable {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.zoneColumn {
|
||||
min-width: 0;
|
||||
border-inline-end: 1px solid var(--mantine-color-default-border);
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.zoneColumn:last-child {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.zoneColumn[data-drop-target="true"] {
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.zoneColumn[data-drop-target="true"] .zoneItems::after {
|
||||
content: "";
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
|
||||
}
|
||||
|
||||
.zoneColumnHeader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 1.75rem;
|
||||
margin: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoneItems {
|
||||
display: flex;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sortableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sortableItem[data-drop-target="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0.2rem;
|
||||
inset-inline-start: -0.2rem;
|
||||
z-index: 1;
|
||||
width: 2px;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
background: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.sortableItem[data-dragging="true"] {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.itemButton,
|
||||
.dragOverlay {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
cursor: grab;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.itemButton[data-wide="true"],
|
||||
.dragOverlay[data-wide="true"] {
|
||||
width: 4.75rem;
|
||||
}
|
||||
|
||||
.itemButton:hover,
|
||||
.itemButton:focus-visible,
|
||||
.sortableItem[data-drop-target="true"] .itemButton,
|
||||
.sortableItem[data-selected="true"] .itemButton {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
background: var(--mantine-primary-color-light-hover);
|
||||
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.itemButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dragOverlay {
|
||||
pointer-events: none;
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
.searchPreview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.logoPreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logoPreview[data-with-title="true"] {
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
.logoPreview span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchPreview[data-input="true"] {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.375rem;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.searchPreview span {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hiddenPreview {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.floatingAccount {
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
.dashboardPreview {
|
||||
height: 13rem;
|
||||
overflow: hidden;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.dashboardLayout {
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.tray {
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.configuration {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 42%, var(--mantine-color-body));
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.heading,
|
||||
.tray,
|
||||
.stage {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.stage {
|
||||
min-height: 19rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.device,
|
||||
.zoneColumn,
|
||||
.itemButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
-653
@@ -1,653 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PointerActivationConstraints } from "@dnd-kit/dom";
|
||||
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
|
||||
import type { DragEndEvent } from "@dnd-kit/react";
|
||||
import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react";
|
||||
import { useSortable } from "@dnd-kit/react/sortable";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
IconUserCircle,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type {
|
||||
HeaderBuiltinItemId,
|
||||
HeaderItem,
|
||||
HeaderPreferences,
|
||||
HeaderZoneId,
|
||||
HeaderZones,
|
||||
} from "@homarr/validation/user";
|
||||
import {
|
||||
createBoardHeaderItem,
|
||||
createBuiltinHeaderItem,
|
||||
getDefaultHeaderZone,
|
||||
getHeaderItemKey,
|
||||
getHeaderItems,
|
||||
getHeaderItemZone,
|
||||
headerBuiltinItemIds,
|
||||
isRequiredHeaderItem,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { HomarrLogo } from "~/components/layout/logo/homarr-logo";
|
||||
import classes from "./header-composer.module.css";
|
||||
|
||||
interface HeaderComposerProps {
|
||||
value: HeaderPreferences;
|
||||
onChange: (value: HeaderPreferences) => void;
|
||||
boards: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
}
|
||||
|
||||
interface HeaderItemDragData {
|
||||
kind: "item";
|
||||
itemKey: string;
|
||||
zone: HeaderZoneId;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface HeaderZoneDropData {
|
||||
kind: "zone";
|
||||
zone: HeaderZoneId;
|
||||
}
|
||||
|
||||
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
|
||||
|
||||
const headerSortableSensors = [
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })],
|
||||
}),
|
||||
KeyboardSensor,
|
||||
];
|
||||
const headerSortableGroup = "header-composer";
|
||||
const headerSortablePlugins = [SortableKeyboardPlugin];
|
||||
const headerGuideZones = ["left", "center", "right"] as const;
|
||||
|
||||
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
|
||||
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const zoneTableRef = useRef<HTMLDivElement>(null);
|
||||
const { branding } = useSettings();
|
||||
const t = useI18n("management.page.user.setting.general.header");
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
const activeItems = getHeaderItems(value.zones);
|
||||
|
||||
useEffect(() => {
|
||||
const zoneTable = zoneTableRef.current;
|
||||
if (!zoneTable) return;
|
||||
|
||||
const clearSelectionFromEmptyArea = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest("[data-header-composer-item]")) return;
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
zoneTable.addEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||
return () => zoneTable.removeEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||
}, []);
|
||||
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
|
||||
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
|
||||
});
|
||||
const availableBoards = boards.filter(
|
||||
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
|
||||
);
|
||||
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
|
||||
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
|
||||
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
|
||||
|
||||
const toggleSelectedItem = (itemKey: string) => {
|
||||
setSelectedItemKey((currentItemKey) => {
|
||||
if (currentItemKey === itemKey) return null;
|
||||
return itemKey;
|
||||
});
|
||||
};
|
||||
|
||||
const itemLabel = (item: HeaderItem) => {
|
||||
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
|
||||
return t(`items.${item.id}` as never);
|
||||
};
|
||||
|
||||
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
|
||||
|
||||
const removeItem = (item: HeaderItem) => {
|
||||
if (isRequiredHeaderItem(item)) return;
|
||||
const zone = getHeaderItemZone(value.zones, item);
|
||||
if (!zone) return;
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
updateZones({
|
||||
...value.zones,
|
||||
[zone]: value.zones[zone].filter((candidate) => getHeaderItemKey(candidate) !== itemKey),
|
||||
});
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
const addItem = (item: HeaderItem) => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
if (activeItemKeySet.has(itemKey)) return;
|
||||
const zone = getDefaultHeaderZone(item);
|
||||
updateZones({ ...value.zones, [zone]: [...value.zones[zone], item] });
|
||||
setSelectedItemKey(itemKey);
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ canceled, operation }: DragEndEvent) => {
|
||||
setDraggedItemKey(null);
|
||||
const source = operation.source;
|
||||
const target = operation.target;
|
||||
if (canceled || !source || !target) return;
|
||||
|
||||
const sourceData = source.data as HeaderItemDragData;
|
||||
const targetData = target.data as HeaderDropData;
|
||||
if (sourceData.kind !== "item") return;
|
||||
|
||||
let targetIndex = value.zones[targetData.zone].length;
|
||||
if (targetData.kind === "item") {
|
||||
if (targetData.itemKey === sourceData.itemKey) return;
|
||||
targetIndex = targetData.index;
|
||||
}
|
||||
|
||||
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
|
||||
if (nextZones === value.zones) return;
|
||||
updateZones(nextZones);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={classes.composer} padding={0} radius="lg" withBorder>
|
||||
<Stack gap={0}>
|
||||
<Group className={classes.heading} justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" radius="md" size="lg">
|
||||
<IconLayoutDashboard size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="lg">
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant={value.visible ? "default" : "filled"}
|
||||
aria-label={value.visible ? t("action.hide") : t("action.show")}
|
||||
onClick={() => onChange({ ...value, visible: !value.visible })}
|
||||
>
|
||||
{value.visible ? <IconEye size={18} /> : <IconEyeOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box className={classes.stage} data-visible={value.visible}>
|
||||
<Paper className={classes.device} radius="lg" withBorder>
|
||||
{value.visible ? (
|
||||
<DragDropProvider
|
||||
onDragStart={({ operation }) => setDraggedItemKey(operation.source?.id.toString() ?? null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={classes.previewHeader}>
|
||||
<div ref={zoneTableRef} className={classes.zoneTable}>
|
||||
{headerGuideZones.map((zone) => (
|
||||
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
|
||||
{value.zones[zone].map((item, index) => (
|
||||
<SortableHeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
item={item}
|
||||
zone={zone}
|
||||
index={index}
|
||||
label={itemLabel(item)}
|
||||
searchDisplay={value.searchDisplay}
|
||||
logoDisplay={value.logoDisplay}
|
||||
logoTitle={branding.appName}
|
||||
boardLogo={
|
||||
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
|
||||
}
|
||||
configureLabel={t("action.configure", { item: itemLabel(item) })}
|
||||
selected={selectedItemKey === getHeaderItemKey(item)}
|
||||
onSelect={toggleSelectedItem}
|
||||
/>
|
||||
))}
|
||||
</HeaderZoneColumn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedItem ? (
|
||||
<HeaderItemPreview
|
||||
item={draggedItem}
|
||||
label={itemLabel(draggedItem)}
|
||||
compact={false}
|
||||
searchDisplay={value.searchDisplay}
|
||||
logoDisplay={value.logoDisplay}
|
||||
logoTitle={branding.appName}
|
||||
boardLogo={
|
||||
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
|
||||
}
|
||||
overlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
) : (
|
||||
<div className={classes.hiddenPreview}>
|
||||
<ThemeIcon className={classes.floatingAccount} radius="xl" size="xl" aria-label={t("items.user")}>
|
||||
<IconUserCircle size={22} />
|
||||
</ThemeIcon>
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.dashboardPreview}>
|
||||
<BoardLayoutThumbnail
|
||||
preview={previewBoard?.preview ?? null}
|
||||
label={previewBoard ? t("preview.board", { name: previewBoard.name }) : t("preview.empty")}
|
||||
className={classes.dashboardLayout}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Stack className={classes.tray} gap="sm">
|
||||
{selectedItem ? (
|
||||
<Paper className={classes.configuration} p="sm" radius="md" withBorder>
|
||||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={650} size="sm">
|
||||
{itemLabel(selectedItem)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("configuration.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{selectedItem.type === "builtin" && selectedItem.id === "search" ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value.searchDisplay}
|
||||
onChange={(searchDisplay) =>
|
||||
onChange({ ...value, searchDisplay: searchDisplay as HeaderPreferences["searchDisplay"] })
|
||||
}
|
||||
data={[
|
||||
{ value: "input", label: t("configuration.searchDisplay.input") },
|
||||
{ value: "icon", label: t("configuration.searchDisplay.icon") },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{selectedItem.type === "builtin" && selectedItem.id === "logo" ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={value.logoDisplay}
|
||||
onChange={(logoDisplay) =>
|
||||
onChange({ ...value, logoDisplay: logoDisplay as HeaderPreferences["logoDisplay"] })
|
||||
}
|
||||
data={[
|
||||
{ value: "logo", label: t("configuration.logoDisplay.logo") },
|
||||
{ value: "logoAndText", label: t("configuration.logoDisplay.logoAndText") },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{!isRequiredHeaderItem(selectedItem) ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => removeItem(selectedItem)}
|
||||
>
|
||||
{t("action.remove", { item: itemLabel(selectedItem) })}
|
||||
</Button>
|
||||
) : (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.requiredAlways")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Stack gap={0}>
|
||||
<Text fw={650} size="sm">
|
||||
{t("available.title")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("available.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
disabled={availableBoards.length === 0}
|
||||
>
|
||||
{t("available.boardShortcut")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("available.chooseBoard")}</Menu.Label>
|
||||
{availableBoards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
leftSection={
|
||||
<Avatar src={board.logoImageUrl} size="xs" radius="sm">
|
||||
<IconLayoutDashboard size={14} />
|
||||
</Avatar>
|
||||
}
|
||||
onClick={() => addItem(createBoardHeaderItem(board.id))}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{inactiveBuiltinIds.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("available.allAdded")}
|
||||
</Text>
|
||||
) : (
|
||||
inactiveBuiltinIds.map((itemId) => {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
const Icon = getBuiltinItemIcon(itemId);
|
||||
return (
|
||||
<Tooltip key={itemId} label={t("available.add", { item: itemLabel(item) })}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="default"
|
||||
aria-label={t("available.add", { item: itemLabel(item) })}
|
||||
onClick={() => addItem(item)}
|
||||
>
|
||||
<Icon size={19} stroke={1.7} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeaderZoneColumnProps {
|
||||
zone: HeaderZoneId;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
|
||||
const { isDropTarget, ref } = useDroppable({
|
||||
id: `header-zone:${zone}`,
|
||||
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
|
||||
});
|
||||
|
||||
return (
|
||||
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
|
||||
<Text className={classes.zoneColumnHeader} component="h4">
|
||||
{label}
|
||||
</Text>
|
||||
<div className={classes.zoneItems}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableHeaderItemProps {
|
||||
item: HeaderItem;
|
||||
zone: HeaderZoneId;
|
||||
index: number;
|
||||
label: string;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null;
|
||||
configureLabel: string;
|
||||
selected: boolean;
|
||||
onSelect: (itemKey: string) => void;
|
||||
}
|
||||
|
||||
const SortableHeaderItem = ({
|
||||
item,
|
||||
zone,
|
||||
index,
|
||||
label,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
configureLabel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: SortableHeaderItemProps) => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
|
||||
id: itemKey,
|
||||
index,
|
||||
group: `${headerSortableGroup}:${zone}`,
|
||||
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
|
||||
sensors: headerSortableSensors,
|
||||
plugins: headerSortablePlugins,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes.sortableItem}
|
||||
data-dragging={isDragging || undefined}
|
||||
data-drop-target={isDropTarget || undefined}
|
||||
data-entry-id={itemKey}
|
||||
data-selected={selected || undefined}
|
||||
>
|
||||
<button
|
||||
ref={handleRef}
|
||||
type="button"
|
||||
className={classes.itemButton}
|
||||
data-header-composer-item
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-wide={
|
||||
(item.type === "builtin" &&
|
||||
((item.id === "search" && searchDisplay === "input") ||
|
||||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||
undefined
|
||||
}
|
||||
aria-label={configureLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(itemKey);
|
||||
}}
|
||||
>
|
||||
<HeaderItemPreview
|
||||
item={item}
|
||||
label={label}
|
||||
compact={false}
|
||||
searchDisplay={searchDisplay}
|
||||
logoDisplay={logoDisplay}
|
||||
logoTitle={logoTitle}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderItemPreview = ({
|
||||
item,
|
||||
label,
|
||||
compact,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
overlay = false,
|
||||
}: {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
compact: boolean;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null | undefined;
|
||||
overlay?: boolean;
|
||||
}) => {
|
||||
const content = (
|
||||
<PreviewItem
|
||||
item={item}
|
||||
label={label}
|
||||
compact={compact}
|
||||
searchDisplay={searchDisplay}
|
||||
logoDisplay={logoDisplay}
|
||||
logoTitle={logoTitle}
|
||||
boardLogo={boardLogo}
|
||||
/>
|
||||
);
|
||||
if (!overlay) return content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.dragOverlay}
|
||||
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||
data-wide={
|
||||
(item.type === "builtin" &&
|
||||
!compact &&
|
||||
((item.id === "search" && searchDisplay === "input") ||
|
||||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PreviewItem = ({
|
||||
item,
|
||||
label,
|
||||
compact,
|
||||
searchDisplay,
|
||||
logoDisplay,
|
||||
logoTitle,
|
||||
boardLogo,
|
||||
}: {
|
||||
item: HeaderItem;
|
||||
label: string;
|
||||
compact: boolean;
|
||||
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||
logoTitle: string;
|
||||
boardLogo: string | null | undefined;
|
||||
}) => {
|
||||
if (item.type === "board") {
|
||||
return (
|
||||
<Avatar src={boardLogo} size={22} radius="sm">
|
||||
<IconLayoutDashboard size={17} />
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "logo") {
|
||||
return (
|
||||
<div className={classes.logoPreview} data-with-title={logoDisplay === "logoAndText" || undefined}>
|
||||
<HomarrLogo size={26} />
|
||||
{logoDisplay === "logoAndText" ? <span>{logoTitle}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === "search") {
|
||||
const showInput = searchDisplay === "input" && !compact;
|
||||
return (
|
||||
<div className={classes.searchPreview} data-input={showInput || undefined}>
|
||||
<IconSearch size={18} />
|
||||
{showInput ? <span>{label}…</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = getBuiltinItemIcon(item.id);
|
||||
return <Icon size={20} stroke={1.7} />;
|
||||
};
|
||||
|
||||
const moveHeaderItem = (
|
||||
zones: HeaderZones,
|
||||
itemKey: string,
|
||||
targetZone: HeaderZoneId,
|
||||
requestedTargetIndex: number,
|
||||
): HeaderZones => {
|
||||
let sourceItem: HeaderItem | undefined;
|
||||
|
||||
for (const zone of headerGuideZones) {
|
||||
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
|
||||
if (sourceItem) break;
|
||||
}
|
||||
|
||||
if (!sourceItem) return zones;
|
||||
|
||||
const nextZones: HeaderZones = {
|
||||
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||
};
|
||||
const targetItems = nextZones[targetZone];
|
||||
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
|
||||
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
|
||||
return nextZones;
|
||||
};
|
||||
|
||||
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
|
||||
if (id === null) return undefined;
|
||||
return items.find((item) => getHeaderItemKey(item) === id);
|
||||
};
|
||||
|
||||
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
|
||||
if (itemId === "search") return IconSearch;
|
||||
if (itemId === "home") return IconHome;
|
||||
if (itemId === "boardSwitcher") return IconReplace;
|
||||
if (itemId === "assistant") return IconRobot;
|
||||
if (itemId === "docker") return IconBrandDocker;
|
||||
if (itemId === "boardEdit") return IconPencil;
|
||||
if (itemId === "boardSettings") return IconSettings;
|
||||
if (itemId === "settings") return IconSettings;
|
||||
if (itemId === "themeToggle") return IconSunMoon;
|
||||
if (itemId === "user") return IconUserCircle;
|
||||
return IconLayoutDashboard;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -8,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
@@ -37,7 +38,7 @@ export async function generateMetadata(props: Props) {
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
title: t("metaTitle", { username: user.name! }),
|
||||
title: createMetaTitle(t("metaTitle", { username: user.name! })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ export default async function EditUserPage(props: Props) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
|
||||
const boards = await api.board.getAllBoards();
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
const isSelf = session?.user.id === user.id;
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
@@ -70,18 +71,23 @@ export default async function EditUserPage(props: Props) {
|
||||
</Alert>
|
||||
)}
|
||||
<Title>{tGeneral("title")}</Title>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
preview: board.preview,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
profileAvatar={<UserProfileAvatarForm user={user} />}
|
||||
/>
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ base: "100%", lg: 260 }}>
|
||||
<UserProfileAvatarForm user={user} />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -21,7 +22,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.user.create");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserListComponent } from "./_components/user-list";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -19,7 +20,7 @@ export async function generateMetadata() {
|
||||
const t = await getI18n("management.page.user.list");
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
@@ -25,10 +24,6 @@ import {
|
||||
assistantProviderRequiresApiKey,
|
||||
assistantReasoningModes,
|
||||
} from "@homarr/definitions";
|
||||
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { fallbackLocale, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
@@ -375,7 +370,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
|
||||
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.text();
|
||||
@@ -419,11 +414,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
const requestedLocale = (await cookies()).get(localeCookieKey)?.value;
|
||||
let locale: SupportedLanguage = fallbackLocale;
|
||||
if (requestedLocale && isLocaleSupported(requestedLocale)) locale = requestedLocale;
|
||||
const t = await getI18n({ locale, namespace: "assistant.unavailable" });
|
||||
return Response.json({ error: t("notConfigured") }, { status: 503 });
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
let homarrProviderToken: string | null | undefined;
|
||||
|
||||
@@ -1,59 +1,39 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
const branding = serverSettings.branding;
|
||||
const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl;
|
||||
const icons: NonNullable<MetadataRoute.Manifest["icons"]> = [
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
];
|
||||
|
||||
if (appIcon) {
|
||||
icons.unshift({
|
||||
src: appIcon,
|
||||
sizes: "any",
|
||||
purpose: "any",
|
||||
});
|
||||
} else {
|
||||
icons.unshift(
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Homarr",
|
||||
short_name: "Homarr",
|
||||
description: "Your dashboard for managing your server.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#fff",
|
||||
theme_color: "#fff",
|
||||
icons: [
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/192.maskable.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: branding.appName,
|
||||
short_name: branding.appName,
|
||||
description: "Your dashboard for managing your server.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#fff",
|
||||
theme_color: branding.primaryColor,
|
||||
icons,
|
||||
{
|
||||
src: "/images/pwa/512.maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
|
||||
|
||||
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
|
||||
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
|
||||
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||
};
|
||||
|
||||
export const buildAssistantConversationMarkdown = ({
|
||||
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
|
||||
const header = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||
"",
|
||||
`- Conversation ID: \`${thread.id}\``,
|
||||
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
|
||||
|
||||
@@ -25,6 +25,7 @@ const EnabledAssistantRoot = dynamic(() =>
|
||||
|
||||
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
||||
useRegisterAssistantSpotlightPlaceholder(description);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
|
||||
@@ -856,7 +856,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
: t("busy.description"),
|
||||
unavailable: !canSend,
|
||||
alwaysVisible: true,
|
||||
placement: "fallback" as const,
|
||||
interaction: (query: string) =>
|
||||
createAssistantPromptInteraction({
|
||||
sendPrompt,
|
||||
|
||||
@@ -19,7 +19,6 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
|
||||
description,
|
||||
unavailable: true,
|
||||
alwaysVisible: true,
|
||||
placement: "fallback" as const,
|
||||
interaction: (_query: string) => ({ type: "none" as const }),
|
||||
}),
|
||||
[description, t],
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"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;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background:
|
||||
linear-gradient(
|
||||
to top,
|
||||
radial-gradient(
|
||||
circle at 15% 10%,
|
||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
||||
transparent 55%
|
||||
transparent 35%
|
||||
),
|
||||
var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@@ -12,22 +12,15 @@ interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({
|
||||
preview,
|
||||
label,
|
||||
fitFullLayout = false,
|
||||
className,
|
||||
}: BoardLayoutThumbnailProps) => {
|
||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
@@ -45,7 +38,7 @@ export const BoardLayoutThumbnail = ({
|
||||
});
|
||||
if (lanes.length === 0) {
|
||||
return (
|
||||
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
@@ -62,7 +55,7 @@ export const BoardLayoutThumbnail = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={canvasClassName} role="img" aria-label={label}>
|
||||
<Box className={classes.canvas} role="img" aria-label={label}>
|
||||
<div
|
||||
className={classes.lanes}
|
||||
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
@@ -36,19 +35,12 @@ import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
|
||||
export interface BoardSwitcherControls {
|
||||
open: () => void;
|
||||
preload: () => void;
|
||||
hotkey: string;
|
||||
}
|
||||
|
||||
interface BoardSwitcherProps {
|
||||
children: (controls: BoardSwitcherControls) => ReactNode;
|
||||
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
||||
}
|
||||
|
||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
const t = useI18n("board.action.switcher");
|
||||
const tBoard = useI18n("board");
|
||||
const manageBoardsT = useI18n("management.page.board");
|
||||
const currentBoard = useOptionalBoard();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -93,20 +85,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
}, []);
|
||||
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 }]]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -222,9 +222,7 @@ export const WidgetContextMenu = ({
|
||||
[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);
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
|
||||
name: innerProps.previousName,
|
||||
});
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
void utils.board.getAllBoards.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
|
||||
|
||||
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(gridDirectory)
|
||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const appShellHeaderHeight = 60;
|
||||
export const appShellLogoHeight = appShellHeaderHeight - 8;
|
||||
@@ -1,45 +1,49 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { appShellLogoHeight } from "./constants";
|
||||
import { ConfigurableHeader } from "./header/configurable-header";
|
||||
import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
import { LazySpotlight } from "./header/lazy-spotlight";
|
||||
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
logoWithTitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
boardEditAction?: ReactNode;
|
||||
boardSettingsAction?: ReactNode;
|
||||
hasNavigation?: boolean;
|
||||
}
|
||||
|
||||
export const MainHeader = async ({
|
||||
logo,
|
||||
logoWithTitle,
|
||||
actions,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
hasNavigation = true,
|
||||
}: Props) => {
|
||||
const session = await auth();
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
return (
|
||||
<ConfigurableHeader
|
||||
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
|
||||
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
|
||||
actions={actions}
|
||||
boardEditAction={boardEditAction}
|
||||
boardSettingsAction={boardSettingsAction}
|
||||
hasNavigation={hasNavigation}
|
||||
avatar={<CurrentUserAvatar size="md" />}
|
||||
userId={session?.user.id ?? null}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
/>
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
style={{ overflowX: "hidden" }}
|
||||
data-advanced-focus-background
|
||||
data-app-shell-header
|
||||
>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
<UnstyledButton component={Link} href="/">
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
.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);
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AppShellHeader,
|
||||
Avatar,
|
||||
Group,
|
||||
Indicator,
|
||||
Loader,
|
||||
Tooltip,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLayoutDashboard,
|
||||
IconReplace,
|
||||
IconRobot,
|
||||
IconSettings,
|
||||
IconSunMoon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { HeaderItem } from "@homarr/validation/user";
|
||||
import { getHeaderItemKey, getHeaderItems } from "@homarr/validation/user";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
import { BoardSwitcher } from "~/components/board/board-switcher";
|
||||
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||
import { ClientBurger } from "./burger";
|
||||
import { HeaderButton } from "./button";
|
||||
import { DockerQuickAccessModal } from "./docker-quick-access-modal";
|
||||
import { HeaderLogo } from "./header-logo";
|
||||
import { LazySpotlight } from "./lazy-spotlight";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./search";
|
||||
import { TourTarget } from "./tour-target";
|
||||
import { UserButtonClient } from "./user-client";
|
||||
import classes from "./configurable-header.module.css";
|
||||
|
||||
interface ConfigurableHeaderProps {
|
||||
logo: ReactNode;
|
||||
logoWithTitle: ReactNode;
|
||||
actions?: ReactNode;
|
||||
boardEditAction?: ReactNode;
|
||||
boardSettingsAction?: ReactNode;
|
||||
hasNavigation: boolean;
|
||||
avatar: ReactNode;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
}
|
||||
|
||||
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
|
||||
|
||||
const floatingControlsIntroDurationMs = 5_000;
|
||||
const floatingControlsDismissDelayMs = 900;
|
||||
|
||||
export const ConfigurableHeader = ({
|
||||
logo,
|
||||
logoWithTitle,
|
||||
actions,
|
||||
boardEditAction,
|
||||
boardSettingsAction,
|
||||
hasNavigation,
|
||||
avatar,
|
||||
userId,
|
||||
isAdmin,
|
||||
isDockerEnabled,
|
||||
}: ConfigurableHeaderProps) => {
|
||||
const { headerPreferences } = useSettings();
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
const t = useI18n("management.page.user.setting.general.header");
|
||||
const allHeaderItems = getHeaderItems(headerPreferences.zones);
|
||||
const hasBoardShortcuts = allHeaderItems.some((item) => item.type === "board");
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, {
|
||||
enabled: hasBoardShortcuts,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||
|
||||
return (
|
||||
<BoardSwitcher>
|
||||
{(boardSwitcher) => {
|
||||
const renderItems = (items: HeaderItem[]) =>
|
||||
items.map((item) => (
|
||||
<HeaderItem
|
||||
key={getHeaderItemKey(item)}
|
||||
item={item}
|
||||
logo={logo}
|
||||
logoWithTitle={logoWithTitle}
|
||||
boardEditAction={boardEditAction}
|
||||
boardSettingsAction={boardSettingsAction}
|
||||
avatar={avatar}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||
searchDisplay={headerPreferences.searchDisplay}
|
||||
logoDisplay={headerPreferences.logoDisplay}
|
||||
boardSwitcher={boardSwitcher}
|
||||
assistant={assistant}
|
||||
openDockerModal={openDockerModal}
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
label={(key) => t(`items.${key}` as never)}
|
||||
boardUnavailableLabel={t("items.boardUnavailable")}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.root[data-display="logo"] {
|
||||
justify-content: center;
|
||||
width: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-display="logo"] img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import classes from "./header-logo.module.css";
|
||||
|
||||
interface HeaderLogoProps {
|
||||
display: "logo" | "logoAndText";
|
||||
logo: ReactNode;
|
||||
logoWithTitle: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
|
||||
let content = logo;
|
||||
if (display === "logoAndText") content = logoWithTitle;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
href="/"
|
||||
className={classes.root}
|
||||
data-display={display}
|
||||
aria-label={label}
|
||||
>
|
||||
{content}
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { HotkeyItem } from "@mantine/hooks";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
@@ -14,18 +13,23 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
|
||||
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 = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isPreloaded, setIsPreloaded] = useState(false);
|
||||
|
||||
useHotkeys(spotlightHotkeys, [], true);
|
||||
useHotkeys(
|
||||
isMounted
|
||||
? []
|
||||
: [
|
||||
[
|
||||
hotkeys.openSpotlight,
|
||||
() => {
|
||||
setIsMounted(true);
|
||||
openSpotlight();
|
||||
},
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
@@ -57,8 +61,12 @@ export const LazySpotlight = () => {
|
||||
return isMounted || isPreloaded ? (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||
<Spotlight />
|
||||
{isMounted && (
|
||||
<>
|
||||
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||
<Spotlight />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
|
||||
w={400}
|
||||
size="sm"
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={() => openSpotlight()}
|
||||
onClick={openSpotlight}
|
||||
radius="xl"
|
||||
>
|
||||
{`${t("search.placeholder")}...`}
|
||||
@@ -28,14 +28,12 @@ export const DesktopSearchInput = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
|
||||
const t = useI18n();
|
||||
export const MobileSearchButton = () => {
|
||||
return (
|
||||
<HeaderButton
|
||||
data-homarr-dev-benchmark-interaction="search"
|
||||
onClick={() => openSpotlight()}
|
||||
className={alwaysVisible ? undefined : classes.mobileSearch}
|
||||
aria-label={t("search.placeholder")}
|
||||
onClick={openSpotlight}
|
||||
className={classes.mobileSearch}
|
||||
>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
|
||||
@@ -8,22 +8,18 @@ import { useHotkeys } from "@mantine/hooks";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
boardSwitcher: BoardSwitcherControls;
|
||||
}
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -49,8 +45,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitch
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
|
||||
return (
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
|
||||
<UnstyledButton aria-label={t("open")}>
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
{avatar}
|
||||
</UpdateIndicator>
|
||||
|
||||
@@ -2,19 +2,13 @@ import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||
import { UserButtonClient } from "./user-client";
|
||||
|
||||
export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
return (
|
||||
<UserButtonClient
|
||||
avatar={<CurrentUserAvatar size="md" />}
|
||||
isAdmin={isAdmin}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
boardSwitcher={boardSwitcher}
|
||||
/>
|
||||
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { homarrLogoPath } from "./constants";
|
||||
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
|
||||
@@ -13,9 +12,8 @@ interface LogoProps {
|
||||
|
||||
const useImageOptions = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
return {
|
||||
src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
|
||||
src: board.logoImageUrl ?? homarrLogoPath,
|
||||
alt: "Board logo",
|
||||
shouldUseNextImage: false,
|
||||
};
|
||||
@@ -33,13 +31,12 @@ interface CommonLogoWithTitleProps {
|
||||
|
||||
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const { branding } = useSettings();
|
||||
const imageOptions = useImageOptions();
|
||||
return (
|
||||
<LogoWithTitle
|
||||
size={size}
|
||||
hideTitleOnMobile={hideTitleOnMobile}
|
||||
title={board.pageTitle ?? branding.appName}
|
||||
title={board.pageTitle ?? homarrPageTitle}
|
||||
image={imageOptions}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const homarrLogoPath = "/logo/logo.png";
|
||||
@@ -1,39 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
import { homarrLogoPath } from "./constants";
|
||||
|
||||
interface LogoProps {
|
||||
size: number;
|
||||
}
|
||||
|
||||
const useBrandLogo = () => {
|
||||
const { branding } = useSettings();
|
||||
const configuredLogoUrl = branding.logoImageUrl?.trim();
|
||||
return {
|
||||
title: branding.appName,
|
||||
image: {
|
||||
src: configuredLogoUrl || homarrLogoPath,
|
||||
alt: `${branding.appName} logo`,
|
||||
shouldUseNextImage: !configuredLogoUrl,
|
||||
},
|
||||
};
|
||||
export const homarrLogoPath = "/logo/logo.png";
|
||||
export const homarrPageTitle = "Homarr";
|
||||
|
||||
const imageOptions = {
|
||||
src: homarrLogoPath,
|
||||
alt: "Homarr logo",
|
||||
shouldUseNextImage: true,
|
||||
};
|
||||
|
||||
export const HomarrLogo = ({ size }: LogoProps) => {
|
||||
const brand = useBrandLogo();
|
||||
return <Logo size={size} {...brand.image} />;
|
||||
};
|
||||
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />;
|
||||
|
||||
interface CommonLogoWithTitleProps {
|
||||
size: LogoWithTitleProps["size"];
|
||||
hideTitleOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
||||
const brand = useBrandLogo();
|
||||
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
|
||||
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
||||
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />;
|
||||
};
|
||||
@@ -9,18 +9,14 @@ interface LogoProps {
|
||||
shouldUseNextImage?: boolean;
|
||||
}
|
||||
|
||||
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
|
||||
const normalizedSrc = src.trim();
|
||||
if (normalizedSrc.length === 0) return null;
|
||||
|
||||
return shouldUseNextImage ? (
|
||||
<Image className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
|
||||
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
|
||||
shouldUseNextImage ? (
|
||||
<Image className="logo" src={src} alt={alt} width={size} height={size} />
|
||||
) : (
|
||||
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
|
||||
<img className="logo" src={src} alt={alt} width={size} height={size} />
|
||||
);
|
||||
};
|
||||
|
||||
const logoWithTitleSizes = {
|
||||
lg: { logoSize: 48, titleOrder: 1 },
|
||||
|
||||
@@ -15,17 +15,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||
|
||||
const pathMatches = (pathname: string, href: string, exact = false) => {
|
||||
if (pathname === href) return true;
|
||||
if (exact) return false;
|
||||
return pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
const getMostSpecificMatchingHref = (pathname: string, items: NavigationLinkHref[]) =>
|
||||
items
|
||||
.filter((item) => pathMatches(pathname, item.href, item.exact))
|
||||
.toSorted((first, second) => second.href.length - first.href.length)
|
||||
.at(0)?.href;
|
||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const useClientPathname = () => {
|
||||
const pathname = usePathname();
|
||||
@@ -41,7 +31,6 @@ const useClientPathname = () => {
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const { pathname, isClient } = useClientPathname();
|
||||
const tourId = props["data-onboarding-tour-id"];
|
||||
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
|
||||
const link = props.external ? (
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
@@ -50,7 +39,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={isActive}
|
||||
active={isClient && pathMatches(pathname, props.href)}
|
||||
/>
|
||||
);
|
||||
return withOptionalTourTarget(tourId, link);
|
||||
@@ -58,18 +47,11 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const { pathname, isClient } = useClientPathname();
|
||||
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
|
||||
const isActive = isClient && activeItemHref !== undefined;
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) setOpened(true);
|
||||
}, [isActive]);
|
||||
|
||||
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
|
||||
const nav = (
|
||||
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
@@ -85,8 +67,6 @@ interface CommonNavigationLinkProps {
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
exact?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||
items: NavigationLinkHref[];
|
||||
|
||||
@@ -72,7 +72,6 @@ interface CommonNavigationLinkProps {
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
exact?: boolean;
|
||||
}
|
||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||
items: NavigationLinkHref[];
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShell } from "@mantine/core";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import { useOptionalBackgroundProps } from "./background";
|
||||
import { appShellHeaderHeight } from "./constants";
|
||||
@@ -21,13 +20,11 @@ export const ClientShell = ({
|
||||
}: PropsWithChildren<ClientShellProps>) => {
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
const backgroundProps = useOptionalBackgroundProps();
|
||||
const { headerPreferences } = useSettings();
|
||||
const isHeaderVisible = hasHeader && headerPreferences.visible;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
|
||||
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
|
||||
@@ -8,9 +8,10 @@ import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||
import type { FloatingPosition } from "@mantine/core";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
|
||||
interface TourShellProps extends PropsWithChildren {
|
||||
steps: OnboardingTourStep[];
|
||||
started: boolean;
|
||||
@@ -78,7 +79,6 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
||||
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||
const tCommon = useI18n("common.action");
|
||||
const tTour = useI18n("onboardingTour");
|
||||
const { branding } = useSettings();
|
||||
const router = useRouter();
|
||||
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -145,13 +145,7 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
|
||||
</Group>
|
||||
{stepIndexFromController(controller) === 0 && (
|
||||
<Center py="xs">
|
||||
<Image
|
||||
src={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||
alt={`${branding.appName} logo`}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||
import type { BoardSwitcherControls } from "./board/board-switcher";
|
||||
import { BoardSwitcher } from "./board/board-switcher";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
@@ -33,7 +33,6 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
boardSwitcher: BoardSwitcherControls;
|
||||
}
|
||||
|
||||
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
@@ -42,7 +41,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
|
||||
.join(" + ");
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
@@ -60,107 +59,111 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
||||
}, [logoutUrl]);
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={boardSwitcher.open}
|
||||
onFocus={boardSwitcher.preload}
|
||||
onPointerEnter={boardSwitcher.preload}
|
||||
>
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<BoardSwitcher>
|
||||
{({ open, preload, hotkey }) => (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={open}
|
||||
onFocus={preload}
|
||||
onPointerEnter={preload}
|
||||
>
|
||||
{t("preferences")}
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</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.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>
|
||||
</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>
|
||||
)}
|
||||
</BoardSwitcher>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const createMetaTitle = (name: string) => `${name} • Homarr`;
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, mergeThemeOverrides } from "@mantine/core";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { theme as homarrTheme } from "@homarr/ui";
|
||||
|
||||
export const generateColorScale = (hex: string) => {
|
||||
const lightnessSteps = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
|
||||
const colors = lightnessSteps.map((lightness) => {
|
||||
if (lightness < 0) return lighten(hex, -lightness);
|
||||
return darken(hex, lightness);
|
||||
});
|
||||
|
||||
return colors.map((color) => {
|
||||
const channels = color.split("(")[1]?.replaceAll(" ", "").replace(")", "").split(",");
|
||||
if (!channels) return hex;
|
||||
const value = channels
|
||||
.map((channel) => parseInt(channel, 10))
|
||||
.slice(0, 3)
|
||||
.map((channel) => channel.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
return `#${value}`;
|
||||
}) as unknown as MantineColorsTuple;
|
||||
};
|
||||
|
||||
export const createBrandTheme = (branding: ServerSettings["branding"]): MantineThemeOverride =>
|
||||
mergeThemeOverrides(
|
||||
homarrTheme,
|
||||
createTheme({
|
||||
colors: {
|
||||
primaryColor: generateColorScale(branding.primaryColor),
|
||||
secondaryColor: generateColorScale(branding.secondaryColor),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
defaultRadius: branding.defaultRadius,
|
||||
components: {
|
||||
ActionIcon: { defaultProps: { radius: branding.defaultRadius } },
|
||||
Button: { defaultProps: { radius: branding.defaultRadius } },
|
||||
Card: { defaultProps: { radius: branding.defaultRadius } },
|
||||
Paper: { defaultProps: { radius: branding.defaultRadius } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -495,7 +495,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
models: [
|
||||
{
|
||||
id: "homarr-assistant",
|
||||
name: "Assistant (Demo)",
|
||||
name: "Homarr Assistant (Demo)",
|
||||
description: null,
|
||||
contextLength: null,
|
||||
promptPrice: null,
|
||||
@@ -507,7 +507,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
}
|
||||
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
|
||||
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
|
||||
@@ -708,7 +708,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
getConfigurationAsync(ctx.db),
|
||||
]);
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
}
|
||||
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
|
||||
if (input.modelId !== configuration.modelId && !selectedModel) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
@@ -87,9 +86,7 @@ interface BoardItemPlacementRectangle {
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const manageOverviewInputSchema = z
|
||||
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
||||
.optional();
|
||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -126,79 +123,6 @@ const findFirstAvailableBoardItemPosition = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const searchAccessibleBoardsAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
query: string,
|
||||
limit?: number,
|
||||
) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
permissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
|
||||
const foundBoards = await ctx.db.query.boards.findMany({
|
||||
where: and(
|
||||
like(boards.name, `%${query}%`),
|
||||
ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
inArray(boards.id, boardIds),
|
||||
),
|
||||
),
|
||||
limit,
|
||||
orderBy: asc(boards.name),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
creatorId: true,
|
||||
isPublic: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: getBoardGroupPermissionWhere(permissionsOfCurrentUserGroupsWhenPresent),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return foundBoards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
permissions: constructBoardPermissions(board, ctx.session),
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
};
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
exists: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
@@ -303,14 +227,9 @@ export const boardRouter = createTRPCRouter({
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||
const sessionUserId = ctx.session?.user.id;
|
||||
const userId = input?.userId ?? sessionUserId;
|
||||
if (userId !== sessionUserId && !ctx.session?.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "You are not allowed to view boards for this user" });
|
||||
}
|
||||
const userId = ctx.session?.user.id;
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -360,7 +279,7 @@ export const boardRouter = createTRPCRouter({
|
||||
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
|
||||
},
|
||||
},
|
||||
where: getAccessibleBoardsWhere(canViewAllBoards, userId, boardIds),
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
|
||||
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
|
||||
@@ -563,18 +482,80 @@ export const boardRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
}),
|
||||
catalog: publicProcedure
|
||||
.meta({ mcp: { enabled: true, description: "List every board the current user can access." } })
|
||||
.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
|
||||
search: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Search accessible boards by name. REQUIRED: query (string). OPTIONAL: limit (number).",
|
||||
},
|
||||
})
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
|
||||
const foundBoards = await ctx.db.query.boards.findMany({
|
||||
where: and(
|
||||
like(boards.name, `%${input.query}%`),
|
||||
ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
inArray(boards.id, boardIds),
|
||||
),
|
||||
),
|
||||
limit: input.limit,
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
creatorId: true,
|
||||
isPublic: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return foundBoards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
permissions: constructBoardPermissions(board, ctx.session),
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
createBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.meta({
|
||||
@@ -2585,7 +2566,6 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
permissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2609,24 +2589,6 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
|
||||
return { boardIds, currentUser, groupMemberships };
|
||||
};
|
||||
|
||||
const getCanViewAllBoards = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) => {
|
||||
const permissions = new Set(
|
||||
groupMemberships.flatMap((membership) => membership.group.permissions.map(({ permission }) => permission)),
|
||||
);
|
||||
return getPermissionsWithChildren([...permissions]).includes("board-view-all");
|
||||
};
|
||||
|
||||
export const getAccessibleBoardIdsForUserAsync = async (db: Database, userId: string) => {
|
||||
const { boardIds, groupMemberships } = await getBoardAccessContextAsync(db, userId);
|
||||
const accessibleBoards = await db.query.boards.findMany({
|
||||
columns: { id: true },
|
||||
where: getAccessibleBoardsWhere(getCanViewAllBoards(groupMemberships), userId, boardIds),
|
||||
});
|
||||
return new Set(accessibleBoards.map(({ id }) => id));
|
||||
};
|
||||
|
||||
const getBoardGroupPermissionWhere = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) =>
|
||||
|
||||
@@ -130,28 +130,6 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
|
||||
return null;
|
||||
}),
|
||||
catalog: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List search engines available to the current user.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
where: ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
|
||||
orderBy: asc(searchEngines.name),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
kind: true,
|
||||
url: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
// Public dashboards have no session: restrict anonymous users to generic
|
||||
|
||||
@@ -10,12 +10,7 @@ import {
|
||||
} from "@homarr/db/queries";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import {
|
||||
authBrandingSchema,
|
||||
brandingServerSettingsSchema,
|
||||
defaultServerSettingsKeys,
|
||||
parseBrandingSettings,
|
||||
} from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
@@ -27,14 +22,7 @@ const boardServerSettingsSchema = z.object({
|
||||
}) satisfies z.ZodType<ServerSettings["board"]>;
|
||||
|
||||
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
|
||||
const brandingServerSettingsUpdateSchema = brandingServerSettingsSchema.partial().extend({
|
||||
authBranding: authBrandingSchema.partial().optional(),
|
||||
});
|
||||
const legacyAuthBrandingUpdateSchema = z.object({
|
||||
showCustomAppNameOnLogin: z.boolean().optional(),
|
||||
showCustomLogoOnLogin: z.boolean().optional(),
|
||||
showCustomGreetingOnLogin: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
@@ -42,14 +30,6 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await getServerSettingsAsync(ctx.db);
|
||||
}),
|
||||
getBranding: publicProcedure
|
||||
.meta({
|
||||
mcp: { enabled: true, description: "Returns the public instance branding configuration." },
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const branding = await getServerSettingByKeyAsync(ctx.db, "branding");
|
||||
return parseBrandingSettings(branding);
|
||||
}),
|
||||
getBoardSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
@@ -120,27 +100,10 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.settingsKey === "branding") {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, "branding");
|
||||
const parsedInput = brandingServerSettingsUpdateSchema.parse(input.value);
|
||||
const legacyInput = legacyAuthBrandingUpdateSchema.parse(input.value);
|
||||
const authBranding = { ...current.authBranding };
|
||||
authBranding.showAppName = legacyInput.showCustomAppNameOnLogin ?? authBranding.showAppName;
|
||||
authBranding.showLogo = legacyInput.showCustomLogoOnLogin ?? authBranding.showLogo;
|
||||
authBranding.showGreeting = legacyInput.showCustomGreetingOnLogin ?? authBranding.showGreeting;
|
||||
Object.assign(authBranding, parsedInput.authBranding);
|
||||
const value = brandingServerSettingsSchema.parse({
|
||||
...parseBrandingSettings(current),
|
||||
...parsedInput,
|
||||
authBranding,
|
||||
});
|
||||
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
|
||||
return;
|
||||
}
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[typeof input.settingsKey]);
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
}),
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe("saveSettings", () => {
|
||||
});
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "analytics",
|
||||
value: stringify({
|
||||
@@ -124,7 +124,7 @@ describe("board settings API", () => {
|
||||
forceDisableStatus: true,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
@@ -155,7 +155,7 @@ describe("board settings API", () => {
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
|
||||
@@ -15,11 +15,8 @@ import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import {
|
||||
getHeaderItems,
|
||||
headerPreferencesMutationSchema,
|
||||
parseHeaderPreferences,
|
||||
userChangeColorSchemeSchema,
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangePasswordApiSchema,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
userPingIconsEnabledSchema,
|
||||
userRegistrationApiSchema,
|
||||
} from "@homarr/validation/user";
|
||||
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import {
|
||||
@@ -44,7 +40,6 @@ import {
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { getAccessibleBoardIdsForUserAsync } from "./board";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
||||
@@ -373,7 +368,6 @@ export const userRouter = createTRPCRouter({
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
headerPreferences: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -411,7 +405,6 @@ export const userRouter = createTRPCRouter({
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
headerPreferences: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -726,63 +719,6 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeHeaderPreferences: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/header-preferences",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
},
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await ctx.db.query.users.findFirst({
|
||||
columns: { headerPreferences: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
|
||||
if (submittedBoardIds.length > 0) {
|
||||
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
|
||||
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
|
||||
const hasUnavailableShortcut = submittedBoardIds.some(
|
||||
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
|
||||
);
|
||||
if (hasUnavailableShortcut) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "One or more board shortcuts are unavailable to this user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
@@ -893,9 +829,6 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
|
||||
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
|
||||
|
||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
|
||||
|
||||
@@ -19,10 +19,8 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"beszel_getAlerts",
|
||||
"beszel_getSystems",
|
||||
"beszel_getSystemStats",
|
||||
"board_catalog",
|
||||
"board_getAllBoards",
|
||||
"board_getBoardSettings",
|
||||
"board_search",
|
||||
"calendar_findAllEvents",
|
||||
"customWidget_get",
|
||||
"customWidget_getAuthoringPrompt",
|
||||
@@ -65,7 +63,6 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"mediaServer_getCurrentStreams",
|
||||
"patchmon_getStats",
|
||||
"serverSettings_getBoardSettings",
|
||||
"serverSettings_getBranding",
|
||||
"smartHome_entityDetails",
|
||||
"smartHome_entityState",
|
||||
"widgetSecrets_getConfiguredKinds",
|
||||
|
||||
@@ -13,7 +13,6 @@ export const getRscUserSettingsAsync = cache(async (userId: string) =>
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
headerPreferences: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
|
||||
@@ -144,7 +144,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
|
||||
type: "function",
|
||||
function: {
|
||||
name: "customWidget_getComponent",
|
||||
description: "Get installed documentation and safety rules for one named Custom JSX component.",
|
||||
description:
|
||||
"Get installed documentation and safety rules for one named Custom JSX component. Fetch only a selected component, one tool call at a time, after any optional example.",
|
||||
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
|
||||
},
|
||||
},
|
||||
@@ -162,7 +163,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
|
||||
type: "function",
|
||||
function: {
|
||||
name: "customWidget_getExample",
|
||||
description: "Get one installed Custom JSX example by catalog ID.",
|
||||
description:
|
||||
"Optionally get one installed Custom JSX example by catalog ID before requesting any component documentation.",
|
||||
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
|
||||
},
|
||||
},
|
||||
@@ -251,11 +253,24 @@ export function executeAssistantEvaluationTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
): unknown {
|
||||
const componentDocumentCount = state.calledTools.filter(
|
||||
(toolName) => toolName === "customWidget_getComponent",
|
||||
).length;
|
||||
if (name === "customWidget_getExample") {
|
||||
if (componentDocumentCount > 0) {
|
||||
return {
|
||||
error:
|
||||
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
|
||||
};
|
||||
}
|
||||
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
|
||||
if (!example) return { error: "Custom JSX example not found" };
|
||||
state.calledTools.push(name);
|
||||
const { template, ...widget } = example.widget;
|
||||
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
|
||||
}
|
||||
const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8;
|
||||
if (
|
||||
name === "customWidget_getComponent" &&
|
||||
state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit
|
||||
) {
|
||||
if (name === "customWidget_getComponent" && componentDocumentCount >= componentDocumentLimit) {
|
||||
return {
|
||||
error:
|
||||
"The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.",
|
||||
@@ -277,12 +292,6 @@ export function executeAssistantEvaluationTool(
|
||||
? getCustomWidgetSharedProps(names)
|
||||
: { error: "At least one shared prop name is required" };
|
||||
}
|
||||
if (name === "customWidget_getExample") {
|
||||
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
|
||||
if (!example) return { error: "Custom JSX example not found" };
|
||||
const { template, ...widget } = example.widget;
|
||||
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
|
||||
}
|
||||
if (name === "customWidget_validate") {
|
||||
const parsed = parseDefinition(input.widget);
|
||||
if (!parsed.success) return { valid: false, issues: parsed.issues };
|
||||
@@ -576,11 +585,11 @@ async function runAssistantAttempt(args: {
|
||||
process.stdout.write(` tool: ${toolCall.function.name}\n`);
|
||||
let output: unknown;
|
||||
try {
|
||||
const parsed = JSON.parse(toolCall.function.arguments) as unknown;
|
||||
const input =
|
||||
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const parsed: unknown = JSON.parse(toolCall.function.arguments);
|
||||
let input: Record<string, unknown> = {};
|
||||
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
input = Object.fromEntries(Object.entries(parsed));
|
||||
}
|
||||
output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input);
|
||||
} catch (error) {
|
||||
output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" };
|
||||
|
||||
@@ -219,7 +219,7 @@ export function buildRepairPrompt(
|
||||
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
|
||||
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
|
||||
|
||||
The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
|
||||
The installed Homarr skill and runtime references below are authoritative. The verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes unless the validated manifest contradicts them. Do not invent external endpoint or authentication objections from outside assumptions. Decorative icons paired with equivalent adjacent visible status text need no separate aria-label. A Badge containing explicit visible status text is not color-only. A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
|
||||
|
||||
Installed Homarr Custom Widget authoring contract:
|
||||
${getCustomWidgetSkillContent()}
|
||||
@@ -254,16 +254,14 @@ Required review behavior:
|
||||
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
|
||||
- Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array.
|
||||
|
||||
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
|
||||
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
|
||||
}
|
||||
|
||||
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
|
||||
return (
|
||||
result.total >= 90 &&
|
||||
result.total >= 85 &&
|
||||
Object.values(result.categories).every((score) => score >= 75) &&
|
||||
result.categories.goalFulfillment >= 85 &&
|
||||
result.categories.visualQuality >= 85 &&
|
||||
result.categories.dailyUsefulness >= 85 &&
|
||||
result.categories.complexityDiscipline >= 80 &&
|
||||
result.dailyUseDecision === "would-use-daily" &&
|
||||
result.fatalProblems.length === 0
|
||||
|
||||
@@ -15,58 +15,55 @@ if (!apiKey) {
|
||||
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
|
||||
const assistantMode = process.argv.includes("--assistant");
|
||||
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
|
||||
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
|
||||
const selectableCases = assistantMode
|
||||
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
|
||||
(testCase) =>
|
||||
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
|
||||
testCase.expectations !== undefined,
|
||||
)
|
||||
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
|
||||
const selectedCases = requestedCase
|
||||
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
|
||||
: selectableCases;
|
||||
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
|
||||
if (totalLoopBudget < selectedCases.length) {
|
||||
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
|
||||
const maxLoops = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
|
||||
let selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES;
|
||||
if (assistantMode) {
|
||||
selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
|
||||
(testCase) =>
|
||||
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
|
||||
testCase.expectations !== undefined,
|
||||
);
|
||||
}
|
||||
let selectedCases = selectableCases;
|
||||
if (requestedCase)
|
||||
selectedCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase);
|
||||
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
|
||||
|
||||
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
|
||||
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
|
||||
await mkdir(outputRoot, { recursive: true });
|
||||
|
||||
const results: Array<AiEvaluationResult | CustomWidgetAssistantEvaluationResult> = [];
|
||||
let remainingLoopBudget = totalLoopBudget;
|
||||
for (const testCase of selectedCases) {
|
||||
const remainingCases = selectedCases.length - results.length;
|
||||
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
|
||||
process.stdout.write(`Evaluating ${testCase.id}...\n`);
|
||||
const result = assistantMode
|
||||
? await evaluateCustomWidgetAssistantCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
})
|
||||
: await evaluateCustomWidgetCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
let result: AiEvaluationResult | CustomWidgetAssistantEvaluationResult;
|
||||
if (assistantMode) {
|
||||
result = await evaluateCustomWidgetAssistantCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
} else {
|
||||
result = await evaluateCustomWidgetCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
}
|
||||
results.push(result);
|
||||
remainingLoopBudget -= result.attempts;
|
||||
process.stdout.write(
|
||||
result.judge
|
||||
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
|
||||
: ` failed after ${result.attempts} attempt(s)\n`,
|
||||
);
|
||||
if (result.judge) {
|
||||
process.stdout.write(` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`);
|
||||
} else {
|
||||
process.stdout.write(` failed after ${result.attempts} attempt(s)\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
import { customJsxAuthoringCatalog } from "./component-catalog";
|
||||
import {
|
||||
redactCustomWidgetAiContext,
|
||||
redactCustomWidgetAiResponse,
|
||||
redactCustomWidgetAiText,
|
||||
redactCustomWidgetAiUrl,
|
||||
} from "./definition-security";
|
||||
import { customJsxExamples } from "./examples";
|
||||
|
||||
export interface CustomWidgetAiDraft {
|
||||
@@ -106,19 +112,28 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
|
||||
|
||||
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
|
||||
|
||||
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
|
||||
Mandatory sequence — follow it exactly:
|
||||
1. First call customWidget_getSkill and follow the complete installed skill and bundled references.
|
||||
2. Call customWidget_schema, then customWidget_getComponentCatalog.
|
||||
3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call. Then choose at most four component names total and never request a fifth. Fetch only those named docs and, once, any named shared props with customWidget_getSharedProps.
|
||||
4. Before validation, quickly reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span; use catalog components instead. Immediately call customWidget_validate with the complete definition and repair every issue.
|
||||
5. Call customWidget_previewCreate with that exact validated definition.
|
||||
6. Call customWidget_previewQuery once for every query returned by the preview and inspect every response.
|
||||
7. Call customWidget_createFromPreview with the exact fully tested preview session ID.
|
||||
|
||||
Make one documentation tool call at a time. Never issue batched customWidget_getComponent calls, speculate about components, or walk the catalog. If preview evidence requires a definition change, restart at step 4 with the revised complete definition. The live resources are authoritative for this Homarr release.
|
||||
|
||||
Keep the documentation phase bounded. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only to resolve a concrete validation issue or missing interaction; never walk the catalog component-by-component. Move promptly from the smallest sufficient documentation set to a complete customWidget_validate call.
|
||||
|
||||
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
|
||||
Construct one credential-free definition. Supply required manual parameters to preview queries, inspect the real response shape and HTTP status, and open or provide the returned previewPath for visual review. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
|
||||
|
||||
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
|
||||
|
||||
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the response supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show timestamps as concise human-readable absolute dates and times; do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
|
||||
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
|
||||
|
||||
Create distinctive polish with one semantic accent, clear surface contrast, purposeful typography, and compact narrow-tile rows rather than decorative gradients or excessive nested cards. Label standalone icons for assistive technology or pair them with visible text.
|
||||
Create distinctive polish with one restrained accent for decorative chrome, while operational, degraded, and down states retain distinct semantic colors. Use clear surface contrast and purposeful typography rather than decorative gradients or excessive nested cards. Do not wrap every metric or service row in Paper or Card. Prefer one dominant surface with clearly separated service rows, a one-column compact narrow layout, and an intentionally more spacious wide layout. Label standalone icons for assistive technology or pair them with visible text.
|
||||
|
||||
Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
|
||||
|
||||
@@ -129,7 +144,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
|
||||
if (documentationUrl) {
|
||||
const documentationSection = formatBudgetedContextSection(
|
||||
"API documentation URL (reference only)",
|
||||
documentationUrl,
|
||||
redactCustomWidgetAiUrl(documentationUrl),
|
||||
700,
|
||||
"data",
|
||||
);
|
||||
@@ -138,7 +153,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
|
||||
if (request?.trim()) {
|
||||
const requestSection = formatBudgetedContextSection(
|
||||
"User-authored widget request (product intent only)",
|
||||
request.trim(),
|
||||
redactCustomWidgetAiText(request.trim()),
|
||||
1_700,
|
||||
"request",
|
||||
);
|
||||
@@ -201,7 +216,7 @@ function buildCustomWidgetPromptSections(
|
||||
const sections = [CUSTOM_WIDGET_CONTEXT_BOUNDARY_INSTRUCTION];
|
||||
const requestSection = formatBudgetedContextSection(
|
||||
"User-authored widget request (product intent only)",
|
||||
request?.trim() || "Describe the widget you want to create.",
|
||||
redactCustomWidgetAiText(request?.trim() || "Describe the widget you want to create."),
|
||||
1_700,
|
||||
"request",
|
||||
);
|
||||
@@ -209,7 +224,7 @@ function buildCustomWidgetPromptSections(
|
||||
if (documentationUrl) {
|
||||
const documentationSection = formatBudgetedContextSection(
|
||||
"API documentation URL (reference only)",
|
||||
documentationUrl,
|
||||
redactCustomWidgetAiUrl(documentationUrl),
|
||||
700,
|
||||
"data",
|
||||
);
|
||||
@@ -218,7 +233,7 @@ function buildCustomWidgetPromptSections(
|
||||
if (currentConfig) {
|
||||
const draftSection = formatBudgetedContextSection(
|
||||
"Current raw widget draft",
|
||||
JSON.stringify(currentConfig, null, 2),
|
||||
JSON.stringify(redactCustomWidgetAiContext(currentConfig), null, 2),
|
||||
4_000,
|
||||
"data",
|
||||
);
|
||||
@@ -227,14 +242,19 @@ function buildCustomWidgetPromptSections(
|
||||
if (diagnostics?.length) {
|
||||
const diagnosticSection = formatBudgetedContextSection(
|
||||
"Current normalized diagnostics",
|
||||
JSON.stringify(diagnostics),
|
||||
JSON.stringify(redactCustomWidgetAiContext(diagnostics)),
|
||||
1_500,
|
||||
"data",
|
||||
);
|
||||
if (diagnosticSection) sections.push(diagnosticSection);
|
||||
}
|
||||
if (rawResponse) {
|
||||
const responseSection = formatBudgetedContextSection("Sample API response", rawResponse, 1_000, "data");
|
||||
const responseSection = formatBudgetedContextSection(
|
||||
"Sample API response",
|
||||
redactCustomWidgetAiResponse(rawResponse),
|
||||
1_000,
|
||||
"data",
|
||||
);
|
||||
if (responseSection) sections.push(responseSection);
|
||||
}
|
||||
const authoringBudget = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - footerLength - 8;
|
||||
|
||||
@@ -191,6 +191,70 @@ export function redactCustomWidgetCredentialLiterals(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiContext(value: unknown, path: DefinitionPath = []): unknown {
|
||||
if (Array.isArray(value)) return value.map((entry, index) => redactCustomWidgetAiContext(entry, [...path, index]));
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => {
|
||||
const entryPath = [...path, key];
|
||||
const risk = getCustomWidgetCredentialKeyRisk(key);
|
||||
if (risk === "strong" && !isSchemaAuthenticationControl(entryPath)) {
|
||||
if (!isHarmlessCustomWidgetCredentialSetting(entry)) return [key, "[REDACTED]"];
|
||||
}
|
||||
if (risk === "ambiguous" && typeof entry === "string") {
|
||||
if (redactCustomWidgetCredentialLiterals(entry) !== entry) return [key, "[REDACTED]"];
|
||||
}
|
||||
return [key, redactCustomWidgetAiContext(entry, entryPath)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (typeof value !== "string") return value;
|
||||
|
||||
const lastPathPart = path.at(-1);
|
||||
let key = "";
|
||||
if (typeof lastPathPart === "string") key = lastPathPart;
|
||||
if (["sources", "requests", "options"].includes(key)) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return redactCustomWidgetAiContext(parsed, path);
|
||||
} catch {
|
||||
/* Invalid editor JSON is still useful prompt context. */
|
||||
}
|
||||
}
|
||||
if (key === "baseUrl" || key === "iconUrl") {
|
||||
return redactCustomWidgetCredentialLiterals(redactCustomWidgetAiUrl(value));
|
||||
}
|
||||
return redactCustomWidgetAiText(value);
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiResponse(value: string): string {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return JSON.stringify(redactCustomWidgetAiContext(parsed), null, 2);
|
||||
} catch {
|
||||
return redactCustomWidgetAiText(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "[REDACTED_URL]";
|
||||
}
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiText(value: string): string {
|
||||
return redactCustomWidgetCredentialLiterals(
|
||||
value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactCustomWidgetAiUrl(candidate)),
|
||||
);
|
||||
}
|
||||
|
||||
function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
|
||||
return (
|
||||
path.length === 3 &&
|
||||
|
||||
@@ -36,8 +36,8 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
|
||||
options: {},
|
||||
template: `<Stack gap="md" p="md">
|
||||
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
|
||||
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
|
||||
<Group justify="space-between" wrap="wrap"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton label="Refresh service health" /></Group>
|
||||
{status.overview?.loading ? <Skeleton height={180} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">Service health is unavailable. Use Refresh service health to try again. {status.overview.error}</Alert> : <Paper withBorder p={{ base: "sm", sm: "md" }} radius="lg"><Stack gap="md"><SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={{ base: "xs", sm: "lg" }}><Stack gap={2}><Text size="xs" c="dimmed">Services</Text><Text fw={700}>{data.overview?.services?.length ?? 0}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Online</Text><Text fw={700} c="green">{(data.overview?.services ?? []).filter(service => service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Offline</Text><Text fw={700} c="red">{(data.overview?.services ?? []).filter(service => !service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Last checked</Text><Text size="sm">{(data.overview?.checkedAt ?? data.overview?.lastCheckedAt) ? Date.toLocaleDateString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") + " " + Date.toLocaleTimeString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") : "Not reported"}</Text></Stack></SimpleGrid><Divider />{(data.overview?.services?.length ?? 0) > 0 ? <Stack gap={0}>{data.overview.services.map(service => <Group key={service.name} justify="space-between" wrap="wrap" py={{ base: "xs", sm: "sm" }}><Stack gap={2}><Text size="sm" fw={600}>{service.name}</Text><Text size="xs" c="dimmed">{service.latency != null ? service.latency + " ms" : "Latency unavailable"}</Text></Stack><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group>)}</Stack> : <Alert color="gray" title="No services">No services were returned. Use Refresh service health to try again.</Alert>}</Stack></Paper>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { customJsxCatalogComponentByName } from "../core/component-catalog";
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
@@ -31,6 +32,20 @@ export function analyzeCustomJsxElement(
|
||||
const name = nameNode ? customJsxTagName(nameNode) : null;
|
||||
const resolvedName = name ? resolveCustomJsxComponentName(name) : null;
|
||||
const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined;
|
||||
const attributes = nodesOf(opening.attributes);
|
||||
if (resolvedName === "TablerIcon") {
|
||||
const attributeNames = new Set<string>();
|
||||
for (const attribute of attributes) {
|
||||
if (attribute.type !== "JSXAttribute") continue;
|
||||
const attributeName = nodeOf(attribute.name);
|
||||
if (attributeName?.type === "JSXIdentifier") attributeNames.add(String(attributeName.name));
|
||||
}
|
||||
for (const prop of customJsxCatalogComponentByName.get(resolvedName)?.props ?? []) {
|
||||
if (prop.required && !attributeNames.has(prop.name)) {
|
||||
context.add(opening, `MISSING_REQUIRED_PROP: '${prop.name}' on ${name} is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (descriptor?.safety === "denied") {
|
||||
context.add(
|
||||
opening,
|
||||
@@ -46,7 +61,7 @@ export function analyzeCustomJsxElement(
|
||||
);
|
||||
}
|
||||
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
for (const attribute of attributes) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) context.visit(argument, depth + 1, bindings);
|
||||
@@ -71,7 +86,11 @@ export function analyzeCustomJsxElement(
|
||||
attributeName !== "bind" &&
|
||||
!customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName)
|
||||
) {
|
||||
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
if (resolvedName === "TablerIcon") {
|
||||
context.add(attribute, `UNKNOWN_COMPONENT_PROP: '${attributeName}' on ${name} is not supported`);
|
||||
} else {
|
||||
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
}
|
||||
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
|
||||
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge
|
||||
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
|
||||
import { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
type Namespace = Readonly<Record<string, unknown>>;
|
||||
type Namespace = object;
|
||||
|
||||
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
|
||||
let value: unknown = namespace;
|
||||
@@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
|
||||
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
|
||||
return undefined;
|
||||
}
|
||||
value = (value as Record<string, unknown>)[segment];
|
||||
value = Reflect.get(value, segment);
|
||||
}
|
||||
return typeof value === "function" || (typeof value === "object" && value !== null)
|
||||
? (value as ComponentType<never>)
|
||||
@@ -233,9 +233,9 @@ export interface CustomJsxComponentAdapters {
|
||||
|
||||
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
|
||||
const namespaces: Record<string, Namespace> = {
|
||||
"@mantine/core": Core as unknown as Namespace,
|
||||
"@mantine/charts": Charts as unknown as Namespace,
|
||||
"@mantine/dates": Dates as unknown as Namespace,
|
||||
"@mantine/core": Core,
|
||||
"@mantine/charts": Charts,
|
||||
"@mantine/dates": Dates,
|
||||
};
|
||||
const components: Record<string, ComponentType<never>> = {};
|
||||
for (const descriptor of enabledCustomJsxComponents) {
|
||||
@@ -253,7 +253,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
components[descriptor.name] = wrap(descriptor.name, component, additions);
|
||||
}
|
||||
}
|
||||
const core = Core as unknown as Namespace;
|
||||
const core: Namespace = Core;
|
||||
for (const name of [
|
||||
"Notification",
|
||||
"LoadingOverlay",
|
||||
|
||||
@@ -146,6 +146,24 @@ describe("Custom Widget assistant live evaluation harness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a late example without reducing the existing component-document budget", () => {
|
||||
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
|
||||
if (!testCase) throw new Error("Fake service-health case is missing");
|
||||
const state = createAssistantEvaluationState();
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" });
|
||||
}
|
||||
expect(
|
||||
executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }),
|
||||
).toEqual({
|
||||
error:
|
||||
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
|
||||
});
|
||||
expect(state.calledTools).not.toContain("customWidget_getExample");
|
||||
expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("enforces explicit multi-iteration preview requests before persistence", () => {
|
||||
const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
|
||||
if (!baseCase) throw new Error("Fake service-health case is missing");
|
||||
|
||||
@@ -226,14 +226,38 @@ describe("AI authoring evaluation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires exceptional goal, design, practicality, and complexity scores", () => {
|
||||
expect(judgePasses(makeJudgeResult(90))).toBe(true);
|
||||
it("requires an excellent result without weak categories", () => {
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(85),
|
||||
categories: {
|
||||
...makeCategories(85),
|
||||
visualQuality: 76,
|
||||
dailyUsefulness: 76,
|
||||
complexityDiscipline: 80,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(judgePasses(makeJudgeResult(84))).toBe(false);
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(90),
|
||||
categories: { ...makeCategories(90), accessibility: 74 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(92),
|
||||
categories: { ...makeCategories(92), dailyUsefulness: 84 },
|
||||
categories: { ...makeCategories(92), goalFulfillment: 84 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(92),
|
||||
categories: { ...makeCategories(92), complexityDiscipline: 79 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false);
|
||||
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -257,6 +281,20 @@ describe("AI authoring evaluation", () => {
|
||||
expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2));
|
||||
expect(prompt).toContain("# Bundled file: references/runtime.md");
|
||||
expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility");
|
||||
expect(prompt).toContain(
|
||||
"verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes",
|
||||
);
|
||||
expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions");
|
||||
expect(prompt).toContain(
|
||||
"Decorative icons paired with equivalent adjacent visible status text need no separate aria-label",
|
||||
);
|
||||
expect(prompt).toContain("A Badge containing explicit visible status text is not color-only");
|
||||
expect(prompt).toContain(
|
||||
"A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"',
|
||||
);
|
||||
});
|
||||
|
||||
it("computes the weighted score and refuses inflated advisory verdicts", () => {
|
||||
|
||||
@@ -53,11 +53,46 @@ describe("AI prompt", () => {
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"choose at most four component names total and never request a fifth",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"operational, degraded, and down states retain distinct semantic colors",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"a one-column compact narrow layout, and an intentionally more spacious wide layout",
|
||||
);
|
||||
const orderedSteps = [
|
||||
"1. First call customWidget_getSkill",
|
||||
"2. Call customWidget_schema",
|
||||
"3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call",
|
||||
"4. Before validation",
|
||||
"5. Call customWidget_previewCreate",
|
||||
"6. Call customWidget_previewQuery once for every query",
|
||||
"7. Call customWidget_createFromPreview",
|
||||
];
|
||||
for (const [index, step] of orderedSteps.entries()) {
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step);
|
||||
if (index === 0) continue;
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan(
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step),
|
||||
);
|
||||
}
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
|
||||
);
|
||||
@@ -74,7 +109,7 @@ describe("AI prompt", () => {
|
||||
|
||||
it("preserves raw context and the final instruction when optional context is large", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it");
|
||||
expect(prompt).toContain('"data":"');
|
||||
expect(prompt).toContain('"data": "');
|
||||
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
|
||||
expect(prompt.length).toBeLessThanOrEqual(12_000);
|
||||
});
|
||||
@@ -102,4 +137,25 @@ describe("AI prompt", () => {
|
||||
|
||||
expect(prompt).toContain("Use compact cards and show the current latency");
|
||||
});
|
||||
|
||||
it("redacts credentials from widget context", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
JSON.stringify({ token: "sensitive" }),
|
||||
{
|
||||
requests: {
|
||||
status: {
|
||||
path: "/status?credential=Bearer-sk-secret-123456",
|
||||
headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"Use Authorization: Bearer sk-secret-123456",
|
||||
);
|
||||
|
||||
expect(prompt).not.toContain("sensitive");
|
||||
expect(prompt).not.toContain("sk-secret-123456");
|
||||
expect(prompt).toContain('"X-Auth": "[REDACTED]"');
|
||||
expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCustomWidgetSkill,
|
||||
getCustomWidgetSkillContent,
|
||||
} from "../core/authoring-resources";
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
|
||||
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
|
||||
|
||||
@@ -55,6 +56,11 @@ describe("authoring resources", () => {
|
||||
sources: { default: { baseUrl: "https://pokeapi.co" } },
|
||||
},
|
||||
});
|
||||
const serviceDashboard = getCustomWidgetExample("service-dashboard");
|
||||
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
|
||||
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
|
||||
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
|
||||
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
|
||||
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
|
||||
expect(catalog.sharedProps).toMatchObject({
|
||||
count: expect.any(Number),
|
||||
|
||||
@@ -43,6 +43,21 @@ describe("shared Custom JSX policy", () => {
|
||||
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects TablerIcon without its required name prop", () => {
|
||||
expect(validateCustomJsxTemplate('<TablerIcon icon="IconHeartbeat" />')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts common bounded formatting and collection operations", () => {
|
||||
expect(
|
||||
validateCustomJsxTemplate(
|
||||
|
||||
@@ -277,7 +277,8 @@ function EditorLoader() {
|
||||
function formatCode(value: string, language: "jsx" | "json" | "css") {
|
||||
if (language === "json") {
|
||||
try {
|
||||
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
|
||||
--> statement-breakpoint
|
||||
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
|
||||
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}');
|
||||
File diff suppressed because it is too large.
Load diff
@@ -337,13 +337,6 @@
|
||||
"when": 1786217088950,
|
||||
"tag": "0047_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "5",
|
||||
"when": 1787556966768,
|
||||
"tag": "0048_add_header_preferences_and_branding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "serverSetting" ("setting_key", "value")
|
||||
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
|
||||
ON CONFLICT ("setting_key") DO NOTHING;
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user