From 9303dbd284b366e48b5b60d1f2e88d34eee19f33 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 25 Aug 2026 17:09:47 +0200 Subject: [PATCH] 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)