From 9303dbd284b366e48b5b60d1f2e88d34eee19f33 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 25 Aug 2026 17:09:47 +0200 Subject: [PATCH 1/7] feat: add configurable headers and instance branding (#6690) --- .../after-the-installation.mdx | 2 +- apps/docs/docs/management/assistant.mdx | 6 +- apps/docs/docs/management/boards/index.mdx | 2 +- .../custom-widgets/agent-authoring.mdx | 2 +- .../docs/management/custom-widgets/index.mdx | 2 +- apps/docs/docs/management/mcp.mdx | 2 +- apps/docs/static/api/open-api-schema.json | 351 ++ .../[locale]/_client-providers/mantine.tsx | 10 +- .../app/[locale]/auth/invite/[id]/page.tsx | 32 +- .../src/app/[locale]/auth/login/page.tsx | 28 +- .../[locale]/boards/(content)/_creator.tsx | 25 +- .../boards/(content)/_header-actions.tsx | 28 +- .../app/[locale]/boards/(content)/_theme.tsx | 41 +- .../[locale]/boards/(content)/not-found.tsx | 5 +- .../boards/[name]/settings/_appereance.tsx | 14 +- .../boards/[name]/settings/_general.tsx | 22 +- .../boards/[name]/settings/_settings-form.tsx | 8 +- .../app/[locale]/boards/_layout-creator.tsx | 14 +- apps/nextjs/src/app/[locale]/layout.tsx | 90 +- .../manage/_components/hero-banner.tsx | 18 +- .../src/app/[locale]/manage/about/page.tsx | 5 +- .../app/[locale]/manage/assistant/page.tsx | 2 +- .../nextjs/src/app/[locale]/manage/layout.tsx | 3 +- apps/nextjs/src/app/[locale]/manage/page.tsx | 3 +- .../_components/branding-settings-form.tsx | 255 ++ .../settings/_components/settings-form.tsx | 10 + .../src/app/[locale]/manage/settings/page.tsx | 3 +- .../app/[locale]/manage/tools/api/page.tsx | 3 +- .../app/[locale]/manage/tools/backup/page.tsx | 3 +- .../app/[locale]/manage/tools/logs/page.tsx | 3 +- .../app/[locale]/manage/tools/tasks/page.tsx | 3 +- .../_components/_general-settings-form.tsx | 293 +- .../_components/header-composer.module.css | 273 ++ .../general/_components/header-composer.tsx | 653 ++++ .../manage/users/[userId]/general/page.tsx | 36 +- .../app/[locale]/manage/users/create/page.tsx | 3 +- .../src/app/[locale]/manage/users/page.tsx | 3 +- .../src/app/api/assistant/chat/route.ts | 13 +- apps/nextjs/src/app/manifest.ts | 80 +- .../assistant-conversation-export.ts | 4 +- .../components/assistant/assistant-gate.tsx | 1 - .../board/board-layout-thumbnail.module.css | 6 +- .../board/board-layout-thumbnail.tsx | 15 +- .../src/components/board/board-switcher.tsx | 8 +- .../board/items/widget-context-menu.tsx | 4 +- .../board/modals/board-rename-modal.tsx | 1 + .../grid/test/grid-import-boundary.spec.ts | 4 +- .../nextjs/src/components/layout/constants.ts | 1 + apps/nextjs/src/components/layout/header.tsx | 70 +- .../header/configurable-header.module.css | 142 + .../layout/header/configurable-header.tsx | 418 +++ .../layout/header/header-logo.module.css | 22 + .../components/layout/header/header-logo.tsx | 30 + .../src/components/layout/header/search.tsx | 6 +- .../components/layout/header/user-client.tsx | 10 +- .../src/components/layout/header/user.tsx | 10 +- .../src/components/layout/logo/board-logo.tsx | 9 +- .../src/components/layout/logo/constants.ts | 1 + .../components/layout/logo/homarr-logo.tsx | 34 +- .../src/components/layout/logo/logo.tsx | 12 +- .../src/components/layout/navigation-link.tsx | 30 +- .../src/components/layout/navigation.tsx | 1 + apps/nextjs/src/components/layout/shell.tsx | 5 +- .../src/components/onboarding/tour-shell.tsx | 12 +- .../src/components/user-avatar-menu.tsx | 205 +- apps/nextjs/src/metadata.ts | 1 - apps/nextjs/src/theme/branding.ts | 43 + packages/api/src/router/assistant.ts | 6 +- packages/api/src/router/board.ts | 33 +- packages/api/src/router/serverSettings.ts | 43 +- .../src/router/test/serverSettings.spec.ts | 6 +- packages/api/src/router/user.ts | 69 +- packages/api/src/test/mcp.spec.ts | 1 + packages/api/src/user-server.ts | 1 + ...48_add_header_preferences_and_branding.sql | 4 + .../migrations/mysql/meta/0048_snapshot.json | 2817 +++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 7 + ...14_add_header_preferences_and_branding.sql | 5 + .../postgresql/meta/0014_snapshot.json | 2677 ++++++++++++++++ .../migrations/postgresql/meta/_journal.json | 7 + ...46_add_header_preferences_and_branding.sql | 5 + .../migrations/sqlite/meta/0046_snapshot.json | 2707 ++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/queries/server-setting.ts | 20 +- packages/db/schema/mysql.ts | 2 + packages/db/schema/postgresql.ts | 2 + packages/db/schema/sqlite.ts | 2 + .../src/icon-picker/icon-picker.tsx | 7 +- .../onboarding/src/onboarding-auth-shell.tsx | 69 +- .../src/onboarding-studio.module.css | 46 + .../onboarding/src/onboarding-wordmark.tsx | 40 +- packages/onboarding/src/setup-studio.tsx | 57 +- packages/server-settings/package.json | 3 +- packages/server-settings/src/index.ts | 110 + packages/settings/package.json | 1 + packages/settings/src/creator.ts | 41 +- packages/translation/src/lang/en.json | 166 +- .../ui/src/components/corner-style-picker.tsx | 66 + packages/ui/src/components/index.tsx | 2 + packages/validation/src/header-preferences.ts | 237 ++ packages/validation/src/user.ts | 36 + pnpm-lock.yaml | 6 + 102 files changed, 12153 insertions(+), 609 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx create mode 100644 apps/nextjs/src/components/layout/header/configurable-header.module.css create mode 100644 apps/nextjs/src/components/layout/header/configurable-header.tsx create mode 100644 apps/nextjs/src/components/layout/header/header-logo.module.css create mode 100644 apps/nextjs/src/components/layout/header/header-logo.tsx create mode 100644 apps/nextjs/src/components/layout/logo/constants.ts delete mode 100644 apps/nextjs/src/metadata.ts create mode 100644 apps/nextjs/src/theme/branding.ts create mode 100644 packages/db/migrations/mysql/0048_add_header_preferences_and_branding.sql create mode 100644 packages/db/migrations/mysql/meta/0048_snapshot.json create mode 100644 packages/db/migrations/postgresql/0014_add_header_preferences_and_branding.sql create mode 100644 packages/db/migrations/postgresql/meta/0014_snapshot.json create mode 100644 packages/db/migrations/sqlite/0046_add_header_preferences_and_branding.sql create mode 100644 packages/db/migrations/sqlite/meta/0046_snapshot.json create mode 100644 packages/ui/src/components/corner-style-picker.tsx create mode 100644 packages/validation/src/header-preferences.ts diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 6fbb301d0..695441dfd 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -55,7 +55,7 @@ The studio keeps related choices together: 2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup. 3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it. 4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets. -5. **Extend** explains and optionally configures 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. Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 3bda33bca..69cf28b9e 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -1,5 +1,5 @@ --- -description: Configure and use Homarr Assistant. +description: Configure and use Assistant. tags: - Assistant - AI @@ -8,10 +8,10 @@ tags: # 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. -![Homarr Assistant conversation with suggested prompts](/img/assistant/conversation.png) +![Assistant conversation with suggested prompts](/img/assistant/conversation.png) ## Set it up diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 651cd6e7c..11d1ba271 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -317,7 +317,7 @@ Change the radius of your items and Containers. This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. -When 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 editable review form before requesting approval to save the change. It does not edit responsive layouts, access control, or Danger zone actions. diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index 8d9380157..a33d1cf9b 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential runtime components. -To exercise the real 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 OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 80406808a..33dcfed89 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 { ### Design with AI The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and, -when [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 update tool for your approval. diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index c0ed2e7b1..9deb3585f 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/ With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools. -The optional [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 approval before running mutating procedures. diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 83b48a665..d101285ea 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -3453,6 +3453,9 @@ "enableRightClickOnWidgets": { "type": "boolean" }, + "headerPreferences": { + "type": "string" + }, "defaultSearchEngineId": { "anyOf": [ { @@ -3488,6 +3491,7 @@ "firstDayOfWeek", "pingIconsEnabled", "enableRightClickOnWidgets", + "headerPreferences", "defaultSearchEngineId", "openSearchInNewTab", "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": { "patch": { "operationId": "userRouter-changeDdgBangs", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index 493d368f1..da4c9be33 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -1,6 +1,7 @@ "use client"; import type { PropsWithChildren } from "react"; +import { useMemo } from "react"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import dayjs from "dayjs"; @@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client"; import { parseCookies, setClientCookie } from "@homarr/common"; import { colorSchemeCookieKey } from "@homarr/definitions"; import { theme } from "@homarr/ui"; +import type { ServerSettings } from "@homarr/server-settings"; + +import { createBrandTheme } from "~/theme/branding"; export const CustomMantineProvider = ({ children, defaultColorScheme, -}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => { + branding, +}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => { const manager = useColorSchemeManager(); + const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]); return ( {children} diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx index 0f7ddefc7..5f90f790e 100644 --- a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx @@ -1,11 +1,13 @@ import { notFound } from "next/navigation"; import { IconUserPlus } from "@tabler/icons-react"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { and, db, eq } from "@homarr/db"; import { invites } from "@homarr/db/schema"; import { OnboardingAuthShell } from "@homarr/onboarding"; +import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings"; import { getI18n } from "@homarr/translation/server"; import { RegistrationForm } from "./_registration-form"; @@ -20,11 +22,9 @@ interface InviteUsagePageProps { } export default async function InviteUsagePage(props: InviteUsagePageProps) { - const searchParams = await props.searchParams; - const params = await props.params; if (!isProviderEnabled("credentials")) notFound(); - const session = await auth(); + const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]); if (session) notFound(); const invite = await db.query.invites.findFirst({ @@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) { 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 ( } + appName={branding.appName} + showAppName={branding.authBranding.showAppName} + showAppLogo={branding.authBranding.showLogo} + primaryColor={branding.primaryColor} + secondaryColor={branding.secondaryColor} + wordmarkPrimaryColor={wordmarkColors.primaryColor} + wordmarkSecondaryColor={wordmarkColors.secondaryColor} + logoImageUrl={branding.logoImageUrl ?? undefined} + backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined} + backgroundOverlay={branding.signInBackgroundOverlay} + radius={branding.defaultRadius} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion footer={t("description", { username: invite.creator.name! })} > diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index b1c0a1d1b..c5bb4f050 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -2,9 +2,11 @@ import { redirect } from "next/navigation"; import { Alert, Code, Text } from "@mantine/core"; import { IconLogin } from "@tabler/icons-react"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; import { OnboardingAuthShell } from "@homarr/onboarding"; +import { getBrandingColorOverrides } from "@homarr/server-settings"; import { getI18n } from "@homarr/translation/server"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; @@ -19,17 +21,35 @@ interface LoginProps { } export default async function Login(props: LoginProps) { - const searchParams = await props.searchParams; - const session = await auth(); + const [searchParams, session] = await Promise.all([props.searchParams, auth()]); if (session) { 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 ( - }> + } + 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 ? ( } color="blue" variant="light"> diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 9cf907b5e..ae4f66802 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation"; import { TRPCError } from "@trpc/server"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { makeQueryClient } from "@homarr/api/shared"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; @@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; -import { createMetaTitle } from "~/metadata"; import { env } from "~/env"; import { createBoardLayout } from "../_layout-creator"; import type { Board, Item } from "../_types"; import { ClientBoard } from "./_client"; -import { BoardContentHeaderActions } from "./_header-actions"; +import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions"; const logger = createLogger({ module: "createBoardContentPage" }); const getQueryClient = cache(makeQueryClient); @@ -36,7 +36,8 @@ export const createBoardContentPage = >( }: Props) => { return { layout: createBoardLayout({ - headerActions: , + headerBoardEditAction: , + headerBoardSettingsAction: , getInitialBoardAsync: getInitialBoard, withTour: true, }), @@ -103,18 +104,24 @@ export const createBoardContentPage = >( }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { try { - const board = await getInitialBoard(await params); - const t = await getI18n("board"); + const [board, t, serverSettings] = await Promise.all([ + getInitialBoard(await params), + getI18n("board"), + getRscServerSettingsAsync(), + ]); + const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png"; + const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo; + const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon; return { - title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })), + title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }), icons: { - icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, - apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined, + icon: favicon, + apple: favicon, }, appleWebApp: { startupImage: { - url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png", + url: favicon, }, }, }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 476706ded..41552ff34 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () => ]); const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false }); -export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { +export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); - const t = useI18n("board"); const { hasChangeAccess } = useBoardPermissions(board); if (!hasChangeAccess) return null; @@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool return ( <> {isEditMode && } - - - {!demoReadOnly && ( - - - - - - )} ); }; +export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => { + const board = useRequiredBoard(); + const t = useI18n("board"); + const { hasChangeAccess } = useBoardPermissions(board); + + if (!hasChangeAccess || demoReadOnly) return null; + + return ( + + + + + + ); +}; + const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled"); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index 0ea5c9cf0..e1d2b1ace 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -1,30 +1,33 @@ "use client"; import type { PropsWithChildren } from "react"; -import type { MantineColorsTuple } from "@mantine/core"; -import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; +import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; import { useRequiredBoard } from "@homarr/boards/context"; import type { ColorScheme } from "@homarr/definitions"; +import { useSettings } from "@homarr/settings"; import { useColorSchemeManager } from "../../_client-providers/mantine"; +import { generateColorScale } from "~/theme/branding"; export const BoardMantineProvider = ({ children, defaultColorScheme, }: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { const board = useRequiredBoard(); + const { branding } = useSettings(); const colorSchemeManager = useColorSchemeManager(); + const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor; const theme = createTheme({ colors: { - primaryColor: generateColors(board.primaryColor), - secondaryColor: generateColors(board.secondaryColor), - iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"), + primaryColor: generateColorScale(primaryColor), + secondaryColor: generateColorScale(board.secondaryColor), + iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"), }, primaryColor: "primaryColor", autoContrast: true, - defaultRadius: "sm", + defaultRadius: branding.defaultRadius, fontSizes: { "2xl": rem(24), "3xl": rem(28), @@ -43,29 +46,3 @@ export const BoardMantineProvider = ({ ); }; - -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; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/not-found.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/not-found.tsx index 016d0163e..803b840ba 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/not-found.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/not-found.tsx @@ -1,5 +1,6 @@ import { IconHomeOff } from "@tabler/icons-react"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { auth } from "@homarr/auth/next"; import { db } from "@homarr/db"; import { boards } from "@homarr/db/schema"; @@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server"; import type { BoardNotFoundProps } from "~/components/board/not-found"; import { BoardNotFound } from "~/components/board/not-found"; +import { homarrLogoPath } from "~/components/layout/logo/constants"; export default async function NotFoundBoardHomePage() { const boardNotFoundProps = await getPropsAsync(); @@ -19,8 +21,9 @@ const getPropsAsync = async (): Promise => { const t = await getI18n("board"); if (boardCount === 0) { + const { branding } = await getRscServerSettingsAsync(); return { - icon: { src: "/favicon.ico", alt: "Homarr logo" }, + icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` }, title: t("error.noBoard.title"), description: t("error.noBoard.description"), link: { label: t("error.noBoard.link"), href: "/manage/boards" }, diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx index 674e5f29b..1e5e07ce4 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx @@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react"; import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { BoardColorInput } from "@homarr/ui"; +import { useSettings } from "@homarr/settings"; import { SectionCard } from "~/components/manage/section-card"; -import { generateColors } from "../../(content)/_theme"; +import { generateColorScale } from "~/theme/branding"; import type { FormValues } from "./_settings-form"; interface Props { @@ -40,13 +41,19 @@ export const ColorSettingsContent = ({ form }: Props) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const theme = useMantineTheme(); + const { branding } = useSettings(); return ( - + @@ -121,7 +128,8 @@ interface ColorsPreviewProps { const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { const theme = useMantineTheme(); - const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000"); + const colors = + previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000"); return ( diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index db832acf7..5dd02c674 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater"; import type { UseFormReturnType } from "@homarr/form"; import { IconPicker } from "@homarr/forms-collection"; import { useI18n } from "@homarr/translation/client"; +import { useSettings } from "@homarr/settings"; import { SectionCard } from "~/components/manage/section-card"; -import { createMetaTitle } from "~/metadata"; import type { Board } from "../../_types"; import type { FormValues } from "./_settings-form"; @@ -21,20 +21,26 @@ interface Props { export const GeneralSettingsContent = ({ board, form }: Props) => { const t = useI18n("board"); + const { branding } = useSettings(); + const defaultMetaTitle = t("content.metaTitle", { boardName: board.name }); useLogoPreview(form.values.logoImageUrl); - const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); + const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle); return ( - + } {...form.getInputProps("metaTitle")} /> @@ -43,7 +49,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => { @@ -51,7 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => { @@ -72,9 +78,9 @@ const useLogoPreview = (url: string | null) => { }, [logoDebounced, updateBoard]); }; -const useMetaTitlePreview = (title: string | null) => { +const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => { const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); - useDocumentTitle(metaTitleDebounced); + useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle); return { isPending: (title ?? "") !== metaTitleDebounced, diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx index 2fba83d92..1e71deeb2 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx @@ -10,10 +10,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; +import { homarrLogoPath } from "~/components/layout/logo/constants"; import { SectionCard } from "~/components/manage/section-card"; import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; @@ -88,6 +89,7 @@ interface BoardSettingsFormProps { export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => { const t = useI18n("common"); const tSection = useI18n("board.setting.section"); + const { branding } = useSettings(); const { updateBoard } = useUpdateBoard(); const savePartialSettings = useSavePartialSettingsMutation(board); const saveLayouts = useSaveLayoutsMutation(board); @@ -142,7 +144,9 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib const [, canonicalLayouts] = await Promise.all([ partialSettingsChanged ? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => { - updateFavicon(values.faviconImageUrl ?? homarrLogoPath); + updateFavicon( + values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath, + ); }) : Promise.resolve(), layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null), diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index c06a20196..b23b0b081 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -12,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { ModalProvider } from "@homarr/modals"; import { MainHeader } from "~/components/layout/header"; -import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; +import { appShellLogoHeight } from "~/components/layout/constants"; +import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { env } from "~/env"; @@ -27,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme"; const logger = createLogger({ module: "createBoardLayout" }); interface CreateBoardLayoutProps { - headerActions: JSX.Element; + headerActions?: JSX.Element; + headerBoardEditAction?: JSX.Element; + headerBoardSettingsAction?: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; withTour?: boolean; } export const createBoardLayout = ({ headerActions, + headerBoardEditAction, + headerBoardSettingsAction, getInitialBoardAsync: getInitialBoard, withTour = false, }: CreateBoardLayoutProps) => { @@ -105,8 +110,11 @@ export const createBoardLayout = ({ } + logo={} + logoWithTitle={} actions={headerActions} + boardEditAction={headerBoardEditAction} + boardSettingsAction={headerBoardSettingsAction} hasNavigation={false} /> {children} diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 3baca0b39..bbce84cce 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { SettingsProvider } from "@homarr/settings"; import { SpotlightProvider } from "@homarr/spotlight"; import type { SupportedLanguage } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; +import { getI18n } from "@homarr/translation/server"; import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { Analytics } from "~/components/layout/analytics"; @@ -53,35 +54,45 @@ const fontSans = Inter({ const logger = createLogger({ module: "rootLayout" }); -// eslint-disable-next-line no-restricted-syntax -export const generateMetadata = async (): Promise => ({ - title: "Homarr", - description: - "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", - openGraph: { - title: "Homarr Dashboard", - description: - "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", - url: env.HOMARR_WEBSITE_URL, - siteName: "Homarr", - }, - icons: { - icon: "/logo/logo.png", - apple: "/logo/logo.png", - }, - appleWebApp: { - title: "Homarr", - capable: true, - startupImage: { url: "/logo/logo.png" }, - statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default", - }, -}); +export const generateMetadata = async (): Promise => { + const [serverSettings, colorScheme, t] = await Promise.all([ + getRscServerSettingsAsync(), + getCurrentColorSchemeAsync(), + getI18n("metadata"), + ]); + const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding; + const logo = logoImageUrl ?? "/logo/logo.png"; + const favicon = faviconImageUrl ?? logo; + const description = t("description"); -export const viewport: Viewport = { - themeColor: [ - { media: "(prefers-color-scheme: light)", color: "white" }, - { media: "(prefers-color-scheme: dark)", color: "black" }, - ], + return { + title: { + default: appName, + template: `%s • ${appName}`, + }, + description, + openGraph: { + title: t("dashboardTitle", { appName }), + description, + url: env.HOMARR_WEBSITE_URL, + siteName: appName, + }, + icons: { + icon: favicon, + apple: logo, + }, + appleWebApp: { + title: appName, + capable: true, + startupImage: { url: logo }, + statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default", + }, + }; +}; + +export const generateViewport = async (): Promise => { + const serverSettings = await getRscServerSettingsAsync(); + return { themeColor: serverSettings.branding.primaryColor }; }; export default async function Layout(props: { @@ -94,6 +105,7 @@ export default async function Layout(props: { } const sessionPromise = auth(); + const serverSettingsPromise = getRscServerSettingsAsync(); const userPromise = sessionPromise.then((session) => session ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { @@ -102,18 +114,17 @@ export default async function Layout(props: { }) : null, ); - const assistantAvailabilityPromise: Promise = sessionPromise.then((session) => - !session - ? "unauthenticated" - : api.assistant - .getAvailability() - .then((availability) => (availability.enabled ? "enabled" : "unconfigured")) - .catch(() => "error"), - ); + const assistantAvailabilityPromise: Promise = sessionPromise.then((session) => { + if (!session) return "unauthenticated"; + return api.assistant + .getAvailability() + .then((availability) => (availability.enabled ? "enabled" : "unconfigured")) + .catch(() => "error"); + }); const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([ sessionPromise, userPromise, - getRscServerSettingsAsync(), + serverSettingsPromise, getCurrentColorSchemeAsync(), assistantAvailabilityPromise, ]); @@ -148,6 +159,7 @@ export default async function Layout(props: { }, search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId }, user: { enableGravatar: serverSettings.user.enableGravatar }, + branding: serverSettings.branding, }} {...innerProps} /> @@ -156,7 +168,9 @@ export default async function Layout(props: { (innerProps) => , (innerProps) => , (innerProps) => , - (innerProps) => , + (innerProps) => ( + + ), (innerProps) => , (innerProps) => , (innerProps) => , diff --git a/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx index 52340a72e..c513201a1 100644 --- a/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx +++ b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx @@ -2,11 +2,13 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core"; import { getI18n } from "@homarr/translation/server"; import { IntegrationMarquee } from "@homarr/ui"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import classes from "./hero-banner.module.css"; export const HeroBanner = async () => { - const t = await getI18n("management.page.home"); + const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]); + const { appName, logoImageUrl } = serverSettings.branding; return ( @@ -14,9 +16,17 @@ export const HeroBanner = async () => { {t("heroBanner.title")} - - - {t("heroBanner.subtitle", { app: "Homarr" })} + + {`${appName} + {t("heroBanner.subtitle", { app: appName })} diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx index d0388f427..f105302d2 100644 --- a/apps/nextjs/src/app/[locale]/manage/about/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx @@ -37,9 +37,8 @@ import { capitalize, objectEntries } from "@homarr/common"; import { hotkeys } from "@homarr/definitions"; import { getI18n } from "@homarr/translation/server"; -import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; +import { homarrLogoPath } from "~/components/layout/logo/constants"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { createMetaTitle } from "~/metadata"; import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader"; import githubContributors from "@static-data/contributors.json"; import openCollectiveContributors from "@static-data/opencollective-contributors.json"; @@ -50,7 +49,7 @@ export async function generateMetadata() { const t = await getI18n("management"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/page.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/page.tsx index 479beeec1..a3ee8961e 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/page.tsx @@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration"; export async function generateMetadata() { const t = await getI18n("management.page.settings.section.assistant"); return { - title: `${t("title")} • Homarr`, + title: t("title"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 4ede79837..49f2af415 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env"; import { getI18n } from "@homarr/translation/server"; import { MainHeader } from "~/components/layout/header"; -import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; +import { homarrLogoPath } from "~/components/layout/logo/constants"; import type { NavigationLink } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; @@ -74,6 +74,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { label: t("items.home"), icon: IconHomeFilled, href: "/manage", + exact: true, "data-onboarding-tour-id": "manage-welcome", }, { diff --git a/apps/nextjs/src/app/[locale]/manage/page.tsx b/apps/nextjs/src/app/[locale]/manage/page.tsx index d635b9db3..b7925c235 100644 --- a/apps/nextjs/src/app/[locale]/manage/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/page.tsx @@ -7,14 +7,13 @@ import { getI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { createMetaTitle } from "~/metadata"; import { HeroBanner } from "./_components/hero-banner"; export async function generateMetadata(): Promise { const t = await getI18n("management"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx new file mode 100644 index 000000000..ae580a324 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx @@ -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; +} + +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 ( + + +
+ + + {t("identity.description")} + + + + form.setFieldValue("branding.logoImageUrl", value || null)} + error={logoInput.error} + /> + form.setFieldValue("branding.faviconImageUrl", value || null)} + error={faviconInput.error} + /> + + +
+ +
+ + + + + + + + + [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]), + ) as Record<(typeof cornerStyleValues)[number], string> + } + onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)} + /> + +
+ +
+ + + + form.setFieldValue("branding.signInBackgroundImageUrl", value || null)} + error={backgroundInput.error} + /> + + + + + + + {t("signInOverlay.label")} + + + {Math.round(branding.signInBackgroundOverlay * 100)}% + + + `${Math.round(value * 100)}%`} + disabled={!branding.signInBackgroundImageUrl} + value={branding.signInBackgroundOverlay} + onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)} + /> + + + + + {t("authVisibility.description")} + + + + + + + + + +
+ + +
+
+ ); +}; + +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 ( + + + + {t("summary.title")} + + {t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)} + + + + + + {t("groups.identity")} + + + + + + + + {appName} + + + + + + + {t("summary.palette")} + + + + + {branding.primaryColor} + + + + {branding.secondaryColor} + + + + {branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")} + + + + + + {t("groups.authentication")} + + + {backgroundStatus} · {greetingStatus} + + + + + + + + + + + ); +}; + +const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => { + const t = useI18n("management.page.settings.section.branding.summary"); + + return ( + + {label}: {visible ? t("on") : t("off")} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx index a2f0aa954..ba8661966 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx @@ -12,12 +12,14 @@ import { colorSchemes } from "@homarr/definitions"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; +import { brandingServerSettingsSchema } from "@homarr/server-settings"; import { useI18n } from "@homarr/translation/client"; import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar"; import { AnalyticsSettings } from "./analytics.settings"; import { AppearanceSettingsForm } from "./appearance-settings-form"; import { BoardSettingsForm } from "./board-settings-form"; +import { BrandingSettingsForm } from "./branding-settings-form"; import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings"; import { CultureSettingsForm } from "./culture-settings-form"; import { SearchSettingsForm } from "./search-settings-form"; @@ -37,6 +39,7 @@ const settingsFormSchema = z.object({ defaultSearchEngineId: z.string().nullable(), defaultColorScheme: z.enum(colorSchemes), defaultLocale: z.string(), + branding: brandingServerSettingsSchema, }); export type FormValues = z.infer; @@ -55,6 +58,7 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({ defaultSearchEngineId: initialData.search.defaultSearchEngineId, defaultColorScheme: initialData.appearance.defaultColorScheme, defaultLocale: initialData.culture.defaultLocale, + branding: initialData.branding, }); interface SettingsFormProps { @@ -137,6 +141,11 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn value: { defaultColorScheme: values.defaultColorScheme }, }, { settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } }, + { + settingsKey: "branding", + when: changed("branding"), + value: values.branding, + }, ]; const promises = groups @@ -176,6 +185,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn + diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 61b05415a..18052e01d 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -9,10 +9,9 @@ import { SettingsForm } from "./_components/settings-form"; export async function generateMetadata() { const t = await getI18n("management"); - const metaTitle = `${t("metaTitle")} • Homarr`; return { - title: metaTitle, + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index 73370b5c8..eb2cd98b7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -9,7 +9,6 @@ import { auth } from "@homarr/auth/next"; import { extractBaseUrlFromHeaders } from "@homarr/common"; import { getI18n } from "@homarr/translation/server"; -import { createMetaTitle } from "~/metadata"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; import { ApiKeysManagement } from "./components/api-keys"; @@ -63,7 +62,7 @@ export async function generateMetadata() { const t = await getI18n("management"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx index 673a6ab7d..731c5873c 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx @@ -5,7 +5,6 @@ import { auth } from "@homarr/auth/next"; import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { getI18n } from "@homarr/translation/server"; -import { createMetaTitle } from "~/metadata"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { BackupExportCard } from "./_components/backup-export-card"; import { BackupImportCard } from "./_components/backup-import-card"; @@ -19,7 +18,7 @@ export async function generateMetadata() { const t = await getI18n("management.page.tool.backup"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index 0eab621fb..bb18da0a8 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -11,7 +11,6 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { fullHeightWithoutHeaderAndFooter } from "~/constants"; -import { createMetaTitle } from "~/metadata"; import { ClientSideTerminalComponent } from "./client"; import { LogFontSizeSlider } from "./font-size-slider"; import { LogLevelSelection } from "./level-selection"; @@ -25,7 +24,7 @@ export async function generateMetadata() { const t = await getI18n("management"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx index 674227277..14c6af817 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/tasks/page.tsx @@ -6,7 +6,6 @@ import { auth } from "@homarr/auth/next"; import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { createMetaTitle } from "~/metadata"; import { TasksTable } from "./_components/tasks-table"; export async function generateMetadata() { @@ -17,7 +16,7 @@ export async function generateMetadata() { const t = await getI18n("management"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx index f61a82d1f..82869df21 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx @@ -1,7 +1,9 @@ "use client"; +import type { ReactNode } from "react"; import { useEffect, useMemo, useRef } from "react"; import { + Box, Button, Card, Divider, @@ -22,12 +24,15 @@ import { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import type { BoardPreviewData } from "@homarr/boards/layout-preview"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { + headerPreferencesSchema, + parseHeaderPreferences, userChangeHomeBoardsSchema, userChangeSearchPreferencesSchema, userEditProfileSchema, @@ -36,9 +41,9 @@ import { userPingIconsEnabledSchema, } from "@homarr/validation/user"; -import type { Board } from "~/app/[locale]/boards/_types"; import { BoardSelect } from "~/components/board/board-select"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; +import { HeaderComposer } from "./header-composer"; dayjs.extend(localeData); @@ -53,6 +58,7 @@ const userGeneralSettingsSchema = z.object({ firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets, + headerPreferences: headerPreferencesSchema, }); type FormValues = z.infer; @@ -65,9 +71,10 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [ interface UserGeneralSettingsFormProps { user: RouterOutputs["user"]["getById"]; - boardsData: Pick[]; + boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[]; searchEnginesData: { value: string; label: string }[]; showLanguageSelector?: boolean; + profileAvatar?: ReactNode; } const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({ @@ -81,6 +88,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, pingIconsEnabled: user.pingIconsEnabled, enableRightClickOnWidgets: user.enableRightClickOnWidgets, + headerPreferences: parseHeaderPreferences(user.headerPreferences), }); export const UserGeneralSettingsForm = ({ @@ -88,6 +96,7 @@ export const UserGeneralSettingsForm = ({ boardsData, searchEnginesData, showLanguageSelector = false, + profileAvatar, }: UserGeneralSettingsFormProps) => { const tUser = useI18n("user"); const tCommon = useI18n("common"); @@ -101,6 +110,7 @@ export const UserGeneralSettingsForm = ({ const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation(); + const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation(); const initialValues = buildInitialValues(user); const initialValuesRef = useRef(initialValues); @@ -121,6 +131,7 @@ export const UserGeneralSettingsForm = ({ changeFirstDayOfWeekMutation, changePingIconsEnabledMutation, changeEnableRightClickOnWidgetsMutation, + changeHeaderPreferencesMutation, ]; const isPending = mutations.some((m) => m.isPending); @@ -184,6 +195,14 @@ export const UserGeneralSettingsForm = ({ enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets, }), }, + { + when: changed("headerPreferences"), + action: () => + changeHeaderPreferencesMutation.mutateAsync({ + id: user.id, + headerPreferences: parsed.data.headerPreferences, + }), + }, ]; const promises = saveActions.filter((s) => s.when).map((s) => s.action()); @@ -218,135 +237,149 @@ export const UserGeneralSettingsForm = ({ const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString(); return ( -
- - - - - - {tUser("name")} - {!isCredentialsUser && ( - - {tUserManagement("fieldsDisabledExternalProvider")} - + + form.setFieldValue("headerPreferences", headerPreferences)} + boards={boardsData} + homeBoardId={form.values.homeBoardId} + mobileHomeBoardId={form.values.mobileHomeBoardId} + /> + + + + + + + + + {tUser("name")} + {!isCredentialsUser && ( + + {tUserManagement("fieldsDisabledExternalProvider")} + + )} + + + + + + + + + + + {tGeneral("item.board.title")} + + + + + + + + + + + {tGeneral("item.search")} + + + - - - - - - - - - {tGeneral("item.language")} - - - {showLanguageSelector && } - {tGeneral("item.firstDayOfWeek")} - firstDayOfWeekOnChange(parseInt(nextValue, 10))} - > - - {FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => ( - - ))} - - - - {tGeneral("item.accessibility")} - - - - - - -
- {form.isDirty() && ( - - - {tCommon("unsavedChanges")} - - - - - - - )} -
-
- + +
+ {profileAvatar ? {profileAvatar} : null} +
+
); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css new file mode 100644 index 000000000..de2ca84c5 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.module.css @@ -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; + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx new file mode 100644 index 000000000..318e17f69 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx @@ -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(null); + const [selectedItemKey, setSelectedItemKey] = useState(null); + const zoneTableRef = useRef(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 ( + + + + + + + + + + {t("title")} + + + + {t("description")} + + + + + onChange({ ...value, visible: !value.visible })} + > + {value.visible ? : } + + + + + + + + {value.visible ? ( + setDraggedItemKey(operation.source?.id.toString() ?? null)} + onDragEnd={handleDragEnd} + > +
+
+ {headerGuideZones.map((zone) => ( + + {value.zones[zone].map((item, index) => ( + + ))} + + ))} +
+
+ + {draggedItem ? ( + + ) : null} + +
+ ) : ( +
+ + + +
+ )} +
+ +
+
+
+ + + {selectedItem ? ( + + + + + {itemLabel(selectedItem)} + + + {t("configuration.description")} + + + + {selectedItem.type === "builtin" && selectedItem.id === "search" ? ( + + 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" ? ( + + 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) ? ( + + ) : ( + + {t("available.requiredAlways")} + + )} + + + + ) : null} + + + + + {t("available.title")} + + + {t("available.description")} + + + + + + + + {t("available.chooseBoard")} + {availableBoards.map((board) => ( + + + + } + onClick={() => addItem(createBoardHeaderItem(board.id))} + > + {board.name} + + ))} + + + + + {inactiveBuiltinIds.length === 0 ? ( + + {t("available.allAdded")} + + ) : ( + inactiveBuiltinIds.map((itemId) => { + const item = createBuiltinHeaderItem(itemId); + const Icon = getBuiltinItemIcon(itemId); + return ( + + addItem(item)} + > + + + + ); + }) + )} + + +
+
+ ); +}; + +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 ( +
+ + {label} + +
{children}
+
+ ); +}; + +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 ( +
+ +
+ ); +}; + +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 = ( + + ); + if (!overlay) return content; + + return ( +
+ {content} +
+ ); +}; + +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 ( + + + + ); + } + + if (item.id === "logo") { + return ( +
+ + {logoDisplay === "logoAndText" ? {logoTitle} : null} +
+ ); + } + + if (item.id === "search") { + const showInput = searchDisplay === "input" && !compact; + return ( +
+ + {showInput ? {label}… : null} +
+ ); + } + + const Icon = getBuiltinItemIcon(item.id); + return ; +}; + +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; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index 3c21160c3..4ba467d78 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -1,5 +1,5 @@ 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 { api } from "@homarr/api/server"; @@ -8,7 +8,6 @@ import { getI18n } from "@homarr/translation/server"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; -import { createMetaTitle } from "~/metadata"; import { canAccessUserEditPage } from "../access"; import { DeleteUserButton } from "./_components/_delete-user-button"; import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; @@ -38,7 +37,7 @@ export async function generateMetadata(props: Props) { return { // 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(); } - const boards = await api.board.getAllBoards(); + const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id }); const searchEngines = await api.searchEngine.getSelectable(); const isSelf = session?.user.id === user.id; const isCredentialsUser = user.provider === "credentials"; @@ -71,23 +70,18 @@ export default async function EditUserPage(props: Props) { )} {tGeneral("title")} - - - ({ - id: board.id, - name: board.name, - logoImageUrl: board.logoImageUrl, - }))} - searchEnginesData={searchEngines} - showLanguageSelector={isSelf} - /> - - - - - + ({ + id: board.id, + name: board.name, + logoImageUrl: board.logoImageUrl, + preview: board.preview, + }))} + searchEnginesData={searchEngines} + showLanguageSelector={isSelf} + profileAvatar={} + /> {session?.user.id === user.id && ( diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx index 41abd5393..5a2a5e96c 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx @@ -8,7 +8,6 @@ import { everyoneGroup } from "@homarr/definitions"; import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { createMetaTitle } from "~/metadata"; import { UserCreateStepperComponent } from "./_components/create-user-stepper"; export async function generateMetadata() { @@ -22,7 +21,7 @@ export async function generateMetadata() { const t = await getI18n("management.page.user.create"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx index 57271286d..5ac353e20 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx @@ -9,7 +9,6 @@ import { Link } from "@homarr/ui"; import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; -import { createMetaTitle } from "~/metadata"; import { UserListComponent } from "./_components/user-list"; export async function generateMetadata() { @@ -20,7 +19,7 @@ export async function generateMetadata() { const t = await getI18n("management.page.user.list"); return { - title: createMetaTitle(t("metaTitle")), + title: t("metaTitle"), }; } diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index d59733da0..ab222be86 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { MetadataExtractor } from "@ai-sdk/openai-compatible"; import type { ToolSet, UIMessage } from "ai"; import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai"; +import { cookies } from "next/headers"; import { z } from "zod/v4"; import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; @@ -24,6 +25,10 @@ import { assistantProviderRequiresApiKey, assistantReasoningModes, } from "@homarr/definitions"; +import { localeCookieKey } from "@homarr/definitions/cookie"; +import type { SupportedLanguage } from "@homarr/translation"; +import { fallbackLocale, isLocaleSupported } from "@homarr/translation"; +import { getI18n } from "@homarr/translation/server"; import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts"; @@ -370,7 +375,7 @@ export async function POST(request: Request) { const session = await auth(); 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(); @@ -414,7 +419,11 @@ export async function POST(request: Request) { const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false; 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; diff --git a/apps/nextjs/src/app/manifest.ts b/apps/nextjs/src/app/manifest.ts index 21c17f3df..8ba3420a8 100644 --- a/apps/nextjs/src/app/manifest.ts +++ b/apps/nextjs/src/app/manifest.ts @@ -1,39 +1,59 @@ 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 { + const serverSettings = await getRscServerSettingsAsync(); + const branding = serverSettings.branding; + const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl; + const icons: NonNullable = [ + { + 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 { - name: "Homarr", - short_name: "Homarr", + name: branding.appName, + short_name: branding.appName, description: "Your dashboard for managing your server.", start_url: "/", display: "standalone", background_color: "#fff", - theme_color: "#fff", - icons: [ - { - src: "/images/pwa/192.maskable.png", - sizes: "192x192", - type: "image/png", - purpose: "any", - }, - { - src: "/images/pwa/192.maskable.png", - sizes: "192x192", - type: "image/png", - purpose: "maskable", - }, - { - src: "/images/pwa/512.maskable.png", - sizes: "512x512", - type: "image/png", - purpose: "any", - }, - { - src: "/images/pwa/512.maskable.png", - sizes: "512x512", - type: "image/png", - purpose: "maskable", - }, - ], + theme_color: branding.primaryColor, + icons, }; } diff --git a/apps/nextjs/src/components/assistant/assistant-conversation-export.ts b/apps/nextjs/src/components/assistant/assistant-conversation-export.ts index 9cd3e9de6..a1deab185 100644 --- a/apps/nextjs/src/components/assistant/assistant-conversation-export.ts +++ b/apps/nextjs/src/components/assistant/assistant-conversation-export.ts @@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => { const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`; - return `# ${title}\n\n> 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 = ({ @@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({ const header = [ `# ${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}\``, `- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`, diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx index f33fe5810..2713f4958 100644 --- a/apps/nextjs/src/components/assistant/assistant-gate.tsx +++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx @@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() => const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => { useRegisterAssistantSpotlightPlaceholder(description); - const value = useMemo( () => ({ enabled: false, diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css index 7ef19c047..d7391a25d 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css @@ -5,10 +5,10 @@ overflow: hidden; color: var(--mantine-color-dimmed); background: - radial-gradient( - circle at 15% 10%, + linear-gradient( + to top, color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), - transparent 35% + transparent 55% ), var(--mantine-color-body); } diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index c73482aac..2d959d24d 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps { preview: BoardPreviewData | null; label: string; fitFullLayout?: boolean; + className?: string; } 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); if (!preview || !layout) { return ( -
+
); @@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: }); if (lanes.length === 0) { return ( -
+
); @@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: }; return ( - +
`${columnCount}fr`).join(" ") }} diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 2454eda9f..8b9505aae 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -36,8 +36,14 @@ import classes from "./board-switcher.module.css"; export const boardSwitcherHotkey = "shift+c"; +export interface BoardSwitcherControls { + open: () => void; + preload: () => void; + hotkey: string; +} + interface BoardSwitcherProps { - children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode; + children: (controls: BoardSwitcherControls) => ReactNode; } export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index f0ec8debf..a4fc9b52a 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -222,7 +222,9 @@ export const WidgetContextMenu = ({ [setItemOptions], ); - if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}; + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { + return <>{children}; + } const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden); diff --git a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx index 91a81fa6f..4e91146fb 100644 --- a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx +++ b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx @@ -24,6 +24,7 @@ export const BoardRenameModal = createModal(({ actions, innerProps } name: innerProps.previousName, }); void utils.board.getHomeBoard.invalidate(); + void utils.board.getAllBoards.invalidate(); }, }); const form = useZodForm(boardRenameSchema.omit({ id: true }), { diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts index 827756828..3f1dff30b 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts @@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts"); describe("board grid import boundary", () => { - test("keeps every current dnd-kit runtime import inside the edit-only module", () => { - const runtimeImports = getSourceFiles(nextjsSourceDirectory) + test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => { + const runtimeImports = getSourceFiles(gridDirectory) .flatMap(getRuntimeImports) .filter(({ specifier }) => isCurrentDndKitImport(specifier)) .map(({ filePath, kind, specifier }) => ({ diff --git a/apps/nextjs/src/components/layout/constants.ts b/apps/nextjs/src/components/layout/constants.ts index 2799f8304..3c4c18a14 100644 --- a/apps/nextjs/src/components/layout/constants.ts +++ b/apps/nextjs/src/components/layout/constants.ts @@ -1 +1,2 @@ export const appShellHeaderHeight = 60; +export const appShellLogoHeight = appShellHeaderHeight - 8; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 2ad377576..4f268039e 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -1,49 +1,45 @@ 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 { ClientBurger } from "./header/burger"; -import { DesktopSearchInput, MobileSearchButton } from "./header/search"; -import { TourTarget } from "./header/tour-target"; -import { UserButton } from "./header/user"; -import { HomarrLogoWithTitle } from "./logo/homarr-logo"; -import { LazySpotlight } from "./header/lazy-spotlight"; +import { CurrentUserAvatar } from "~/components/user-avatar"; +import { appShellLogoHeight } from "./constants"; +import { ConfigurableHeader } from "./header/configurable-header"; +import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo"; interface Props { logo?: ReactNode; + logoWithTitle?: ReactNode; actions?: ReactNode; + boardEditAction?: ReactNode; + boardSettingsAction?: ReactNode; 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 ( - - - - {hasNavigation && } - - {logo ?? } - - - - - - - {actions} - - - - - - - - + } + logoWithTitle={logoWithTitle ?? } + actions={actions} + boardEditAction={boardEditAction} + boardSettingsAction={boardSettingsAction} + hasNavigation={hasNavigation} + avatar={} + userId={session?.user.id ?? null} + isAdmin={isAdmin} + isDockerEnabled={isDockerEnabled} + /> ); }; diff --git a/apps/nextjs/src/components/layout/header/configurable-header.module.css b/apps/nextjs/src/components/layout/header/configurable-header.module.css new file mode 100644 index 000000000..692adbad2 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/configurable-header.module.css @@ -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; + } +} diff --git a/apps/nextjs/src/components/layout/header/configurable-header.tsx b/apps/nextjs/src/components/layout/header/configurable-header.tsx new file mode 100644 index 000000000..5d4bf0ef3 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/configurable-header.tsx @@ -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) => { + const renderItems = (items: HeaderItem[]) => + items.map((item) => ( + t(`items.${key}` as never)} + boardUnavailableLabel={t("items.boardUnavailable")} + /> + )); + + return ( + <> + {headerPreferences.visible ? ( + +
+
+ {hasNavigation ? : null} + {renderItems(headerPreferences.zones.left)} +
+
+ {renderItems(headerPreferences.zones.center)} +
+
+ {renderItems(headerPreferences.zones.right)} + {actions ? {actions} : null} +
+
+
+ ) : ( + + {hasNavigation ? : null} + + + )} + + + ); + }} +
+ ); +}; + +const FloatingHeaderControls = ({ children }: { children: ReactNode }) => { + const [isIntroComplete, setIsIntroComplete] = useState(false); + const [isVisible, setIsVisible] = useState(true); + const isInteractingRef = useRef(false); + const dismissTimeoutRef = useRef(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 ( + <> +
+ { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + endInteraction(); + }} + > + {children} + + + ); +}; + +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; + 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 ( + + + + + + ); + } + return ( + + + + + + + + ); + } + + if (item.id === "logo") { + return ( + + ); + } + + if (item.id === "boardEdit") return boardEditAction; + if (item.id === "boardSettings") return boardSettingsAction; + + if (item.id === "search") { + return ( + +
+ {searchDisplay === "input" ? ( + <> + + + + ) : ( + + )} +
+
+ ); + } + + if (item.id === "home") { + return ( + + + + + + ); + } + + if (item.id === "boardSwitcher") { + return ( + + + + + + ); + } + + if (item.id === "assistant") { + if (!assistant?.enabled) return null; + return ( + + : assistant.unreadCount} + > + + + + + + ); + } + + if (item.id === "docker") { + if (!isDockerEnabled) return null; + return ( + + + + + + ); + } + + if (item.id === "settings") { + const href = userId ? `/manage/users/${userId}/general` : "/auth/login"; + return ( + + + + + + ); + } + + if (item.id === "themeToggle") { + return ( + + + + + + ); + } + + return ( + + + + ); +}; diff --git a/apps/nextjs/src/components/layout/header/header-logo.module.css b/apps/nextjs/src/components/layout/header/header-logo.module.css new file mode 100644 index 000000000..f5185ea95 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/header-logo.module.css @@ -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; +} diff --git a/apps/nextjs/src/components/layout/header/header-logo.tsx b/apps/nextjs/src/components/layout/header/header-logo.tsx new file mode 100644 index 000000000..f68924618 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/header-logo.tsx @@ -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 ( + + {content} + + ); +}; diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index ad837e272..1ba05cbbe 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -28,12 +28,14 @@ export const DesktopSearchInput = () => { ); }; -export const MobileSearchButton = () => { +export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => { + const t = useI18n(); return ( openSpotlight()} - className={classes.mobileSearch} + className={alwaysVisible ? undefined : classes.mobileSearch} + aria-label={t("search.placeholder")} > diff --git a/apps/nextjs/src/components/layout/header/user-client.tsx b/apps/nextjs/src/components/layout/header/user-client.tsx index 952354db8..4b1cb5ed9 100644 --- a/apps/nextjs/src/components/layout/header/user-client.tsx +++ b/apps/nextjs/src/components/layout/header/user-client.tsx @@ -8,18 +8,22 @@ import { useHotkeys } from "@mantine/hooks"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { hotkeys } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; import { UserAvatarMenu } from "~/components/user-avatar-menu"; +import type { BoardSwitcherControls } from "~/components/board/board-switcher"; import { UpdateIndicator } from "./update"; interface UserButtonClientProps { avatar: ReactNode; isAdmin: boolean; isDockerEnabled: boolean; + boardSwitcher: BoardSwitcherControls; } -export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => { +export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => { const [canCheckForUpdates, setCanCheckForUpdates] = useState(false); + const t = useI18n("common.userAvatar.menu"); const session = useSession(); const { toggleColorScheme } = useMantineColorScheme(); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); @@ -45,8 +49,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined; return ( - - + + {avatar} diff --git a/apps/nextjs/src/components/layout/header/user.tsx b/apps/nextjs/src/components/layout/header/user.tsx index e1dc9ad1f..23d72df4f 100644 --- a/apps/nextjs/src/components/layout/header/user.tsx +++ b/apps/nextjs/src/components/layout/header/user.tsx @@ -2,13 +2,19 @@ import { auth } from "@homarr/auth/next"; import { env } from "@homarr/docker/env"; import { CurrentUserAvatar } from "~/components/user-avatar"; +import type { BoardSwitcherControls } from "~/components/board/board-switcher"; import { UserButtonClient } from "./user-client"; -export const UserButton = async () => { +export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => { const session = await auth(); const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; return ( - } isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} /> + } + isAdmin={isAdmin} + isDockerEnabled={isDockerEnabled} + boardSwitcher={boardSwitcher} + /> ); }; diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx index ad2f1ca2e..dfa9daf28 100644 --- a/apps/nextjs/src/components/layout/logo/board-logo.tsx +++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx @@ -1,8 +1,9 @@ "use client"; 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 { Logo, LogoWithTitle } from "./logo"; @@ -12,8 +13,9 @@ interface LogoProps { const useImageOptions = () => { const board = useRequiredBoard(); + const { branding } = useSettings(); return { - src: board.logoImageUrl ?? homarrLogoPath, + src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath, alt: "Board logo", shouldUseNextImage: false, }; @@ -31,12 +33,13 @@ interface CommonLogoWithTitleProps { export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => { const board = useRequiredBoard(); + const { branding } = useSettings(); const imageOptions = useImageOptions(); return ( ); diff --git a/apps/nextjs/src/components/layout/logo/constants.ts b/apps/nextjs/src/components/layout/logo/constants.ts new file mode 100644 index 000000000..99b5a8af5 --- /dev/null +++ b/apps/nextjs/src/components/layout/logo/constants.ts @@ -0,0 +1 @@ +export const homarrLogoPath = "/logo/logo.png"; diff --git a/apps/nextjs/src/components/layout/logo/homarr-logo.tsx b/apps/nextjs/src/components/layout/logo/homarr-logo.tsx index bdfe0086f..30aad1858 100644 --- a/apps/nextjs/src/components/layout/logo/homarr-logo.tsx +++ b/apps/nextjs/src/components/layout/logo/homarr-logo.tsx @@ -1,25 +1,39 @@ +"use client"; + +import { useSettings } from "@homarr/settings"; + import type { LogoWithTitleProps } from "./logo"; import { Logo, LogoWithTitle } from "./logo"; +import { homarrLogoPath } from "./constants"; interface LogoProps { size: number; } -export const homarrLogoPath = "/logo/logo.png"; -export const homarrPageTitle = "Homarr"; - -const imageOptions = { - src: homarrLogoPath, - alt: "Homarr logo", - shouldUseNextImage: true, +const useBrandLogo = () => { + const { branding } = useSettings(); + const configuredLogoUrl = branding.logoImageUrl?.trim(); + return { + title: branding.appName, + image: { + src: configuredLogoUrl || homarrLogoPath, + alt: `${branding.appName} logo`, + shouldUseNextImage: !configuredLogoUrl, + }, + }; }; -export const HomarrLogo = ({ size }: LogoProps) => ; +export const HomarrLogo = ({ size }: LogoProps) => { + const brand = useBrandLogo(); + return ; +}; interface CommonLogoWithTitleProps { size: LogoWithTitleProps["size"]; + hideTitleOnMobile?: boolean; } -export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => { - return ; +export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => { + const brand = useBrandLogo(); + return ; }; diff --git a/apps/nextjs/src/components/layout/logo/logo.tsx b/apps/nextjs/src/components/layout/logo/logo.tsx index 36eaba57b..a2a541295 100644 --- a/apps/nextjs/src/components/layout/logo/logo.tsx +++ b/apps/nextjs/src/components/layout/logo/logo.tsx @@ -9,14 +9,18 @@ interface LogoProps { shouldUseNextImage?: boolean; } -export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => - shouldUseNextImage ? ( - {alt} +export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => { + const normalizedSrc = src.trim(); + if (normalizedSrc.length === 0) return null; + + return shouldUseNextImage ? ( + {alt} ) : ( // 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 - {alt} + {alt} ); +}; const logoWithTitleSizes = { lg: { logoSize: 48, titleOrder: 1 }, diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 383692cfe..3ed4154af 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -15,7 +15,17 @@ export const CommonNavLink = (props: ClientNavigationLink) => const withOptionalTourTarget = (id: string | undefined, children: ReactElement) => id ? {children} : 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 pathname = usePathname(); @@ -31,6 +41,7 @@ const useClientPathname = () => { const NavLinkHref = (props: NavigationLinkHref) => { const { pathname, isClient } = useClientPathname(); const tourId = props["data-onboarding-tour-id"]; + const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact)); const link = props.external ? ( ) : ( @@ -39,7 +50,7 @@ const NavLinkHref = (props: NavigationLinkHref) => { label={props.label} leftSection={props.icon} href={props.href} - active={isClient && pathMatches(pathname, props.href)} + active={isActive} /> ); return withOptionalTourTarget(tourId, link); @@ -47,11 +58,18 @@ const NavLinkHref = (props: NavigationLinkHref) => { const NavLinkWithItems = (props: NavigationLinkWithItems) => { 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 = ( - + {props.items.map((item) => ( - + ))} ); @@ -67,6 +85,8 @@ interface CommonNavigationLinkProps { interface NavigationLinkHref extends CommonNavigationLinkProps { href: string; external?: boolean; + exact?: boolean; + active?: boolean; } interface NavigationLinkWithItems extends CommonNavigationLinkProps { items: NavigationLinkHref[]; diff --git a/apps/nextjs/src/components/layout/navigation.tsx b/apps/nextjs/src/components/layout/navigation.tsx index 692c54c51..dbe7b987d 100644 --- a/apps/nextjs/src/components/layout/navigation.tsx +++ b/apps/nextjs/src/components/layout/navigation.tsx @@ -72,6 +72,7 @@ interface CommonNavigationLinkProps { interface NavigationLinkHref extends CommonNavigationLinkProps { href: string; external?: boolean; + exact?: boolean; } interface NavigationLinkWithItems extends CommonNavigationLinkProps { items: NavigationLinkHref[]; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 56001cafb..5f14a7da5 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from "react"; import { AppShell } from "@mantine/core"; import { useAtomValue } from "jotai"; +import { useSettings } from "@homarr/settings"; import { useOptionalBackgroundProps } from "./background"; import { appShellHeaderHeight } from "./constants"; @@ -20,11 +21,13 @@ export const ClientShell = ({ }: PropsWithChildren) => { const collapsed = useAtomValue(navigationCollapsedAtom); const backgroundProps = useOptionalBackgroundProps(); + const { headerPreferences } = useSettings(); + const isHeaderVisible = hasHeader && headerPreferences.visible; return ( { export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => { const tCommon = useI18n("common.action"); const tTour = useI18n("onboardingTour"); + const { branding } = useSettings(); const router = useRouter(); const forwardActionRef = useRef<(() => void) | null>(null); @@ -145,7 +145,13 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre {stepIndexFromController(controller) === 0 && (
- Homarr + {`${branding.appName}
)} diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 9ec551171..9a357e03c 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -23,7 +23,7 @@ import { Link } from "@homarr/ui"; import { useAuthContext } from "~/app/[locale]/_client-providers/session"; 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 { CurrentLanguageCombobox } from "./language/current-language-combobox"; import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal"; @@ -33,6 +33,7 @@ interface UserAvatarMenuProps { children: ReactNode; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; isDockerEnabled?: boolean; + boardSwitcher: BoardSwitcherControls; } 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)}`)) .join(" + "); -export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => { +export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => { const t = useI18n("common.userAvatar.menu"); const tBoard = useI18n("board"); const session = useSession(); @@ -59,111 +60,107 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: }, [logoutUrl]); return ( - - {({ open, preload, hotkey }) => ( - // We use keepMounted so we can add event listeners to prevent navigating away without saving the board - - - - }> - {t("homeBoard")} - - } - rightSection={{formatHotkeyLabel(hotkey, t("modifier"))}} - onClick={open} - onFocus={preload} - onPointerEnter={preload} - > - {tBoard("action.switch")} - - + // We use keepMounted so we can add event listeners to prevent navigating away without saving the board + + + + }> + {t("homeBoard")} + + } + rightSection={{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}} + onClick={boardSwitcher.open} + onFocus={boardSwitcher.preload} + onPointerEnter={boardSwitcher.preload} + > + {tBoard("action.switch")} + + - - - - - - - - {Boolean(session.data) && ( - <> - {assistant?.enabled && ( - : - } - rightSection={ - assistant.unreadCount > 0 ? ( - - {assistant.unreadCount > 99 ? "99+" : assistant.unreadCount} - - ) : assistant.isRunning ? ( - - {t("assistantThinking")} - - ) : ( - {formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))} - ) - } - onClick={assistant.open} - > - {t("assistant")} - - )} - } - > - {t("preferences")} - - - }> - {t("management")} - - {isDockerEnabled && ( - } onClick={() => openDockerModal()}> - {invariantTechnicalLabels.docker} - - )} - - )} - - {session.status === "authenticated" ? ( - } color="red"> - {t("logout")} - - ) : ( - }> - {t("login")} - - )} - - - - ) : assistant?.unreadCount ? ( - assistant.unreadCount > 99 ? ( - "99+" + + + + + + + + {Boolean(session.data) && ( + <> + {assistant?.enabled && ( + : + } + rightSection={ + assistant.unreadCount > 0 ? ( + + {assistant.unreadCount > 99 ? "99+" : assistant.unreadCount} + + ) : assistant.isRunning ? ( + + {t("assistantThinking")} + ) : ( - assistant.unreadCount + {formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))} ) - ) : undefined - } + } + onClick={assistant.open} + > + {t("assistant")} + + )} + } > - {children} - - - - )} - + {t("preferences")} + + + }> + {t("management")} + + {isDockerEnabled && ( + } onClick={() => openDockerModal()}> + {invariantTechnicalLabels.docker} + + )} + + )} + + {session.status === "authenticated" ? ( + } color="red"> + {t("logout")} + + ) : ( + }> + {t("login")} + + )} + + + + ) : assistant?.unreadCount ? ( + assistant.unreadCount > 99 ? ( + "99+" + ) : ( + assistant.unreadCount + ) + ) : undefined + } + > + {children} + + + ); }; diff --git a/apps/nextjs/src/metadata.ts b/apps/nextjs/src/metadata.ts deleted file mode 100644 index 856ecc9d3..000000000 --- a/apps/nextjs/src/metadata.ts +++ /dev/null @@ -1 +0,0 @@ -export const createMetaTitle = (name: string) => `${name} • Homarr`; diff --git a/apps/nextjs/src/theme/branding.ts b/apps/nextjs/src/theme/branding.ts new file mode 100644 index 000000000..4976d7f87 --- /dev/null +++ b/apps/nextjs/src/theme/branding.ts @@ -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 } }, + }, + }), + ); diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 03540ef0a..34f570abb 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -495,7 +495,7 @@ export const assistantRouter = createTRPCRouter({ models: [ { id: "homarr-assistant", - name: "Homarr Assistant (Demo)", + name: "Assistant (Demo)", description: null, contextLength: null, promptPrice: null, @@ -507,7 +507,7 @@ export const assistantRouter = createTRPCRouter({ }; } if (!configuration?.enabled || !configuration.modelId) { - throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." }); + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." }); } const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []); const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId; @@ -708,7 +708,7 @@ export const assistantRouter = createTRPCRouter({ getConfigurationAsync(ctx.db), ]); if (!configuration?.enabled || !configuration.modelId) { - throw new TRPCError({ code: "PRECONDITION_FAILED", message: "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); if (input.modelId !== configuration.modelId && !selectedModel) { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 66447c837..7b94e814e 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -87,7 +87,9 @@ interface BoardItemPlacementRectangle { const boardItemPlacementTails = new Map>(); 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 (boardId: string, operation: () => Promise) => { const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve(); @@ -140,6 +142,7 @@ const searchAccessibleBoardsAsync = async ( group: { with: { boardPermissions: {}, + permissions: {}, }, }, }, @@ -300,9 +303,14 @@ export const boardRouter = createTRPCRouter({ })); }), 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 groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); + const canViewAllBoards = getCanViewAllBoards(groupMemberships); const dbBoards = await ctx.db.query.boards.findMany({ columns: { @@ -352,7 +360,7 @@ export const boardRouter = createTRPCRouter({ 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)); @@ -2577,6 +2585,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin group: { with: { boardPermissions: {}, + permissions: {}, }, }, }, @@ -2600,6 +2609,24 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin return { boardIds, currentUser, groupMemberships }; }; +const getCanViewAllBoards = ( + groupMemberships: Awaited>["groupMemberships"], +) => { + const permissions = new Set( + groupMemberships.flatMap((membership) => membership.group.permissions.map(({ permission }) => permission)), + ); + return getPermissionsWithChildren([...permissions]).includes("board-view-all"); +}; + +export const getAccessibleBoardIdsForUserAsync = async (db: Database, userId: string) => { + const { boardIds, groupMemberships } = await getBoardAccessContextAsync(db, userId); + const accessibleBoards = await db.query.boards.findMany({ + columns: { id: true }, + where: getAccessibleBoardsWhere(getCanViewAllBoards(groupMemberships), userId, boardIds), + }); + return new Set(accessibleBoards.map(({ id }) => id)); +}; + const getBoardGroupPermissionWhere = ( groupMemberships: Awaited>["groupMemberships"], ) => diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index d49d4ad83..38bfa712d 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -10,7 +10,12 @@ import { } from "@homarr/db/queries"; import { boards, serverSettings } from "@homarr/db/schema"; 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"; @@ -22,7 +27,14 @@ const boardServerSettingsSchema = z.object({ }) satisfies z.ZodType; const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial(); - +const brandingServerSettingsUpdateSchema = brandingServerSettingsSchema.partial().extend({ + authBranding: authBrandingSchema.partial().optional(), +}); +const legacyAuthBrandingUpdateSchema = z.object({ + showCustomAppNameOnLogin: z.boolean().optional(), + showCustomLogoOnLogin: z.boolean().optional(), + showCustomGreetingOnLogin: z.boolean().optional(), +}); export const serverSettingsRouter = createTRPCRouter({ getCulture: publicProcedure.query(async ({ ctx }) => { return await getServerSettingByKeyAsync(ctx.db, "culture"); @@ -30,6 +42,14 @@ export const serverSettingsRouter = createTRPCRouter({ getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => { return await getServerSettingsAsync(ctx.db); }), + getBranding: publicProcedure + .meta({ + mcp: { enabled: true, description: "Returns the public instance branding configuration." }, + }) + .query(async ({ ctx }) => { + const branding = await getServerSettingByKeyAsync(ctx.db, "branding"); + return parseBrandingSettings(branding); + }), getBoardSettings: permissionRequiredProcedure .requiresPermission("admin") .meta({ @@ -100,10 +120,27 @@ export const serverSettingsRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + if (input.settingsKey === "branding") { + const current = await getServerSettingByKeyAsync(ctx.db, "branding"); + const parsedInput = brandingServerSettingsUpdateSchema.parse(input.value); + const legacyInput = legacyAuthBrandingUpdateSchema.parse(input.value); + const authBranding = { ...current.authBranding }; + authBranding.showAppName = legacyInput.showCustomAppNameOnLogin ?? authBranding.showAppName; + authBranding.showLogo = legacyInput.showCustomLogoOnLogin ?? authBranding.showLogo; + authBranding.showGreeting = legacyInput.showCustomGreetingOnLogin ?? authBranding.showGreeting; + Object.assign(authBranding, parsedInput.authBranding); + const value = brandingServerSettingsSchema.parse({ + ...parseBrandingSettings(current), + ...parsedInput, + authBranding, + }); + await updateServerSettingByKeyAsync(ctx.db, "branding", value); + return; + } const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey); await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, { ...current, ...input.value, - } as ServerSettings[keyof ServerSettings]); + } as ServerSettings[typeof input.settingsKey]); }), }); diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index c045b1288..506135732 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -79,7 +79,7 @@ describe("saveSettings", () => { }); const dbSettings = await db.select().from(serverSettings); - expect(dbSettings).toStrictEqual([ + expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([ { settingKey: "analytics", value: stringify({ @@ -124,7 +124,7 @@ describe("board settings API", () => { forceDisableStatus: true, }); const dbSettings = await db.select().from(serverSettings); - expect(dbSettings).toStrictEqual([ + expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([ { settingKey: "board", value: stringify(result), @@ -155,7 +155,7 @@ describe("board settings API", () => { enableStatusByDefault: false, }); const dbSettings = await db.select().from(serverSettings); - expect(dbSettings).toStrictEqual([ + expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([ { settingKey: "board", value: stringify(result), diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 7a1ed92f4..17c2bcd4c 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -15,8 +15,11 @@ import { selectUserSchema } from "@homarr/db/validationSchemas"; import type { SupportedAuthProvider } from "@homarr/definitions"; import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions"; import { byIdSchema } from "@homarr/validation/common"; -import type { userBaseCreateSchema } from "@homarr/validation/user"; +import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user"; import { + getHeaderItems, + headerPreferencesMutationSchema, + parseHeaderPreferences, userChangeColorSchemeSchema, userChangeHomeBoardsSchema, userChangePasswordApiSchema, @@ -30,6 +33,7 @@ import { userPingIconsEnabledSchema, userRegistrationApiSchema, } from "@homarr/validation/user"; +import { serializeHeaderPreferences } from "@homarr/validation/header-preferences"; import { convertIntersectionToZodObject } from "../schema-merger"; import { @@ -40,6 +44,7 @@ import { protectedProcedure, publicProcedure, } from "../trpc"; +import { getAccessibleBoardIdsForUserAsync } from "./board"; import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfCredentialsDisabled } from "./invite/checks"; import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences"; @@ -368,6 +373,7 @@ export const userRouter = createTRPCRouter({ firstDayOfWeek: true, pingIconsEnabled: true, enableRightClickOnWidgets: true, + headerPreferences: true, defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, @@ -405,6 +411,7 @@ export const userRouter = createTRPCRouter({ firstDayOfWeek: true, pingIconsEnabled: true, enableRightClickOnWidgets: true, + headerPreferences: true, defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, @@ -719,6 +726,63 @@ export const userRouter = createTRPCRouter({ }) .where(eq(users.id, input.id)); }), + changeHeaderPreferences: protectedProcedure + .meta({ + openapi: { + method: "PATCH", + path: "/api/users/header-preferences", + tags: ["users"], + protect: true, + }, + mcp: { + enabled: true, + description: + "Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view", + }, + }) + .input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema })) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const targetUser = await ctx.db.query.users.findFirst({ + columns: { headerPreferences: true }, + where: eq(users.id, input.id), + }); + if (!targetUser) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const submittedBoardIds = getBoardShortcutIds(input.headerPreferences); + if (submittedBoardIds.length > 0) { + const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences))); + const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id); + const hasUnavailableShortcut = submittedBoardIds.some( + (boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId), + ); + if (hasUnavailableShortcut) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more board shortcuts are unavailable to this user", + }); + } + } + + await ctx.db + .update(users) + .set({ + headerPreferences: serializeHeaderPreferences(input.headerPreferences), + }) + .where(eq(users.id, input.id)); + }), changeDdgBangs: protectedProcedure .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) .output(z.void()) @@ -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, "groupIds">) => { const hashedPassword = await hashPasswordAsync(input.password); diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index fcab9f24d..4f275abce 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -65,6 +65,7 @@ const MCP_TOOL_ALLOWLIST = { "mediaServer_getCurrentStreams", "patchmon_getStats", "serverSettings_getBoardSettings", + "serverSettings_getBranding", "smartHome_entityDetails", "smartHome_entityState", "widgetSecrets_getConfiguredKinds", diff --git a/packages/api/src/user-server.ts b/packages/api/src/user-server.ts index e0455af77..f05f0d2c3 100644 --- a/packages/api/src/user-server.ts +++ b/packages/api/src/user-server.ts @@ -13,6 +13,7 @@ export const getRscUserSettingsAsync = cache(async (userId: string) => firstDayOfWeek: true, pingIconsEnabled: true, enableRightClickOnWidgets: true, + headerPreferences: true, defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, diff --git a/packages/db/migrations/mysql/0048_add_header_preferences_and_branding.sql b/packages/db/migrations/mysql/0048_add_header_preferences_and_branding.sql new file mode 100644 index 000000000..f9f1439a3 --- /dev/null +++ b/packages/db/migrations/mysql/0048_add_header_preferences_and_branding.sql @@ -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"}}'); diff --git a/packages/db/migrations/mysql/meta/0048_snapshot.json b/packages/db/migrations/mysql/meta/0048_snapshot.json new file mode 100644 index 000000000..3edeaffc4 --- /dev/null +++ b/packages/db/migrations/mysql/meta/0048_snapshot.json @@ -0,0 +1,2817 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ec84f043-0abe-4b32-a9ea-7bfe6e790d6a", + "prevId": "5d4a439e-2e7b-4845-a6b4-f24ca5b95408", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "apiKey_id": { + "name": "apiKey_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "assistant_configuration": { + "name": "assistant_configuration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "web_search_enabled": { + "name": "web_search_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'openrouter'" + }, + "base_url": { + "name": "base_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https://openrouter.ai/api/v1'" + }, + "model_discovery_path": { + "name": "model_discovery_path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/models'" + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "assistant_configuration_id": { + "name": "assistant_configuration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "assistant_message": { + "name": "assistant_message", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ai-sdk/v6'" + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "assistant_message__thread_id_created_at_idx": { + "name": "assistant_message__thread_id_created_at_idx", + "columns": ["thread_id", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "assistant_message_thread_id_assistant_thread_id_fk": { + "name": "assistant_message_thread_id_assistant_thread_id_fk", + "tableFrom": "assistant_message", + "tableTo": "assistant_thread", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assistant_message_id": { + "name": "assistant_message_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "assistant_thread": { + "name": "assistant_thread", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "assistant_thread__user_id_updated_at_idx": { + "name": "assistant_thread__user_id_updated_at_idx", + "columns": ["user_id", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "assistant_thread_user_id_user_id_fk": { + "name": "assistant_thread_user_id_user_id_fk", + "tableFrom": "assistant_thread", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assistant_thread_id": { + "name": "assistant_thread_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('lg')" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cron_job_configuration_name": { + "name": "cron_job_configuration_name", + "columns": ["name"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_widget_v2_definition": { + "name": "custom_widget_v2_definition", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sources": { + "name": "sources", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requests": { + "name": "requests", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_v2_definition_creator_id_user_id_fk": { + "name": "custom_widget_v2_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_v2_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_v2_definition_id": { + "name": "custom_widget_v2_definition_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_widget_v2_secret": { + "name": "custom_widget_v2_secret", + "columns": { + "source_id": { + "name": "source_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_v2_secret_definition_id_fk": { + "name": "custom_widget_v2_secret_definition_id_fk", + "tableFrom": "custom_widget_v2_secret", + "tableTo": "custom_widget_v2_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_v2_secret_definition_id_source_id_kind_pk": { + "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk", + "columns": ["definition_id", "source_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_id": { + "name": "iconRepository_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_id": { + "name": "icon_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "left_gutter_column_count": { + "name": "left_gutter_column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "right_gutter_column_count": { + "name": "right_gutter_column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "layout_id": { + "name": "layout_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_widget_definition": { + "name": "custom_widget_definition", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "header_name": { + "name": "header_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_type": { + "name": "display_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'singleValue'" + }, + "display_config": { + "name": "display_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_definition_creator_id_user_id_fk": { + "name": "custom_widget_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_definition_id": { + "name": "custom_widget_definition_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_widget_secret": { + "name": "custom_widget_secret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "cw_secret_definition_id_cw_definition_id_fk": { + "name": "cw_secret_definition_id_cw_definition_id_fk", + "tableFrom": "custom_widget_secret", + "tableTo": "custom_widget_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_secret_definition_id_kind_pk": { + "name": "custom_widget_secret_definition_id_kind_pk", + "columns": ["definition_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "LONGBLOB", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_id": { + "name": "media_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onboarding_id": { + "name": "onboarding_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_engine_id": { + "name": "search_engine_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"] + } + }, + "checkConstraint": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_setting_key": { + "name": "serverSetting_setting_key", + "columns": ["setting_key"] + } + }, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"] + } + }, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_session_token": { + "name": "session_session_token", + "columns": ["session_token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_right_click_on_widgets": { + "name": "enable_right_click_on_widgets", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "header_preferences": { + "name": "header_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "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\"}]}}')" + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "widget_secret": { + "name": "widget_secret", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "widget_secret_item_id_item_id_fk": { + "name": "widget_secret_item_id_item_id_fk", + "tableFrom": "widget_secret", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "widget_secret_item_id_kind_pk": { + "name": "widget_secret_item_id_kind_pk", + "columns": ["item_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 2a3fa36d5..79a69d531 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1786217088950, "tag": "0047_add_assistant_tables", "breakpoints": true + }, + { + "idx": 48, + "version": "5", + "when": 1787556966768, + "tag": "0048_add_header_preferences_and_branding", + "breakpoints": true } ] } diff --git a/packages/db/migrations/postgresql/0014_add_header_preferences_and_branding.sql b/packages/db/migrations/postgresql/0014_add_header_preferences_and_branding.sql new file mode 100644 index 000000000..3325bab84 --- /dev/null +++ b/packages/db/migrations/postgresql/0014_add_header_preferences_and_branding.sql @@ -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; diff --git a/packages/db/migrations/postgresql/meta/0014_snapshot.json b/packages/db/migrations/postgresql/meta/0014_snapshot.json new file mode 100644 index 000000000..8e5cdd87d --- /dev/null +++ b/packages/db/migrations/postgresql/meta/0014_snapshot.json @@ -0,0 +1,2677 @@ +{ + "id": "a1398ea3-5939-47f7-840c-2b9b693a2b0c", + "prevId": "ab2696e9-42d1-4aa0-bc60-b6d9fc50a7dc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apiKey": { + "name": "apiKey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assistant_configuration": { + "name": "assistant_configuration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "web_search_enabled": { + "name": "web_search_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'openrouter'" + }, + "base_url": { + "name": "base_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "default": "'https://openrouter.ai/api/v1'" + }, + "model_discovery_path": { + "name": "model_discovery_path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "default": "'/models'" + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assistant_message": { + "name": "assistant_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'ai-sdk/v6'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_message__thread_id_created_at_idx": { + "name": "assistant_message__thread_id_created_at_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assistant_message_thread_id_assistant_thread_id_fk": { + "name": "assistant_message_thread_id_assistant_thread_id_fk", + "tableFrom": "assistant_message", + "tableTo": "assistant_thread", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assistant_thread": { + "name": "assistant_thread", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_thread__user_id_updated_at_idx": { + "name": "assistant_thread__user_id_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assistant_thread_user_id_user_id_fk": { + "name": "assistant_thread_user_id_user_id_fk", + "tableFrom": "assistant_thread", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardGroupPermission": { + "name": "boardGroupPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardUserPermission": { + "name": "boardUserPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board": { + "name": "board", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cron_job_configuration": { + "name": "cron_job_configuration", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_widget_v2_definition": { + "name": "custom_widget_v2_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requests": { + "name": "requests", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_v2_definition_creator_id_user_id_fk": { + "name": "custom_widget_v2_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_v2_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_widget_v2_secret": { + "name": "custom_widget_v2_secret", + "schema": "", + "columns": { + "source_id": { + "name": "source_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_v2_secret_definition_id_fk": { + "name": "custom_widget_v2_secret_definition_id_fk", + "tableFrom": "custom_widget_v2_secret", + "tableTo": "custom_widget_v2_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_v2_secret_definition_id_source_id_kind_pk": { + "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk", + "columns": ["definition_id", "source_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupMember": { + "name": "groupMember", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupPermission": { + "name": "groupPermission", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.iconRepository": { + "name": "iconRepository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.icon": { + "name": "icon", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_item": { + "name": "integration_item", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationSecret": { + "name": "integrationSecret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationUserPermission": { + "name": "integrationUserPermission", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_layout": { + "name": "item_layout", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.layout": { + "name": "layout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "column_count": { + "name": "column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "left_gutter_column_count": { + "name": "left_gutter_column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "right_gutter_column_count": { + "name": "right_gutter_column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_widget_definition": { + "name": "custom_widget_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "header_name": { + "name": "header_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_type": { + "name": "display_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'singleValue'" + }, + "display_config": { + "name": "display_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_definition_creator_id_user_id_fk": { + "name": "custom_widget_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_widget_secret": { + "name": "custom_widget_secret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_secret_definition_id_custom_widget_definition_id_fk": { + "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk", + "tableFrom": "custom_widget_secret", + "tableTo": "custom_widget_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_secret_definition_id_kind_pk": { + "name": "custom_widget_secret_definition_id_kind_pk", + "columns": ["definition_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding": { + "name": "onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_engine": { + "name": "search_engine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "nullsNotDistinct": false, + "columns": ["short"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_collapse_state": { + "name": "section_collapse_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_layout": { + "name": "section_layout", + "schema": "", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverSetting": { + "name": "serverSetting", + "schema": "", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "nullsNotDistinct": false, + "columns": ["setting_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_right_click_on_widgets": { + "name": "enable_right_click_on_widgets", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "header_preferences": { + "name": "header_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "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\"}]}}'" + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.widget_secret": { + "name": "widget_secret", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "widget_secret_item_id_item_id_fk": { + "name": "widget_secret_item_id_item_id_fk", + "tableFrom": "widget_secret", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "widget_secret_item_id_kind_pk": { + "name": "widget_secret_item_id_kind_pk", + "columns": ["item_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "schema": "", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index 6006e43d3..ffbdc6fd2 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1786217088954, "tag": "0013_add_assistant_tables", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1787556966592, + "tag": "0014_add_header_preferences_and_branding", + "breakpoints": true } ] } diff --git a/packages/db/migrations/sqlite/0046_add_header_preferences_and_branding.sql b/packages/db/migrations/sqlite/0046_add_header_preferences_and_branding.sql new file mode 100644 index 000000000..789638134 --- /dev/null +++ b/packages/db/migrations/sqlite/0046_add_header_preferences_and_branding.sql @@ -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; diff --git a/packages/db/migrations/sqlite/meta/0046_snapshot.json b/packages/db/migrations/sqlite/meta/0046_snapshot.json new file mode 100644 index 000000000..bf89440f3 --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0046_snapshot.json @@ -0,0 +1,2707 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "efc119da-0745-4f07-a783-0dd19f24cb4f", + "prevId": "5114a19e-3b78-482a-a68b-69874a05ee76", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": ["provider", "provider_account_id"], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant_configuration": { + "name": "assistant_configuration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "web_search_enabled": { + "name": "web_search_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'openrouter'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https://openrouter.ai/api/v1'" + }, + "model_discovery_path": { + "name": "model_discovery_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/models'" + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant_message": { + "name": "assistant_message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ai-sdk/v6'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "assistant_message__thread_id_created_at_idx": { + "name": "assistant_message__thread_id_created_at_idx", + "columns": ["thread_id", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "assistant_message_thread_id_assistant_thread_id_fk": { + "name": "assistant_message_thread_id_assistant_thread_id_fk", + "tableFrom": "assistant_message", + "tableTo": "assistant_thread", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant_thread": { + "name": "assistant_thread", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "assistant_thread__user_id_updated_at_idx": { + "name": "assistant_thread__user_id_updated_at_idx", + "columns": ["user_id", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "assistant_thread_user_id_user_id_fk": { + "name": "assistant_thread_user_id_user_id_fk", + "tableFrom": "assistant_thread", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": ["board_id", "user_id", "permission"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_widget_v2_definition": { + "name": "custom_widget_v2_definition", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sources": { + "name": "sources", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requests": { + "name": "requests", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_v2_definition_creator_id_user_id_fk": { + "name": "custom_widget_v2_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_v2_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_widget_v2_secret": { + "name": "custom_widget_v2_secret", + "columns": { + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_v2_secret_definition_id_fk": { + "name": "custom_widget_v2_secret_definition_id_fk", + "tableFrom": "custom_widget_v2_secret", + "tableTo": "custom_widget_v2_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_v2_secret_definition_id_source_id_kind_pk": { + "columns": ["definition_id", "source_id", "kind"], + "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "columns": ["group_id", "user_id"], + "name": "groupMember_group_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": ["integration_id", "group_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["item_id", "integration_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": ["integration_id", "user_id", "permission"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "columns": ["item_id", "section_id", "layout_id"], + "name": "item_layout_item_id_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "left_gutter_column_count": { + "name": "left_gutter_column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "right_gutter_column_count": { + "name": "right_gutter_column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "breakpoint": { + "name": "breakpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_widget_definition": { + "name": "custom_widget_definition", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "header_name": { + "name": "header_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_type": { + "name": "display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'singleValue'" + }, + "display_config": { + "name": "display_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_definition_creator_id_user_id_fk": { + "name": "custom_widget_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_widget_secret": { + "name": "custom_widget_secret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_secret_definition_id_custom_widget_definition_id_fk": { + "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk", + "tableFrom": "custom_widget_secret", + "tableTo": "custom_widget_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_secret_definition_id_kind_pk": { + "columns": ["definition_id", "kind"], + "name": "custom_widget_secret_definition_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"], + "isUnique": true + } + }, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "columns": ["user_id", "section_id"], + "name": "section_collapse_state_user_id_section_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "columns": ["section_id", "layout_id"], + "name": "section_layout_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "columns": ["hostname", "thumbprint"], + "name": "trusted_certificate_hostname_hostname_thumbprint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_right_click_on_widgets": { + "name": "enable_right_click_on_widgets", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "header_preferences": { + "name": "header_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "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\"}]}}'" + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "widget_secret": { + "name": "widget_secret", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "widget_secret_item_id_item_id_fk": { + "name": "widget_secret_item_id_item_id_fk", + "tableFrom": "widget_secret", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "widget_secret_item_id_kind_pk": { + "columns": ["item_id", "kind"], + "name": "widget_secret_item_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index da059e51c..c51331604 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1786217088950, "tag": "0045_add_assistant_tables", "breakpoints": true + }, + { + "idx": 46, + "version": "6", + "when": 1787556956665, + "tag": "0046_add_header_preferences_and_branding", + "breakpoints": true } ] } diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts index 371b18f29..33e30f078 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -1,7 +1,7 @@ import SuperJSON from "superjson"; 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 { eq } from ".."; @@ -18,10 +18,15 @@ export const getServerSettingsAsync = async (db: Database) => { return acc; } + const parsedSetting = SuperJSON.parse>(setting.value); + if (settingKey === "branding") { + acc[settingKey] = parseBrandingSettings(parsedSetting) as never; + return acc; + } acc[settingKey] = { ...defaultServerSettings[settingKey], - ...SuperJSON.parse(setting.value), - }; + ...parsedSetting, + } as never; return acc; }, {} as ServerSettings); }; @@ -35,7 +40,14 @@ export const getServerSettingByKeyAsync = async (dbSettings.value); + const parsedSetting = SuperJSON.parse(dbSettings.value); + if (key === "branding") { + return parseBrandingSettings(parsedSetting) as ServerSettings[TKey]; + } + return { + ...defaultServerSettings[key], + ...parsedSetting, + } as ServerSettings[TKey]; }; export const updateServerSettingByKeyAsync = async ( diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 216d80757..214b21ab0 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -43,6 +43,7 @@ import type { WidgetKind, } from "@homarr/definitions"; import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core"; +import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences"; const customBlob = customType<{ data: Buffer }>({ dataType() { @@ -85,6 +86,7 @@ export const users = mysqlTable("user", { firstDayOfWeek: tinyint().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean().default(false).notNull(), enableRightClickOnWidgets: boolean().default(true).notNull(), + headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(), completedManageTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(), }); diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 12aa9e5d1..ca525b594 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -41,6 +41,7 @@ import type { WidgetKind, } from "@homarr/definitions"; import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core"; +import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences"; const customBlob = customType<{ data: Buffer }>({ dataType() { @@ -83,6 +84,7 @@ export const users = pgTable("user", { firstDayOfWeek: smallint().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean().default(false).notNull(), enableRightClickOnWidgets: boolean().default(true).notNull(), + headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(), completedManageTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(), }); diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 9157a1335..a8cadcba9 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -29,6 +29,7 @@ import type { WidgetKind, } from "@homarr/definitions"; import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core"; +import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences"; export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite"; @@ -65,6 +66,7 @@ export const users = sqliteTable("user", { firstDayOfWeek: int().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(), + headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(), completedManageTour: int({ mode: "boolean" }).default(false).notNull(), completedBoardTour: int({ mode: "boolean" }).default(false).notNull(), }); diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index 7db5e495c..7e42ccade 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import type { FocusEventHandler, KeyboardEventHandler } from "react"; +import type { FocusEventHandler, KeyboardEventHandler, ReactNode } from "react"; import { startTransition, useEffect, useMemo, useState } from "react"; import { ActionIcon, @@ -48,6 +48,7 @@ interface IconPickerProps { onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; label?: string; + description?: ReactNode; placeholder?: string; suggestedSearch?: string | null; withAsterisk?: boolean; @@ -63,6 +64,7 @@ export const IconPicker = ({ onBlur, withAsterisk = true, label, + description, placeholder, suggestedSearch, }: IconPickerProps) => { @@ -272,6 +274,7 @@ export const IconPicker = ({ (query.data ? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) }) : tCommon("iconPicker.headerLoading")); + const inputDescription = description === undefined ? tCommon("iconPicker.description") : description; return ( ( -
+export const OnboardingAuthShell = ({ + title, + description, + icon, + children, + footer, + appName, + showAppName = false, + showAppLogo = false, + primaryColor, + secondaryColor, + wordmarkPrimaryColor, + wordmarkSecondaryColor, + logoImageUrl, + backgroundImageUrl, + backgroundOverlay = 0.55, + radius = "lg", +}: OnboardingAuthShellProps) => ( +
+ {backgroundImageUrl ? ( +
+ ) : null}
-
- -
- + {showAppName || showAppLogo ? ( +
+ +
+ ) : null} + diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css index f8c8ecd67..d4947f38b 100644 --- a/packages/onboarding/src/onboarding-studio.module.css +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -88,6 +88,33 @@ 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 { 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)); } +.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 { width: min(100%, 36rem); margin: auto; diff --git a/packages/onboarding/src/onboarding-wordmark.tsx b/packages/onboarding/src/onboarding-wordmark.tsx index 550cff21b..15b6fd2c8 100644 --- a/packages/onboarding/src/onboarding-wordmark.tsx +++ b/packages/onboarding/src/onboarding-wordmark.tsx @@ -9,6 +9,10 @@ import classes from "./onboarding-studio.module.css"; import { useOnboardingSounds } from "./use-onboarding-sounds"; interface OnboardingWordmarkProps { + appName?: string; + logoImageUrl?: string; + showAppName?: boolean; + showAppLogo?: boolean; primaryColor?: string; secondaryColor?: string; large?: boolean; @@ -16,7 +20,15 @@ interface OnboardingWordmarkProps { 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 sounds = useOnboardingSounds(); 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 ( +
+ {visibleLogoImageUrl ? ( + {showAppName + ) : null} + {showAppName ? {resolvedAppName} : null} +
+ ); + } + if (primaryColor === undefined && secondaryColor === undefined) { - return Homarr; + return {resolvedAppName}; } return ( celebrate(event.currentTarget)} diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index 75c44d090..3efc44c21 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -34,7 +34,6 @@ import { UnstyledButton, useMantineColorScheme, } from "@mantine/core"; -import type { MantineSize } from "@mantine/core"; import { IconAdjustments, IconAlertCircle, @@ -91,7 +90,16 @@ import { import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications"; import type { SupportedLanguage } from "@homarr/translation"; 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 type { OnboardingStudioProps } from "./types"; @@ -146,7 +154,6 @@ const sectionDefinitions = [ { id: "review", icon: IconCheck }, ] as const; -const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const; const initialPrimaryColor = "#fa5252"; const initialSecondaryColor = "#fd7e14"; const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = []; @@ -176,7 +183,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard"); const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor); const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor); - const [itemRadius, setItemRadius] = useState("lg"); + const [itemRadius, setItemRadius] = useState("lg"); const [columnCount, setColumnCount] = useState(10); const [leftSidebar, setLeftSidebar] = useState(false); const [rightSidebar, setRightSidebar] = useState(false); @@ -797,8 +804,8 @@ interface StudioSectionProps { setPrimaryColor: (value: string) => void; secondaryColor: string; setSecondaryColor: (value: string) => void; - itemRadius: MantineSize; - setItemRadius: (value: MantineSize) => void; + itemRadius: CornerStyle; + setItemRadius: (value: CornerStyle) => void; columnCount: number; setColumnCount: (value: number) => void; leftSidebar: boolean; @@ -1452,32 +1459,16 @@ const BoardBuilder = (props: StudioSectionProps) => { ) : null}
- - - {t("radius")} - - ({ value, label: value.toUpperCase() }))} - /> - - {radiusValues.map((radius) => ( -
- ))} - - + [cornerStyle, t(`radiusOptions.${cornerStyle}`)]), + ) as Record + } + onChange={props.setItemRadius} + /> @@ -1543,7 +1534,7 @@ const BoardPreview = ({ appCount, }: { ariaLabel: string; - itemRadius: MantineSize; + itemRadius: CornerStyle; baseColumnCount: number; leftSidebar: boolean; rightSidebar: boolean; diff --git a/packages/server-settings/package.json b/packages/server-settings/package.json index 4decc00ae..2be779ea6 100644 --- a/packages/server-settings/package.json +++ b/packages/server-settings/package.json @@ -22,7 +22,8 @@ }, "dependencies": { "@homarr/definitions": "workspace:^0.1.0", - "@homarr/translation": "workspace:^0.1.0" + "@homarr/translation": "workspace:^0.1.0", + "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", diff --git a/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts index a8a52ea99..1921e37d4 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -1,5 +1,6 @@ import type { ColorScheme } from "@homarr/definitions"; import type { SupportedLanguage } from "@homarr/translation"; +import { z } from "zod/v4"; export const defaultServerSettingsKeys = [ "analytics", @@ -7,12 +8,120 @@ export const defaultServerSettingsKeys = [ "board", "user", "appearance", + "branding", "culture", "search", ] as const; export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record>; +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; + +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) => + branding.primaryColor.toLowerCase() === defaultBrandingSettings.primaryColor && + branding.secondaryColor.toLowerCase() === defaultBrandingSettings.secondaryColor; + +export const getBrandingColorOverrides = ( + branding: Pick, +): Partial> => { + 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 = { analytics: { enableGeneral: true, @@ -36,6 +145,7 @@ export const defaultServerSettings = { appearance: { defaultColorScheme: "auto" as ColorScheme, }, + branding: defaultBrandingSettings, culture: { defaultLocale: "en" as SupportedLanguage, }, diff --git a/packages/settings/package.json b/packages/settings/package.json index 18f93e280..736ee55ce 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -24,6 +24,7 @@ "dependencies": { "@homarr/db": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", + "@homarr/validation": "workspace:^0.1.0", "@mantine/dates": "catalog:", "next": "catalog:", "react": "catalog:", diff --git a/packages/settings/src/creator.ts b/packages/settings/src/creator.ts index ee7dfaf6e..7991c91db 100644 --- a/packages/settings/src/creator.ts +++ b/packages/settings/src/creator.ts @@ -1,19 +1,30 @@ import type { User } from "@homarr/db/schema"; 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< - User, - | "firstDayOfWeek" - | "defaultSearchEngineId" - | "homeBoardId" - | "mobileHomeBoardId" - | "openSearchInNewTab" - | "ddgBangs" - | "pingIconsEnabled" - | "enableRightClickOnWidgets" -> & - Pick & - Pick; +export type SettingsContextProps = Omit< + Pick< + User, + | "firstDayOfWeek" + | "defaultSearchEngineId" + | "homeBoardId" + | "mobileHomeBoardId" + | "openSearchInNewTab" + | "ddgBangs" + | "pingIconsEnabled" + | "enableRightClickOnWidgets" + | "headerPreferences" + >, + "headerPreferences" +> & { headerPreferences: HeaderPreferences } & Pick< + ServerSettings["board"], + "enableStatusByDefault" | "forceDisableStatus" + > & + Pick & { + branding: ServerSettings["branding"]; + }; export interface PublicServerSettings { search: Pick; @@ -22,6 +33,7 @@ export interface PublicServerSettings { "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" >; user: Pick; + branding: ServerSettings["branding"]; } export type UserSettings = Pick< @@ -34,6 +46,7 @@ export type UserSettings = Pick< | "ddgBangs" | "pingIconsEnabled" | "enableRightClickOnWidgets" + | "headerPreferences" >; export const createSettings = ({ @@ -51,7 +64,9 @@ export const createSettings = ({ mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, pingIconsEnabled: user?.pingIconsEnabled ?? false, enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true, + headerPreferences: parseHeaderPreferences(user?.headerPreferences), enableStatusByDefault: serverSettings.board.enableStatusByDefault, forceDisableStatus: serverSettings.board.forceDisableStatus, enableGravatar: serverSettings.user.enableGravatar, + branding: parseBrandingSettings(serverSettings.branding), }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 33cdc8f95..59e09e437 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -142,6 +142,13 @@ "secondaryColor": "Secondary color", "resetColors": "Reset colors", "radius": "Widget corner radius", + "radiusOptions": { + "xs": "Tight", + "sm": "Subtle", + "md": "Balanced", + "lg": "Rounded", + "xl": "Soft" + }, "columns": "Board columns", "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}}", @@ -242,7 +249,9 @@ }, "invite": { "title": "Join Homarr", + "titleWithAppName": "Join {appName}", "subtitle": "Welcome to Homarr! Please create your account", + "subtitleWithAppName": "Welcome to {appName}! Please create your account", "description": "You were invited by {username}" } }, @@ -1847,13 +1856,14 @@ }, "userAvatar": { "menu": { + "open": "Open account menu", "management": "Management", "preferences": "Your preferences", "logout": "Logout", "login": "Login", "homeBoard": "Your home board", "updateAvailable": "{countUpdates} updates available: {tag}", - "assistant": "Homarr Assistant", + "assistant": "Assistant", "assistantThinking": "Thinking", "modifier": "Mod" } @@ -5336,7 +5346,8 @@ } }, "primaryColor": { - "label": "Primary color" + "label": "Primary color", + "locked": "Your administrator keeps the instance brand color on every board." }, "secondaryColor": { "label": "Secondary color" @@ -5676,6 +5687,65 @@ "setting": { "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": { "language": "Language & Region", "board": { @@ -5874,6 +5944,80 @@ "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": { "title": "Culture", "defaultLocale": { @@ -6318,7 +6462,7 @@ "openSpotlightApps": "Open app search", "openSpotlightAssistant": "Open Assistant mode", "openSpotlightPreferences": "Open preferences mode", - "openAssistant": "Open Homarr Assistant" + "openAssistant": "Open Assistant" }, "note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS" } @@ -6802,7 +6946,7 @@ }, "assistant": { "label": "Assistant", - "placeholder": "Ask Homarr Assistant" + "placeholder": "Ask Assistant" }, "external": { "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": { - "title": "Homarr Assistant", - "open": "Open Homarr Assistant", + "title": "Assistant", + "open": "Open Assistant", "close": "Close assistant", "minimize": "Minimize assistant", - "spotlight": "Ask Homarr Assistant", + "spotlight": "Ask Assistant", "spotlightDescription": "Type and send a prompt without leaving the command menu.", "spotlightPrompt": { "title": "Ask Assistant", @@ -7525,7 +7673,7 @@ "checking": "Checking whether the assistant is available…", "error": "The assistant's availability could not be checked. Try again.", "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", "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." } }, - "composerPlaceholder": "Ask Homarr Assistant…", + "composerPlaceholder": "Ask Assistant…", "runtime": { "model": "Model", "searchModels": "Search models", diff --git a/packages/ui/src/components/corner-style-picker.tsx b/packages/ui/src/components/corner-style-picker.tsx new file mode 100644 index 000000000..57e3b1785 --- /dev/null +++ b/packages/ui/src/components/corner-style-picker.tsx @@ -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; + onChange: (value: CornerStyle) => void; + compact?: boolean; +} + +export const CornerStylePicker = ({ + label, + description, + value, + labels, + onChange, + compact = false, +}: CornerStylePickerProps) => { + const previewHeight = compact ? 24 : 34; + + return ( + + + {label} + + + {description ? ( + + {description} + + ) : null} + + {cornerStyleValues.map((cornerStyle) => ( + onChange(cornerStyle)} + radius={cornerStyle} + p="xs" + aria-label={labels[cornerStyle]} + style={compact ? { minHeight: "auto", height: "auto" } : undefined} + > + + + {labels[cornerStyle]} + + + ))} + + + + ); +}; diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 941412a72..776f12837 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -27,6 +27,8 @@ export { LanguageIcon } from "./language-icon"; export { LanguageCombobox } from "./language-combobox"; export { ColorSchemeCombobox } from "./color-scheme-combobox"; 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 { Link } from "./link"; export { diff --git a/packages/validation/src/header-preferences.ts b/packages/validation/src/header-preferences.ts new file mode 100644 index 000000000..e5866a043 --- /dev/null +++ b/packages/validation/src/header-preferences.ts @@ -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; +export type HeaderBoardItem = z.infer; +export type HeaderItem = z.infer; +export type HeaderItemId = HeaderItem; +export type HeaderZones = Record; + +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; + +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, +): 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, +): HeaderPreferences => { + const zones: Record = { 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): 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)); diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 0983f2e97..8d8777866 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -8,6 +8,42 @@ import { zodEnumFromArray } from "./enums"; import { createCustomErrorParams } from "./form/i18n"; import { nullableEmailSchema, optionalEmailSchema } from "./email"; +export { + createBoardHeaderItem, + createBuiltinHeaderItem, + defaultHeaderPreferences, + getDefaultHeaderZone, + getHeaderItemKey, + getHeaderItems, + getHeaderItemZone, + headerBuiltinItemIds, + headerItemIds, + headerItemSchema, + headerLogoDisplayValues, + headerPreferenceItemIds, + headerPreferencesMutationSchema, + headerPreferencesSchema, + headerPreferencesVersion, + headerSearchDisplayValues, + headerZoneIds, + isRequiredHeaderItem, + parseHeaderPreferences, + requiredHeaderBuiltinItemIds, +} from "./header-preferences"; +export type { + HeaderBoardItem, + HeaderBuiltinItem, + HeaderBuiltinItemId, + HeaderItem, + HeaderItemId, + HeaderLogoDisplay, + HeaderPreferenceItemId, + HeaderPreferences, + HeaderSearchDisplay, + HeaderZoneId, + HeaderZones, +} from "./header-preferences"; + // We always want the lowercase version of the username to compare it in a case-insensitive way export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43b4000ef..f0d9d55c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2599,6 +2599,9 @@ importers: '@homarr/translation': specifier: workspace:^0.1.0 version: link:../translation + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@homarr/tsconfig': specifier: workspace:^0.1.0 @@ -2615,6 +2618,9 @@ importers: '@homarr/server-settings': specifier: workspace:^0.1.0 version: link:../server-settings + '@homarr/validation': + specifier: workspace:^0.1.0 + version: link:../validation '@mantine/dates': specifier: 'catalog:' version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) From 3ee9b4f0c4ba821fad01449b2d15efdf7de56a3b Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 25 Aug 2026 17:11:24 +0200 Subject: [PATCH 2/7] feat(custom-widgets): harden AI authoring (credential redaction, JSX policy) (#6700) --- .../scripts/ai-assistant-evaluation.ts | 43 ++++++---- .../custom-widgets/scripts/ai-evaluation.ts | 8 +- .../scripts/evaluate-ai-authoring.ts | 81 +++++++++---------- packages/custom-widgets/src/core/ai-prompt.ts | 42 +++++++--- .../src/core/definition-security.ts | 64 +++++++++++++++ packages/custom-widgets/src/core/examples.ts | 4 +- .../custom-widgets/src/jsx/analyzer-jsx.ts | 23 +++++- .../src/jsx/runtime-components.tsx | 12 +-- .../src/test/ai-assistant-evaluation.spec.ts | 18 +++++ .../src/test/ai-evaluation.spec.ts | 44 +++++++++- .../custom-widgets/src/test/ai-prompt.spec.ts | 62 +++++++++++++- .../src/test/authoring-resources.spec.ts | 6 ++ .../src/test/jsx-policy.spec.ts | 15 ++++ .../src/workbench/code-editor.tsx | 3 +- 14 files changed, 333 insertions(+), 92 deletions(-) diff --git a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts index f2b2cfe86..7b11e6ac3 100644 --- a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts @@ -144,7 +144,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] = type: "function", function: { name: "customWidget_getComponent", - description: "Get installed documentation and safety rules for one named Custom JSX component.", + description: + "Get installed documentation and safety rules for one named Custom JSX component. Fetch only a selected component, one tool call at a time, after any optional example.", parameters: objectSchema({ name: { type: "string" } }, ["name"]), }, }, @@ -162,7 +163,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] = type: "function", function: { name: "customWidget_getExample", - description: "Get one installed Custom JSX example by catalog ID.", + description: + "Optionally get one installed Custom JSX example by catalog ID before requesting any component documentation.", parameters: objectSchema({ name: { type: "string" } }, ["name"]), }, }, @@ -251,11 +253,24 @@ export function executeAssistantEvaluationTool( name: string, input: Record, ): unknown { + const componentDocumentCount = state.calledTools.filter( + (toolName) => toolName === "customWidget_getComponent", + ).length; + if (name === "customWidget_getExample") { + if (componentDocumentCount > 0) { + return { + error: + "An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.", + }; + } + const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null; + if (!example) return { error: "Custom JSX example not found" }; + state.calledTools.push(name); + const { template, ...widget } = example.widget; + return { ...example, widget: { ...widget, templateLines: template.split("\n") } }; + } const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8; - if ( - name === "customWidget_getComponent" && - state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit - ) { + if (name === "customWidget_getComponent" && componentDocumentCount >= componentDocumentLimit) { return { error: "The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.", @@ -277,12 +292,6 @@ export function executeAssistantEvaluationTool( ? getCustomWidgetSharedProps(names) : { error: "At least one shared prop name is required" }; } - if (name === "customWidget_getExample") { - const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null; - if (!example) return { error: "Custom JSX example not found" }; - const { template, ...widget } = example.widget; - return { ...example, widget: { ...widget, templateLines: template.split("\n") } }; - } if (name === "customWidget_validate") { const parsed = parseDefinition(input.widget); if (!parsed.success) return { valid: false, issues: parsed.issues }; @@ -576,11 +585,11 @@ async function runAssistantAttempt(args: { process.stdout.write(` tool: ${toolCall.function.name}\n`); let output: unknown; try { - const parsed = JSON.parse(toolCall.function.arguments) as unknown; - const input = - parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; + const parsed: unknown = JSON.parse(toolCall.function.arguments); + let input: Record = {}; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + input = Object.fromEntries(Object.entries(parsed)); + } output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input); } catch (error) { output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" }; diff --git a/packages/custom-widgets/scripts/ai-evaluation.ts b/packages/custom-widgets/scripts/ai-evaluation.ts index 35db9c72b..f5a6b5246 100644 --- a/packages/custom-widgets/scripts/ai-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-evaluation.ts @@ -219,7 +219,7 @@ export function buildRepairPrompt( export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string { return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates. -The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status. with loading/ok/status/error fields while successful payloads are exposed as data.. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility. +The installed Homarr skill and runtime references below are authoritative. The verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes unless the validated manifest contradicts them. Do not invent external endpoint or authentication objections from outside assumptions. Decorative icons paired with equivalent adjacent visible status text need no separate aria-label. A Badge containing explicit visible status text is not color-only. A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper. In particular, request state is exposed as status. with loading/ok/status/error fields while successful payloads are exposed as data.. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility. Installed Homarr Custom Widget authoring contract: ${getCustomWidgetSkillContent()} @@ -254,16 +254,14 @@ Required review behavior: - Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems. - Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array. -Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`; +Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`; } export function judgePasses(result: CustomWidgetJudgeResult): boolean { return ( - result.total >= 90 && + result.total >= 85 && Object.values(result.categories).every((score) => score >= 75) && result.categories.goalFulfillment >= 85 && - result.categories.visualQuality >= 85 && - result.categories.dailyUsefulness >= 85 && result.categories.complexityDiscipline >= 80 && result.dailyUseDecision === "would-use-daily" && result.fatalProblems.length === 0 diff --git a/packages/custom-widgets/scripts/evaluate-ai-authoring.ts b/packages/custom-widgets/scripts/evaluate-ai-authoring.ts index 9a9841379..3c7138a39 100644 --- a/packages/custom-widgets/scripts/evaluate-ai-authoring.ts +++ b/packages/custom-widgets/scripts/evaluate-ai-authoring.ts @@ -15,58 +15,55 @@ if (!apiKey) { const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length); const assistantMode = process.argv.includes("--assistant"); const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10); -const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10; -const selectableCases = assistantMode - ? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter( - (testCase) => - (testCase.sampleResponse !== undefined || testCase.previewResponses?.length) && - testCase.expectations !== undefined, - ) - : CUSTOM_WIDGET_AI_EVALUATION_CASES; -const selectedCases = requestedCase - ? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase) - : selectableCases; -if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`); -if (totalLoopBudget < selectedCases.length) { - throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`); +const maxLoops = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10; +let selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES; +if (assistantMode) { + selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter( + (testCase) => + (testCase.sampleResponse !== undefined || testCase.previewResponses?.length) && + testCase.expectations !== undefined, + ); } +let selectedCases = selectableCases; +if (requestedCase) + selectedCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase); +if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`); const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-"); const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId); await mkdir(outputRoot, { recursive: true }); const results: Array = []; -let remainingLoopBudget = totalLoopBudget; for (const testCase of selectedCases) { - const remainingCases = selectedCases.length - results.length; - const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases)); process.stdout.write(`Evaluating ${testCase.id}...\n`); - const result = assistantMode - ? await evaluateCustomWidgetAssistantCase({ - testCase, - apiKey, - baseUrl: providerBaseUrl, - outputRoot, - maxLoops, - generatorModel, - judgeModel, - }) - : await evaluateCustomWidgetCase({ - testCase, - apiKey, - baseUrl: providerBaseUrl, - outputRoot, - maxLoops, - generatorModel, - judgeModel, - }); + let result: AiEvaluationResult | CustomWidgetAssistantEvaluationResult; + if (assistantMode) { + result = await evaluateCustomWidgetAssistantCase({ + testCase, + apiKey, + baseUrl: providerBaseUrl, + outputRoot, + maxLoops, + generatorModel, + judgeModel, + }); + } else { + result = await evaluateCustomWidgetCase({ + testCase, + apiKey, + baseUrl: providerBaseUrl, + outputRoot, + maxLoops, + generatorModel, + judgeModel, + }); + } results.push(result); - remainingLoopBudget -= result.attempts; - process.stdout.write( - result.judge - ? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n` - : ` failed after ${result.attempts} attempt(s)\n`, - ); + if (result.judge) { + process.stdout.write(` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`); + } else { + process.stdout.write(` failed after ${result.attempts} attempt(s)\n`); + } } const summary = { diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index 612ef143e..0576d90c1 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -1,5 +1,11 @@ import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema"; import { customJsxAuthoringCatalog } from "./component-catalog"; +import { + redactCustomWidgetAiContext, + redactCustomWidgetAiResponse, + redactCustomWidgetAiText, + redactCustomWidgetAiUrl, +} from "./definition-security"; import { customJsxExamples } from "./examples"; export interface CustomWidgetAiDraft { @@ -106,19 +112,28 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT; export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time. -First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release. +Mandatory sequence — follow it exactly: +1. First call customWidget_getSkill and follow the complete installed skill and bundled references. +2. Call customWidget_schema, then customWidget_getComponentCatalog. +3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call. Then choose at most four component names total and never request a fifth. Fetch only those named docs and, once, any named shared props with customWidget_getSharedProps. +4. Before validation, quickly reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span; use catalog components instead. Immediately call customWidget_validate with the complete definition and repair every issue. +5. Call customWidget_previewCreate with that exact validated definition. +6. Call customWidget_previewQuery once for every query returned by the preview and inspect every response. +7. Call customWidget_createFromPreview with the exact fully tested preview session ID. + +Make one documentation tool call at a time. Never issue batched customWidget_getComponent calls, speculate about components, or walk the catalog. If preview evidence requires a definition change, restart at step 4 with the revised complete definition. The live resources are authoritative for this Homarr release. Keep the documentation phase bounded. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only to resolve a concrete validation issue or missing interaction; never walk the catalog component-by-component. Move promptly from the smallest sufficient documentation set to a complete customWidget_validate call. -Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs. +Construct one credential-free definition. Supply required manual parameters to preview queries, inspect the real response shape and HTTP status, and open or provide the returned previewPath for visual review. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs. Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions. -Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the response supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path. +Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show timestamps as concise human-readable absolute dates and times; do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper. Give recoverable load errors and empty states a clear refresh or retry path. Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware. -Create distinctive polish with one semantic accent, clear surface contrast, purposeful typography, and compact narrow-tile rows rather than decorative gradients or excessive nested cards. Label standalone icons for assistive technology or pair them with visible text. +Create distinctive polish with one restrained accent for decorative chrome, while operational, degraded, and down states retain distinct semantic colors. Use clear surface contrast and purposeful typography rather than decorative gradients or excessive nested cards. Do not wrap every metric or service row in Paper or Card. Prefer one dominant surface with clearly separated service rows, a one-column compact narrow layout, and an intentionally more spacious wide layout. Label standalone icons for assistive technology or pair them with visible text. Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state. @@ -129,7 +144,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio if (documentationUrl) { const documentationSection = formatBudgetedContextSection( "API documentation URL (reference only)", - documentationUrl, + redactCustomWidgetAiUrl(documentationUrl), 700, "data", ); @@ -138,7 +153,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio if (request?.trim()) { const requestSection = formatBudgetedContextSection( "User-authored widget request (product intent only)", - request.trim(), + redactCustomWidgetAiText(request.trim()), 1_700, "request", ); @@ -201,7 +216,7 @@ function buildCustomWidgetPromptSections( const sections = [CUSTOM_WIDGET_CONTEXT_BOUNDARY_INSTRUCTION]; const requestSection = formatBudgetedContextSection( "User-authored widget request (product intent only)", - request?.trim() || "Describe the widget you want to create.", + redactCustomWidgetAiText(request?.trim() || "Describe the widget you want to create."), 1_700, "request", ); @@ -209,7 +224,7 @@ function buildCustomWidgetPromptSections( if (documentationUrl) { const documentationSection = formatBudgetedContextSection( "API documentation URL (reference only)", - documentationUrl, + redactCustomWidgetAiUrl(documentationUrl), 700, "data", ); @@ -218,7 +233,7 @@ function buildCustomWidgetPromptSections( if (currentConfig) { const draftSection = formatBudgetedContextSection( "Current raw widget draft", - JSON.stringify(currentConfig, null, 2), + JSON.stringify(redactCustomWidgetAiContext(currentConfig), null, 2), 4_000, "data", ); @@ -227,14 +242,19 @@ function buildCustomWidgetPromptSections( if (diagnostics?.length) { const diagnosticSection = formatBudgetedContextSection( "Current normalized diagnostics", - JSON.stringify(diagnostics), + JSON.stringify(redactCustomWidgetAiContext(diagnostics)), 1_500, "data", ); if (diagnosticSection) sections.push(diagnosticSection); } if (rawResponse) { - const responseSection = formatBudgetedContextSection("Sample API response", rawResponse, 1_000, "data"); + const responseSection = formatBudgetedContextSection( + "Sample API response", + redactCustomWidgetAiResponse(rawResponse), + 1_000, + "data", + ); if (responseSection) sections.push(responseSection); } const authoringBudget = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - footerLength - 8; diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts index c5c215fcd..1aa39f350 100644 --- a/packages/custom-widgets/src/core/definition-security.ts +++ b/packages/custom-widgets/src/core/definition-security.ts @@ -191,6 +191,70 @@ export function redactCustomWidgetCredentialLiterals(value: string): string { ); } +export function redactCustomWidgetAiContext(value: unknown, path: DefinitionPath = []): unknown { + if (Array.isArray(value)) return value.map((entry, index) => redactCustomWidgetAiContext(entry, [...path, index])); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => { + const entryPath = [...path, key]; + const risk = getCustomWidgetCredentialKeyRisk(key); + if (risk === "strong" && !isSchemaAuthenticationControl(entryPath)) { + if (!isHarmlessCustomWidgetCredentialSetting(entry)) return [key, "[REDACTED]"]; + } + if (risk === "ambiguous" && typeof entry === "string") { + if (redactCustomWidgetCredentialLiterals(entry) !== entry) return [key, "[REDACTED]"]; + } + return [key, redactCustomWidgetAiContext(entry, entryPath)]; + }), + ); + } + if (typeof value !== "string") return value; + + const lastPathPart = path.at(-1); + let key = ""; + if (typeof lastPathPart === "string") key = lastPathPart; + if (["sources", "requests", "options"].includes(key)) { + try { + const parsed: unknown = JSON.parse(value); + return redactCustomWidgetAiContext(parsed, path); + } catch { + /* Invalid editor JSON is still useful prompt context. */ + } + } + if (key === "baseUrl" || key === "iconUrl") { + return redactCustomWidgetCredentialLiterals(redactCustomWidgetAiUrl(value)); + } + return redactCustomWidgetAiText(value); +} + +export function redactCustomWidgetAiResponse(value: string): string { + try { + const parsed: unknown = JSON.parse(value); + return JSON.stringify(redactCustomWidgetAiContext(parsed), null, 2); + } catch { + return redactCustomWidgetAiText(value); + } +} + +export function redactCustomWidgetAiUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return "[REDACTED_URL]"; + } +} + +export function redactCustomWidgetAiText(value: string): string { + return redactCustomWidgetCredentialLiterals( + value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactCustomWidgetAiUrl(candidate)), + ); +} + function isSchemaAuthenticationControl(path: DefinitionPath): boolean { return ( path.length === 3 && diff --git a/packages/custom-widgets/src/core/examples.ts b/packages/custom-widgets/src/core/examples.ts index 30d90a473..b3d1b30d6 100644 --- a/packages/custom-widgets/src/core/examples.ts +++ b/packages/custom-widgets/src/core/examples.ts @@ -36,8 +36,8 @@ export const customJsxExamples: readonly CustomJsxExample[] = [ requests: { overview: { path: "/api/overview", cacheSeconds: 30 } }, options: {}, template: ` - System overviewLive service health - {status.overview?.loading ? : status.overview?.error ? {status.overview.error} : {(data.overview?.services ?? []).map(service => {service.name}{service.online ? "Online" : "Offline"}{service.latency ?? "—"} ms)}} + System overviewLive service health + {status.overview?.loading ? : status.overview?.error ? Service health is unavailable. Use Refresh service health to try again. {status.overview.error} : Services{data.overview?.services?.length ?? 0}Online{(data.overview?.services ?? []).filter(service => service.online).length}Offline{(data.overview?.services ?? []).filter(service => !service.online).length}Last checked{(data.overview?.checkedAt ?? data.overview?.lastCheckedAt) ? Date.toLocaleDateString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") + " " + Date.toLocaleTimeString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") : "Not reported"}{(data.overview?.services?.length ?? 0) > 0 ? {data.overview.services.map(service => {service.name}{service.latency != null ? service.latency + " ms" : "Latency unavailable"}{service.online ? "Online" : "Offline"})} : No services were returned. Use Refresh service health to try again.}} `, }, }, diff --git a/packages/custom-widgets/src/jsx/analyzer-jsx.ts b/packages/custom-widgets/src/jsx/analyzer-jsx.ts index a68f045f7..03d1da519 100644 --- a/packages/custom-widgets/src/jsx/analyzer-jsx.ts +++ b/packages/custom-widgets/src/jsx/analyzer-jsx.ts @@ -1,3 +1,4 @@ +import { customJsxCatalogComponentByName } from "../core/component-catalog"; import { customJsxBindableComponentNames, customJsxComponentByName, @@ -31,6 +32,20 @@ export function analyzeCustomJsxElement( const name = nameNode ? customJsxTagName(nameNode) : null; const resolvedName = name ? resolveCustomJsxComponentName(name) : null; const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined; + const attributes = nodesOf(opening.attributes); + if (resolvedName === "TablerIcon") { + const attributeNames = new Set(); + for (const attribute of attributes) { + if (attribute.type !== "JSXAttribute") continue; + const attributeName = nodeOf(attribute.name); + if (attributeName?.type === "JSXIdentifier") attributeNames.add(String(attributeName.name)); + } + for (const prop of customJsxCatalogComponentByName.get(resolvedName)?.props ?? []) { + if (prop.required && !attributeNames.has(prop.name)) { + context.add(opening, `MISSING_REQUIRED_PROP: '${prop.name}' on ${name} is required`); + } + } + } if (descriptor?.safety === "denied") { context.add( opening, @@ -46,7 +61,7 @@ export function analyzeCustomJsxElement( ); } - for (const attribute of nodesOf(opening.attributes)) { + for (const attribute of attributes) { if (attribute.type === "JSXSpreadAttribute") { const argument = nodeOf(attribute.argument); if (argument) context.visit(argument, depth + 1, bindings); @@ -71,7 +86,11 @@ export function analyzeCustomJsxElement( attributeName !== "bind" && !customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName) ) { - context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning"); + if (resolvedName === "TablerIcon") { + context.add(attribute, `UNKNOWN_COMPONENT_PROP: '${attributeName}' on ${name} is not supported`); + } else { + context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning"); + } } else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) { context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning"); } diff --git a/packages/custom-widgets/src/jsx/runtime-components.tsx b/packages/custom-widgets/src/jsx/runtime-components.tsx index 66ed0a843..463482d2b 100644 --- a/packages/custom-widgets/src/jsx/runtime-components.tsx +++ b/packages/custom-widgets/src/jsx/runtime-components.tsx @@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy"; import { sanitizeCustomJsxProps } from "./safe-properties"; -type Namespace = Readonly>; +type Namespace = object; function resolveExport(namespace: Namespace, name: string): ComponentType | undefined { let value: unknown = namespace; @@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) { return undefined; } - value = (value as Record)[segment]; + value = Reflect.get(value, segment); } return typeof value === "function" || (typeof value === "object" && value !== null) ? (value as ComponentType) @@ -233,9 +233,9 @@ export interface CustomJsxComponentAdapters { export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record> { const namespaces: Record = { - "@mantine/core": Core as unknown as Namespace, - "@mantine/charts": Charts as unknown as Namespace, - "@mantine/dates": Dates as unknown as Namespace, + "@mantine/core": Core, + "@mantine/charts": Charts, + "@mantine/dates": Dates, }; const components: Record> = {}; for (const descriptor of enabledCustomJsxComponents) { @@ -253,7 +253,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): components[descriptor.name] = wrap(descriptor.name, component, additions); } } - const core = Core as unknown as Namespace; + const core: Namespace = Core; for (const name of [ "Notification", "LoadingOverlay", diff --git a/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts b/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts index 03ec6cab5..8bee1553d 100644 --- a/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts +++ b/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts @@ -146,6 +146,24 @@ describe("Custom Widget assistant live evaluation harness", () => { }); }); + it("rejects a late example without reducing the existing component-document budget", () => { + const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health"); + if (!testCase) throw new Error("Fake service-health case is missing"); + const state = createAssistantEvaluationState(); + + for (let index = 0; index < 8; index += 1) { + executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" }); + } + expect( + executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }), + ).toEqual({ + error: + "An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.", + }); + expect(state.calledTools).not.toContain("customWidget_getExample"); + expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8); + }); + it("enforces explicit multi-iteration preview requests before persistence", () => { const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health"); if (!baseCase) throw new Error("Fake service-health case is missing"); diff --git a/packages/custom-widgets/src/test/ai-evaluation.spec.ts b/packages/custom-widgets/src/test/ai-evaluation.spec.ts index f20c923d0..550abb466 100644 --- a/packages/custom-widgets/src/test/ai-evaluation.spec.ts +++ b/packages/custom-widgets/src/test/ai-evaluation.spec.ts @@ -226,14 +226,38 @@ describe("AI authoring evaluation", () => { }); }); - it("requires exceptional goal, design, practicality, and complexity scores", () => { - expect(judgePasses(makeJudgeResult(90))).toBe(true); + it("requires an excellent result without weak categories", () => { + expect( + judgePasses({ + ...makeJudgeResult(85), + categories: { + ...makeCategories(85), + visualQuality: 76, + dailyUsefulness: 76, + complexityDiscipline: 80, + }, + }), + ).toBe(true); + expect(judgePasses(makeJudgeResult(84))).toBe(false); + expect( + judgePasses({ + ...makeJudgeResult(90), + categories: { ...makeCategories(90), accessibility: 74 }, + }), + ).toBe(false); expect( judgePasses({ ...makeJudgeResult(92), - categories: { ...makeCategories(92), dailyUsefulness: 84 }, + categories: { ...makeCategories(92), goalFulfillment: 84 }, }), ).toBe(false); + expect( + judgePasses({ + ...makeJudgeResult(92), + categories: { ...makeCategories(92), complexityDiscipline: 79 }, + }), + ).toBe(false); + expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false); expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false); }); @@ -257,6 +281,20 @@ describe("AI authoring evaluation", () => { expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2)); expect(prompt).toContain("# Bundled file: references/runtime.md"); expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility"); + expect(prompt).toContain( + "verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes", + ); + expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions"); + expect(prompt).toContain( + "Decorative icons paired with equivalent adjacent visible status text need no separate aria-label", + ); + expect(prompt).toContain("A Badge containing explicit visible status text is not color-only"); + expect(prompt).toContain( + "A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper", + ); + expect(prompt).toContain( + 'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"', + ); }); it("computes the weighted score and refuses inflated advisory verdicts", () => { diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts index 0859b58a3..76ee47d65 100644 --- a/packages/custom-widgets/src/test/ai-prompt.spec.ts +++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts @@ -53,11 +53,46 @@ describe("AI prompt", () => { expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( + "choose at most four component names total and never request a fifth", + ); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( + "reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span", + ); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( + "do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper", + ); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( + "operational, degraded, and down states retain distinct semantic colors", + ); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( + "a one-column compact narrow layout, and an intentionally more spacious wide layout", + ); + const orderedSteps = [ + "1. First call customWidget_getSkill", + "2. Call customWidget_schema", + "3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call", + "4. Before validation", + "5. Call customWidget_previewCreate", + "6. Call customWidget_previewQuery once for every query", + "7. Call customWidget_createFromPreview", + ]; + for (const [index, step] of orderedSteps.entries()) { + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step); + if (index === 0) continue; + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan( + CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step), + ); + } expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan( CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"), ); @@ -74,7 +109,7 @@ describe("AI prompt", () => { it("preserves raw context and the final instruction when optional context is large", () => { const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it"); - expect(prompt).toContain('"data":"'); + expect(prompt).toContain('"data": "'); expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true); expect(prompt.length).toBeLessThanOrEqual(12_000); }); @@ -102,4 +137,25 @@ describe("AI prompt", () => { expect(prompt).toContain("Use compact cards and show the current latency"); }); + + it("redacts credentials from widget context", () => { + const prompt = buildCustomWidgetAiPrompt( + undefined, + JSON.stringify({ token: "sensitive" }), + { + requests: { + status: { + path: "/status?credential=Bearer-sk-secret-123456", + headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" }, + }, + }, + }, + "Use Authorization: Bearer sk-secret-123456", + ); + + expect(prompt).not.toContain("sensitive"); + expect(prompt).not.toContain("sk-secret-123456"); + expect(prompt).toContain('"X-Auth": "[REDACTED]"'); + expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"'); + }); }); diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 5dace960e..658613952 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -10,6 +10,7 @@ import { getCustomWidgetSkill, getCustomWidgetSkillContent, } from "../core/authoring-resources"; +import { validateCustomJsxTemplate } from "../jsx/analyzer"; const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget"); @@ -55,6 +56,11 @@ describe("authoring resources", () => { sources: { default: { baseUrl: "https://pokeapi.co" } }, }, }); + const serviceDashboard = getCustomWidgetExample("service-dashboard"); + if (!serviceDashboard) throw new Error("Service dashboard example is missing"); + expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]); + expect(serviceDashboard.widget.template.match(/ { expect(validateCustomJsxTemplate('')).toEqual([]); }); + test("rejects TablerIcon without its required name prop", () => { + expect(validateCustomJsxTemplate('')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + severity: "error", + message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"), + }), + expect.objectContaining({ + severity: "error", + message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"), + }), + ]), + ); + }); + test("accepts common bounded formatting and collection operations", () => { expect( validateCustomJsxTemplate( diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx index 16f34839a..efb3219c7 100644 --- a/packages/custom-widgets/src/workbench/code-editor.tsx +++ b/packages/custom-widgets/src/workbench/code-editor.tsx @@ -277,7 +277,8 @@ function EditorLoader() { function formatCode(value: string, language: "jsx" | "json" | "css") { if (language === "json") { try { - return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; + const parsed: unknown = JSON.parse(value); + return `${JSON.stringify(parsed, null, 2)}\n`; } catch { return value; } From 6548da3772609b230d10799d0bd3cac3fd9f0b45 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 25 Aug 2026 22:08:52 +0200 Subject: [PATCH 3/7] fix: stabilize e2e login hydration (#6706) --- e2e/assistant-management.spec.ts | 9 ++------- e2e/lazy-widgets.spec.ts | 11 ++--------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/e2e/assistant-management.spec.ts b/e2e/assistant-management.spec.ts index 1aaf0042b..c97883e97 100644 --- a/e2e/assistant-management.spec.ts +++ b/e2e/assistant-management.spec.ts @@ -3,6 +3,7 @@ import { describe, test } from "vitest"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { loginAsync } from "./shared/login"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; import * as sqliteSchema from "../packages/db/schema/sqlite"; @@ -40,13 +41,7 @@ describe("Assistant management", () => { const page = await context.newPage(); try { - await page.goto(`${baseUrl}/auth/login`); - await page.getByLabel("Username").fill(adminCredentials.username); - await page.locator("#password").fill(adminCredentials.password); - await page.getByRole("button", { name: "Login" }).click(); - await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 30_000 }); - - await page.goto(`${baseUrl}/manage/assistant`); + await loginAsync({ page, baseUrl, credentials: adminCredentials, destination: "/manage/assistant" }); await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible(); await expect(page.getByText("Connection ready")).toBeVisible(); await expect(page.getByText("Encrypted API key saved")).toBeVisible(); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index d777ed0dc..4a595f754 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -8,6 +8,7 @@ import { describe, test } from "vitest"; import * as sqliteSchema from "../packages/db/schema/sqlite"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { loginAsync } from "./shared/login"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; const adminCredentials = { @@ -116,15 +117,7 @@ describe("lazy widget application graph", () => { let releaseRefresh: (() => void) | undefined; try { - await page.goto(`${baseUrl}/auth/login`); - await page.getByLabel("Username").fill(adminCredentials.username); - await page.locator("#password").fill(adminCredentials.password); - const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", { - waitUntil: "commit", - timeout: 60_000, - }); - await page.locator("css=button[type='submit']").click(); - await signedIn; + await loginAsync({ page, baseUrl, credentials: adminCredentials }); const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first(); await expect(visibleBoard).toBeVisible({ timeout: 30_000 }); From 40e34181e7d2032cce00aaefe62ab33b7467c0d3 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 26 Aug 2026 16:44:36 +1000 Subject: [PATCH 4/7] fix(board-v2): icon and tiny text fix (#6704) --- packages/ui/index.ts | 29 +++++ .../src/audio-stats/audio-stats-content.tsx | 3 +- packages/widgets/src/bazarr/component.tsx | 3 +- .../src/beszel-system-grid/component.tsx | 25 ++-- .../src/beszel-system-stats/component.tsx | 7 +- .../src/beszel-system-table/component.tsx | 25 ++-- .../widgets/src/beszel/_shared/stats-view.tsx | 5 +- .../src/bookmarks/bookmarks-widget.tsx | 7 +- .../widgets/src/calendar/calender-day.tsx | 9 +- packages/widgets/src/clock/advanced-view.tsx | 3 +- packages/widgets/src/clock/component.tsx | 2 +- .../widgets/src/clock/weather-summary.tsx | 44 ++++--- packages/widgets/src/coolify/resource-row.tsx | 7 +- packages/widgets/src/countdown/component.tsx | 9 +- packages/widgets/src/custom-api/component.tsx | 7 +- .../src/dns-hole/summary/component.tsx | 10 +- packages/widgets/src/docker/component.tsx | 12 +- .../widgets/src/errors/base-component.tsx | 4 +- packages/widgets/src/firewall/component.tsx | 3 +- .../cluster/cluster-health.tsx | 5 +- .../cluster/resource-popover.tsx | 12 +- .../src/health-monitoring/rings/cpu-ring.tsx | 4 +- .../health-monitoring/rings/cpu-temp-ring.tsx | 4 +- .../src/health-monitoring/rings/gpu-ring.tsx | 7 +- .../health-monitoring/rings/memory-ring.tsx | 4 +- .../src/health-monitoring/system-health.tsx | 113 ++++++++++++------ .../src/immich/album-carousel/component.tsx | 3 +- .../src/immich/server-stats/component.tsx | 9 +- .../widgets/src/media-missing/component.tsx | 7 +- .../src/minecraft/server-status/component.tsx | 12 +- .../network-status/variants/wifi-variant.tsx | 3 +- .../network-status/variants/wired-variant.tsx | 3 +- .../network-controller/summary/component.tsx | 22 ++-- .../widgets/src/notifications/component.tsx | 7 +- .../widgets/src/paperless-ngx/component.tsx | 3 +- packages/widgets/src/patchmon/component.tsx | 10 +- .../execute-automation/component.tsx | 3 +- .../src/speedtest-tracker/speed-stat-card.tsx | 3 +- .../system-resources/chart/common-chart.tsx | 5 +- packages/widgets/src/timer/component.tsx | 7 +- packages/widgets/src/traefik/component.tsx | 5 +- .../widgets/src/uptime-kuma/component.tsx | 3 +- packages/widgets/src/weather/advanced.tsx | 23 ++-- .../widgets/src/weather/animated-icon.tsx | 7 +- packages/widgets/src/weather/compact.tsx | 9 +- packages/widgets/src/weather/details.tsx | 19 +-- packages/widgets/src/weather/icon.tsx | 11 +- packages/widgets/src/wud/component.tsx | 3 +- 48 files changed, 368 insertions(+), 172 deletions(-) diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 0933ebe7d..dcb7cf300 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import type { Icon123, IconProps } from "@tabler/icons-react"; export * from "./src"; @@ -5,3 +6,31 @@ export * from "./src"; export type TablerIcon = typeof Icon123; export type TablerIconProps = IconProps; + +/** + * Icon sizes tied to Mantine's font-size scale instead of raw pixel + * numbers, so icons stay legible under the board's CSS zoom scaling. + * Pass as the `style` prop (not `size`) - tabler icons convert `size` into + * raw SVG width/height attributes, and CSS `var()` isn't guaranteed to + * resolve there (Firefox especially). `style` is real CSS, so it does. + */ +export const iconSizes = { + xs: { width: "var(--mantine-font-size-xs)", height: "var(--mantine-font-size-xs)", flexShrink: 0 }, + sm: { width: "var(--mantine-font-size-sm)", height: "var(--mantine-font-size-sm)", flexShrink: 0 }, + md: { width: "var(--mantine-font-size-md)", height: "var(--mantine-font-size-md)", flexShrink: 0 }, + lg: { width: "var(--mantine-font-size-lg)", height: "var(--mantine-font-size-lg)", flexShrink: 0 }, + xl: { width: "var(--mantine-font-size-xl)", height: "var(--mantine-font-size-xl)", flexShrink: 0 }, +} as const satisfies Record; + +/** + * For icon/element sizes that don't fit one of `iconSizes`' fixed tokens (e.g. a deliberately + * oversized icon). Same reasoning as `iconSizes`: pass as `style`, not a raw `size` number - a + * bare pixel value never re-scales when the board's zoom level changes, so it renders at a + * fraction of its intended size on any board that isn't at 100% zoom (which is most boards with + * more than a couple of widgets). + */ +export const zoomCompensatedSize = (px: number): CSSProperties => ({ + width: `calc(${px}px * var(--board-canvas-ui-scale, 1))`, + height: `calc(${px}px * var(--board-canvas-ui-scale, 1))`, + flexShrink: 0, +}); diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index 75958584a..f239961f5 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -5,6 +5,7 @@ import { Text } from "@mantine/core"; import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; import { useI18n } from "@homarr/translation/client"; +import { zoomCompensatedSize } from "@homarr/ui"; import classes from "./component.module.css"; import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; @@ -66,7 +67,7 @@ export function AudioStatsContent({ > {visibleStats.map(({ optionKey, statKey, value, Icon }) => (
- + {value} {t(statKey as never)}
diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 518901f7b..acca27878 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -7,6 +7,7 @@ import { IconAlertTriangle, IconBell, IconDeviceTv, IconMovie, IconPlugConnected import { clientApi } from "@homarr/api/client"; import { getIntegrationName } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; +import { zoomCompensatedSize } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; @@ -122,7 +123,7 @@ export default function BazarrWidget({ return (
- + {typeof value === "string" && value.trim() === "" ? "—" : value} diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 3cb6ce591..2a46778a4 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -35,6 +35,7 @@ import { formatBytes } from "@homarr/common"; import { invariantTechnicalLabels } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { zoomCompensatedSize } from "@homarr/ui"; import classes from "./component.module.css"; @@ -207,7 +208,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={invariantTechnicalLabels.cpu} value={formatPercent(s.cpu)} progress={{ value: s.cpu, color: thresholdColor(s.cpu) }} @@ -222,7 +223,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={t("metric.memory")} value={formatPercent(s.memory)} progress={{ value: s.memory, color: thresholdColor(s.memory) }} @@ -236,7 +237,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ key: "showDisk", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( - + {t("metric.disk")} @@ -251,7 +252,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={invariantTechnicalLabels.gpu} value={formatPercent(s.gpu)} progress={{ value: s.gpu, color: thresholdColor(s.gpu) }} @@ -266,7 +267,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={t("metric.loadAvg")} value={formatLoadAvg(s.loadAvg)} size={sz} @@ -280,7 +281,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={t("metric.net")} value={formatByteRate(s.netBytes)} size={sz} @@ -294,7 +295,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={t("metric.temp")} value={formatTemp(s.temp, false)} size={sz} @@ -311,7 +312,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ return ( } + icon={} label={t("metric.battery")} value={`${s.battery?.[0] ?? 0}%`} size={sz} @@ -326,7 +327,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, _t: SystemCardProps["t"], sz: SizeConfig, tCommon: SystemCardProps["tCommon"]) => ( } + icon={} label={tCommon("services")} value={String(s.services)} size={sz} @@ -340,7 +341,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={t("metric.uptime")} value={formatUptime(s.uptime)} size={sz} @@ -354,7 +355,7 @@ const metricRenderers: BeszelMetricRenderer[] = [ render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } + icon={} label={t("metric.agent")} value={s.agentVersion} size={sz} @@ -487,7 +488,7 @@ export default function BeszelSystemGridWidget({
- + {t("empty.noSystems")} diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index ca3604d50..0aca4348f 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -11,6 +11,7 @@ import { constructBoardPermissions } from "@homarr/auth/shared"; import { useOptionalBoard } from "@homarr/boards/context"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import classes from "./component.module.css"; @@ -147,7 +148,7 @@ export default function BeszelSystemStatsWidget({
- + {tBeszel("empty.noSystems")} @@ -161,7 +162,7 @@ export default function BeszelSystemStatsWidget({ return (
- + {t("error.systemNotFound")} @@ -199,7 +200,7 @@ export default function BeszelSystemStatsWidget({