Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fcb518d8 | ||
|
|
90859dcde6 | ||
|
|
a7630b7d6d | ||
|
|
2dbeb792a7 | ||
|
|
e68a1491ae | ||
|
|
29c827f1be | ||
|
|
d0fe65a1d6 | ||
|
|
3c83e646ca | ||
|
|
27decf209a | ||
|
|
75bf432859 | ||
|
|
12cad9d3fb | ||
|
|
d39d6cf888 | ||
|
|
2613a556b1 | ||
|
|
346bead673 | ||
|
|
3a16f63e26 | ||
|
|
54afb14030 | ||
|
|
af394577b8 | ||
|
|
f8d3d5893a | ||
|
|
803d590a16 | ||
|
|
aa891d61ed | ||
|
|
f52a391157 | ||
|
|
d4b075ba55 | ||
|
|
86550aadc9 |
No files matched your search
@@ -55,7 +55,7 @@ The studio keeps related choices together:
|
|||||||
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
||||||
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
|
||||||
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
|
||||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||||
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
|
||||||
|
|
||||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Configure and use Homarr Assistant.
|
description: Configure and use Assistant.
|
||||||
tags:
|
tags:
|
||||||
- Assistant
|
- Assistant
|
||||||
- AI
|
- AI
|
||||||
@@ -8,10 +8,10 @@ tags:
|
|||||||
|
|
||||||
# Assistant
|
# Assistant
|
||||||
|
|
||||||
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
|
||||||
approval before making changes.
|
approval before making changes.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Set it up
|
## Set it up
|
||||||
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ Change the radius of your items and Containers.
|
|||||||
This is the field where you can enter custom CSS.
|
This is the field where you can enter custom CSS.
|
||||||
The styling will only be applied on the respective board page.
|
The styling will only be applied on the respective board page.
|
||||||
|
|
||||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||||
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
|
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
|
||||||
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
|
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
|
||||||
or Danger zone actions.
|
or Danger zone actions.
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
|
|||||||
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
||||||
runtime components.
|
runtime components.
|
||||||
|
|
||||||
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
|
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
|
|||||||
### Design with AI
|
### Design with AI
|
||||||
|
|
||||||
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
|
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
|
||||||
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
|
||||||
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
|
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
|
||||||
update tool for your approval.
|
update tool for your approval.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
|
|||||||
|
|
||||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||||
|
|
||||||
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
|
The optional [Assistant](./assistant) uses these same tool definitions internally.
|
||||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||||
approval before running mutating procedures.
|
approval before running mutating procedures.
|
||||||
|
|
||||||
|
|||||||
+351
@@ -3453,6 +3453,9 @@
|
|||||||
"enableRightClickOnWidgets": {
|
"enableRightClickOnWidgets": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"headerPreferences": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"defaultSearchEngineId": {
|
"defaultSearchEngineId": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -3488,6 +3491,7 @@
|
|||||||
"firstDayOfWeek",
|
"firstDayOfWeek",
|
||||||
"pingIconsEnabled",
|
"pingIconsEnabled",
|
||||||
"enableRightClickOnWidgets",
|
"enableRightClickOnWidgets",
|
||||||
|
"headerPreferences",
|
||||||
"defaultSearchEngineId",
|
"defaultSearchEngineId",
|
||||||
"openSearchInNewTab",
|
"openSearchInNewTab",
|
||||||
"ddgBangs",
|
"ddgBangs",
|
||||||
@@ -4346,6 +4350,353 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/users/header-preferences": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "userRouter-changeHeaderPreferences",
|
||||||
|
"tags": ["users"],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"apikey": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headerPreferences": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "number",
|
||||||
|
"const": 3
|
||||||
|
},
|
||||||
|
"visible": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"searchDisplay": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["input", "icon"]
|
||||||
|
},
|
||||||
|
"logoDisplay": {
|
||||||
|
"default": "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": {
|
"/api/users/ddg-bangs": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "userRouter-changeDdgBangs",
|
"operationId": "userRouter-changeDdgBangs",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
|
|||||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
import { theme } from "@homarr/ui";
|
import { theme } from "@homarr/ui";
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
|
||||||
|
import { createBrandTheme } from "~/theme/branding";
|
||||||
|
|
||||||
export const CustomMantineProvider = ({
|
export const CustomMantineProvider = ({
|
||||||
children,
|
children,
|
||||||
defaultColorScheme,
|
defaultColorScheme,
|
||||||
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
|
branding,
|
||||||
|
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
|
||||||
const manager = useColorSchemeManager();
|
const manager = useColorSchemeManager();
|
||||||
|
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
|
||||||
return (
|
return (
|
||||||
<DirectionProvider>
|
<DirectionProvider>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme={defaultColorScheme}
|
defaultColorScheme={defaultColorScheme}
|
||||||
colorSchemeManager={manager}
|
colorSchemeManager={manager}
|
||||||
theme={theme}
|
theme={configuredTheme}
|
||||||
cssVariablesResolver={v8CssVariablesResolver}
|
cssVariablesResolver={v8CssVariablesResolver}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { IconUserPlus } from "@tabler/icons-react";
|
import { IconUserPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { and, db, eq } from "@homarr/db";
|
import { and, db, eq } from "@homarr/db";
|
||||||
import { invites } from "@homarr/db/schema";
|
import { invites } from "@homarr/db/schema";
|
||||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||||
|
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { RegistrationForm } from "./_registration-form";
|
import { RegistrationForm } from "./_registration-form";
|
||||||
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const params = await props.params;
|
|
||||||
if (!isProviderEnabled("credentials")) notFound();
|
if (!isProviderEnabled("credentials")) notFound();
|
||||||
|
|
||||||
const session = await auth();
|
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
|
||||||
if (session) notFound();
|
if (session) notFound();
|
||||||
|
|
||||||
const invite = await db.query.invites.findFirst({
|
const invite = await db.query.invites.findFirst({
|
||||||
@@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
|||||||
|
|
||||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||||
|
|
||||||
const t = await getI18n("user.page.invite");
|
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
|
||||||
|
const branding = serverSettings.branding;
|
||||||
|
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||||
|
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
|
||||||
|
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||||
|
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
|
||||||
|
const defaultDescription = showCustomAppName
|
||||||
|
? t("subtitleWithAppName", { appName: branding.appName })
|
||||||
|
: t("subtitle");
|
||||||
|
const description = showCustomGreeting ? branding.greeting : defaultDescription;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingAuthShell
|
<OnboardingAuthShell
|
||||||
title={t("title")}
|
title={title}
|
||||||
description={t("subtitle")}
|
description={description}
|
||||||
icon={<IconUserPlus size={24} />}
|
icon={<IconUserPlus size={24} />}
|
||||||
|
appName={branding.appName}
|
||||||
|
showAppName={branding.authBranding.showAppName}
|
||||||
|
showAppLogo={branding.authBranding.showLogo}
|
||||||
|
primaryColor={branding.primaryColor}
|
||||||
|
secondaryColor={branding.secondaryColor}
|
||||||
|
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||||
|
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||||
|
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||||
|
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||||
|
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||||
|
radius={branding.defaultRadius}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
footer={t("description", { username: invite.creator.name! })}
|
footer={t("description", { username: invite.creator.name! })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { redirect } from "next/navigation";
|
|||||||
import { Alert, Code, Text } from "@mantine/core";
|
import { Alert, Code, Text } from "@mantine/core";
|
||||||
import { IconLogin } from "@tabler/icons-react";
|
import { IconLogin } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
import { env } from "@homarr/auth/env";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||||
|
import { getBrandingColorOverrides } from "@homarr/server-settings";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||||
|
|
||||||
@@ -19,17 +21,35 @@ interface LoginProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Login(props: LoginProps) {
|
export default async function Login(props: LoginProps) {
|
||||||
const searchParams = await props.searchParams;
|
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getI18n("user.page.login");
|
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
|
||||||
|
const branding = serverSettings.branding;
|
||||||
|
const wordmarkColors = getBrandingColorOverrides(branding);
|
||||||
|
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
|
||||||
|
const description = showCustomGreeting ? branding.greeting : t("subtitle");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
<OnboardingAuthShell
|
||||||
|
title={t("title")}
|
||||||
|
description={description}
|
||||||
|
icon={<IconLogin size={24} />}
|
||||||
|
appName={branding.appName}
|
||||||
|
showAppName={branding.authBranding.showAppName}
|
||||||
|
showAppLogo={branding.authBranding.showLogo}
|
||||||
|
primaryColor={branding.primaryColor}
|
||||||
|
secondaryColor={branding.secondaryColor}
|
||||||
|
wordmarkPrimaryColor={wordmarkColors.primaryColor}
|
||||||
|
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
|
||||||
|
logoImageUrl={branding.logoImageUrl ?? undefined}
|
||||||
|
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
|
||||||
|
backgroundOverlay={branding.signInBackgroundOverlay}
|
||||||
|
radius={branding.defaultRadius}
|
||||||
|
>
|
||||||
{appEnv.DEMO_MODE ? (
|
{appEnv.DEMO_MODE ? (
|
||||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
|
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
import { makeQueryClient } from "@homarr/api/shared";
|
import { makeQueryClient } from "@homarr/api/shared";
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
@@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
import type { Board, Item } from "../_types";
|
import type { Board, Item } from "../_types";
|
||||||
import { ClientBoard } from "./_client";
|
import { ClientBoard } from "./_client";
|
||||||
import { BoardContentHeaderActions } from "./_header-actions";
|
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
|
||||||
|
|
||||||
const logger = createLogger({ module: "createBoardContentPage" });
|
const logger = createLogger({ module: "createBoardContentPage" });
|
||||||
const getQueryClient = cache(makeQueryClient);
|
const getQueryClient = cache(makeQueryClient);
|
||||||
@@ -36,7 +36,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
}: Props<TParams>) => {
|
}: Props<TParams>) => {
|
||||||
return {
|
return {
|
||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||||
|
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour: true,
|
withTour: true,
|
||||||
}),
|
}),
|
||||||
@@ -103,18 +104,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
const board = await getInitialBoard(await params);
|
const [board, t, serverSettings] = await Promise.all([
|
||||||
const t = await getI18n("board");
|
getInitialBoard(await params),
|
||||||
|
getI18n("board"),
|
||||||
|
getRscServerSettingsAsync(),
|
||||||
|
]);
|
||||||
|
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
|
||||||
|
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
|
||||||
|
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
|
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
|
||||||
icons: {
|
icons: {
|
||||||
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
icon: favicon,
|
||||||
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
apple: favicon,
|
||||||
},
|
},
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
startupImage: {
|
startupImage: {
|
||||||
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
|
url: favicon,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
|
|||||||
]);
|
]);
|
||||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||||
|
|
||||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const t = useI18n("board");
|
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
|
||||||
if (!hasChangeAccess) return null;
|
if (!hasChangeAccess) return null;
|
||||||
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <BoardEditActions />}
|
{isEditMode && <BoardEditActions />}
|
||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
|
||||||
<TourTarget id="board-settings">
|
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
|
||||||
<IconSettings stroke={1.5} />
|
|
||||||
</HeaderButton>
|
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const t = useI18n("board");
|
||||||
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
|
||||||
|
if (!hasChangeAccess || demoReadOnly) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourTarget id="board-settings">
|
||||||
|
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||||
|
<IconSettings stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</TourTarget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||||
|
import { generateColorScale } from "~/theme/branding";
|
||||||
|
|
||||||
export const BoardMantineProvider = ({
|
export const BoardMantineProvider = ({
|
||||||
children,
|
children,
|
||||||
defaultColorScheme,
|
defaultColorScheme,
|
||||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const { branding } = useSettings();
|
||||||
const colorSchemeManager = useColorSchemeManager();
|
const colorSchemeManager = useColorSchemeManager();
|
||||||
|
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
colors: {
|
colors: {
|
||||||
primaryColor: generateColors(board.primaryColor),
|
primaryColor: generateColorScale(primaryColor),
|
||||||
secondaryColor: generateColors(board.secondaryColor),
|
secondaryColor: generateColorScale(board.secondaryColor),
|
||||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
|
||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
defaultRadius: "sm",
|
defaultRadius: branding.defaultRadius,
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
"2xl": rem(24),
|
"2xl": rem(24),
|
||||||
"3xl": rem(28),
|
"3xl": rem(28),
|
||||||
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateColors = (hex: string) => {
|
|
||||||
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
|
|
||||||
const rgbaColors = lightnessForColors.map((lightness) => {
|
|
||||||
if (lightness < 0) {
|
|
||||||
return lighten(hex, -lightness);
|
|
||||||
}
|
|
||||||
return darken(hex, lightness);
|
|
||||||
});
|
|
||||||
|
|
||||||
return rgbaColors.map((color) => {
|
|
||||||
return (
|
|
||||||
"#" +
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
color
|
|
||||||
.split("(")[1]!
|
|
||||||
.replaceAll(" ", "")
|
|
||||||
.replace(")", "")
|
|
||||||
.split(",")
|
|
||||||
.map((color) => parseInt(color, 10))
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((color) => color.toString(16).padStart(2, "0"))
|
|
||||||
.join("")
|
|
||||||
);
|
|
||||||
}) as unknown as MantineColorsTuple;
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IconHomeOff } from "@tabler/icons-react";
|
import { IconHomeOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import { boards } from "@homarr/db/schema";
|
import { boards } from "@homarr/db/schema";
|
||||||
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
import type { BoardNotFoundProps } from "~/components/board/not-found";
|
||||||
import { BoardNotFound } from "~/components/board/not-found";
|
import { BoardNotFound } from "~/components/board/not-found";
|
||||||
|
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||||
|
|
||||||
export default async function NotFoundBoardHomePage() {
|
export default async function NotFoundBoardHomePage() {
|
||||||
const boardNotFoundProps = await getPropsAsync();
|
const boardNotFoundProps = await getPropsAsync();
|
||||||
@@ -19,8 +21,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
|
|||||||
const t = await getI18n("board");
|
const t = await getI18n("board");
|
||||||
|
|
||||||
if (boardCount === 0) {
|
if (boardCount === 0) {
|
||||||
|
const { branding } = await getRscServerSettingsAsync();
|
||||||
return {
|
return {
|
||||||
icon: { src: "/favicon.ico", alt: "Homarr logo" },
|
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
|
||||||
title: t("error.noBoard.title"),
|
title: t("error.noBoard.title"),
|
||||||
description: t("error.noBoard.description"),
|
description: t("error.noBoard.description"),
|
||||||
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react";
|
|||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { BoardColorInput } from "@homarr/ui";
|
import { BoardColorInput } from "@homarr/ui";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { generateColors } from "../../(content)/_theme";
|
import { generateColorScale } from "~/theme/branding";
|
||||||
import type { FormValues } from "./_settings-form";
|
import type { FormValues } from "./_settings-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,13 +41,19 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
|||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
const { branding } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
<SectionCard title={tBoard("setting.section.appearance.title")}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
|
<BoardColorInput
|
||||||
|
label={tBoard("field.primaryColor.label")}
|
||||||
|
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
|
||||||
|
disabled={branding.lockPrimaryColor}
|
||||||
|
{...form.getInputProps("primaryColor")}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
@@ -121,7 +128,8 @@ interface ColorsPreviewProps {
|
|||||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
|
const colors =
|
||||||
|
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap={0} wrap="nowrap">
|
<Group gap={0} wrap="nowrap">
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
|
|||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { IconPicker } from "@homarr/forms-collection";
|
import { IconPicker } from "@homarr/forms-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import type { FormValues } from "./_settings-form";
|
import type { FormValues } from "./_settings-form";
|
||||||
|
|
||||||
@@ -21,20 +21,26 @@ interface Props {
|
|||||||
|
|
||||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||||
const t = useI18n("board");
|
const t = useI18n("board");
|
||||||
|
const { branding } = useSettings();
|
||||||
|
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
|
||||||
|
|
||||||
useLogoPreview(form.values.logoImageUrl);
|
useLogoPreview(form.values.logoImageUrl);
|
||||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={t("setting.section.general.title")}>
|
<SectionCard title={t("setting.section.general.title")}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
|
<TextInput
|
||||||
|
label={t("field.pageTitle.label")}
|
||||||
|
placeholder={branding.appName}
|
||||||
|
{...form.getInputProps("pageTitle")}
|
||||||
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("field.metaTitle.label")}
|
label={t("field.metaTitle.label")}
|
||||||
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
|
placeholder={defaultMetaTitle}
|
||||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||||
{...form.getInputProps("metaTitle")}
|
{...form.getInputProps("metaTitle")}
|
||||||
/>
|
/>
|
||||||
@@ -43,7 +49,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
|||||||
<IconPicker
|
<IconPicker
|
||||||
{...form.getInputProps("logoImageUrl")}
|
{...form.getInputProps("logoImageUrl")}
|
||||||
label={t("field.logoImageUrl.label")}
|
label={t("field.logoImageUrl.label")}
|
||||||
placeholder="/logo/logo.png"
|
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||||
withAsterisk={false}
|
withAsterisk={false}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -51,7 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
|
|||||||
<IconPicker
|
<IconPicker
|
||||||
{...form.getInputProps("faviconImageUrl")}
|
{...form.getInputProps("faviconImageUrl")}
|
||||||
label={t("field.faviconImageUrl.label")}
|
label={t("field.faviconImageUrl.label")}
|
||||||
placeholder="/logo/logo.png"
|
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
|
||||||
withAsterisk={false}
|
withAsterisk={false}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -72,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
|
|||||||
}, [logoDebounced, updateBoard]);
|
}, [logoDebounced, updateBoard]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useMetaTitlePreview = (title: string | null) => {
|
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
|
||||||
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
|
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
|
||||||
useDocumentTitle(metaTitleDebounced);
|
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPending: (title ?? "") !== metaTitleDebounced,
|
isPending: (title ?? "") !== metaTitleDebounced,
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ interface BoardSettingsFormProps {
|
|||||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||||
const t = useI18n("common");
|
const t = useI18n("common");
|
||||||
const tSection = useI18n("board.setting.section");
|
const tSection = useI18n("board.setting.section");
|
||||||
|
const { branding } = useSettings();
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||||
const saveLayouts = useSaveLayoutsMutation(board);
|
const saveLayouts = useSaveLayoutsMutation(board);
|
||||||
@@ -142,7 +144,9 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
|||||||
const [, canonicalLayouts] = await Promise.all([
|
const [, canonicalLayouts] = await Promise.all([
|
||||||
partialSettingsChanged
|
partialSettingsChanged
|
||||||
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
updateFavicon(
|
||||||
|
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
|
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { appShellLogoHeight } from "~/components/layout/constants";
|
||||||
|
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -27,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
const logger = createLogger({ module: "createBoardLayout" });
|
const logger = createLogger({ module: "createBoardLayout" });
|
||||||
|
|
||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions?: JSX.Element;
|
||||||
|
headerBoardEditAction?: JSX.Element;
|
||||||
|
headerBoardSettingsAction?: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
withTour?: boolean;
|
withTour?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
|
headerBoardEditAction,
|
||||||
|
headerBoardSettingsAction,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour = false,
|
withTour = false,
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
@@ -105,8 +110,11 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||||
<ClientShell hasNavigation={false}>
|
<ClientShell hasNavigation={false}>
|
||||||
<MainHeader
|
<MainHeader
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
logo={<BoardLogo size={appShellLogoHeight} />}
|
||||||
|
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
|
boardEditAction={headerBoardEditAction}
|
||||||
|
boardSettingsAction={headerBoardSettingsAction}
|
||||||
hasNavigation={false}
|
hasNavigation={false}
|
||||||
/>
|
/>
|
||||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { SettingsProvider } from "@homarr/settings";
|
|||||||
import { SpotlightProvider } from "@homarr/spotlight";
|
import { SpotlightProvider } from "@homarr/spotlight";
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
@@ -53,35 +54,45 @@ const fontSans = Inter({
|
|||||||
|
|
||||||
const logger = createLogger({ module: "rootLayout" });
|
const logger = createLogger({ module: "rootLayout" });
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
export const generateMetadata = async (): Promise<Metadata> => {
|
||||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
const [serverSettings, colorScheme, t] = await Promise.all([
|
||||||
title: "Homarr",
|
getRscServerSettingsAsync(),
|
||||||
description:
|
getCurrentColorSchemeAsync(),
|
||||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
getI18n("metadata"),
|
||||||
openGraph: {
|
]);
|
||||||
title: "Homarr Dashboard",
|
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
|
||||||
description:
|
const logo = logoImageUrl ?? "/logo/logo.png";
|
||||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
const favicon = faviconImageUrl ?? logo;
|
||||||
url: env.HOMARR_WEBSITE_URL,
|
const description = t("description");
|
||||||
siteName: "Homarr",
|
|
||||||
},
|
|
||||||
icons: {
|
|
||||||
icon: "/logo/logo.png",
|
|
||||||
apple: "/logo/logo.png",
|
|
||||||
},
|
|
||||||
appleWebApp: {
|
|
||||||
title: "Homarr",
|
|
||||||
capable: true,
|
|
||||||
startupImage: { url: "/logo/logo.png" },
|
|
||||||
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
return {
|
||||||
themeColor: [
|
title: {
|
||||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
default: appName,
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
template: `%s • ${appName}`,
|
||||||
],
|
},
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title: t("dashboardTitle", { appName }),
|
||||||
|
description,
|
||||||
|
url: env.HOMARR_WEBSITE_URL,
|
||||||
|
siteName: appName,
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: favicon,
|
||||||
|
apple: logo,
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
title: appName,
|
||||||
|
capable: true,
|
||||||
|
startupImage: { url: logo },
|
||||||
|
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateViewport = async (): Promise<Viewport> => {
|
||||||
|
const serverSettings = await getRscServerSettingsAsync();
|
||||||
|
return { themeColor: serverSettings.branding.primaryColor };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Layout(props: {
|
export default async function Layout(props: {
|
||||||
@@ -94,6 +105,7 @@ export default async function Layout(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionPromise = auth();
|
const sessionPromise = auth();
|
||||||
|
const serverSettingsPromise = getRscServerSettingsAsync();
|
||||||
const userPromise = sessionPromise.then((session) =>
|
const userPromise = sessionPromise.then((session) =>
|
||||||
session
|
session
|
||||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||||
@@ -102,18 +114,17 @@ export default async function Layout(props: {
|
|||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||||
!session
|
if (!session) return "unauthenticated";
|
||||||
? "unauthenticated"
|
return api.assistant
|
||||||
: api.assistant
|
.getAvailability()
|
||||||
.getAvailability()
|
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
.catch(() => "error");
|
||||||
.catch(() => "error"),
|
});
|
||||||
);
|
|
||||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||||
sessionPromise,
|
sessionPromise,
|
||||||
userPromise,
|
userPromise,
|
||||||
getRscServerSettingsAsync(),
|
serverSettingsPromise,
|
||||||
getCurrentColorSchemeAsync(),
|
getCurrentColorSchemeAsync(),
|
||||||
assistantAvailabilityPromise,
|
assistantAvailabilityPromise,
|
||||||
]);
|
]);
|
||||||
@@ -148,6 +159,7 @@ export default async function Layout(props: {
|
|||||||
},
|
},
|
||||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||||
|
branding: serverSettings.branding,
|
||||||
}}
|
}}
|
||||||
{...innerProps}
|
{...innerProps}
|
||||||
/>
|
/>
|
||||||
@@ -156,7 +168,9 @@ export default async function Layout(props: {
|
|||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||||
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
||||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
(innerProps) => (
|
||||||
|
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
|
||||||
|
),
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
|
|||||||
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationMarquee } from "@homarr/ui";
|
import { IntegrationMarquee } from "@homarr/ui";
|
||||||
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
|
|
||||||
import classes from "./hero-banner.module.css";
|
import classes from "./hero-banner.module.css";
|
||||||
|
|
||||||
export const HeroBanner = async () => {
|
export const HeroBanner = async () => {
|
||||||
const t = await getI18n("management.page.home");
|
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
|
||||||
|
const { appName, logoImageUrl } = serverSettings.branding;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||||
@@ -14,9 +16,17 @@ export const HeroBanner = async () => {
|
|||||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||||
{t("heroBanner.title")}
|
{t("heroBanner.title")}
|
||||||
</Title>
|
</Title>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
<Image
|
||||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
|
src={logoImageUrl ?? "/logo/logo.png"}
|
||||||
|
alt={`${appName} logo`}
|
||||||
|
w="auto"
|
||||||
|
h={{ base: 48, md: 64 }}
|
||||||
|
maw={{ base: 128, md: 192 }}
|
||||||
|
fit="contain"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ import { capitalize, objectEntries } from "@homarr/common";
|
|||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||||
import githubContributors from "@static-data/contributors.json";
|
import githubContributors from "@static-data/contributors.json";
|
||||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||||
@@ -50,7 +49,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management");
|
const t = await getI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
|
|||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getI18n("management.page.settings.section.assistant");
|
const t = await getI18n("management.page.settings.section.assistant");
|
||||||
return {
|
return {
|
||||||
title: `${t("title")} • Homarr`,
|
title: t("title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
import { homarrLogoPath } from "~/components/layout/logo/constants";
|
||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
@@ -74,6 +74,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
icon: IconHomeFilled,
|
icon: IconHomeFilled,
|
||||||
href: "/manage",
|
href: "/manage",
|
||||||
|
exact: true,
|
||||||
"data-onboarding-tour-id": "manage-welcome",
|
"data-onboarding-tour-id": "manage-welcome",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { HeroBanner } from "./_components/hero-banner";
|
import { HeroBanner } from "./_components/hero-banner";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getI18n("management");
|
const t = await getI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
ColorSwatch,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Slider,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
import { IconPicker } from "@homarr/forms-collection";
|
||||||
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
|
import type { FormValues } from "./settings-form";
|
||||||
|
|
||||||
|
interface BrandingSettingsFormProps {
|
||||||
|
form: UseFormReturnType<FormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
||||||
|
const t = useI18n("management.page.settings.section.branding");
|
||||||
|
const logoInput = form.getInputProps("branding.logoImageUrl");
|
||||||
|
const faviconInput = form.getInputProps("branding.faviconImageUrl");
|
||||||
|
const backgroundInput = form.getInputProps("branding.signInBackgroundImageUrl");
|
||||||
|
const branding = form.values.branding;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={t("title")}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Fieldset legend={t("groups.identity")} p="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("identity.description")}
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm" verticalSpacing="sm">
|
||||||
|
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
|
||||||
|
<IconPicker
|
||||||
|
label={t("logo.label")}
|
||||||
|
description={null}
|
||||||
|
placeholder={t("logo.placeholder")}
|
||||||
|
withAsterisk={false}
|
||||||
|
value={logoInput.value ?? ""}
|
||||||
|
onChange={(value) => form.setFieldValue("branding.logoImageUrl", value || null)}
|
||||||
|
error={logoInput.error}
|
||||||
|
/>
|
||||||
|
<IconPicker
|
||||||
|
label={t("favicon.label")}
|
||||||
|
description={null}
|
||||||
|
placeholder={t("favicon.placeholder")}
|
||||||
|
withAsterisk={false}
|
||||||
|
value={faviconInput.value ?? ""}
|
||||||
|
onChange={(value) => form.setFieldValue("branding.faviconImageUrl", value || null)}
|
||||||
|
error={faviconInput.error}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<Fieldset legend={t("groups.appearance")} p="sm">
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
|
<BoardColorInput
|
||||||
|
label={t("primaryColor.label")}
|
||||||
|
defaultColor="#fa5252"
|
||||||
|
{...form.getInputProps("branding.primaryColor")}
|
||||||
|
/>
|
||||||
|
<BoardColorInput
|
||||||
|
label={t("secondaryColor.label")}
|
||||||
|
defaultColor="#fd7e14"
|
||||||
|
{...form.getInputProps("branding.secondaryColor")}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Switch
|
||||||
|
label={t("lockPrimaryColor.label")}
|
||||||
|
description={t("lockPrimaryColor.description")}
|
||||||
|
{...form.getInputProps("branding.lockPrimaryColor", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<CornerStylePicker
|
||||||
|
compact
|
||||||
|
label={t("defaultRadius.label")}
|
||||||
|
value={branding.defaultRadius}
|
||||||
|
labels={
|
||||||
|
Object.fromEntries(
|
||||||
|
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]),
|
||||||
|
) as Record<(typeof cornerStyleValues)[number], string>
|
||||||
|
}
|
||||||
|
onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<Fieldset legend={t("groups.authentication")} p="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
|
||||||
|
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
|
||||||
|
<IconPicker
|
||||||
|
label={t("signInBackground.label")}
|
||||||
|
description={null}
|
||||||
|
placeholder={t("signInBackground.placeholder")}
|
||||||
|
withAsterisk={false}
|
||||||
|
value={backgroundInput.value ?? ""}
|
||||||
|
onChange={(value) => form.setFieldValue("branding.signInBackgroundImageUrl", value || null)}
|
||||||
|
error={backgroundInput.error}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" verticalSpacing="sm">
|
||||||
|
<Stack gap={6} justify="center">
|
||||||
|
<Group justify="space-between" gap="sm">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("signInOverlay.label")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{Math.round(branding.signInBackgroundOverlay * 100)}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Slider
|
||||||
|
aria-label={t("signInOverlay.label")}
|
||||||
|
min={0}
|
||||||
|
max={0.9}
|
||||||
|
step={0.05}
|
||||||
|
label={(value) => `${Math.round(value * 100)}%`}
|
||||||
|
disabled={!branding.signInBackgroundImageUrl}
|
||||||
|
value={branding.signInBackgroundOverlay}
|
||||||
|
onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("authVisibility.description")}
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={3} spacing="xs">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
label={t("authVisibility.appName")}
|
||||||
|
{...form.getInputProps("branding.authBranding.showAppName", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
label={t("authVisibility.logo")}
|
||||||
|
{...form.getInputProps("branding.authBranding.showLogo", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
label={t("authVisibility.greeting")}
|
||||||
|
{...form.getInputProps("branding.authBranding.showGreeting", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<BrandingSummary form={form} />
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrandingSummary = ({ form }: BrandingSettingsFormProps) => {
|
||||||
|
const t = useI18n("management.page.settings.section.branding");
|
||||||
|
const branding = form.values.branding;
|
||||||
|
const appName = branding.appName.trim() || "Homarr";
|
||||||
|
const logoImageUrl = branding.logoImageUrl || "/logo/logo.png";
|
||||||
|
const faviconImageUrl = branding.faviconImageUrl || logoImageUrl;
|
||||||
|
const backgroundStatus = branding.signInBackgroundImageUrl
|
||||||
|
? t("summary.backgroundConfigured", { overlay: Math.round(branding.signInBackgroundOverlay * 100) })
|
||||||
|
: t("summary.backgroundDefault");
|
||||||
|
const greetingStatus = branding.greeting.trim() ? t("summary.greetingConfigured") : t("summary.greetingDefault");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="sm" radius={branding.defaultRadius}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" gap="xs" wrap="wrap">
|
||||||
|
<Text fw={650}>{t("summary.title")}</Text>
|
||||||
|
<Badge variant="light">
|
||||||
|
{t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" verticalSpacing="sm">
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||||
|
{t("groups.identity")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Avatar.Group>
|
||||||
|
<Avatar src={logoImageUrl} alt={t("logo.label")} />
|
||||||
|
<Avatar src={faviconImageUrl} alt={t("favicon.label")} />
|
||||||
|
</Avatar.Group>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{appName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||||
|
{t("summary.palette")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="md">
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<ColorSwatch color={branding.primaryColor} size={20} />
|
||||||
|
<Text size="xs">{branding.primaryColor}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<ColorSwatch color={branding.secondaryColor} size={20} />
|
||||||
|
<Text size="xs">{branding.secondaryColor}</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Badge color={branding.lockPrimaryColor ? "primaryColor" : "gray"} variant="light" w="fit-content">
|
||||||
|
{branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")}
|
||||||
|
</Badge>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
|
||||||
|
{t("groups.authentication")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{backgroundStatus} · {greetingStatus}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<VisibilityBadge label={t("summary.appName")} visible={branding.authBranding.showAppName} />
|
||||||
|
<VisibilityBadge label={t("summary.logo")} visible={branding.authBranding.showLogo} />
|
||||||
|
<VisibilityBadge label={t("summary.greeting")} visible={branding.authBranding.showGreeting} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => {
|
||||||
|
const t = useI18n("management.page.settings.section.branding.summary");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge size="xs" color={visible ? "green" : "gray"} variant="light">
|
||||||
|
{label}: {visible ? t("on") : t("off")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,12 +12,14 @@ import { colorSchemes } from "@homarr/definitions";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||||
|
import { brandingServerSettingsSchema } from "@homarr/server-settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||||
import { AnalyticsSettings } from "./analytics.settings";
|
import { AnalyticsSettings } from "./analytics.settings";
|
||||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||||
import { BoardSettingsForm } from "./board-settings-form";
|
import { BoardSettingsForm } from "./board-settings-form";
|
||||||
|
import { BrandingSettingsForm } from "./branding-settings-form";
|
||||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||||
import { CultureSettingsForm } from "./culture-settings-form";
|
import { CultureSettingsForm } from "./culture-settings-form";
|
||||||
import { SearchSettingsForm } from "./search-settings-form";
|
import { SearchSettingsForm } from "./search-settings-form";
|
||||||
@@ -37,6 +39,7 @@ const settingsFormSchema = z.object({
|
|||||||
defaultSearchEngineId: z.string().nullable(),
|
defaultSearchEngineId: z.string().nullable(),
|
||||||
defaultColorScheme: z.enum(colorSchemes),
|
defaultColorScheme: z.enum(colorSchemes),
|
||||||
defaultLocale: z.string(),
|
defaultLocale: z.string(),
|
||||||
|
branding: brandingServerSettingsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||||
@@ -55,6 +58,7 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
|||||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||||
defaultLocale: initialData.culture.defaultLocale,
|
defaultLocale: initialData.culture.defaultLocale,
|
||||||
|
branding: initialData.branding,
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
@@ -137,6 +141,11 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
|||||||
value: { defaultColorScheme: values.defaultColorScheme },
|
value: { defaultColorScheme: values.defaultColorScheme },
|
||||||
},
|
},
|
||||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||||
|
{
|
||||||
|
settingsKey: "branding",
|
||||||
|
when: changed("branding"),
|
||||||
|
value: values.branding,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const promises = groups
|
const promises = groups
|
||||||
@@ -176,6 +185,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
|||||||
<UserSettingsForm form={form} />
|
<UserSettingsForm form={form} />
|
||||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||||
<AppearanceSettingsForm form={form} />
|
<AppearanceSettingsForm form={form} />
|
||||||
|
<BrandingSettingsForm form={form} />
|
||||||
<CultureSettingsForm form={form} />
|
<CultureSettingsForm form={form} />
|
||||||
<AnalyticsSettings form={form} />
|
<AnalyticsSettings form={form} />
|
||||||
<CrawlingAndIndexingSettings form={form} />
|
<CrawlingAndIndexingSettings form={form} />
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ import { SettingsForm } from "./_components/settings-form";
|
|||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getI18n("management");
|
const t = await getI18n("management");
|
||||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||||
import { ApiKeysManagement } from "./components/api-keys";
|
import { ApiKeysManagement } from "./components/api-keys";
|
||||||
@@ -63,7 +62,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management");
|
const t = await getI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { BackupExportCard } from "./_components/backup-export-card";
|
import { BackupExportCard } from "./_components/backup-export-card";
|
||||||
import { BackupImportCard } from "./_components/backup-import-card";
|
import { BackupImportCard } from "./_components/backup-import-card";
|
||||||
@@ -19,7 +18,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management.page.tool.backup");
|
const t = await getI18n("management.page.tool.backup");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
|
|||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { ClientSideTerminalComponent } from "./client";
|
import { ClientSideTerminalComponent } from "./client";
|
||||||
import { LogFontSizeSlider } from "./font-size-slider";
|
import { LogFontSizeSlider } from "./font-size-slider";
|
||||||
import { LogLevelSelection } from "./level-selection";
|
import { LogLevelSelection } from "./level-selection";
|
||||||
@@ -25,7 +24,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management");
|
const t = await getI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { TasksTable } from "./_components/tasks-table";
|
import { TasksTable } from "./_components/tasks-table";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -17,7 +16,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management");
|
const t = await getI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+163
-130
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -22,12 +24,15 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import {
|
import {
|
||||||
|
headerPreferencesSchema,
|
||||||
|
parseHeaderPreferences,
|
||||||
userChangeHomeBoardsSchema,
|
userChangeHomeBoardsSchema,
|
||||||
userChangeSearchPreferencesSchema,
|
userChangeSearchPreferencesSchema,
|
||||||
userEditProfileSchema,
|
userEditProfileSchema,
|
||||||
@@ -36,9 +41,9 @@ import {
|
|||||||
userPingIconsEnabledSchema,
|
userPingIconsEnabledSchema,
|
||||||
} from "@homarr/validation/user";
|
} from "@homarr/validation/user";
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
|
||||||
import { BoardSelect } from "~/components/board/board-select";
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
|
import { HeaderComposer } from "./header-composer";
|
||||||
|
|
||||||
dayjs.extend(localeData);
|
dayjs.extend(localeData);
|
||||||
|
|
||||||
@@ -53,6 +58,7 @@ const userGeneralSettingsSchema = z.object({
|
|||||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||||
|
headerPreferences: headerPreferencesSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||||
@@ -65,9 +71,10 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
|||||||
|
|
||||||
interface UserGeneralSettingsFormProps {
|
interface UserGeneralSettingsFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||||
searchEnginesData: { value: string; label: string }[];
|
searchEnginesData: { value: string; label: string }[];
|
||||||
showLanguageSelector?: boolean;
|
showLanguageSelector?: boolean;
|
||||||
|
profileAvatar?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||||
@@ -81,6 +88,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
|||||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||||
pingIconsEnabled: user.pingIconsEnabled,
|
pingIconsEnabled: user.pingIconsEnabled,
|
||||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||||
|
headerPreferences: parseHeaderPreferences(user.headerPreferences),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserGeneralSettingsForm = ({
|
export const UserGeneralSettingsForm = ({
|
||||||
@@ -88,6 +96,7 @@ export const UserGeneralSettingsForm = ({
|
|||||||
boardsData,
|
boardsData,
|
||||||
searchEnginesData,
|
searchEnginesData,
|
||||||
showLanguageSelector = false,
|
showLanguageSelector = false,
|
||||||
|
profileAvatar,
|
||||||
}: UserGeneralSettingsFormProps) => {
|
}: UserGeneralSettingsFormProps) => {
|
||||||
const tUser = useI18n("user");
|
const tUser = useI18n("user");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
@@ -101,6 +110,7 @@ export const UserGeneralSettingsForm = ({
|
|||||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||||
|
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
|
||||||
|
|
||||||
const initialValues = buildInitialValues(user);
|
const initialValues = buildInitialValues(user);
|
||||||
const initialValuesRef = useRef(initialValues);
|
const initialValuesRef = useRef(initialValues);
|
||||||
@@ -121,6 +131,7 @@ export const UserGeneralSettingsForm = ({
|
|||||||
changeFirstDayOfWeekMutation,
|
changeFirstDayOfWeekMutation,
|
||||||
changePingIconsEnabledMutation,
|
changePingIconsEnabledMutation,
|
||||||
changeEnableRightClickOnWidgetsMutation,
|
changeEnableRightClickOnWidgetsMutation,
|
||||||
|
changeHeaderPreferencesMutation,
|
||||||
];
|
];
|
||||||
const isPending = mutations.some((m) => m.isPending);
|
const isPending = mutations.some((m) => m.isPending);
|
||||||
|
|
||||||
@@ -184,6 +195,14 @@ export const UserGeneralSettingsForm = ({
|
|||||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
when: changed("headerPreferences"),
|
||||||
|
action: () =>
|
||||||
|
changeHeaderPreferencesMutation.mutateAsync({
|
||||||
|
id: user.id,
|
||||||
|
headerPreferences: parsed.data.headerPreferences,
|
||||||
|
}),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||||
@@ -218,135 +237,149 @@ export const UserGeneralSettingsForm = ({
|
|||||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<Stack gap="lg">
|
||||||
<Stack gap="lg">
|
<HeaderComposer
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
value={form.values.headerPreferences}
|
||||||
<Card withBorder bg="transparent">
|
onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
|
||||||
<Stack gap="md">
|
boards={boardsData}
|
||||||
<Stack gap={2}>
|
homeBoardId={form.values.homeBoardId}
|
||||||
<Title order={3}>{tUser("name")}</Title>
|
mobileHomeBoardId={form.values.mobileHomeBoardId}
|
||||||
{!isCredentialsUser && (
|
/>
|
||||||
<Text c="dimmed" size="sm">
|
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||||
{tUserManagement("fieldsDisabledExternalProvider")}
|
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||||
</Text>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tUser("name")}</Title>
|
||||||
|
{!isCredentialsUser && (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{tUserManagement("fieldsDisabledExternalProvider")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<TextInput
|
||||||
|
disabled={!isCredentialsUser}
|
||||||
|
label={tUser("field.username.label")}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
disabled={!isCredentialsUser}
|
||||||
|
label={tUser("field.email.label")}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<BoardSelect
|
||||||
|
label={tGeneral("item.board.type.general")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
|
w="100%"
|
||||||
|
withinPortal
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
<BoardSelect
|
||||||
|
label={tGeneral("item.board.type.mobile")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
|
w="100%"
|
||||||
|
withinPortal
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<Select
|
||||||
|
label={tUser("field.defaultSearchEngine.label")}
|
||||||
|
w="100%"
|
||||||
|
data={searchEnginesData}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={tUser("field.openSearchInNewTab.label")}
|
||||||
|
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={tUser("field.ddgBangs.label")}
|
||||||
|
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||||
|
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
|
<Radio.Group
|
||||||
|
{...firstDayOfWeekInputProps}
|
||||||
|
value={firstDayOfWeekValue}
|
||||||
|
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||||
|
>
|
||||||
|
<Group mt="xs" wrap="wrap">
|
||||||
|
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||||
|
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Radio.Group>
|
||||||
|
<Divider my="xs" />
|
||||||
|
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||||
|
<Switch
|
||||||
|
label={tUser("field.pingIconsEnabled.label")}
|
||||||
|
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={tUser("field.enableRightClickOnWidgets.label")}
|
||||||
|
description={tUser("field.enableRightClickOnWidgets.description")}
|
||||||
|
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
|
{form.isDirty() && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||||
|
{tCommon("action.discard")}
|
||||||
|
</Button>
|
||||||
|
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||||
|
{tCommon("action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</div>
|
||||||
<Divider />
|
|
||||||
<TextInput
|
|
||||||
disabled={!isCredentialsUser}
|
|
||||||
label={tUser("field.username.label")}
|
|
||||||
withAsterisk
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
disabled={!isCredentialsUser}
|
|
||||||
label={tUser("field.email.label")}
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</form>
|
||||||
|
</Box>
|
||||||
<Card withBorder bg="transparent">
|
{profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
|
||||||
<Stack gap="md">
|
</Group>
|
||||||
<Stack gap={2}>
|
</Stack>
|
||||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
<BoardSelect
|
|
||||||
label={tGeneral("item.board.type.general")}
|
|
||||||
clearable
|
|
||||||
boards={boardsData}
|
|
||||||
w="100%"
|
|
||||||
withinPortal
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
|
||||||
/>
|
|
||||||
<BoardSelect
|
|
||||||
label={tGeneral("item.board.type.mobile")}
|
|
||||||
clearable
|
|
||||||
boards={boardsData}
|
|
||||||
w="100%"
|
|
||||||
withinPortal
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
<Select
|
|
||||||
label={tUser("field.defaultSearchEngine.label")}
|
|
||||||
w="100%"
|
|
||||||
data={searchEnginesData}
|
|
||||||
comboboxProps={{ withinPortal: true }}
|
|
||||||
{...form.getInputProps("defaultSearchEngineId")}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={tUser("field.openSearchInNewTab.label")}
|
|
||||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={tUser("field.ddgBangs.label")}
|
|
||||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder bg="transparent">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
|
||||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
|
||||||
<Radio.Group
|
|
||||||
{...firstDayOfWeekInputProps}
|
|
||||||
value={firstDayOfWeekValue}
|
|
||||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
|
||||||
>
|
|
||||||
<Group mt="xs" wrap="wrap">
|
|
||||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
|
||||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Radio.Group>
|
|
||||||
<Divider my="xs" />
|
|
||||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
|
||||||
<Switch
|
|
||||||
label={tUser("field.pingIconsEnabled.label")}
|
|
||||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={tUser("field.enableRightClickOnWidgets.label")}
|
|
||||||
description={tUser("field.enableRightClickOnWidgets.description")}
|
|
||||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<div style={{ position: "sticky", bottom: 20 }}>
|
|
||||||
{form.isDirty() && (
|
|
||||||
<Card withBorder>
|
|
||||||
<Group justify="space-between" wrap="wrap">
|
|
||||||
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
|
|
||||||
<Group>
|
|
||||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
|
||||||
{tCommon("action.discard")}
|
|
||||||
</Button>
|
|
||||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
|
||||||
{tCommon("action.saveChanges")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
.composer {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading,
|
||||||
|
.tray {
|
||||||
|
padding: var(--mantine-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 21rem;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||||
|
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
|
||||||
|
background-size: 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 34rem);
|
||||||
|
min-height: 18rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
box-shadow: var(--mantine-shadow-lg);
|
||||||
|
transition:
|
||||||
|
width 180ms ease,
|
||||||
|
min-height 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewHeader {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 5rem;
|
||||||
|
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneTable {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
width: 100%;
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneColumn {
|
||||||
|
min-width: 0;
|
||||||
|
border-inline-end: 1px solid var(--mantine-color-default-border);
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneColumn:last-child {
|
||||||
|
border-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneColumn[data-drop-target="true"] {
|
||||||
|
background: var(--mantine-primary-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneColumn[data-drop-target="true"] .zoneItems::after {
|
||||||
|
content: "";
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: 1px dashed var(--mantine-primary-color-filled);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneColumnHeader {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneItems {
|
||||||
|
display: flex;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableItem {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableItem[data-drop-target="true"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 0.2rem;
|
||||||
|
inset-inline-start: -0.2rem;
|
||||||
|
z-index: 1;
|
||||||
|
width: 2px;
|
||||||
|
border-radius: var(--mantine-radius-xl);
|
||||||
|
background: var(--mantine-primary-color-filled);
|
||||||
|
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableItem[data-dragging="true"] {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemButton,
|
||||||
|
.dragOverlay {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemButton {
|
||||||
|
cursor: grab;
|
||||||
|
transition:
|
||||||
|
border-color 120ms ease,
|
||||||
|
background-color 120ms ease,
|
||||||
|
box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemButton[data-wide="true"],
|
||||||
|
.dragOverlay[data-wide="true"] {
|
||||||
|
width: 4.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemButton:hover,
|
||||||
|
.itemButton:focus-visible,
|
||||||
|
.sortableItem[data-drop-target="true"] .itemButton,
|
||||||
|
.sortableItem[data-selected="true"] .itemButton {
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
|
background: var(--mantine-primary-color-light-hover);
|
||||||
|
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemButton:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragOverlay {
|
||||||
|
pointer-events: none;
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
|
box-shadow: var(--mantine-shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchPreview {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview[data-with-title="true"] {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-inline: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchPreview[data-input="true"] {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-inline: 0.375rem;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchPreview span {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenPreview {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floatingAccount {
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardPreview {
|
||||||
|
height: 13rem;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardLayout {
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray {
|
||||||
|
border-top: 1px solid var(--mantine-color-default-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.configuration {
|
||||||
|
background: color-mix(in srgb, var(--mantine-primary-color-light) 42%, var(--mantine-color-body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
.heading,
|
||||||
|
.tray,
|
||||||
|
.stage {
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
min-height: 19rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.device,
|
||||||
|
.zoneColumn,
|
||||||
|
.itemButton {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+653
@@ -0,0 +1,653 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { PointerActivationConstraints } from "@dnd-kit/dom";
|
||||||
|
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
|
||||||
|
import type { DragEndEvent } from "@dnd-kit/react";
|
||||||
|
import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react";
|
||||||
|
import { useSortable } from "@dnd-kit/react/sortable";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBrandDocker,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
|
IconHome,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconPencil,
|
||||||
|
IconPlus,
|
||||||
|
IconReplace,
|
||||||
|
IconRobot,
|
||||||
|
IconSearch,
|
||||||
|
IconSettings,
|
||||||
|
IconSunMoon,
|
||||||
|
IconUserCircle,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type {
|
||||||
|
HeaderBuiltinItemId,
|
||||||
|
HeaderItem,
|
||||||
|
HeaderPreferences,
|
||||||
|
HeaderZoneId,
|
||||||
|
HeaderZones,
|
||||||
|
} from "@homarr/validation/user";
|
||||||
|
import {
|
||||||
|
createBoardHeaderItem,
|
||||||
|
createBuiltinHeaderItem,
|
||||||
|
getDefaultHeaderZone,
|
||||||
|
getHeaderItemKey,
|
||||||
|
getHeaderItems,
|
||||||
|
getHeaderItemZone,
|
||||||
|
headerBuiltinItemIds,
|
||||||
|
isRequiredHeaderItem,
|
||||||
|
} from "@homarr/validation/user";
|
||||||
|
|
||||||
|
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||||
|
import { HomarrLogo } from "~/components/layout/logo/homarr-logo";
|
||||||
|
import classes from "./header-composer.module.css";
|
||||||
|
|
||||||
|
interface HeaderComposerProps {
|
||||||
|
value: HeaderPreferences;
|
||||||
|
onChange: (value: HeaderPreferences) => void;
|
||||||
|
boards: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
|
||||||
|
homeBoardId: string | null;
|
||||||
|
mobileHomeBoardId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderItemDragData {
|
||||||
|
kind: "item";
|
||||||
|
itemKey: string;
|
||||||
|
zone: HeaderZoneId;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderZoneDropData {
|
||||||
|
kind: "zone";
|
||||||
|
zone: HeaderZoneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
|
||||||
|
|
||||||
|
const headerSortableSensors = [
|
||||||
|
PointerSensor.configure({
|
||||||
|
activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })],
|
||||||
|
}),
|
||||||
|
KeyboardSensor,
|
||||||
|
];
|
||||||
|
const headerSortableGroup = "header-composer";
|
||||||
|
const headerSortablePlugins = [SortableKeyboardPlugin];
|
||||||
|
const headerGuideZones = ["left", "center", "right"] as const;
|
||||||
|
|
||||||
|
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
|
||||||
|
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
|
||||||
|
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||||
|
const zoneTableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { branding } = useSettings();
|
||||||
|
const t = useI18n("management.page.user.setting.general.header");
|
||||||
|
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||||
|
const activeItems = getHeaderItems(value.zones);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const zoneTable = zoneTableRef.current;
|
||||||
|
if (!zoneTable) return;
|
||||||
|
|
||||||
|
const clearSelectionFromEmptyArea = (event: PointerEvent) => {
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
if (event.target.closest("[data-header-composer-item]")) return;
|
||||||
|
setSelectedItemKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
zoneTable.addEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||||
|
return () => zoneTable.removeEventListener("pointerdown", clearSelectionFromEmptyArea);
|
||||||
|
}, []);
|
||||||
|
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
|
||||||
|
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
|
||||||
|
const item = createBuiltinHeaderItem(itemId);
|
||||||
|
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
|
||||||
|
});
|
||||||
|
const availableBoards = boards.filter(
|
||||||
|
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
|
||||||
|
);
|
||||||
|
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
|
||||||
|
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
|
||||||
|
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
|
||||||
|
|
||||||
|
const toggleSelectedItem = (itemKey: string) => {
|
||||||
|
setSelectedItemKey((currentItemKey) => {
|
||||||
|
if (currentItemKey === itemKey) return null;
|
||||||
|
return itemKey;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemLabel = (item: HeaderItem) => {
|
||||||
|
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
|
||||||
|
return t(`items.${item.id}` as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
|
||||||
|
|
||||||
|
const removeItem = (item: HeaderItem) => {
|
||||||
|
if (isRequiredHeaderItem(item)) return;
|
||||||
|
const zone = getHeaderItemZone(value.zones, item);
|
||||||
|
if (!zone) return;
|
||||||
|
const itemKey = getHeaderItemKey(item);
|
||||||
|
updateZones({
|
||||||
|
...value.zones,
|
||||||
|
[zone]: value.zones[zone].filter((candidate) => getHeaderItemKey(candidate) !== itemKey),
|
||||||
|
});
|
||||||
|
setSelectedItemKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (item: HeaderItem) => {
|
||||||
|
const itemKey = getHeaderItemKey(item);
|
||||||
|
if (activeItemKeySet.has(itemKey)) return;
|
||||||
|
const zone = getDefaultHeaderZone(item);
|
||||||
|
updateZones({ ...value.zones, [zone]: [...value.zones[zone], item] });
|
||||||
|
setSelectedItemKey(itemKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = ({ canceled, operation }: DragEndEvent) => {
|
||||||
|
setDraggedItemKey(null);
|
||||||
|
const source = operation.source;
|
||||||
|
const target = operation.target;
|
||||||
|
if (canceled || !source || !target) return;
|
||||||
|
|
||||||
|
const sourceData = source.data as HeaderItemDragData;
|
||||||
|
const targetData = target.data as HeaderDropData;
|
||||||
|
if (sourceData.kind !== "item") return;
|
||||||
|
|
||||||
|
let targetIndex = value.zones[targetData.zone].length;
|
||||||
|
if (targetData.kind === "item") {
|
||||||
|
if (targetData.itemKey === sourceData.itemKey) return;
|
||||||
|
targetIndex = targetData.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
|
||||||
|
if (nextZones === value.zones) return;
|
||||||
|
updateZones(nextZones);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={classes.composer} padding={0} radius="lg" withBorder>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group className={classes.heading} justify="space-between" align="flex-start" wrap="wrap">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" radius="md" size="lg">
|
||||||
|
<IconLayoutDashboard size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{t("title")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant={value.visible ? "default" : "filled"}
|
||||||
|
aria-label={value.visible ? t("action.hide") : t("action.show")}
|
||||||
|
onClick={() => onChange({ ...value, visible: !value.visible })}
|
||||||
|
>
|
||||||
|
{value.visible ? <IconEye size={18} /> : <IconEyeOff size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box className={classes.stage} data-visible={value.visible}>
|
||||||
|
<Paper className={classes.device} radius="lg" withBorder>
|
||||||
|
{value.visible ? (
|
||||||
|
<DragDropProvider
|
||||||
|
onDragStart={({ operation }) => setDraggedItemKey(operation.source?.id.toString() ?? null)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className={classes.previewHeader}>
|
||||||
|
<div ref={zoneTableRef} className={classes.zoneTable}>
|
||||||
|
{headerGuideZones.map((zone) => (
|
||||||
|
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
|
||||||
|
{value.zones[zone].map((item, index) => (
|
||||||
|
<SortableHeaderItem
|
||||||
|
key={getHeaderItemKey(item)}
|
||||||
|
item={item}
|
||||||
|
zone={zone}
|
||||||
|
index={index}
|
||||||
|
label={itemLabel(item)}
|
||||||
|
searchDisplay={value.searchDisplay}
|
||||||
|
logoDisplay={value.logoDisplay}
|
||||||
|
logoTitle={branding.appName}
|
||||||
|
boardLogo={
|
||||||
|
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
|
||||||
|
}
|
||||||
|
configureLabel={t("action.configure", { item: itemLabel(item) })}
|
||||||
|
selected={selectedItemKey === getHeaderItemKey(item)}
|
||||||
|
onSelect={toggleSelectedItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</HeaderZoneColumn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{draggedItem ? (
|
||||||
|
<HeaderItemPreview
|
||||||
|
item={draggedItem}
|
||||||
|
label={itemLabel(draggedItem)}
|
||||||
|
compact={false}
|
||||||
|
searchDisplay={value.searchDisplay}
|
||||||
|
logoDisplay={value.logoDisplay}
|
||||||
|
logoTitle={branding.appName}
|
||||||
|
boardLogo={
|
||||||
|
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
|
||||||
|
}
|
||||||
|
overlay
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
) : (
|
||||||
|
<div className={classes.hiddenPreview}>
|
||||||
|
<ThemeIcon className={classes.floatingAccount} radius="xl" size="xl" aria-label={t("items.user")}>
|
||||||
|
<IconUserCircle size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classes.dashboardPreview}>
|
||||||
|
<BoardLayoutThumbnail
|
||||||
|
preview={previewBoard?.preview ?? null}
|
||||||
|
label={previewBoard ? t("preview.board", { name: previewBoard.name }) : t("preview.empty")}
|
||||||
|
className={classes.dashboardLayout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack className={classes.tray} gap="sm">
|
||||||
|
{selectedItem ? (
|
||||||
|
<Paper className={classes.configuration} p="sm" radius="md" withBorder>
|
||||||
|
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={650} size="sm">
|
||||||
|
{itemLabel(selectedItem)}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("configuration.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
{selectedItem.type === "builtin" && selectedItem.id === "search" ? (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={value.searchDisplay}
|
||||||
|
onChange={(searchDisplay) =>
|
||||||
|
onChange({ ...value, searchDisplay: searchDisplay as HeaderPreferences["searchDisplay"] })
|
||||||
|
}
|
||||||
|
data={[
|
||||||
|
{ value: "input", label: t("configuration.searchDisplay.input") },
|
||||||
|
{ value: "icon", label: t("configuration.searchDisplay.icon") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{selectedItem.type === "builtin" && selectedItem.id === "logo" ? (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={value.logoDisplay}
|
||||||
|
onChange={(logoDisplay) =>
|
||||||
|
onChange({ ...value, logoDisplay: logoDisplay as HeaderPreferences["logoDisplay"] })
|
||||||
|
}
|
||||||
|
data={[
|
||||||
|
{ value: "logo", label: t("configuration.logoDisplay.logo") },
|
||||||
|
{ value: "logoAndText", label: t("configuration.logoDisplay.logoAndText") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!isRequiredHeaderItem(selectedItem) ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconX size={14} />}
|
||||||
|
onClick={() => removeItem(selectedItem)}
|
||||||
|
>
|
||||||
|
{t("action.remove", { item: itemLabel(selectedItem) })}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("available.requiredAlways")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group justify="space-between" gap="xs">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={650} size="sm">
|
||||||
|
{t("available.title")}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("available.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
disabled={availableBoards.length === 0}
|
||||||
|
>
|
||||||
|
{t("available.boardShortcut")}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>{t("available.chooseBoard")}</Menu.Label>
|
||||||
|
{availableBoards.map((board) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={board.id}
|
||||||
|
leftSection={
|
||||||
|
<Avatar src={board.logoImageUrl} size="xs" radius="sm">
|
||||||
|
<IconLayoutDashboard size={14} />
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
onClick={() => addItem(createBoardHeaderItem(board.id))}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
{inactiveBuiltinIds.length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("available.allAdded")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
inactiveBuiltinIds.map((itemId) => {
|
||||||
|
const item = createBuiltinHeaderItem(itemId);
|
||||||
|
const Icon = getBuiltinItemIcon(itemId);
|
||||||
|
return (
|
||||||
|
<Tooltip key={itemId} label={t("available.add", { item: itemLabel(item) })}>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
aria-label={t("available.add", { item: itemLabel(item) })}
|
||||||
|
onClick={() => addItem(item)}
|
||||||
|
>
|
||||||
|
<Icon size={19} stroke={1.7} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeaderZoneColumnProps {
|
||||||
|
zone: HeaderZoneId;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
|
||||||
|
const { isDropTarget, ref } = useDroppable({
|
||||||
|
id: `header-zone:${zone}`,
|
||||||
|
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
|
||||||
|
<Text className={classes.zoneColumnHeader} component="h4">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<div className={classes.zoneItems}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SortableHeaderItemProps {
|
||||||
|
item: HeaderItem;
|
||||||
|
zone: HeaderZoneId;
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||||
|
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||||
|
logoTitle: string;
|
||||||
|
boardLogo: string | null;
|
||||||
|
configureLabel: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (itemKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableHeaderItem = ({
|
||||||
|
item,
|
||||||
|
zone,
|
||||||
|
index,
|
||||||
|
label,
|
||||||
|
searchDisplay,
|
||||||
|
logoDisplay,
|
||||||
|
logoTitle,
|
||||||
|
boardLogo,
|
||||||
|
configureLabel,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: SortableHeaderItemProps) => {
|
||||||
|
const itemKey = getHeaderItemKey(item);
|
||||||
|
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
|
||||||
|
id: itemKey,
|
||||||
|
index,
|
||||||
|
group: `${headerSortableGroup}:${zone}`,
|
||||||
|
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
|
||||||
|
sensors: headerSortableSensors,
|
||||||
|
plugins: headerSortablePlugins,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classes.sortableItem}
|
||||||
|
data-dragging={isDragging || undefined}
|
||||||
|
data-drop-target={isDropTarget || undefined}
|
||||||
|
data-entry-id={itemKey}
|
||||||
|
data-selected={selected || undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
ref={handleRef}
|
||||||
|
type="button"
|
||||||
|
className={classes.itemButton}
|
||||||
|
data-header-composer-item
|
||||||
|
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||||
|
data-wide={
|
||||||
|
(item.type === "builtin" &&
|
||||||
|
((item.id === "search" && searchDisplay === "input") ||
|
||||||
|
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
aria-label={configureLabel}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onSelect(itemKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeaderItemPreview
|
||||||
|
item={item}
|
||||||
|
label={label}
|
||||||
|
compact={false}
|
||||||
|
searchDisplay={searchDisplay}
|
||||||
|
logoDisplay={logoDisplay}
|
||||||
|
logoTitle={logoTitle}
|
||||||
|
boardLogo={boardLogo}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeaderItemPreview = ({
|
||||||
|
item,
|
||||||
|
label,
|
||||||
|
compact,
|
||||||
|
searchDisplay,
|
||||||
|
logoDisplay,
|
||||||
|
logoTitle,
|
||||||
|
boardLogo,
|
||||||
|
overlay = false,
|
||||||
|
}: {
|
||||||
|
item: HeaderItem;
|
||||||
|
label: string;
|
||||||
|
compact: boolean;
|
||||||
|
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||||
|
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||||
|
logoTitle: string;
|
||||||
|
boardLogo: string | null | undefined;
|
||||||
|
overlay?: boolean;
|
||||||
|
}) => {
|
||||||
|
const content = (
|
||||||
|
<PreviewItem
|
||||||
|
item={item}
|
||||||
|
label={label}
|
||||||
|
compact={compact}
|
||||||
|
searchDisplay={searchDisplay}
|
||||||
|
logoDisplay={logoDisplay}
|
||||||
|
logoTitle={logoTitle}
|
||||||
|
boardLogo={boardLogo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!overlay) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.dragOverlay}
|
||||||
|
data-kind={item.type === "builtin" ? item.id : "board"}
|
||||||
|
data-wide={
|
||||||
|
(item.type === "builtin" &&
|
||||||
|
!compact &&
|
||||||
|
((item.id === "search" && searchDisplay === "input") ||
|
||||||
|
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PreviewItem = ({
|
||||||
|
item,
|
||||||
|
label,
|
||||||
|
compact,
|
||||||
|
searchDisplay,
|
||||||
|
logoDisplay,
|
||||||
|
logoTitle,
|
||||||
|
boardLogo,
|
||||||
|
}: {
|
||||||
|
item: HeaderItem;
|
||||||
|
label: string;
|
||||||
|
compact: boolean;
|
||||||
|
searchDisplay: HeaderPreferences["searchDisplay"];
|
||||||
|
logoDisplay: HeaderPreferences["logoDisplay"];
|
||||||
|
logoTitle: string;
|
||||||
|
boardLogo: string | null | undefined;
|
||||||
|
}) => {
|
||||||
|
if (item.type === "board") {
|
||||||
|
return (
|
||||||
|
<Avatar src={boardLogo} size={22} radius="sm">
|
||||||
|
<IconLayoutDashboard size={17} />
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "logo") {
|
||||||
|
return (
|
||||||
|
<div className={classes.logoPreview} data-with-title={logoDisplay === "logoAndText" || undefined}>
|
||||||
|
<HomarrLogo size={26} />
|
||||||
|
{logoDisplay === "logoAndText" ? <span>{logoTitle}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "search") {
|
||||||
|
const showInput = searchDisplay === "input" && !compact;
|
||||||
|
return (
|
||||||
|
<div className={classes.searchPreview} data-input={showInput || undefined}>
|
||||||
|
<IconSearch size={18} />
|
||||||
|
{showInput ? <span>{label}…</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = getBuiltinItemIcon(item.id);
|
||||||
|
return <Icon size={20} stroke={1.7} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveHeaderItem = (
|
||||||
|
zones: HeaderZones,
|
||||||
|
itemKey: string,
|
||||||
|
targetZone: HeaderZoneId,
|
||||||
|
requestedTargetIndex: number,
|
||||||
|
): HeaderZones => {
|
||||||
|
let sourceItem: HeaderItem | undefined;
|
||||||
|
|
||||||
|
for (const zone of headerGuideZones) {
|
||||||
|
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
|
||||||
|
if (sourceItem) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceItem) return zones;
|
||||||
|
|
||||||
|
const nextZones: HeaderZones = {
|
||||||
|
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||||
|
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||||
|
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
|
||||||
|
};
|
||||||
|
const targetItems = nextZones[targetZone];
|
||||||
|
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
|
||||||
|
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
|
||||||
|
return nextZones;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
|
||||||
|
if (id === null) return undefined;
|
||||||
|
return items.find((item) => getHeaderItemKey(item) === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
|
||||||
|
if (itemId === "search") return IconSearch;
|
||||||
|
if (itemId === "home") return IconHome;
|
||||||
|
if (itemId === "boardSwitcher") return IconReplace;
|
||||||
|
if (itemId === "assistant") return IconRobot;
|
||||||
|
if (itemId === "docker") return IconBrandDocker;
|
||||||
|
if (itemId === "boardEdit") return IconPencil;
|
||||||
|
if (itemId === "boardSettings") return IconSettings;
|
||||||
|
if (itemId === "settings") return IconSettings;
|
||||||
|
if (itemId === "themeToggle") return IconSunMoon;
|
||||||
|
if (itemId === "user") return IconUserCircle;
|
||||||
|
return IconLayoutDashboard;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
|
import { Alert, Stack, Title } from "@mantine/core";
|
||||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -8,7 +8,6 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||||
@@ -38,7 +37,7 @@ export async function generateMetadata(props: Props) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
title: createMetaTitle(t("metaTitle", { username: user.name! })),
|
title: t("metaTitle", { username: user.name! }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ export default async function EditUserPage(props: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
|
||||||
const searchEngines = await api.searchEngine.getSelectable();
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
const isSelf = session?.user.id === user.id;
|
const isSelf = session?.user.id === user.id;
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
@@ -71,23 +70,18 @@ export default async function EditUserPage(props: Props) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
<UserGeneralSettingsForm
|
||||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
user={user}
|
||||||
<UserGeneralSettingsForm
|
boardsData={boards.map((board) => ({
|
||||||
user={user}
|
id: board.id,
|
||||||
boardsData={boards.map((board) => ({
|
name: board.name,
|
||||||
id: board.id,
|
logoImageUrl: board.logoImageUrl,
|
||||||
name: board.name,
|
preview: board.preview,
|
||||||
logoImageUrl: board.logoImageUrl,
|
}))}
|
||||||
}))}
|
searchEnginesData={searchEngines}
|
||||||
searchEnginesData={searchEngines}
|
showLanguageSelector={isSelf}
|
||||||
showLanguageSelector={isSelf}
|
profileAvatar={<UserProfileAvatarForm user={user} />}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
<Box w={{ base: "100%", lg: 260 }}>
|
|
||||||
<UserProfileAvatarForm user={user} />
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{session?.user.id === user.id && (
|
{session?.user.id === user.id && (
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { everyoneGroup } from "@homarr/definitions";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -22,7 +21,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management.page.user.create");
|
const t = await getI18n("management.page.user.create");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
|
|||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { UserListComponent } from "./_components/user-list";
|
import { UserListComponent } from "./_components/user-list";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -20,7 +19,7 @@ export async function generateMetadata() {
|
|||||||
const t = await getI18n("management.page.user.list");
|
const t = await getI18n("management.page.user.list");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: createMetaTitle(t("metaTitle")),
|
title: t("metaTitle"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|||||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||||
import type { ToolSet, UIMessage } from "ai";
|
import type { ToolSet, UIMessage } from "ai";
|
||||||
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||||
@@ -24,6 +25,10 @@ import {
|
|||||||
assistantProviderRequiresApiKey,
|
assistantProviderRequiresApiKey,
|
||||||
assistantReasoningModes,
|
assistantReasoningModes,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
import { fallbackLocale, isLocaleSupported } from "@homarr/translation";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||||
|
|
||||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||||
@@ -370,7 +375,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
|
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestBody = await request.text();
|
const requestBody = await request.text();
|
||||||
@@ -414,7 +419,11 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
const requestedLocale = (await cookies()).get(localeCookieKey)?.value;
|
||||||
|
let locale: SupportedLanguage = fallbackLocale;
|
||||||
|
if (requestedLocale && isLocaleSupported(requestedLocale)) locale = requestedLocale;
|
||||||
|
const t = await getI18n({ locale, namespace: "assistant.unavailable" });
|
||||||
|
return Response.json({ error: t("notConfigured") }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let homarrProviderToken: string | null | undefined;
|
let homarrProviderToken: string | null | undefined;
|
||||||
|
|||||||
@@ -1,39 +1,59 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
|
const serverSettings = await getRscServerSettingsAsync();
|
||||||
|
const branding = serverSettings.branding;
|
||||||
|
const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl;
|
||||||
|
const icons: NonNullable<MetadataRoute.Manifest["icons"]> = [
|
||||||
|
{
|
||||||
|
src: "/images/pwa/192.maskable.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/pwa/512.maskable.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (appIcon) {
|
||||||
|
icons.unshift({
|
||||||
|
src: appIcon,
|
||||||
|
sizes: "any",
|
||||||
|
purpose: "any",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
icons.unshift(
|
||||||
|
{
|
||||||
|
src: "/images/pwa/192.maskable.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/pwa/512.maskable.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "Homarr",
|
name: branding.appName,
|
||||||
short_name: "Homarr",
|
short_name: branding.appName,
|
||||||
description: "Your dashboard for managing your server.",
|
description: "Your dashboard for managing your server.",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#fff",
|
background_color: "#fff",
|
||||||
theme_color: "#fff",
|
theme_color: branding.primaryColor,
|
||||||
icons: [
|
icons,
|
||||||
{
|
|
||||||
src: "/images/pwa/192.maskable.png",
|
|
||||||
sizes: "192x192",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/images/pwa/192.maskable.png",
|
|
||||||
sizes: "192x192",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "maskable",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/images/pwa/512.maskable.png",
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/images/pwa/512.maskable.png",
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "maskable",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
|
|||||||
|
|
||||||
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
|
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
|
||||||
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
|
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
|
||||||
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildAssistantConversationMarkdown = ({
|
export const buildAssistantConversationMarkdown = ({
|
||||||
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
|
|||||||
const header = [
|
const header = [
|
||||||
`# ${title}`,
|
`# ${title}`,
|
||||||
"",
|
"",
|
||||||
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
|
||||||
"",
|
"",
|
||||||
`- Conversation ID: \`${thread.id}\``,
|
`- Conversation ID: \`${thread.id}\``,
|
||||||
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
|
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() =>
|
|||||||
|
|
||||||
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
||||||
useRegisterAssistantSpotlightPlaceholder(description);
|
useRegisterAssistantSpotlightPlaceholder(description);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
linear-gradient(
|
||||||
circle at 15% 10%,
|
to top,
|
||||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
||||||
transparent 35%
|
transparent 55%
|
||||||
),
|
),
|
||||||
var(--mantine-color-body);
|
var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps {
|
|||||||
preview: BoardPreviewData | null;
|
preview: BoardPreviewData | null;
|
||||||
label: string;
|
label: string;
|
||||||
fitFullLayout?: boolean;
|
fitFullLayout?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxThumbnailRows = 12;
|
const maxThumbnailRows = 12;
|
||||||
|
|
||||||
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
|
export const BoardLayoutThumbnail = ({
|
||||||
|
preview,
|
||||||
|
label,
|
||||||
|
fitFullLayout = false,
|
||||||
|
className,
|
||||||
|
}: BoardLayoutThumbnailProps) => {
|
||||||
|
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||||
if (!preview || !layout) {
|
if (!preview || !layout) {
|
||||||
return (
|
return (
|
||||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
@@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
|||||||
});
|
});
|
||||||
if (lanes.length === 0) {
|
if (lanes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
<Center className={canvasClassName} role="img" aria-label={label}>
|
||||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
@@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.canvas} role="img" aria-label={label}>
|
<Box className={canvasClassName} role="img" aria-label={label}>
|
||||||
<div
|
<div
|
||||||
className={classes.lanes}
|
className={classes.lanes}
|
||||||
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
||||||
|
|||||||
@@ -36,8 +36,14 @@ import classes from "./board-switcher.module.css";
|
|||||||
|
|
||||||
export const boardSwitcherHotkey = "shift+c";
|
export const boardSwitcherHotkey = "shift+c";
|
||||||
|
|
||||||
|
export interface BoardSwitcherControls {
|
||||||
|
open: () => void;
|
||||||
|
preload: () => void;
|
||||||
|
hotkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BoardSwitcherProps {
|
interface BoardSwitcherProps {
|
||||||
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
|
children: (controls: BoardSwitcherControls) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ export const WidgetContextMenu = ({
|
|||||||
[setItemOptions],
|
[setItemOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
|
|||||||
name: innerProps.previousName,
|
name: innerProps.previousName,
|
||||||
});
|
});
|
||||||
void utils.board.getHomeBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
|
void utils.board.getAllBoards.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
|
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
|
|||||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||||
|
|
||||||
describe("board grid import boundary", () => {
|
describe("board grid import boundary", () => {
|
||||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
|
||||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
const runtimeImports = getSourceFiles(gridDirectory)
|
||||||
.flatMap(getRuntimeImports)
|
.flatMap(getRuntimeImports)
|
||||||
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
|
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
|
||||||
.map(({ filePath, kind, specifier }) => ({
|
.map(({ filePath, kind, specifier }) => ({
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const appShellHeaderHeight = 60;
|
export const appShellHeaderHeight = 60;
|
||||||
|
export const appShellLogoHeight = appShellHeaderHeight - 8;
|
||||||
@@ -1,49 +1,45 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { Link } from "@homarr/ui";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
|
import { appShellLogoHeight } from "./constants";
|
||||||
import { ClientBurger } from "./header/burger";
|
import { ConfigurableHeader } from "./header/configurable-header";
|
||||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||||
import { TourTarget } from "./header/tour-target";
|
|
||||||
import { UserButton } from "./header/user";
|
|
||||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
|
||||||
import { LazySpotlight } from "./header/lazy-spotlight";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logo?: ReactNode;
|
logo?: ReactNode;
|
||||||
|
logoWithTitle?: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
|
boardEditAction?: ReactNode;
|
||||||
|
boardSettingsAction?: ReactNode;
|
||||||
hasNavigation?: boolean;
|
hasNavigation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
export const MainHeader = async ({
|
||||||
|
logo,
|
||||||
|
logoWithTitle,
|
||||||
|
actions,
|
||||||
|
boardEditAction,
|
||||||
|
boardSettingsAction,
|
||||||
|
hasNavigation = true,
|
||||||
|
}: Props) => {
|
||||||
|
const session = await auth();
|
||||||
|
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||||
|
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShellHeader
|
<ConfigurableHeader
|
||||||
maw="100vw"
|
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
|
||||||
zIndex="var(--homarr-z-index-board-header)"
|
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
|
||||||
style={{ overflowX: "hidden" }}
|
actions={actions}
|
||||||
data-advanced-focus-background
|
boardEditAction={boardEditAction}
|
||||||
data-app-shell-header
|
boardSettingsAction={boardSettingsAction}
|
||||||
>
|
hasNavigation={hasNavigation}
|
||||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
avatar={<CurrentUserAvatar size="md" />}
|
||||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
userId={session?.user.id ?? null}
|
||||||
{hasNavigation && <ClientBurger />}
|
isAdmin={isAdmin}
|
||||||
<UnstyledButton component={Link} href="/">
|
isDockerEnabled={isDockerEnabled}
|
||||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
/>
|
||||||
</UnstyledButton>
|
|
||||||
</Group>
|
|
||||||
<TourTarget id="board-search">
|
|
||||||
<DesktopSearchInput />
|
|
||||||
</TourTarget>
|
|
||||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
|
||||||
{actions}
|
|
||||||
<MobileSearchButton />
|
|
||||||
<TourTarget id="board-user-menu">
|
|
||||||
<UserButton />
|
|
||||||
</TourTarget>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<LazySpotlight />
|
|
||||||
</AppShellHeader>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
AppShellHeader,
|
||||||
|
Avatar,
|
||||||
|
Group,
|
||||||
|
Indicator,
|
||||||
|
Loader,
|
||||||
|
Tooltip,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBrandDocker,
|
||||||
|
IconHome,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconReplace,
|
||||||
|
IconRobot,
|
||||||
|
IconSettings,
|
||||||
|
IconSunMoon,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { HeaderItem } from "@homarr/validation/user";
|
||||||
|
import { getHeaderItemKey, getHeaderItems } from "@homarr/validation/user";
|
||||||
|
|
||||||
|
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||||
|
import { BoardSwitcher } from "~/components/board/board-switcher";
|
||||||
|
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||||
|
import { ClientBurger } from "./burger";
|
||||||
|
import { HeaderButton } from "./button";
|
||||||
|
import { DockerQuickAccessModal } from "./docker-quick-access-modal";
|
||||||
|
import { HeaderLogo } from "./header-logo";
|
||||||
|
import { LazySpotlight } from "./lazy-spotlight";
|
||||||
|
import { DesktopSearchInput, MobileSearchButton } from "./search";
|
||||||
|
import { TourTarget } from "./tour-target";
|
||||||
|
import { UserButtonClient } from "./user-client";
|
||||||
|
import classes from "./configurable-header.module.css";
|
||||||
|
|
||||||
|
interface ConfigurableHeaderProps {
|
||||||
|
logo: ReactNode;
|
||||||
|
logoWithTitle: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
boardEditAction?: ReactNode;
|
||||||
|
boardSettingsAction?: ReactNode;
|
||||||
|
hasNavigation: boolean;
|
||||||
|
avatar: ReactNode;
|
||||||
|
userId: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isDockerEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
|
||||||
|
|
||||||
|
const floatingControlsIntroDurationMs = 5_000;
|
||||||
|
const floatingControlsDismissDelayMs = 900;
|
||||||
|
|
||||||
|
export const ConfigurableHeader = ({
|
||||||
|
logo,
|
||||||
|
logoWithTitle,
|
||||||
|
actions,
|
||||||
|
boardEditAction,
|
||||||
|
boardSettingsAction,
|
||||||
|
hasNavigation,
|
||||||
|
avatar,
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
isDockerEnabled,
|
||||||
|
}: ConfigurableHeaderProps) => {
|
||||||
|
const { headerPreferences } = useSettings();
|
||||||
|
const assistant = useOptionalHomarrAssistant();
|
||||||
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
|
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||||
|
const t = useI18n("management.page.user.setting.general.header");
|
||||||
|
const allHeaderItems = getHeaderItems(headerPreferences.zones);
|
||||||
|
const hasBoardShortcuts = allHeaderItems.some((item) => item.type === "board");
|
||||||
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, {
|
||||||
|
enabled: hasBoardShortcuts,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardSwitcher>
|
||||||
|
{(boardSwitcher) => {
|
||||||
|
const renderItems = (items: HeaderItem[]) =>
|
||||||
|
items.map((item) => (
|
||||||
|
<HeaderItem
|
||||||
|
key={getHeaderItemKey(item)}
|
||||||
|
item={item}
|
||||||
|
logo={logo}
|
||||||
|
logoWithTitle={logoWithTitle}
|
||||||
|
boardEditAction={boardEditAction}
|
||||||
|
boardSettingsAction={boardSettingsAction}
|
||||||
|
avatar={avatar}
|
||||||
|
userId={userId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isDockerEnabled={isDockerEnabled}
|
||||||
|
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
|
||||||
|
searchDisplay={headerPreferences.searchDisplay}
|
||||||
|
logoDisplay={headerPreferences.logoDisplay}
|
||||||
|
boardSwitcher={boardSwitcher}
|
||||||
|
assistant={assistant}
|
||||||
|
openDockerModal={openDockerModal}
|
||||||
|
toggleColorScheme={toggleColorScheme}
|
||||||
|
label={(key) => t(`items.${key}` as never)}
|
||||||
|
boardUnavailableLabel={t("items.boardUnavailable")}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{headerPreferences.visible ? (
|
||||||
|
<AppShellHeader
|
||||||
|
maw="100vw"
|
||||||
|
zIndex="var(--homarr-z-index-board-header)"
|
||||||
|
className={classes.header}
|
||||||
|
data-advanced-focus-background
|
||||||
|
data-app-shell-header
|
||||||
|
>
|
||||||
|
<div className={classes.content}>
|
||||||
|
<div className={classes.zone} data-zone="left">
|
||||||
|
{hasNavigation ? <ClientBurger /> : null}
|
||||||
|
{renderItems(headerPreferences.zones.left)}
|
||||||
|
</div>
|
||||||
|
<div className={classes.zone} data-zone="center">
|
||||||
|
{renderItems(headerPreferences.zones.center)}
|
||||||
|
</div>
|
||||||
|
<div className={classes.zone} data-zone="right">
|
||||||
|
{renderItems(headerPreferences.zones.right)}
|
||||||
|
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShellHeader>
|
||||||
|
) : (
|
||||||
|
<FloatingHeaderControls>
|
||||||
|
{hasNavigation ? <ClientBurger /> : null}
|
||||||
|
<UserButtonClient
|
||||||
|
avatar={avatar}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isDockerEnabled={isDockerEnabled}
|
||||||
|
boardSwitcher={boardSwitcher}
|
||||||
|
/>
|
||||||
|
</FloatingHeaderControls>
|
||||||
|
)}
|
||||||
|
<LazySpotlight />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</BoardSwitcher>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [isIntroComplete, setIsIntroComplete] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const isInteractingRef = useRef(false);
|
||||||
|
const dismissTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearDismissTimeout = () => {
|
||||||
|
if (dismissTimeoutRef.current === null) return;
|
||||||
|
window.clearTimeout(dismissTimeoutRef.current);
|
||||||
|
dismissTimeoutRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSoon = () => {
|
||||||
|
if (!isIntroComplete) return;
|
||||||
|
clearDismissTimeout();
|
||||||
|
dismissTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
if (!isInteractingRef.current) setIsVisible(false);
|
||||||
|
}, floatingControlsDismissDelayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealFromCorner = () => {
|
||||||
|
if (!isIntroComplete) return;
|
||||||
|
clearDismissTimeout();
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startInteraction = () => {
|
||||||
|
isInteractingRef.current = true;
|
||||||
|
clearDismissTimeout();
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endInteraction = () => {
|
||||||
|
isInteractingRef.current = false;
|
||||||
|
dismissSoon();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const introTimeout = window.setTimeout(() => {
|
||||||
|
setIsIntroComplete(true);
|
||||||
|
if (!isInteractingRef.current) setIsVisible(false);
|
||||||
|
}, floatingControlsIntroDurationMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(introTimeout);
|
||||||
|
if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={classes.floatingControlsCorner}
|
||||||
|
data-active={isIntroComplete || undefined}
|
||||||
|
aria-hidden
|
||||||
|
onPointerEnter={revealFromCorner}
|
||||||
|
onPointerMove={revealFromCorner}
|
||||||
|
onPointerDown={revealFromCorner}
|
||||||
|
onPointerLeave={dismissSoon}
|
||||||
|
/>
|
||||||
|
<Group
|
||||||
|
className={classes.floatingControls}
|
||||||
|
data-visible={isVisible || undefined}
|
||||||
|
gap={4}
|
||||||
|
wrap="nowrap"
|
||||||
|
onPointerEnter={startInteraction}
|
||||||
|
onPointerLeave={endInteraction}
|
||||||
|
onFocusCapture={startInteraction}
|
||||||
|
onBlurCapture={(event) => {
|
||||||
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||||
|
endInteraction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeaderItemProps {
|
||||||
|
item: HeaderItem;
|
||||||
|
logo: ReactNode;
|
||||||
|
logoWithTitle: ReactNode;
|
||||||
|
boardEditAction: ReactNode;
|
||||||
|
boardSettingsAction: ReactNode;
|
||||||
|
avatar: ReactNode;
|
||||||
|
userId: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isDockerEnabled: boolean;
|
||||||
|
board: HeaderBoard | undefined;
|
||||||
|
searchDisplay: "input" | "icon";
|
||||||
|
logoDisplay: "logo" | "logoAndText";
|
||||||
|
boardSwitcher: BoardSwitcherControls;
|
||||||
|
assistant: ReturnType<typeof useOptionalHomarrAssistant>;
|
||||||
|
openDockerModal: () => void;
|
||||||
|
toggleColorScheme: () => void;
|
||||||
|
label: (key: string) => string;
|
||||||
|
boardUnavailableLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderItem = ({
|
||||||
|
item,
|
||||||
|
logo,
|
||||||
|
logoWithTitle,
|
||||||
|
boardEditAction,
|
||||||
|
boardSettingsAction,
|
||||||
|
avatar,
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
isDockerEnabled,
|
||||||
|
board,
|
||||||
|
searchDisplay,
|
||||||
|
logoDisplay,
|
||||||
|
boardSwitcher,
|
||||||
|
assistant,
|
||||||
|
openDockerModal,
|
||||||
|
toggleColorScheme,
|
||||||
|
label,
|
||||||
|
boardUnavailableLabel,
|
||||||
|
}: HeaderItemProps) => {
|
||||||
|
if (item.type === "board") {
|
||||||
|
if (!board) {
|
||||||
|
return (
|
||||||
|
<Tooltip label={boardUnavailableLabel}>
|
||||||
|
<HeaderButton aria-label={boardUnavailableLabel} disabled>
|
||||||
|
<IconLayoutDashboard size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip label={board.name}>
|
||||||
|
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
|
||||||
|
<Avatar src={board.logoImageUrl} size={22} radius="sm">
|
||||||
|
<IconLayoutDashboard size={17} stroke={1.5} />
|
||||||
|
</Avatar>
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "logo") {
|
||||||
|
return (
|
||||||
|
<HeaderLogo
|
||||||
|
display={logoDisplay}
|
||||||
|
logo={logo}
|
||||||
|
logoWithTitle={logoWithTitle}
|
||||||
|
label={label("logo")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "boardEdit") return boardEditAction;
|
||||||
|
if (item.id === "boardSettings") return boardSettingsAction;
|
||||||
|
|
||||||
|
if (item.id === "search") {
|
||||||
|
return (
|
||||||
|
<TourTarget id="board-search">
|
||||||
|
<div className={classes.search} data-display={searchDisplay}>
|
||||||
|
{searchDisplay === "input" ? (
|
||||||
|
<>
|
||||||
|
<DesktopSearchInput />
|
||||||
|
<MobileSearchButton />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MobileSearchButton alwaysVisible />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TourTarget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "home") {
|
||||||
|
return (
|
||||||
|
<Tooltip label={label("home")}>
|
||||||
|
<HeaderButton href="/boards" aria-label={label("home")}>
|
||||||
|
<IconHome size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "boardSwitcher") {
|
||||||
|
return (
|
||||||
|
<Tooltip label={label("boardSwitcher")}>
|
||||||
|
<HeaderButton
|
||||||
|
aria-label={label("boardSwitcher")}
|
||||||
|
onClick={boardSwitcher.open}
|
||||||
|
onFocus={boardSwitcher.preload}
|
||||||
|
onPointerEnter={boardSwitcher.preload}
|
||||||
|
>
|
||||||
|
<IconReplace size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "assistant") {
|
||||||
|
if (!assistant?.enabled) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip label={label("assistant")}>
|
||||||
|
<Indicator
|
||||||
|
inline
|
||||||
|
disabled={!assistant.isRunning && assistant.unreadCount === 0}
|
||||||
|
color="red"
|
||||||
|
size={16}
|
||||||
|
label={assistant.isRunning ? <Loader type="bars" color="white" size={8} /> : assistant.unreadCount}
|
||||||
|
>
|
||||||
|
<HeaderButton aria-label={label("assistant")} onClick={assistant.open}>
|
||||||
|
<IconRobot size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Indicator>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "docker") {
|
||||||
|
if (!isDockerEnabled) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip label={invariantTechnicalLabels.docker}>
|
||||||
|
<HeaderButton aria-label={invariantTechnicalLabels.docker} onClick={openDockerModal}>
|
||||||
|
<IconBrandDocker size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "settings") {
|
||||||
|
const href = userId ? `/manage/users/${userId}/general` : "/auth/login";
|
||||||
|
return (
|
||||||
|
<Tooltip label={label("settings")}>
|
||||||
|
<HeaderButton href={href} aria-label={label("settings")}>
|
||||||
|
<IconSettings size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "themeToggle") {
|
||||||
|
return (
|
||||||
|
<Tooltip label={label("themeToggle")}>
|
||||||
|
<HeaderButton aria-label={label("themeToggle")} onClick={toggleColorScheme}>
|
||||||
|
<IconSunMoon size={20} stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourTarget id="board-user-menu">
|
||||||
|
<UserButtonClient
|
||||||
|
avatar={avatar}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isDockerEnabled={isDockerEnabled}
|
||||||
|
boardSwitcher={boardSwitcher}
|
||||||
|
/>
|
||||||
|
</TourTarget>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-display="logo"] {
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(var(--app-shell-header-height) - 0.5rem);
|
||||||
|
height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-display="logo"] img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(var(--app-shell-header-height) - 0.5rem);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import classes from "./header-logo.module.css";
|
||||||
|
|
||||||
|
interface HeaderLogoProps {
|
||||||
|
display: "logo" | "logoAndText";
|
||||||
|
logo: ReactNode;
|
||||||
|
logoWithTitle: ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
|
||||||
|
let content = logo;
|
||||||
|
if (display === "logoAndText") content = logoWithTitle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
component={Link}
|
||||||
|
href="/"
|
||||||
|
className={classes.root}
|
||||||
|
data-display={display}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,12 +28,14 @@ export const DesktopSearchInput = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileSearchButton = () => {
|
export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
|
||||||
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
data-homarr-dev-benchmark-interaction="search"
|
data-homarr-dev-benchmark-interaction="search"
|
||||||
onClick={() => openSpotlight()}
|
onClick={() => openSpotlight()}
|
||||||
className={classes.mobileSearch}
|
className={alwaysVisible ? undefined : classes.mobileSearch}
|
||||||
|
aria-label={t("search.placeholder")}
|
||||||
>
|
>
|
||||||
<IconSearch size={20} stroke={1.5} />
|
<IconSearch size={20} stroke={1.5} />
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ import { useHotkeys } from "@mantine/hooks";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
|
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||||
import { UpdateIndicator } from "./update";
|
import { UpdateIndicator } from "./update";
|
||||||
|
|
||||||
interface UserButtonClientProps {
|
interface UserButtonClientProps {
|
||||||
avatar: ReactNode;
|
avatar: ReactNode;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isDockerEnabled: boolean;
|
isDockerEnabled: boolean;
|
||||||
|
boardSwitcher: BoardSwitcherControls;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
|
||||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||||
|
const t = useI18n("common.userAvatar.menu");
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
@@ -45,8 +49,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
|
|||||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
|
||||||
<UnstyledButton>
|
<UnstyledButton aria-label={t("open")}>
|
||||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||||
{avatar}
|
{avatar}
|
||||||
</UpdateIndicator>
|
</UpdateIndicator>
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
|
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
|
||||||
import { UserButtonClient } from "./user-client";
|
import { UserButtonClient } from "./user-client";
|
||||||
|
|
||||||
export const UserButton = async () => {
|
export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||||
return (
|
return (
|
||||||
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
<UserButtonClient
|
||||||
|
avatar={<CurrentUserAvatar size="md" />}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isDockerEnabled={isDockerEnabled}
|
||||||
|
boardSwitcher={boardSwitcher}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
|
import { homarrLogoPath } from "./constants";
|
||||||
import type { LogoWithTitleProps } from "./logo";
|
import type { LogoWithTitleProps } from "./logo";
|
||||||
import { Logo, LogoWithTitle } from "./logo";
|
import { Logo, LogoWithTitle } from "./logo";
|
||||||
|
|
||||||
@@ -12,8 +13,9 @@ interface LogoProps {
|
|||||||
|
|
||||||
const useImageOptions = () => {
|
const useImageOptions = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const { branding } = useSettings();
|
||||||
return {
|
return {
|
||||||
src: board.logoImageUrl ?? homarrLogoPath,
|
src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
|
||||||
alt: "Board logo",
|
alt: "Board logo",
|
||||||
shouldUseNextImage: false,
|
shouldUseNextImage: false,
|
||||||
};
|
};
|
||||||
@@ -31,12 +33,13 @@ interface CommonLogoWithTitleProps {
|
|||||||
|
|
||||||
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const { branding } = useSettings();
|
||||||
const imageOptions = useImageOptions();
|
const imageOptions = useImageOptions();
|
||||||
return (
|
return (
|
||||||
<LogoWithTitle
|
<LogoWithTitle
|
||||||
size={size}
|
size={size}
|
||||||
hideTitleOnMobile={hideTitleOnMobile}
|
hideTitleOnMobile={hideTitleOnMobile}
|
||||||
title={board.pageTitle ?? homarrPageTitle}
|
title={board.pageTitle ?? branding.appName}
|
||||||
image={imageOptions}
|
image={imageOptions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const homarrLogoPath = "/logo/logo.png";
|
||||||
@@ -1,25 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import type { LogoWithTitleProps } from "./logo";
|
import type { LogoWithTitleProps } from "./logo";
|
||||||
import { Logo, LogoWithTitle } from "./logo";
|
import { Logo, LogoWithTitle } from "./logo";
|
||||||
|
import { homarrLogoPath } from "./constants";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const homarrLogoPath = "/logo/logo.png";
|
const useBrandLogo = () => {
|
||||||
export const homarrPageTitle = "Homarr";
|
const { branding } = useSettings();
|
||||||
|
const configuredLogoUrl = branding.logoImageUrl?.trim();
|
||||||
const imageOptions = {
|
return {
|
||||||
src: homarrLogoPath,
|
title: branding.appName,
|
||||||
alt: "Homarr logo",
|
image: {
|
||||||
shouldUseNextImage: true,
|
src: configuredLogoUrl || homarrLogoPath,
|
||||||
|
alt: `${branding.appName} logo`,
|
||||||
|
shouldUseNextImage: !configuredLogoUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />;
|
export const HomarrLogo = ({ size }: LogoProps) => {
|
||||||
|
const brand = useBrandLogo();
|
||||||
|
return <Logo size={size} {...brand.image} />;
|
||||||
|
};
|
||||||
|
|
||||||
interface CommonLogoWithTitleProps {
|
interface CommonLogoWithTitleProps {
|
||||||
size: LogoWithTitleProps["size"];
|
size: LogoWithTitleProps["size"];
|
||||||
|
hideTitleOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
|
||||||
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />;
|
const brand = useBrandLogo();
|
||||||
|
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
|
||||||
};
|
};
|
||||||
@@ -9,14 +9,18 @@ interface LogoProps {
|
|||||||
shouldUseNextImage?: boolean;
|
shouldUseNextImage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
|
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
|
||||||
shouldUseNextImage ? (
|
const normalizedSrc = src.trim();
|
||||||
<Image className="logo" src={src} alt={alt} width={size} height={size} />
|
if (normalizedSrc.length === 0) return null;
|
||||||
|
|
||||||
|
return shouldUseNextImage ? (
|
||||||
|
<Image className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
|
||||||
) : (
|
) : (
|
||||||
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
|
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img className="logo" src={src} alt={alt} width={size} height={size} />
|
<img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const logoWithTitleSizes = {
|
const logoWithTitleSizes = {
|
||||||
lg: { logoSize: 48, titleOrder: 1 },
|
lg: { logoSize: 48, titleOrder: 1 },
|
||||||
|
|||||||
@@ -15,7 +15,17 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
|
|||||||
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||||
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||||
|
|
||||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
const pathMatches = (pathname: string, href: string, exact = false) => {
|
||||||
|
if (pathname === href) return true;
|
||||||
|
if (exact) return false;
|
||||||
|
return pathname.startsWith(`${href}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMostSpecificMatchingHref = (pathname: string, items: NavigationLinkHref[]) =>
|
||||||
|
items
|
||||||
|
.filter((item) => pathMatches(pathname, item.href, item.exact))
|
||||||
|
.toSorted((first, second) => second.href.length - first.href.length)
|
||||||
|
.at(0)?.href;
|
||||||
|
|
||||||
const useClientPathname = () => {
|
const useClientPathname = () => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -31,6 +41,7 @@ const useClientPathname = () => {
|
|||||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||||
const { pathname, isClient } = useClientPathname();
|
const { pathname, isClient } = useClientPathname();
|
||||||
const tourId = props["data-onboarding-tour-id"];
|
const tourId = props["data-onboarding-tour-id"];
|
||||||
|
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
|
||||||
const link = props.external ? (
|
const link = props.external ? (
|
||||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||||
) : (
|
) : (
|
||||||
@@ -39,7 +50,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
label={props.label}
|
label={props.label}
|
||||||
leftSection={props.icon}
|
leftSection={props.icon}
|
||||||
href={props.href}
|
href={props.href}
|
||||||
active={isClient && pathMatches(pathname, props.href)}
|
active={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return withOptionalTourTarget(tourId, link);
|
return withOptionalTourTarget(tourId, link);
|
||||||
@@ -47,11 +58,18 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
|
|
||||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
const { pathname, isClient } = useClientPathname();
|
const { pathname, isClient } = useClientPathname();
|
||||||
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
|
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
|
||||||
|
const isActive = isClient && activeItemHref !== undefined;
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) setOpened(true);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
const nav = (
|
const nav = (
|
||||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
|
||||||
{props.items.map((item) => (
|
{props.items.map((item) => (
|
||||||
<NavLinkHref key={item.label} {...item} />
|
<NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
@@ -67,6 +85,8 @@ interface CommonNavigationLinkProps {
|
|||||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
external?: boolean;
|
external?: boolean;
|
||||||
|
exact?: boolean;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||||
items: NavigationLinkHref[];
|
items: NavigationLinkHref[];
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ interface CommonNavigationLinkProps {
|
|||||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
external?: boolean;
|
external?: boolean;
|
||||||
|
exact?: boolean;
|
||||||
}
|
}
|
||||||
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
|
||||||
items: NavigationLinkHref[];
|
items: NavigationLinkHref[];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { AppShell } from "@mantine/core";
|
import { AppShell } from "@mantine/core";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import { useOptionalBackgroundProps } from "./background";
|
import { useOptionalBackgroundProps } from "./background";
|
||||||
import { appShellHeaderHeight } from "./constants";
|
import { appShellHeaderHeight } from "./constants";
|
||||||
@@ -20,11 +21,13 @@ export const ClientShell = ({
|
|||||||
}: PropsWithChildren<ClientShellProps>) => {
|
}: PropsWithChildren<ClientShellProps>) => {
|
||||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||||
const backgroundProps = useOptionalBackgroundProps();
|
const backgroundProps = useOptionalBackgroundProps();
|
||||||
|
const { headerPreferences } = useSettings();
|
||||||
|
const isHeaderVisible = hasHeader && headerPreferences.visible;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
{...backgroundProps}
|
{...backgroundProps}
|
||||||
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
|
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
|
||||||
navbar={
|
navbar={
|
||||||
hasNavigation
|
hasNavigation
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
|||||||
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||||
import type { FloatingPosition } from "@mantine/core";
|
import type { FloatingPosition } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
|
||||||
|
|
||||||
interface TourShellProps extends PropsWithChildren {
|
interface TourShellProps extends PropsWithChildren {
|
||||||
steps: OnboardingTourStep[];
|
steps: OnboardingTourStep[];
|
||||||
started: boolean;
|
started: boolean;
|
||||||
@@ -79,6 +78,7 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
|||||||
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||||
const tCommon = useI18n("common.action");
|
const tCommon = useI18n("common.action");
|
||||||
const tTour = useI18n("onboardingTour");
|
const tTour = useI18n("onboardingTour");
|
||||||
|
const { branding } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const forwardActionRef = useRef<(() => void) | null>(null);
|
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
@@ -145,7 +145,13 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
|
|||||||
</Group>
|
</Group>
|
||||||
{stepIndexFromController(controller) === 0 && (
|
{stepIndexFromController(controller) === 0 && (
|
||||||
<Center py="xs">
|
<Center py="xs">
|
||||||
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
<Image
|
||||||
|
src={branding.logoImageUrl ?? "/logo/logo.png"}
|
||||||
|
alt={`${branding.appName} logo`}
|
||||||
|
w={64}
|
||||||
|
h={64}
|
||||||
|
fit="contain"
|
||||||
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Link } from "@homarr/ui";
|
|||||||
|
|
||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||||
import { BoardSwitcher } from "./board/board-switcher";
|
import type { BoardSwitcherControls } from "./board/board-switcher";
|
||||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||||
@@ -33,6 +33,7 @@ interface UserAvatarMenuProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||||
isDockerEnabled?: boolean;
|
isDockerEnabled?: boolean;
|
||||||
|
boardSwitcher: BoardSwitcherControls;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||||
@@ -41,7 +42,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
|||||||
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
|
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
|
||||||
.join(" + ");
|
.join(" + ");
|
||||||
|
|
||||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => {
|
||||||
const t = useI18n("common.userAvatar.menu");
|
const t = useI18n("common.userAvatar.menu");
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
@@ -59,111 +60,107 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
|||||||
}, [logoutUrl]);
|
}, [logoutUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardSwitcher>
|
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||||
{({ open, preload, hotkey }) => (
|
<Menu width={300} withinPortal keepMounted>
|
||||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
<Menu.Dropdown>
|
||||||
<Menu width={300} withinPortal keepMounted>
|
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||||
<Menu.Dropdown>
|
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
{t("homeBoard")}
|
||||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
</Menu.Item>
|
||||||
{t("homeBoard")}
|
<Menu.Item
|
||||||
</Menu.Item>
|
leftSection={<IconReplace size="1rem" />}
|
||||||
<Menu.Item
|
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||||
leftSection={<IconReplace size="1rem" />}
|
onClick={boardSwitcher.open}
|
||||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
onFocus={boardSwitcher.preload}
|
||||||
onClick={open}
|
onPointerEnter={boardSwitcher.preload}
|
||||||
onFocus={preload}
|
>
|
||||||
onPointerEnter={preload}
|
{tBoard("action.switch")}
|
||||||
>
|
</Menu.Item>
|
||||||
{tBoard("action.switch")}
|
<Menu.Divider />
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
|
||||||
|
|
||||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||||
<CurrentColorSchemeCombobox />
|
<CurrentColorSchemeCombobox />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||||
<CurrentLanguageCombobox />
|
<CurrentLanguageCombobox />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
{Boolean(session.data) && (
|
{Boolean(session.data) && (
|
||||||
<>
|
<>
|
||||||
{assistant?.enabled && (
|
{assistant?.enabled && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={
|
leftSection={
|
||||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||||
}
|
}
|
||||||
rightSection={
|
rightSection={
|
||||||
assistant.unreadCount > 0 ? (
|
assistant.unreadCount > 0 ? (
|
||||||
<Badge size="sm" variant="filled" color="red" circle>
|
<Badge size="sm" variant="filled" color="red" circle>
|
||||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : assistant.isRunning ? (
|
) : assistant.isRunning ? (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t("assistantThinking")}
|
{t("assistantThinking")}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
|
||||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={assistant.open}
|
|
||||||
>
|
|
||||||
{t("assistant")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
|
||||||
href={`/manage/users/${session.data?.user.id}/general`}
|
|
||||||
leftSection={<IconSettings size="1rem" />}
|
|
||||||
>
|
|
||||||
{t("preferences")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
|
||||||
{t("management")}
|
|
||||||
</Menu.Item>
|
|
||||||
{isDockerEnabled && (
|
|
||||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
|
||||||
{invariantTechnicalLabels.docker}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Menu.Divider />
|
|
||||||
{session.status === "authenticated" ? (
|
|
||||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
|
||||||
{t("logout")}
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
|
||||||
{t("login")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
<Menu.Target>
|
|
||||||
<Indicator
|
|
||||||
inline
|
|
||||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
|
||||||
color="red"
|
|
||||||
size={20}
|
|
||||||
offset={4}
|
|
||||||
label={
|
|
||||||
assistant?.isRunning ? (
|
|
||||||
<Loader type="bars" color="white" size={10} />
|
|
||||||
) : assistant?.unreadCount ? (
|
|
||||||
assistant.unreadCount > 99 ? (
|
|
||||||
"99+"
|
|
||||||
) : (
|
) : (
|
||||||
assistant.unreadCount
|
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||||
)
|
)
|
||||||
) : undefined
|
}
|
||||||
}
|
onClick={assistant.open}
|
||||||
|
>
|
||||||
|
{t("assistant")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/users/${session.data?.user.id}/general`}
|
||||||
|
leftSection={<IconSettings size="1rem" />}
|
||||||
>
|
>
|
||||||
{children}
|
{t("preferences")}
|
||||||
</Indicator>
|
</Menu.Item>
|
||||||
</Menu.Target>
|
|
||||||
</Menu>
|
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||||
)}
|
{t("management")}
|
||||||
</BoardSwitcher>
|
</Menu.Item>
|
||||||
|
{isDockerEnabled && (
|
||||||
|
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||||
|
{invariantTechnicalLabels.docker}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
{session.status === "authenticated" ? (
|
||||||
|
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||||
|
{t("logout")}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||||
|
{t("login")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
<Menu.Target>
|
||||||
|
<Indicator
|
||||||
|
inline
|
||||||
|
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||||
|
color="red"
|
||||||
|
size={20}
|
||||||
|
offset={4}
|
||||||
|
label={
|
||||||
|
assistant?.isRunning ? (
|
||||||
|
<Loader type="bars" color="white" size={10} />
|
||||||
|
) : assistant?.unreadCount ? (
|
||||||
|
assistant.unreadCount > 99 ? (
|
||||||
|
"99+"
|
||||||
|
) : (
|
||||||
|
assistant.unreadCount
|
||||||
|
)
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Indicator>
|
||||||
|
</Menu.Target>
|
||||||
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const createMetaTitle = (name: string) => `${name} • Homarr`;
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "homarr-assistant",
|
id: "homarr-assistant",
|
||||||
name: "Homarr Assistant (Demo)",
|
name: "Assistant (Demo)",
|
||||||
description: null,
|
description: null,
|
||||||
contextLength: null,
|
contextLength: null,
|
||||||
promptPrice: null,
|
promptPrice: null,
|
||||||
@@ -507,7 +507,7 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!configuration?.enabled || !configuration.modelId) {
|
if (!configuration?.enabled || !configuration.modelId) {
|
||||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
|
||||||
}
|
}
|
||||||
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
|
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
|
||||||
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
|
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
|
||||||
@@ -708,7 +708,7 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
getConfigurationAsync(ctx.db),
|
getConfigurationAsync(ctx.db),
|
||||||
]);
|
]);
|
||||||
if (!configuration?.enabled || !configuration.modelId) {
|
if (!configuration?.enabled || !configuration.modelId) {
|
||||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
|
||||||
}
|
}
|
||||||
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
|
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
|
||||||
if (input.modelId !== configuration.modelId && !selectedModel) {
|
if (input.modelId !== configuration.modelId && !selectedModel) {
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ interface BoardItemPlacementRectangle {
|
|||||||
|
|
||||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||||
const maxManageOverviewPreviewRows = 12;
|
const maxManageOverviewPreviewRows = 12;
|
||||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
const manageOverviewInputSchema = z
|
||||||
|
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
||||||
|
.optional();
|
||||||
|
|
||||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||||
@@ -140,6 +142,7 @@ const searchAccessibleBoardsAsync = async (
|
|||||||
group: {
|
group: {
|
||||||
with: {
|
with: {
|
||||||
boardPermissions: {},
|
boardPermissions: {},
|
||||||
|
permissions: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -300,9 +303,14 @@ export const boardRouter = createTRPCRouter({
|
|||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session?.user.id;
|
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 { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||||
|
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
|
||||||
|
|
||||||
const dbBoards = await ctx.db.query.boards.findMany({
|
const dbBoards = await ctx.db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
@@ -352,7 +360,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
|
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
where: getAccessibleBoardsWhere(canViewAllBoards, userId, boardIds),
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
|
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
|
||||||
@@ -2577,6 +2585,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
|
|||||||
group: {
|
group: {
|
||||||
with: {
|
with: {
|
||||||
boardPermissions: {},
|
boardPermissions: {},
|
||||||
|
permissions: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2600,6 +2609,24 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
|
|||||||
return { boardIds, currentUser, groupMemberships };
|
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 = (
|
const getBoardGroupPermissionWhere = (
|
||||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||||
) =>
|
) =>
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
} from "@homarr/db/queries";
|
} from "@homarr/db/queries";
|
||||||
import { boards, serverSettings } from "@homarr/db/schema";
|
import { boards, serverSettings } from "@homarr/db/schema";
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
import {
|
||||||
|
authBrandingSchema,
|
||||||
|
brandingServerSettingsSchema,
|
||||||
|
defaultServerSettingsKeys,
|
||||||
|
parseBrandingSettings,
|
||||||
|
} from "@homarr/server-settings";
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
@@ -22,7 +27,14 @@ const boardServerSettingsSchema = z.object({
|
|||||||
}) satisfies z.ZodType<ServerSettings["board"]>;
|
}) satisfies z.ZodType<ServerSettings["board"]>;
|
||||||
|
|
||||||
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
|
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({
|
export const serverSettingsRouter = createTRPCRouter({
|
||||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||||
@@ -30,6 +42,14 @@ export const serverSettingsRouter = createTRPCRouter({
|
|||||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||||
return await getServerSettingsAsync(ctx.db);
|
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
|
getBoardSettings: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.meta({
|
.meta({
|
||||||
@@ -100,10 +120,27 @@ export const serverSettingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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);
|
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||||
...current,
|
...current,
|
||||||
...input.value,
|
...input.value,
|
||||||
} as ServerSettings[keyof ServerSettings]);
|
} as ServerSettings[typeof input.settingsKey]);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -79,7 +79,7 @@ describe("saveSettings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dbSettings = await db.select().from(serverSettings);
|
const dbSettings = await db.select().from(serverSettings);
|
||||||
expect(dbSettings).toStrictEqual([
|
expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([
|
||||||
{
|
{
|
||||||
settingKey: "analytics",
|
settingKey: "analytics",
|
||||||
value: stringify({
|
value: stringify({
|
||||||
@@ -124,7 +124,7 @@ describe("board settings API", () => {
|
|||||||
forceDisableStatus: true,
|
forceDisableStatus: true,
|
||||||
});
|
});
|
||||||
const dbSettings = await db.select().from(serverSettings);
|
const dbSettings = await db.select().from(serverSettings);
|
||||||
expect(dbSettings).toStrictEqual([
|
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||||
{
|
{
|
||||||
settingKey: "board",
|
settingKey: "board",
|
||||||
value: stringify(result),
|
value: stringify(result),
|
||||||
@@ -155,7 +155,7 @@ describe("board settings API", () => {
|
|||||||
enableStatusByDefault: false,
|
enableStatusByDefault: false,
|
||||||
});
|
});
|
||||||
const dbSettings = await db.select().from(serverSettings);
|
const dbSettings = await db.select().from(serverSettings);
|
||||||
expect(dbSettings).toStrictEqual([
|
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||||
{
|
{
|
||||||
settingKey: "board",
|
settingKey: "board",
|
||||||
value: stringify(result),
|
value: stringify(result),
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import { selectUserSchema } from "@homarr/db/validationSchemas";
|
|||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||||
import { byIdSchema } from "@homarr/validation/common";
|
import { byIdSchema } from "@homarr/validation/common";
|
||||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
|
||||||
import {
|
import {
|
||||||
|
getHeaderItems,
|
||||||
|
headerPreferencesMutationSchema,
|
||||||
|
parseHeaderPreferences,
|
||||||
userChangeColorSchemeSchema,
|
userChangeColorSchemeSchema,
|
||||||
userChangeHomeBoardsSchema,
|
userChangeHomeBoardsSchema,
|
||||||
userChangePasswordApiSchema,
|
userChangePasswordApiSchema,
|
||||||
@@ -30,6 +33,7 @@ import {
|
|||||||
userPingIconsEnabledSchema,
|
userPingIconsEnabledSchema,
|
||||||
userRegistrationApiSchema,
|
userRegistrationApiSchema,
|
||||||
} from "@homarr/validation/user";
|
} from "@homarr/validation/user";
|
||||||
|
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
|
||||||
|
|
||||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +44,7 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
import { getAccessibleBoardIdsForUserAsync } from "./board";
|
||||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||||
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
||||||
@@ -368,6 +373,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
pingIconsEnabled: true,
|
pingIconsEnabled: true,
|
||||||
enableRightClickOnWidgets: true,
|
enableRightClickOnWidgets: true,
|
||||||
|
headerPreferences: true,
|
||||||
defaultSearchEngineId: true,
|
defaultSearchEngineId: true,
|
||||||
openSearchInNewTab: true,
|
openSearchInNewTab: true,
|
||||||
ddgBangs: true,
|
ddgBangs: true,
|
||||||
@@ -405,6 +411,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
pingIconsEnabled: true,
|
pingIconsEnabled: true,
|
||||||
enableRightClickOnWidgets: true,
|
enableRightClickOnWidgets: true,
|
||||||
|
headerPreferences: true,
|
||||||
defaultSearchEngineId: true,
|
defaultSearchEngineId: true,
|
||||||
openSearchInNewTab: true,
|
openSearchInNewTab: true,
|
||||||
ddgBangs: true,
|
ddgBangs: true,
|
||||||
@@ -719,6 +726,63 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, input.id));
|
.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
|
changeDdgBangs: protectedProcedure
|
||||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||||
.output(z.void())
|
.output(z.void())
|
||||||
@@ -829,6 +893,9 @@ 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 createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||||
const hashedPassword = await hashPasswordAsync(input.password);
|
const hashedPassword = await hashPasswordAsync(input.password);
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const MCP_TOOL_ALLOWLIST = {
|
|||||||
"mediaServer_getCurrentStreams",
|
"mediaServer_getCurrentStreams",
|
||||||
"patchmon_getStats",
|
"patchmon_getStats",
|
||||||
"serverSettings_getBoardSettings",
|
"serverSettings_getBoardSettings",
|
||||||
|
"serverSettings_getBranding",
|
||||||
"smartHome_entityDetails",
|
"smartHome_entityDetails",
|
||||||
"smartHome_entityState",
|
"smartHome_entityState",
|
||||||
"widgetSecrets_getConfiguredKinds",
|
"widgetSecrets_getConfiguredKinds",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const getRscUserSettingsAsync = cache(async (userId: string) =>
|
|||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
pingIconsEnabled: true,
|
pingIconsEnabled: true,
|
||||||
enableRightClickOnWidgets: true,
|
enableRightClickOnWidgets: true,
|
||||||
|
headerPreferences: true,
|
||||||
defaultSearchEngineId: true,
|
defaultSearchEngineId: true,
|
||||||
openSearchInNewTab: true,
|
openSearchInNewTab: true,
|
||||||
ddgBangs: true,
|
ddgBangs: true,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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,6 +337,13 @@
|
|||||||
"when": 1786217088950,
|
"when": 1786217088950,
|
||||||
"tag": "0047_add_assistant_tables",
|
"tag": "0047_add_assistant_tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787556966768,
|
||||||
|
"tag": "0048_add_header_preferences_and_branding",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
|||||||
"when": 1786217088954,
|
"when": 1786217088954,
|
||||||
"tag": "0013_add_assistant_tables",
|
"tag": "0013_add_assistant_tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787556966592,
|
||||||
|
"tag": "0014_add_header_preferences_and_branding",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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 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;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -323,6 +323,13 @@
|
|||||||
"when": 1786217088950,
|
"when": 1786217088950,
|
||||||
"tag": "0045_add_assistant_tables",
|
"tag": "0045_add_assistant_tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787556956665,
|
||||||
|
"tag": "0046_add_header_preferences_and_branding",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
import { defaultServerSettings, defaultServerSettingsKeys, parseBrandingSettings } from "@homarr/server-settings";
|
||||||
|
|
||||||
import type { Database } from "..";
|
import type { Database } from "..";
|
||||||
import { eq } from "..";
|
import { eq } from "..";
|
||||||
@@ -18,10 +18,15 @@ export const getServerSettingsAsync = async (db: Database) => {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedSetting = SuperJSON.parse<Record<string, unknown>>(setting.value);
|
||||||
|
if (settingKey === "branding") {
|
||||||
|
acc[settingKey] = parseBrandingSettings(parsedSetting) as never;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
acc[settingKey] = {
|
acc[settingKey] = {
|
||||||
...defaultServerSettings[settingKey],
|
...defaultServerSettings[settingKey],
|
||||||
...SuperJSON.parse(setting.value),
|
...parsedSetting,
|
||||||
};
|
} as never;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as ServerSettings);
|
}, {} as ServerSettings);
|
||||||
};
|
};
|
||||||
@@ -35,7 +40,14 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
|
|||||||
return defaultServerSettings[key];
|
return defaultServerSettings[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
const parsedSetting = SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||||
|
if (key === "branding") {
|
||||||
|
return parseBrandingSettings(parsedSetting) as ServerSettings[TKey];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...defaultServerSettings[key],
|
||||||
|
...parsedSetting,
|
||||||
|
} as ServerSettings[TKey];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import type {
|
|||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||||
|
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
|
||||||
|
|
||||||
const customBlob = customType<{ data: Buffer }>({
|
const customBlob = customType<{ data: Buffer }>({
|
||||||
dataType() {
|
dataType() {
|
||||||
@@ -85,6 +86,7 @@ export const users = mysqlTable("user", {
|
|||||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: boolean().default(false).notNull(),
|
pingIconsEnabled: boolean().default(false).notNull(),
|
||||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||||
|
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
|
||||||
completedManageTour: boolean().default(false).notNull(),
|
completedManageTour: boolean().default(false).notNull(),
|
||||||
completedBoardTour: boolean().default(false).notNull(),
|
completedBoardTour: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||||
|
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
|
||||||
|
|
||||||
const customBlob = customType<{ data: Buffer }>({
|
const customBlob = customType<{ data: Buffer }>({
|
||||||
dataType() {
|
dataType() {
|
||||||
@@ -83,6 +84,7 @@ export const users = pgTable("user", {
|
|||||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: boolean().default(false).notNull(),
|
pingIconsEnabled: boolean().default(false).notNull(),
|
||||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||||
|
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
|
||||||
completedManageTour: boolean().default(false).notNull(),
|
completedManageTour: boolean().default(false).notNull(),
|
||||||
completedBoardTour: boolean().default(false).notNull(),
|
completedBoardTour: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||||
|
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
|
||||||
|
|
||||||
export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
|
export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ export const users = sqliteTable("user", {
|
|||||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
||||||
|
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
|
||||||
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
import type { FocusEventHandler, KeyboardEventHandler, ReactNode } from "react";
|
||||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -48,6 +48,7 @@ interface IconPickerProps {
|
|||||||
onFocus?: FocusEventHandler;
|
onFocus?: FocusEventHandler;
|
||||||
onBlur?: FocusEventHandler;
|
onBlur?: FocusEventHandler;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
description?: ReactNode;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
suggestedSearch?: string | null;
|
suggestedSearch?: string | null;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -63,6 +64,7 @@ export const IconPicker = ({
|
|||||||
onBlur,
|
onBlur,
|
||||||
withAsterisk = true,
|
withAsterisk = true,
|
||||||
label,
|
label,
|
||||||
|
description,
|
||||||
placeholder,
|
placeholder,
|
||||||
suggestedSearch,
|
suggestedSearch,
|
||||||
}: IconPickerProps) => {
|
}: IconPickerProps) => {
|
||||||
@@ -272,6 +274,7 @@ export const IconPicker = ({
|
|||||||
(query.data
|
(query.data
|
||||||
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
||||||
: tCommon("iconPicker.headerLoading"));
|
: tCommon("iconPicker.headerLoading"));
|
||||||
|
const inputDescription = description === undefined ? tCommon("iconPicker.description") : description;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -359,7 +362,7 @@ export const IconPicker = ({
|
|||||||
withAsterisk={withAsterisk}
|
withAsterisk={withAsterisk}
|
||||||
error={error}
|
error={error}
|
||||||
label={label ?? tCommon("iconPicker.label")}
|
label={label ?? tCommon("iconPicker.label")}
|
||||||
description={tCommon("iconPicker.description")}
|
description={inputDescription}
|
||||||
styles={{
|
styles={{
|
||||||
description: { color: "var(--mantine-color-text)" },
|
description: { color: "var(--mantine-color-text)" },
|
||||||
input: { color: "var(--mantine-color-text)" },
|
input: { color: "var(--mantine-color-text)" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { OnboardingBackdrop } from "./onboarding-backdrop";
|
import { OnboardingBackdrop } from "./onboarding-backdrop";
|
||||||
@@ -11,17 +11,72 @@ interface OnboardingAuthShellProps {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
appName?: string;
|
||||||
|
showAppName?: boolean;
|
||||||
|
showAppLogo?: boolean;
|
||||||
|
primaryColor?: string;
|
||||||
|
secondaryColor?: string;
|
||||||
|
wordmarkPrimaryColor?: string;
|
||||||
|
wordmarkSecondaryColor?: string;
|
||||||
|
logoImageUrl?: string;
|
||||||
|
backgroundImageUrl?: string;
|
||||||
|
backgroundOverlay?: number;
|
||||||
|
radius?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingAuthShell = ({ title, description, icon, children, footer }: OnboardingAuthShellProps) => (
|
export const OnboardingAuthShell = ({
|
||||||
<main className={classes.page}>
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
appName,
|
||||||
|
showAppName = false,
|
||||||
|
showAppLogo = false,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
wordmarkPrimaryColor,
|
||||||
|
wordmarkSecondaryColor,
|
||||||
|
logoImageUrl,
|
||||||
|
backgroundImageUrl,
|
||||||
|
backgroundOverlay = 0.55,
|
||||||
|
radius = "lg",
|
||||||
|
}: OnboardingAuthShellProps) => (
|
||||||
|
<main
|
||||||
|
className={classes.page}
|
||||||
|
data-background-image={backgroundImageUrl ? true : undefined}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--studio-glow-color": primaryColor,
|
||||||
|
"--studio-secondary-glow-color": secondaryColor,
|
||||||
|
"--auth-background-overlay": backgroundOverlay,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{backgroundImageUrl ? (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={classes.authBackground}
|
||||||
|
style={{ backgroundImage: `url(${JSON.stringify(backgroundImageUrl)})` }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<OnboardingBackdrop />
|
<OnboardingBackdrop />
|
||||||
<div className={`${classes.shell} ${classes.authShell}`}>
|
<div className={`${classes.shell} ${classes.authShell}`}>
|
||||||
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
|
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
|
||||||
<div className={classes.authBrand}>
|
{showAppName || showAppLogo ? (
|
||||||
<OnboardingWordmark large />
|
<div className={classes.authBrand}>
|
||||||
</div>
|
<OnboardingWordmark
|
||||||
<Paper className={`${classes.studio} ${classes.authCard}`} radius="lg" p={{ base: "lg", sm: "xl" }}>
|
large
|
||||||
|
appName={appName}
|
||||||
|
showAppName={showAppName}
|
||||||
|
showAppLogo={showAppLogo}
|
||||||
|
primaryColor={wordmarkPrimaryColor}
|
||||||
|
secondaryColor={wordmarkSecondaryColor}
|
||||||
|
logoImageUrl={logoImageUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Paper className={`${classes.studio} ${classes.authCard}`} radius={radius} p={{ base: "lg", sm: "xl" }}>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<Group wrap="nowrap" align="flex-start">
|
<Group wrap="nowrap" align="flex-start">
|
||||||
<ThemeIcon size="xl" radius="lg" variant="light">
|
<ThemeIcon size="xl" radius="lg" variant="light">
|
||||||
|
|||||||
@@ -88,6 +88,33 @@
|
|||||||
max-width: min(92vw, 42rem);
|
max-width: min(92vw, 42rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customBrand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
max-width: min(92vw, 42rem);
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
font-size: clamp(2rem, 6vw, 4rem);
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customBrand[data-large="true"] {
|
||||||
|
font-size: clamp(2.5rem, 8vw, 5.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customBrandImage {
|
||||||
|
width: auto;
|
||||||
|
height: clamp(4rem, 11vw, 7rem);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customBrand[data-large="true"] .customBrandImage {
|
||||||
|
height: clamp(5rem, 16vw, 9rem);
|
||||||
|
}
|
||||||
|
|
||||||
.celebratingWordmark {
|
.celebratingWordmark {
|
||||||
animation: wordmarkJump 360ms ease-out;
|
animation: wordmarkJump 360ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -140,6 +167,25 @@
|
|||||||
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
|
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authBackground {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authBackground::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
content: "";
|
||||||
|
background: rgb(0 0 0 / var(--auth-background-overlay, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page[data-background-image] .marqueeBackdrop {
|
||||||
|
opacity: 0.14;
|
||||||
|
}
|
||||||
|
|
||||||
.authLayout {
|
.authLayout {
|
||||||
width: min(100%, 36rem);
|
width: min(100%, 36rem);
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import classes from "./onboarding-studio.module.css";
|
|||||||
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
||||||
|
|
||||||
interface OnboardingWordmarkProps {
|
interface OnboardingWordmarkProps {
|
||||||
|
appName?: string;
|
||||||
|
logoImageUrl?: string;
|
||||||
|
showAppName?: boolean;
|
||||||
|
showAppLogo?: boolean;
|
||||||
primaryColor?: string;
|
primaryColor?: string;
|
||||||
secondaryColor?: string;
|
secondaryColor?: string;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
@@ -16,7 +20,15 @@ interface OnboardingWordmarkProps {
|
|||||||
|
|
||||||
const defaultWordmarkUrl = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg";
|
const defaultWordmarkUrl = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg";
|
||||||
|
|
||||||
export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => {
|
export const OnboardingWordmark = ({
|
||||||
|
appName,
|
||||||
|
logoImageUrl,
|
||||||
|
showAppName,
|
||||||
|
showAppLogo,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
large = false,
|
||||||
|
}: OnboardingWordmarkProps) => {
|
||||||
const [celebrating, setCelebrating] = useState(false);
|
const [celebrating, setCelebrating] = useState(false);
|
||||||
const sounds = useOnboardingSounds();
|
const sounds = useOnboardingSounds();
|
||||||
const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`;
|
const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`;
|
||||||
@@ -52,14 +64,36 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasVisibilityControls = showAppName !== undefined || showAppLogo !== undefined;
|
||||||
|
const resolvedAppName = appName?.trim() || "Homarr";
|
||||||
|
const shouldRenderDefaultWordmark = showAppLogo && showAppName && !logoImageUrl && resolvedAppName === "Homarr";
|
||||||
|
|
||||||
|
if (hasVisibilityControls && !shouldRenderDefaultWordmark) {
|
||||||
|
const visibleLogoImageUrl = showAppLogo ? (logoImageUrl ?? "/logo/logo.png") : undefined;
|
||||||
|
if (!visibleLogoImageUrl && !showAppName) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.customBrand} data-large={large || undefined} aria-label={resolvedAppName}>
|
||||||
|
{visibleLogoImageUrl ? (
|
||||||
|
<img
|
||||||
|
className={classes.customBrandImage}
|
||||||
|
src={visibleLogoImageUrl}
|
||||||
|
alt={showAppName ? "" : resolvedAppName}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showAppName ? <span>{resolvedAppName}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (primaryColor === undefined && secondaryColor === undefined) {
|
if (primaryColor === undefined && secondaryColor === undefined) {
|
||||||
return <img src={defaultWordmarkUrl} alt="Homarr" width={1477} height={1054} className={className} />;
|
return <img src={defaultWordmarkUrl} alt={resolvedAppName} width={1477} height={1054} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomarrWordmarkLight
|
<HomarrWordmarkLight
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Homarr"
|
aria-label={resolvedAppName}
|
||||||
style={colors}
|
style={colors}
|
||||||
className={className}
|
className={className}
|
||||||
onMouseEnter={(event) => celebrate(event.currentTarget)}
|
onMouseEnter={(event) => celebrate(event.currentTarget)}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { MantineSize } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
@@ -91,7 +90,16 @@ import {
|
|||||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||||
import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui";
|
import type { CornerStyle } from "@homarr/ui";
|
||||||
|
import {
|
||||||
|
BoardColorInput,
|
||||||
|
ColorSchemeCombobox,
|
||||||
|
CornerStylePicker,
|
||||||
|
cornerStyleValues,
|
||||||
|
IntegrationAvatar,
|
||||||
|
LanguageCombobox,
|
||||||
|
Link,
|
||||||
|
} from "@homarr/ui";
|
||||||
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
|
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
|
||||||
|
|
||||||
import type { OnboardingStudioProps } from "./types";
|
import type { OnboardingStudioProps } from "./types";
|
||||||
@@ -146,7 +154,6 @@ const sectionDefinitions = [
|
|||||||
{ id: "review", icon: IconCheck },
|
{ id: "review", icon: IconCheck },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const;
|
|
||||||
const initialPrimaryColor = "#fa5252";
|
const initialPrimaryColor = "#fa5252";
|
||||||
const initialSecondaryColor = "#fd7e14";
|
const initialSecondaryColor = "#fd7e14";
|
||||||
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
|
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
|
||||||
@@ -176,7 +183,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
|||||||
const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard");
|
const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard");
|
||||||
const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor);
|
const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor);
|
||||||
const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor);
|
const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor);
|
||||||
const [itemRadius, setItemRadius] = useState<MantineSize>("lg");
|
const [itemRadius, setItemRadius] = useState<CornerStyle>("lg");
|
||||||
const [columnCount, setColumnCount] = useState(10);
|
const [columnCount, setColumnCount] = useState(10);
|
||||||
const [leftSidebar, setLeftSidebar] = useState(false);
|
const [leftSidebar, setLeftSidebar] = useState(false);
|
||||||
const [rightSidebar, setRightSidebar] = useState(false);
|
const [rightSidebar, setRightSidebar] = useState(false);
|
||||||
@@ -797,8 +804,8 @@ interface StudioSectionProps {
|
|||||||
setPrimaryColor: (value: string) => void;
|
setPrimaryColor: (value: string) => void;
|
||||||
secondaryColor: string;
|
secondaryColor: string;
|
||||||
setSecondaryColor: (value: string) => void;
|
setSecondaryColor: (value: string) => void;
|
||||||
itemRadius: MantineSize;
|
itemRadius: CornerStyle;
|
||||||
setItemRadius: (value: MantineSize) => void;
|
setItemRadius: (value: CornerStyle) => void;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
setColumnCount: (value: number) => void;
|
setColumnCount: (value: number) => void;
|
||||||
leftSidebar: boolean;
|
leftSidebar: boolean;
|
||||||
@@ -1452,32 +1459,16 @@ const BoardBuilder = (props: StudioSectionProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap="xs">
|
<CornerStylePicker
|
||||||
<Text size="sm" fw={500}>
|
label={t("radius")}
|
||||||
{t("radius")}
|
value={props.itemRadius}
|
||||||
</Text>
|
labels={
|
||||||
<OnboardingFloatingControl
|
Object.fromEntries(
|
||||||
ariaLabel={t("radius")}
|
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`radiusOptions.${cornerStyle}`)]),
|
||||||
value={props.itemRadius}
|
) as Record<CornerStyle, string>
|
||||||
onChange={props.setItemRadius}
|
}
|
||||||
options={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))}
|
onChange={props.setItemRadius}
|
||||||
/>
|
/>
|
||||||
<Group gap="xs" aria-hidden>
|
|
||||||
{radiusValues.map((radius) => (
|
|
||||||
<div
|
|
||||||
key={radius}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: `var(--mantine-radius-${radius})`,
|
|
||||||
background: "var(--mantine-color-default)",
|
|
||||||
border: "1px solid var(--mantine-color-default-border)",
|
|
||||||
opacity: props.itemRadius === radius ? 1 : 0.55,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -1543,7 +1534,7 @@ const BoardPreview = ({
|
|||||||
appCount,
|
appCount,
|
||||||
}: {
|
}: {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
itemRadius: MantineSize;
|
itemRadius: CornerStyle;
|
||||||
baseColumnCount: number;
|
baseColumnCount: number;
|
||||||
leftSidebar: boolean;
|
leftSidebar: boolean;
|
||||||
rightSidebar: boolean;
|
rightSidebar: boolean;
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0"
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const defaultServerSettingsKeys = [
|
export const defaultServerSettingsKeys = [
|
||||||
"analytics",
|
"analytics",
|
||||||
@@ -7,12 +8,120 @@ export const defaultServerSettingsKeys = [
|
|||||||
"board",
|
"board",
|
||||||
"user",
|
"user",
|
||||||
"appearance",
|
"appearance",
|
||||||
|
"branding",
|
||||||
"culture",
|
"culture",
|
||||||
"search",
|
"search",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||||
|
|
||||||
|
export const brandingRadiusOptions = ["xs", "sm", "md", "lg", "xl"] as const;
|
||||||
|
export type BrandingRadius = (typeof brandingRadiusOptions)[number];
|
||||||
|
|
||||||
|
export const authBrandingSchema = z.object({
|
||||||
|
showAppName: z.boolean(),
|
||||||
|
showLogo: z.boolean(),
|
||||||
|
showGreeting: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const brandingImageUrlSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(2_048)
|
||||||
|
.refine((value) => {
|
||||||
|
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, "Image must be an HTTP(S) URL or an app-relative path")
|
||||||
|
.nullable();
|
||||||
|
|
||||||
|
export const brandingServerSettingsSchema = z.object({
|
||||||
|
appName: z.string().trim().min(1).max(80),
|
||||||
|
greeting: z.string().trim().max(160),
|
||||||
|
logoImageUrl: brandingImageUrlSchema,
|
||||||
|
faviconImageUrl: brandingImageUrlSchema,
|
||||||
|
primaryColor: z.string().regex(/^#[\da-f]{6}$/iu, "Primary color must be a 6-digit hex color"),
|
||||||
|
secondaryColor: z.string().regex(/^#[\da-f]{6}$/iu, "Secondary color must be a 6-digit hex color"),
|
||||||
|
lockPrimaryColor: z.boolean(),
|
||||||
|
signInBackgroundImageUrl: brandingImageUrlSchema,
|
||||||
|
signInBackgroundOverlay: z.number().min(0).max(0.9),
|
||||||
|
authBranding: authBrandingSchema,
|
||||||
|
defaultRadius: z.enum(brandingRadiusOptions),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
|
||||||
|
|
||||||
|
export const defaultBrandingSettings: BrandingSettings = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
|
||||||
|
branding.primaryColor.toLowerCase() === defaultBrandingSettings.primaryColor &&
|
||||||
|
branding.secondaryColor.toLowerCase() === defaultBrandingSettings.secondaryColor;
|
||||||
|
|
||||||
|
export const getBrandingColorOverrides = (
|
||||||
|
branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">,
|
||||||
|
): Partial<Pick<BrandingSettings, "primaryColor" | "secondaryColor">> => {
|
||||||
|
if (hasDefaultBrandingColors(branding)) return {};
|
||||||
|
return {
|
||||||
|
primaryColor: branding.primaryColor,
|
||||||
|
secondaryColor: branding.secondaryColor,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const legacyLoginBrandingSchema = z.object({
|
||||||
|
showCustomAppNameOnLogin: z.boolean().optional(),
|
||||||
|
showCustomLogoOnLogin: z.boolean().optional(),
|
||||||
|
showCustomGreetingOnLogin: z.boolean().optional(),
|
||||||
|
showCustomAppNameOnInvite: z.boolean().optional(),
|
||||||
|
showCustomLogoOnInvite: z.boolean().optional(),
|
||||||
|
showCustomGreetingOnInvite: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const parseBrandingSettings = (value: unknown): BrandingSettings => {
|
||||||
|
const partialValue = z.record(z.string(), z.unknown()).safeParse(value);
|
||||||
|
if (!partialValue.success) return defaultBrandingSettings;
|
||||||
|
|
||||||
|
const legacyBranding = legacyLoginBrandingSchema.safeParse(partialValue.data);
|
||||||
|
const partialAuthBranding = authBrandingSchema.partial().safeParse(partialValue.data.authBranding);
|
||||||
|
const authBranding = { ...defaultBrandingSettings.authBranding };
|
||||||
|
|
||||||
|
if (legacyBranding.success) {
|
||||||
|
const legacy = legacyBranding.data;
|
||||||
|
authBranding.showAppName = legacy.showCustomAppNameOnLogin ?? authBranding.showAppName;
|
||||||
|
authBranding.showLogo = legacy.showCustomLogoOnLogin ?? authBranding.showLogo;
|
||||||
|
authBranding.showGreeting = legacy.showCustomGreetingOnLogin ?? authBranding.showGreeting;
|
||||||
|
}
|
||||||
|
if (partialAuthBranding.success) Object.assign(authBranding, partialAuthBranding.data);
|
||||||
|
|
||||||
|
const result = brandingServerSettingsSchema.safeParse({
|
||||||
|
...defaultBrandingSettings,
|
||||||
|
...partialValue.data,
|
||||||
|
authBranding,
|
||||||
|
});
|
||||||
|
if (!result.success) return defaultBrandingSettings;
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const defaultServerSettings = {
|
export const defaultServerSettings = {
|
||||||
analytics: {
|
analytics: {
|
||||||
enableGeneral: true,
|
enableGeneral: true,
|
||||||
@@ -36,6 +145,7 @@ export const defaultServerSettings = {
|
|||||||
appearance: {
|
appearance: {
|
||||||
defaultColorScheme: "auto" as ColorScheme,
|
defaultColorScheme: "auto" as ColorScheme,
|
||||||
},
|
},
|
||||||
|
branding: defaultBrandingSettings,
|
||||||
culture: {
|
culture: {
|
||||||
defaultLocale: "en" as SupportedLanguage,
|
defaultLocale: "en" as SupportedLanguage,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/dates": "catalog:",
|
"@mantine/dates": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import type { User } from "@homarr/db/schema";
|
import type { User } from "@homarr/db/schema";
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import { parseBrandingSettings } from "@homarr/server-settings";
|
||||||
|
import type { HeaderPreferences } from "@homarr/validation/user";
|
||||||
|
import { parseHeaderPreferences } from "@homarr/validation/user";
|
||||||
|
|
||||||
export type SettingsContextProps = Pick<
|
export type SettingsContextProps = Omit<
|
||||||
User,
|
Pick<
|
||||||
| "firstDayOfWeek"
|
User,
|
||||||
| "defaultSearchEngineId"
|
| "firstDayOfWeek"
|
||||||
| "homeBoardId"
|
| "defaultSearchEngineId"
|
||||||
| "mobileHomeBoardId"
|
| "homeBoardId"
|
||||||
| "openSearchInNewTab"
|
| "mobileHomeBoardId"
|
||||||
| "ddgBangs"
|
| "openSearchInNewTab"
|
||||||
| "pingIconsEnabled"
|
| "ddgBangs"
|
||||||
| "enableRightClickOnWidgets"
|
| "pingIconsEnabled"
|
||||||
> &
|
| "enableRightClickOnWidgets"
|
||||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
|
| "headerPreferences"
|
||||||
Pick<ServerSettings["user"], "enableGravatar">;
|
>,
|
||||||
|
"headerPreferences"
|
||||||
|
> & { headerPreferences: HeaderPreferences } & Pick<
|
||||||
|
ServerSettings["board"],
|
||||||
|
"enableStatusByDefault" | "forceDisableStatus"
|
||||||
|
> &
|
||||||
|
Pick<ServerSettings["user"], "enableGravatar"> & {
|
||||||
|
branding: ServerSettings["branding"];
|
||||||
|
};
|
||||||
|
|
||||||
export interface PublicServerSettings {
|
export interface PublicServerSettings {
|
||||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||||
@@ -22,6 +33,7 @@ export interface PublicServerSettings {
|
|||||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||||
>;
|
>;
|
||||||
user: Pick<ServerSettings["user"], "enableGravatar">;
|
user: Pick<ServerSettings["user"], "enableGravatar">;
|
||||||
|
branding: ServerSettings["branding"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserSettings = Pick<
|
export type UserSettings = Pick<
|
||||||
@@ -34,6 +46,7 @@ export type UserSettings = Pick<
|
|||||||
| "ddgBangs"
|
| "ddgBangs"
|
||||||
| "pingIconsEnabled"
|
| "pingIconsEnabled"
|
||||||
| "enableRightClickOnWidgets"
|
| "enableRightClickOnWidgets"
|
||||||
|
| "headerPreferences"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const createSettings = ({
|
export const createSettings = ({
|
||||||
@@ -51,7 +64,9 @@ export const createSettings = ({
|
|||||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||||
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
||||||
|
headerPreferences: parseHeaderPreferences(user?.headerPreferences),
|
||||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||||
enableGravatar: serverSettings.user.enableGravatar,
|
enableGravatar: serverSettings.user.enableGravatar,
|
||||||
|
branding: parseBrandingSettings(serverSettings.branding),
|
||||||
});
|
});
|
||||||
@@ -142,6 +142,13 @@
|
|||||||
"secondaryColor": "Secondary color",
|
"secondaryColor": "Secondary color",
|
||||||
"resetColors": "Reset colors",
|
"resetColors": "Reset colors",
|
||||||
"radius": "Widget corner radius",
|
"radius": "Widget corner radius",
|
||||||
|
"radiusOptions": {
|
||||||
|
"xs": "Tight",
|
||||||
|
"sm": "Subtle",
|
||||||
|
"md": "Balanced",
|
||||||
|
"lg": "Rounded",
|
||||||
|
"xl": "Soft"
|
||||||
|
},
|
||||||
"columns": "Board columns",
|
"columns": "Board columns",
|
||||||
"columnsDescription": "Columns define the horizontal grid used to size and position items. 12 is plenty for most people.",
|
"columnsDescription": "Columns define the horizontal grid used to size and position items. 12 is plenty for most people.",
|
||||||
"columnCount": "{count, plural, one {# column} other {# columns}}",
|
"columnCount": "{count, plural, one {# column} other {# columns}}",
|
||||||
@@ -242,7 +249,9 @@
|
|||||||
},
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Join Homarr",
|
"title": "Join Homarr",
|
||||||
|
"titleWithAppName": "Join {appName}",
|
||||||
"subtitle": "Welcome to Homarr! Please create your account",
|
"subtitle": "Welcome to Homarr! Please create your account",
|
||||||
|
"subtitleWithAppName": "Welcome to {appName}! Please create your account",
|
||||||
"description": "You were invited by {username}"
|
"description": "You were invited by {username}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1847,13 +1856,14 @@
|
|||||||
},
|
},
|
||||||
"userAvatar": {
|
"userAvatar": {
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"open": "Open account menu",
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"preferences": "Your preferences",
|
"preferences": "Your preferences",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"homeBoard": "Your home board",
|
"homeBoard": "Your home board",
|
||||||
"updateAvailable": "{countUpdates} updates available: {tag}",
|
"updateAvailable": "{countUpdates} updates available: {tag}",
|
||||||
"assistant": "Homarr Assistant",
|
"assistant": "Assistant",
|
||||||
"assistantThinking": "Thinking",
|
"assistantThinking": "Thinking",
|
||||||
"modifier": "Mod"
|
"modifier": "Mod"
|
||||||
}
|
}
|
||||||
@@ -5336,7 +5346,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"primaryColor": {
|
"primaryColor": {
|
||||||
"label": "Primary color"
|
"label": "Primary color",
|
||||||
|
"locked": "Your administrator keeps the instance brand color on every board."
|
||||||
},
|
},
|
||||||
"secondaryColor": {
|
"secondaryColor": {
|
||||||
"label": "Secondary color"
|
"label": "Secondary color"
|
||||||
@@ -5676,6 +5687,65 @@
|
|||||||
"setting": {
|
"setting": {
|
||||||
"general": {
|
"general": {
|
||||||
"title": "General",
|
"title": "General",
|
||||||
|
"header": {
|
||||||
|
"title": "Header studio",
|
||||||
|
"badge": "Personal",
|
||||||
|
"description": "Drag controls anywhere in the row. Left, Center, and Right stay fixed while controls shift around them.",
|
||||||
|
"device": {
|
||||||
|
"desktop": "Desktop",
|
||||||
|
"mobile": "Mobile"
|
||||||
|
},
|
||||||
|
"zones": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"hide": "Hide the header",
|
||||||
|
"show": "Show the header",
|
||||||
|
"configure": "Move or configure {item}",
|
||||||
|
"remove": "Remove {item}",
|
||||||
|
"dropIntoZone": "Move into the {zone} section"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"board": "Dashboard preview for {name}",
|
||||||
|
"empty": "No dashboard layout is available"
|
||||||
|
},
|
||||||
|
"available": {
|
||||||
|
"title": "Available controls",
|
||||||
|
"description": "Add a control, then drag its full button into position above.",
|
||||||
|
"requiredAlways": "This control is required, but you can move it anywhere in the header.",
|
||||||
|
"allAdded": "Every built-in control is already in your header.",
|
||||||
|
"add": "Add {item}",
|
||||||
|
"boardShortcut": "Board shortcut",
|
||||||
|
"chooseBoard": "Choose a board"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"description": "This control stays the same size as it does in the live header.",
|
||||||
|
"searchDisplay": {
|
||||||
|
"input": "Search bar",
|
||||||
|
"icon": "Icon only"
|
||||||
|
},
|
||||||
|
"logoDisplay": {
|
||||||
|
"logo": "Logo only",
|
||||||
|
"logoAndText": "Logo + text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"logo": "Logo",
|
||||||
|
"search": "Search",
|
||||||
|
"home": "Home board",
|
||||||
|
"boardSwitcher": "Boards",
|
||||||
|
"assistant": "Assistant",
|
||||||
|
"docker": "Docker",
|
||||||
|
"boardEdit": "Edit board",
|
||||||
|
"boardSettings": "Board settings",
|
||||||
|
"settings": "Your settings",
|
||||||
|
"themeToggle": "Toggle theme",
|
||||||
|
"user": "Account",
|
||||||
|
"boardUnavailable": "Board unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"language": "Language & Region",
|
"language": "Language & Region",
|
||||||
"board": {
|
"board": {
|
||||||
@@ -5874,6 +5944,80 @@
|
|||||||
"label": "Default color scheme"
|
"label": "Default color scheme"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"branding": {
|
||||||
|
"title": "Instance branding",
|
||||||
|
"groups": {
|
||||||
|
"identity": "Identity",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"authentication": "Authentication"
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"description": "The application logo is the larger mark used inside the app, on sign-in pages, and for installed apps. The browser icon (favicon) is the small square used in tabs and bookmarks; when empty, it falls back to the application logo."
|
||||||
|
},
|
||||||
|
"appName": {
|
||||||
|
"label": "Application name"
|
||||||
|
},
|
||||||
|
"greeting": {
|
||||||
|
"label": "Custom greeting"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"label": "Application logo",
|
||||||
|
"placeholder": "/logo/logo.png"
|
||||||
|
},
|
||||||
|
"favicon": {
|
||||||
|
"label": "Browser icon (favicon)",
|
||||||
|
"placeholder": "Uses application logo"
|
||||||
|
},
|
||||||
|
"primaryColor": {
|
||||||
|
"label": "Primary color"
|
||||||
|
},
|
||||||
|
"secondaryColor": {
|
||||||
|
"label": "Secondary accent"
|
||||||
|
},
|
||||||
|
"lockPrimaryColor": {
|
||||||
|
"label": "Keep this color on every board",
|
||||||
|
"description": "Prevents board colors from replacing the application accent."
|
||||||
|
},
|
||||||
|
"signInBackground": {
|
||||||
|
"label": "Authentication background",
|
||||||
|
"placeholder": "https://example.com/background.jpg"
|
||||||
|
},
|
||||||
|
"signInOverlay": {
|
||||||
|
"label": "Background overlay"
|
||||||
|
},
|
||||||
|
"defaultRadius": {
|
||||||
|
"label": "Corner style",
|
||||||
|
"options": {
|
||||||
|
"xs": "Tight",
|
||||||
|
"sm": "Subtle",
|
||||||
|
"md": "Balanced",
|
||||||
|
"lg": "Rounded",
|
||||||
|
"xl": "Soft"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authVisibility": {
|
||||||
|
"description": "Choose what appears on every authentication page.",
|
||||||
|
"appName": "Application name",
|
||||||
|
"logo": "Application logo",
|
||||||
|
"greeting": "Greeting"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Current branding",
|
||||||
|
"cornerStyle": "Corner style",
|
||||||
|
"palette": "Palette",
|
||||||
|
"backgroundConfigured": "Custom background · {overlay}% overlay",
|
||||||
|
"backgroundDefault": "Default background",
|
||||||
|
"greetingConfigured": "Custom greeting",
|
||||||
|
"greetingDefault": "Default greeting",
|
||||||
|
"boardColorLocked": "Board color locked",
|
||||||
|
"boardColorFlexible": "Board colors allowed",
|
||||||
|
"appName": "Name",
|
||||||
|
"logo": "Logo",
|
||||||
|
"greeting": "Greeting",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
"culture": {
|
"culture": {
|
||||||
"title": "Culture",
|
"title": "Culture",
|
||||||
"defaultLocale": {
|
"defaultLocale": {
|
||||||
@@ -6318,7 +6462,7 @@
|
|||||||
"openSpotlightApps": "Open app search",
|
"openSpotlightApps": "Open app search",
|
||||||
"openSpotlightAssistant": "Open Assistant mode",
|
"openSpotlightAssistant": "Open Assistant mode",
|
||||||
"openSpotlightPreferences": "Open preferences mode",
|
"openSpotlightPreferences": "Open preferences mode",
|
||||||
"openAssistant": "Open Homarr Assistant"
|
"openAssistant": "Open Assistant"
|
||||||
},
|
},
|
||||||
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
|
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
|
||||||
}
|
}
|
||||||
@@ -6802,7 +6946,7 @@
|
|||||||
},
|
},
|
||||||
"assistant": {
|
"assistant": {
|
||||||
"label": "Assistant",
|
"label": "Assistant",
|
||||||
"placeholder": "Ask Homarr Assistant"
|
"placeholder": "Ask Assistant"
|
||||||
},
|
},
|
||||||
"external": {
|
"external": {
|
||||||
"label": "Web",
|
"label": "Web",
|
||||||
@@ -7508,12 +7652,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"metadata": {
|
||||||
|
"description": "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||||
|
"dashboardTitle": "{appName} Dashboard"
|
||||||
|
},
|
||||||
"assistant": {
|
"assistant": {
|
||||||
"title": "Homarr Assistant",
|
"title": "Assistant",
|
||||||
"open": "Open Homarr Assistant",
|
"open": "Open Assistant",
|
||||||
"close": "Close assistant",
|
"close": "Close assistant",
|
||||||
"minimize": "Minimize assistant",
|
"minimize": "Minimize assistant",
|
||||||
"spotlight": "Ask Homarr Assistant",
|
"spotlight": "Ask Assistant",
|
||||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||||
"spotlightPrompt": {
|
"spotlightPrompt": {
|
||||||
"title": "Ask Assistant",
|
"title": "Ask Assistant",
|
||||||
@@ -7525,7 +7673,7 @@
|
|||||||
"checking": "Checking whether the assistant is available…",
|
"checking": "Checking whether the assistant is available…",
|
||||||
"error": "The assistant's availability could not be checked. Try again.",
|
"error": "The assistant's availability could not be checked. Try again.",
|
||||||
"notConfigured": "An administrator must configure and enable the assistant first.",
|
"notConfigured": "An administrator must configure and enable the assistant first.",
|
||||||
"signIn": "Sign in to use Homarr Assistant."
|
"signIn": "Sign in to use Assistant."
|
||||||
},
|
},
|
||||||
"newConversation": "New conversation",
|
"newConversation": "New conversation",
|
||||||
"conversations": "Conversations",
|
"conversations": "Conversations",
|
||||||
@@ -7568,7 +7716,7 @@
|
|||||||
"prompt": "Help me improve a board's appearance or custom CSS. Show me my boards first, then preserve my current settings while we make the change."
|
"prompt": "Help me improve a board's appearance or custom CSS. Show me my boards first, then preserve my current settings while we make the change."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composerPlaceholder": "Ask Homarr Assistant…",
|
"composerPlaceholder": "Ask Assistant…",
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"searchModels": "Search models",
|
"searchModels": "Search models",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SelectableCard } from "./selectable-card";
|
||||||
|
|
||||||
|
export const cornerStyleValues = ["xs", "sm", "md", "lg", "xl"] as const;
|
||||||
|
|
||||||
|
export type CornerStyle = (typeof cornerStyleValues)[number];
|
||||||
|
|
||||||
|
interface CornerStylePickerProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: CornerStyle;
|
||||||
|
labels: Record<CornerStyle, string>;
|
||||||
|
onChange: (value: CornerStyle) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CornerStylePicker = ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
labels,
|
||||||
|
onChange,
|
||||||
|
compact = false,
|
||||||
|
}: CornerStylePickerProps) => {
|
||||||
|
const previewHeight = compact ? 24 : 34;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="fieldset" m={0} p={0} style={{ border: 0, minWidth: 0 }}>
|
||||||
|
<Text component="legend" fw={500} size="sm" p={0}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs" mt={2}>
|
||||||
|
{description ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<SimpleGrid cols={{ base: 3, xs: 5 }} spacing="xs">
|
||||||
|
{cornerStyleValues.map((cornerStyle) => (
|
||||||
|
<SelectableCard
|
||||||
|
key={cornerStyle}
|
||||||
|
selected={value === cornerStyle}
|
||||||
|
onClick={() => onChange(cornerStyle)}
|
||||||
|
radius={cornerStyle}
|
||||||
|
p="xs"
|
||||||
|
aria-label={labels[cornerStyle]}
|
||||||
|
style={compact ? { minHeight: "auto", height: "auto" } : undefined}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
h={previewHeight}
|
||||||
|
bg="var(--mantine-color-default-hover)"
|
||||||
|
style={{ borderRadius: `var(--mantine-radius-${cornerStyle})` }}
|
||||||
|
/>
|
||||||
|
<Text size="xs" ta="center" mt="xs">
|
||||||
|
{labels[cornerStyle]}
|
||||||
|
</Text>
|
||||||
|
</SelectableCard>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,8 @@ export { LanguageIcon } from "./language-icon";
|
|||||||
export { LanguageCombobox } from "./language-combobox";
|
export { LanguageCombobox } from "./language-combobox";
|
||||||
export { ColorSchemeCombobox } from "./color-scheme-combobox";
|
export { ColorSchemeCombobox } from "./color-scheme-combobox";
|
||||||
export { BoardColorInput } from "./board-color-input";
|
export { BoardColorInput } from "./board-color-input";
|
||||||
|
export { CornerStylePicker, cornerStyleValues } from "./corner-style-picker";
|
||||||
|
export type { CornerStyle } from "./corner-style-picker";
|
||||||
export { IntegrationMarquee } from "./integration-marquee";
|
export { IntegrationMarquee } from "./integration-marquee";
|
||||||
export { Link } from "./link";
|
export { Link } from "./link";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const headerPreferencesVersion = 3 as const;
|
||||||
|
|
||||||
|
export const headerBuiltinItemIds = [
|
||||||
|
"logo",
|
||||||
|
"search",
|
||||||
|
"home",
|
||||||
|
"boardSwitcher",
|
||||||
|
"assistant",
|
||||||
|
"docker",
|
||||||
|
"boardEdit",
|
||||||
|
"boardSettings",
|
||||||
|
"settings",
|
||||||
|
"themeToggle",
|
||||||
|
"user",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const;
|
||||||
|
|
||||||
|
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
||||||
|
export const headerItemIds = headerBuiltinItemIds;
|
||||||
|
|
||||||
|
export type HeaderBuiltinItemId = (typeof headerBuiltinItemIds)[number];
|
||||||
|
export type HeaderPreferenceItemId = HeaderBuiltinItemId;
|
||||||
|
|
||||||
|
export const headerZoneIds = ["left", "center", "right"] as const;
|
||||||
|
export const headerSearchDisplayValues = ["input", "icon"] as const;
|
||||||
|
export const headerLogoDisplayValues = ["logo", "logoAndText"] as const;
|
||||||
|
|
||||||
|
export type HeaderZoneId = (typeof headerZoneIds)[number];
|
||||||
|
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
|
||||||
|
export type HeaderLogoDisplay = (typeof headerLogoDisplayValues)[number];
|
||||||
|
|
||||||
|
const headerBuiltinItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("builtin"),
|
||||||
|
id: z.enum(headerBuiltinItemIds),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const headerBoardItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("board"),
|
||||||
|
boardId: z.string().min(1).max(255),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const headerItemSchema = z.discriminatedUnion("type", [headerBuiltinItemSchema, headerBoardItemSchema]);
|
||||||
|
|
||||||
|
export type HeaderBuiltinItem = z.infer<typeof headerBuiltinItemSchema>;
|
||||||
|
export type HeaderBoardItem = z.infer<typeof headerBoardItemSchema>;
|
||||||
|
export type HeaderItem = z.infer<typeof headerItemSchema>;
|
||||||
|
export type HeaderItemId = HeaderItem;
|
||||||
|
export type HeaderZones = Record<HeaderZoneId, HeaderItem[]>;
|
||||||
|
|
||||||
|
export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinItem => ({ type: "builtin", id });
|
||||||
|
|
||||||
|
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
|
||||||
|
|
||||||
|
export const isRequiredHeaderItem = (item: HeaderItem) =>
|
||||||
|
item.type === "builtin" && requiredHeaderBuiltinItemIds.some((itemId) => itemId === item.id);
|
||||||
|
|
||||||
|
export const getHeaderItemKey = (item: HeaderItem): string => {
|
||||||
|
if (item.type === "board") return `board:${item.boardId}`;
|
||||||
|
return `builtin:${item.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHeaderItems = (zones: HeaderZones): HeaderItem[] => headerZoneIds.flatMap((zone) => zones[zone]);
|
||||||
|
|
||||||
|
export const getHeaderItemZone = (zones: HeaderZones, item: HeaderItem): HeaderZoneId | undefined => {
|
||||||
|
const itemKey = getHeaderItemKey(item);
|
||||||
|
return headerZoneIds.find((zone) => zones[zone].some((candidate) => getHeaderItemKey(candidate) === itemKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultHeaderZone = (item: HeaderItem): HeaderZoneId => {
|
||||||
|
if (item.type === "board") return "right";
|
||||||
|
if (item.id === "logo") return "left";
|
||||||
|
if (item.id === "search") return "center";
|
||||||
|
return "right";
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerItemListSchema = z.array(headerItemSchema).max(100);
|
||||||
|
|
||||||
|
const headerZonesSchema = z
|
||||||
|
.object({
|
||||||
|
left: headerItemListSchema,
|
||||||
|
center: headerItemListSchema,
|
||||||
|
right: headerItemListSchema,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((zones) => getHeaderItems(zones).length > 0, "At least one header item is required")
|
||||||
|
.refine((zones) => {
|
||||||
|
const itemKeys = getHeaderItems(zones).map(getHeaderItemKey);
|
||||||
|
return new Set(itemKeys).size === itemKeys.length;
|
||||||
|
}, "Header items must be unique")
|
||||||
|
.refine(
|
||||||
|
(zones) => getHeaderItems(zones).some((item) => item.type === "builtin" && item.id === "user"),
|
||||||
|
"Account access must remain in the header",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const headerPreferencesSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(headerPreferencesVersion),
|
||||||
|
visible: z.boolean(),
|
||||||
|
searchDisplay: z.enum(headerSearchDisplayValues),
|
||||||
|
logoDisplay: z.enum(headerLogoDisplayValues).default("logo"),
|
||||||
|
zones: headerZonesSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
|
||||||
|
|
||||||
|
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
|
||||||
|
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
|
||||||
|
|
||||||
|
const legacyHeaderItemListSchema = z
|
||||||
|
.array(z.enum(legacyHeaderItemIds))
|
||||||
|
.min(1)
|
||||||
|
.max(legacyHeaderItemIds.length)
|
||||||
|
.refine((items) => new Set(items).size === items.length, "Header items must be unique")
|
||||||
|
.refine((items) => items.includes("user"), "Account access must remain in the header");
|
||||||
|
|
||||||
|
const legacyHeaderPreferencesV1Schema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
visible: z.boolean(),
|
||||||
|
items: legacyHeaderItemListSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const legacyHeaderPreferencesV2Schema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(2),
|
||||||
|
visible: z.boolean(),
|
||||||
|
zones: z
|
||||||
|
.object({
|
||||||
|
left: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||||
|
center: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||||
|
right: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((zones) => {
|
||||||
|
const items = headerZoneIds.flatMap((zone) => zones[zone]);
|
||||||
|
return items.length > 0 && new Set(items).size === items.length && items.includes("user");
|
||||||
|
}, "Header items must be unique and include account access"),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const migrateLegacyItems = (
|
||||||
|
visible: boolean,
|
||||||
|
zones: Record<HeaderZoneId, LegacyHeaderItemId[]>,
|
||||||
|
): HeaderPreferences => ({
|
||||||
|
version: headerPreferencesVersion,
|
||||||
|
visible,
|
||||||
|
searchDisplay: "input",
|
||||||
|
logoDisplay: "logo",
|
||||||
|
zones: {
|
||||||
|
left: zones.left.map(createBuiltinHeaderItem),
|
||||||
|
center: zones.center.map(createBuiltinHeaderItem),
|
||||||
|
right: zones.right.map(createBuiltinHeaderItem),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrateLegacyHeaderPreferencesV1 = (
|
||||||
|
legacy: z.infer<typeof legacyHeaderPreferencesV1Schema>,
|
||||||
|
): HeaderPreferences => {
|
||||||
|
const zones: Record<HeaderZoneId, LegacyHeaderItemId[]> = { left: [], center: [], right: [] };
|
||||||
|
for (const itemId of legacy.items) {
|
||||||
|
const item = createBuiltinHeaderItem(itemId);
|
||||||
|
zones[getDefaultHeaderZone(item)].push(itemId);
|
||||||
|
}
|
||||||
|
return migrateLegacyItems(legacy.visible, zones);
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
|
||||||
|
migrateLegacyItems(legacy.visible, legacy.zones);
|
||||||
|
|
||||||
|
const includeRequiredHeaderItems = (preferences: HeaderPreferences): HeaderPreferences => {
|
||||||
|
const itemKeys = new Set(getHeaderItems(preferences.zones).map(getHeaderItemKey));
|
||||||
|
const missingItems = requiredHeaderBuiltinItemIds
|
||||||
|
.map(createBuiltinHeaderItem)
|
||||||
|
.filter((item) => !itemKeys.has(getHeaderItemKey(item)));
|
||||||
|
if (missingItems.length === 0) return preferences;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preferences,
|
||||||
|
zones: {
|
||||||
|
...preferences.zones,
|
||||||
|
right: [...preferences.zones.right, ...missingItems],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const headerPreferencesMutationSchema = z
|
||||||
|
.discriminatedUnion("version", [
|
||||||
|
headerPreferencesSchema,
|
||||||
|
legacyHeaderPreferencesV1Schema,
|
||||||
|
legacyHeaderPreferencesV2Schema,
|
||||||
|
])
|
||||||
|
.transform((preferences): HeaderPreferences => {
|
||||||
|
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
|
||||||
|
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
|
||||||
|
return includeRequiredHeaderItems(preferences);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const defaultHeaderPreferences = {
|
||||||
|
version: headerPreferencesVersion,
|
||||||
|
visible: true,
|
||||||
|
searchDisplay: "input",
|
||||||
|
logoDisplay: "logo",
|
||||||
|
zones: {
|
||||||
|
left: [createBuiltinHeaderItem("logo")],
|
||||||
|
center: [createBuiltinHeaderItem("search")],
|
||||||
|
right: [
|
||||||
|
createBuiltinHeaderItem("boardEdit"),
|
||||||
|
createBuiltinHeaderItem("boardSettings"),
|
||||||
|
createBuiltinHeaderItem("user"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies HeaderPreferences;
|
||||||
|
|
||||||
|
export const defaultHeaderPreferencesSerialized = JSON.stringify(defaultHeaderPreferences);
|
||||||
|
|
||||||
|
export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
|
||||||
|
try {
|
||||||
|
const decoded = typeof value === "string" ? JSON.parse(value) : value;
|
||||||
|
const parsed = headerPreferencesMutationSchema.safeParse(decoded);
|
||||||
|
if (parsed.success) return parsed.data;
|
||||||
|
return defaultHeaderPreferences;
|
||||||
|
} catch {
|
||||||
|
return defaultHeaderPreferences;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeHeaderPreferences = (value: HeaderPreferences) =>
|
||||||
|
JSON.stringify(includeRequiredHeaderItems(value));
|
||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user