-
- {previewItems.map((item, index) => (
-
- ))}
-
+
+ {leftSidebar ? : null}
+
+
+ {previewItems.map((item) => (
+
+ ))}
- {showRightSidebar ?
: null}
-
-
+
+ {rightSidebar ? : null}
+
);
};
-const PrimerRow = ({
- icon: Icon,
- title,
- description,
-}: {
- icon: typeof IconApps;
- title: string;
- description: string;
-}) => (
-
-
-
-
-
- {title}
-
- {description}
-
-
-
-);
-
const Extensions = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.extend");
return (
-
-
-
+
+
+ {t("title")}
+
+
- }>
+ }>
{t("assistant")}
}>
@@ -1530,13 +1532,13 @@ const Extensions = (props: StudioSectionProps) => {
{t("mcp")}
-
-
+
+
-
+
-
+
@@ -1544,78 +1546,14 @@ const Extensions = (props: StudioSectionProps) => {
);
};
-const AssistantSetup = ({ environment }: { environment: StudioSectionProps["environment"] }) => {
+const AssistantSetup = ({
+ environment,
+ configuration,
+}: {
+ environment: StudioSectionProps["environment"];
+ configuration: ReactNode;
+}) => {
const t = useScopedI18n("init.studio.extend.assistantSetup");
- const assistantSettingsT = useScopedI18n("management.page.settings.section.assistant");
- const reduceMotion = useReducedMotion();
- const [provider, setProvider] = useState("homarr");
- const [baseUrl, setBaseUrl] = useState(assistantProviderPresets.homarr.baseUrl);
- const [apiKey, setApiKey] = useState("");
- const [modelId, setModelId] = useState("");
- const [enabled, setEnabled] = useState(true);
- const [webSearch, setWebSearch] = useState(false);
- const configuration = clientApi.assistant.getAdminConfiguration.useQuery(undefined, {
- enabled: environment.canConfigurePrivileged,
- retry: false,
- });
- const models = clientApi.assistant.discoverModels.useQuery(undefined, {
- enabled:
- environment.canConfigurePrivileged &&
- configuration.data?.connectionConfigured === true &&
- configuration.data.modelDiscoveryPath !== null,
- retry: false,
- });
- const updateConnection = clientApi.assistant.updateConnection.useMutation({
- async onSuccess() {
- await configuration.refetch();
- await models.refetch();
- },
- onError(error) {
- showErrorNotification({ title: t("errorTitle"), message: error.message });
- },
- });
- const updateConfiguration = clientApi.assistant.updateConfiguration.useMutation({
- onSuccess() {
- showSuccessNotification({ title: t("savedTitle"), message: t("savedDescription") });
- },
- onError(error) {
- showErrorNotification({ title: t("errorTitle"), message: error.message });
- },
- });
-
- useEffect(() => {
- const saved = configuration.data;
- if (!saved?.connectionConfigured) return;
- setProvider(saved.provider);
- setBaseUrl(saved.baseUrl);
- setModelId(saved.modelId ?? "");
- setEnabled(saved.enabled);
- setWebSearch(saved.webSearchEnabled);
- setApiKey("");
- }, [configuration.data]);
-
- const preset = assistantProviderPresets[provider];
- const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, "");
- const connectionChanged =
- configuration.data?.connectionConfigured === true &&
- (configuration.data.provider !== provider || configuration.data.baseUrl !== normalizedBaseUrl);
- const modelControlsDisabled = configuration.data?.connectionConfigured !== true || connectionChanged;
- const useManualModelInput =
- !modelControlsDisabled &&
- !models.isFetching &&
- (configuration.data?.modelDiscoveryPath === null || models.isError || models.data?.length === 0);
- const modelOptions = (models.data ?? []).map((model) => ({ value: model.id, label: model.name }));
- if (modelId && !modelOptions.some((model) => model.value === modelId)) {
- modelOptions.unshift({ value: modelId, label: modelId });
- }
- const selectProvider = (value: string | null) => {
- if (!value || !assistantProviderIds.includes(value as AssistantProvider)) return;
- const next = value as AssistantProvider;
- setProvider(next);
- setBaseUrl(next === "homarr" ? environment.workshopApiUrl : assistantProviderPresets[next].baseUrl);
- setModelId("");
- };
-
if (!environment.canConfigurePrivileged) {
return (
} title={t("loginTitle")}>
@@ -1629,116 +1567,7 @@ const AssistantSetup = ({ environment }: { environment: StudioSectionProps["envi
);
}
- return (
-
-
-
-
-
-
-
-
-
- {t("title")}
-
- {t("description")}
-
-
-
-
-
- {preset.requiresApiKey ? (
- setApiKey(event.currentTarget.value)} />
- ) : null}
-
-
-
- {t("connectHint")}
-
-
- {models.error ? {models.error.message} : null}
- {updateConnection.error ? {updateConnection.error.message} : null}
- {useManualModelInput ? (
- setModelId(event.currentTarget.value)}
- placeholder={t("manualModelPlaceholder")}
- description={t("manualModelDescription")}
- />
- ) : (
-
- );
+ return configuration;
};
const WorkshopSetup = ({
@@ -1830,7 +1659,7 @@ const McpSetup = ({ environment }: { environment: StudioSectionProps["environmen
);
return (
- } title={t("title")}>
+ } title={t("title")} my="xs">
{t("description")}
@@ -1896,28 +1725,63 @@ const McpSetup = ({ environment }: { environment: StudioSectionProps["environmen
const Review = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.review");
- const reduceMotion = useReducedMotion();
+ const themeLabel = {
+ auto: t("themeAuto"),
+ light: t("themeLight"),
+ dark: t("themeDark"),
+ }[props.colorScheme];
+ const sidebarCount = Number(props.leftSidebar) + Number(props.rightSidebar);
const summary = [
{
- icon: IconPalette,
- label: t("appearance"),
- value: `${props.selectedLocale} · ${props.colorScheme} · ${props.itemRadius}`,
+ icon: IconLayoutDashboard,
+ label: t("board"),
+ value: props.boardName,
},
{
- icon: IconBrandDocker,
- label: t("services"),
- value: t("servicesValue", {
- integrations: String(props.drafts.length),
- apps: String(props.selectedAppIds.length),
- }),
+ icon: IconLayoutColumns,
+ label: t("columns"),
+ value: t("columnsValue", { count: props.columnCount }),
+ },
+ {
+ icon: IconLayoutSidebar,
+ label: t("sidebars"),
+ value: t("sidebarsValue", { count: sidebarCount }),
},
- { icon: IconLayoutDashboard, label: t("board"), value: props.boardName },
{ icon: IconDeviceMobile, label: t("layouts"), value: t("layoutsValue") },
+ {
+ icon: IconPlugConnected,
+ label: t("integrations"),
+ value: t("integrationsValue", { count: props.drafts.length }),
+ },
+ {
+ icon: IconApps,
+ label: t("apps"),
+ value: t("appsValue", { count: getPreviewAppCount(props) }),
+ },
+ { icon: IconLanguage, label: t("language"), value: props.selectedLocale },
+ { icon: IconSunMoon, label: t("theme"), value: themeLabel },
+ {
+ icon: IconPalette,
+ label: t("colors"),
+ value: (
+
+
+
+ {props.primaryColor}
+
+
+
+ {props.secondaryColor}
+
+
+ ),
+ },
+ { icon: IconBorderRadius, label: t("radius"), value: props.itemRadius.toUpperCase() },
];
return (
-
+
{summary.map(({ icon: Icon, label, value }) => (
@@ -1928,26 +1792,19 @@ const Review = (props: StudioSectionProps) => {
{label}
-
- {value}
-
+ {typeof value === "string" ? (
+
+ {value}
+
+ ) : (
+ value
+ )}
))}
-
- } title={t("automaticTitle")}>
+ } title={t("automaticTitle")} my="xs">
{t("automaticDescription")}
{props.applyProgress > 0 ? (
@@ -1961,12 +1818,7 @@ const Review = (props: StudioSectionProps) => {
{props.applyProgress}%
-
+
) : null}
@@ -2002,6 +1854,7 @@ const createDraft = ({
kind,
name,
url,
+ urlOverridden: false,
source,
secretOption: 0,
secrets: (getAllSecretKindOptions(kind)[0] ?? []).map((secretKind) => ({ kind: secretKind, value: "" })),
diff --git a/packages/onboarding/src/types.ts b/packages/onboarding/src/types.ts
index 7ea7148c6..b9a6cba07 100644
--- a/packages/onboarding/src/types.ts
+++ b/packages/onboarding/src/types.ts
@@ -20,4 +20,5 @@ export interface OnboardingEnvironment {
export interface OnboardingStudioProps {
environment: OnboardingEnvironment;
sqliteRestore?: ReactNode;
+ assistantConfiguration?: ReactNode;
}
diff --git a/packages/onboarding/src/welcome.tsx b/packages/onboarding/src/welcome.tsx
index 6058c847f..8c5acd581 100644
--- a/packages/onboarding/src/welcome.tsx
+++ b/packages/onboarding/src/welcome.tsx
@@ -2,21 +2,20 @@
import { useState } from "react";
import { Alert, Badge, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
-import { IconArrowRight, IconDatabaseImport, IconLayoutDashboard, IconSparkles } from "@tabler/icons-react";
-import { motion, useReducedMotion } from "motion/react";
+import { IconArrowRight, IconDatabaseImport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
+import { OnboardingWordmark } from "./onboarding-wordmark";
import type { OnboardingStudioProps } from "./types";
import classes from "./onboarding-studio.module.css";
export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.welcome");
const claimT = useScopedI18n("init.studio.claim");
- const reduceMotion = useReducedMotion();
const [showRestore, setShowRestore] = useState(false);
const [claimPending, setClaimPending] = useState(false);
const [claimError, setClaimError] = useState(null);
@@ -78,29 +77,19 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) =
-
+
{t("badge")}
-
-
-
- {t("eyebrow")}
-
-
+
+
+
{t("title")}
-
+
{t("description")}
@@ -152,48 +141,6 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) =
) : null}
-
-
-
-
- {[IconLayoutDashboard, IconSparkles, IconDatabaseImport, IconLayoutDashboard].map((Icon, index) => (
-
-
-
- ))}
-
-
-
- {t("ready")}
-
-
-
-
-
-
- {t("panelTitle")}
-
-
- {t("panelDescription")}
-
-
-
- {t("chipDiscover")}
- {t("chipConnect")}
- {t("chipBuild")}
-
-
-
-
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index c90adedb4..b7c97f6ed 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1,10 +1,7 @@
{
"init": {
"studio": {
- "setupBadge": "First-time setup",
"navigationLabel": "Onboarding sections",
- "title": "Setup studio",
- "subtitle": "Configure the essentials, let Homarr infer the rest.",
"back": "Back",
"continue": "Continue",
"buildBoard": "Build my board",
@@ -31,8 +28,7 @@
"review": "Review"
},
"welcome": {
- "badge": "New Homarr",
- "eyebrow": "Your homelab, one useful board away",
+ "badge": "Homarr v2",
"title": "Make home feel organized.",
"description": "Homarr can discover your services, connect the right integrations, and build a responsive first board. You only decide what cannot be inferred.",
"start": "Get started",
@@ -40,18 +36,13 @@
"restoreTitle": "Restore this Homarr",
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
"errorTitle": "Setup could not start",
- "errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues.",
- "ready": "Ready to configure",
- "panelTitle": "From services to a useful board",
- "panelDescription": "Discovery, secure connections, layouts, widgets, Assistant, Workshop, and MCP stay connected in one flow.",
- "chipDiscover": "Discover",
- "chipConnect": "Connect",
- "chipBuild": "Build"
+ "errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues."
},
"account": {
"title": "Create your administrator",
- "description": "This account owns the first setup and protects integration, Assistant, and MCP credentials.",
+ "description": "Specify the credentials for your administrator user.",
"username": "Administrator username",
+ "passwordHint": "Only the 8-character minimum is required. The other password-strength checks are suggestions.",
"create": "Create administrator",
"errorTitle": "Administrator could not be created",
"recoveryTitle": "Administrator account created",
@@ -77,9 +68,11 @@
"themeLight": "Light",
"themeDark": "Dark",
"serverTitle": "Usual server address",
+ "serverOriginHelp": "Homarr uses this address to prefill detected service URLs. You can still edit every service individually.",
"serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.",
"serverOrigin": "Domain, IP, or reverse-proxy path",
"serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://home.example.com/services. Choose how each service is appended below.",
+ "urlModeLabel": "Service address pattern",
"hostPort": "Same host, different ports",
"subdomain": "One subdomain per service",
"path": "One path per service",
@@ -94,7 +87,6 @@
"kubernetes": "Kubernetes",
"assistantWorkshop": "Assistant and Workshop",
"found": "Found {integrations} integrations and {apps} additional apps",
- "retryDocker": "Scan Docker again",
"retryKubernetes": "Check Kubernetes again",
"retryWorkshop": "Check Workshop again",
"unknownVersion": "unknown version",
@@ -105,8 +97,6 @@
"workshopUnavailable": "Local and custom Assistant providers remain available",
"partialTitle": "Some sources could not be scanned",
"partialDescription": "Results from reachable hosts are kept. Check socket permissions or add services manually in the next section.",
- "platformTitle": "Docker, Synology, Kubernetes, and bare metal are all welcome",
- "platformDescription": "Homarr uses capabilities rather than guessing your platform. Detected values are suggestions you can review before anything is created.",
"status": {
"checking": "Checking availability…",
"available": "Available",
@@ -134,9 +124,13 @@
"url": "Service URL",
"noCredentials": "No credentials",
"noCredentialsDescription": "This integration does not need credentials.",
+ "credentialMethod": "Credential method",
"getApiKey": "Open the API key settings for this service",
"openGuide": "Open Homarr's setup guide for this service",
- "validationError": "Complete the URL and required credentials for {name}, or remove it from the selection."
+ "needsConfiguration": "Needs configuration",
+ "configured": "Configured",
+ "validationError": "Complete the URL and required credentials for {name}, or remove it from the selection.",
+ "incompleteConfirmation": "Some integrations need to be configured, are you sure?"
},
"board": {
"title": "Shape the board before it is built",
@@ -150,54 +144,23 @@
"primaryColor": "Primary color",
"secondaryColor": "Secondary color",
"radius": "Widget corner radius",
- "layoutPreset": "Board width preset",
- "layoutFocused": "Focused",
- "layoutBalanced": "Balanced",
- "layoutWide": "Wide",
+ "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}}",
"leftSidebar": "Left sidebar",
"rightSidebar": "Right sidebar",
- "previewLabel": "Live preview of the first board layout",
- "previewLayout": "Layout to preview",
- "previewMobile": "Mobile",
- "previewBase": "Base",
- "widgetPrimer": "A 30-second Homarr primer",
- "primerAppsTitle": "Apps open things",
- "primerAppsDescription": "App tiles are fast links with icons, status, and optional ping checks.",
- "primerWidgetsTitle": "Widgets explain things",
- "primerWidgetsDescription": "Integrations power focused views such as downloads, calendars, requests, and health.",
- "primerEditTitle": "Everything stays editable",
- "primerEditDescription": "Enter edit mode to move, resize, group, or add items independently on Mobile and Base layouts."
+ "previewLabel": "Live preview of the first board layout"
},
"extend": {
- "title": "Add intelligence when it helps",
- "description": "Assistant, Workshop, and MCP work together but remain independent. Configure any now or return later.",
+ "title": "Advanced features",
"tabsLabel": "Assistant, Workshop, and MCP setup",
"assistant": "Assistant",
"workshop": "Workshop",
"mcp": "MCP",
"assistantSetup": {
- "title": "Homarr Assistant",
- "description": "Ask about this Homarr, create and edit apps or boards with approval, inspect integrations, and use the same conversations in the Assistant widget.",
"loginTitle": "Administrator sign-in required",
"loginDescription": "Provider keys and model access are privileged. Finish setup, sign in, and Homarr will open the Assistant configuration directly.",
- "loginAction": "Sign in to configure Assistant",
- "provider": "Provider",
- "baseUrl": "Provider URL",
- "apiKey": "API key",
- "connect": "Save connection and discover models",
- "connectHint": "Keys are encrypted and never returned by the API.",
- "model": "Default model",
- "modelPlaceholder": "Connect a provider first",
- "manualModelPlaceholder": "Enter a model ID",
- "manualModelDescription": "Model discovery is unavailable. Enter the exact model ID exposed by your provider.",
- "discovering": "Discovering models…",
- "enable": "Enable Assistant",
- "webSearch": "Allow supported web search",
- "advanced": "Advanced headers and provider settings",
- "save": "Save Assistant",
- "savedTitle": "Assistant is ready",
- "savedDescription": "The selected model is available from the header and Assistant widget.",
- "errorTitle": "Assistant could not be configured"
+ "loginAction": "Sign in to configure Assistant"
},
"workshopSetup": {
"title": "Homarr Workshop",
@@ -232,18 +195,29 @@
"review": {
"title": "One board, built around your services",
"description": "Homarr tests unsaved connections, imports the selected apps, applies appearance and layouts, then opens the exact board it configured.",
- "appearance": "Language and appearance",
- "services": "Selected services",
- "servicesValue": "{integrations} integrations · {apps} apps",
"board": "First board",
+ "columns": "Board columns",
+ "columnsValue": "{count, plural, one {# column} other {# columns}}",
+ "sidebars": "Sidebars",
+ "sidebarsValue": "{count, plural, =0 {None} one {# sidebar} other {# sidebars}}",
"layouts": "Responsive layouts",
"layoutsValue": "Mobile + Base",
- "previewLabel": "Preview of the board being created",
- "automaticTitle": "What Homarr will infer",
+ "integrations": "Integrations",
+ "integrationsValue": "{count, plural, =0 {None selected} one {# integration} other {# integrations}}",
+ "apps": "App tiles",
+ "appsValue": "{count, plural, =0 {Starter apps only} one {# app tile} other {# app tiles}}",
+ "language": "Language",
+ "theme": "Theme",
+ "themeAuto": "System preference",
+ "themeLight": "Light",
+ "themeDark": "Dark",
+ "colors": "Board colors",
+ "radius": "Widget radius",
+ "automaticTitle": "What Homarr will create",
"automaticDescription": "Compatible widgets, icons, app links, integration relationships, and responsive placement come from the shared Homarr catalog. You can edit every result later.",
"dockerWarningTitle": "Some Docker labels changed during setup",
"dockerWarningDescription": "{count, plural, one {# selected Docker result could not be applied because its service changed, targeted another board, or its widget was incompatible} other {# selected Docker results could not be applied because services changed, targeted another board, or their widgets were incompatible}}.",
- "errorTitle": "Setup paused safely",
+ "errorTitle": "Board could not be built",
"progress": {
"applying": "Testing connections and building your board",
"done": "Your board is ready"
@@ -253,7 +227,6 @@
"title": "Your first board is ready.",
"description": "Homarr connected the pieces you selected and kept the rest editable. Open the board now; the short in-product tour can show you edit mode, widgets, layouts, and search.",
"openBoard": "Open my board",
- "tutorial": "Tip: use edit mode to move or resize widgets. Mobile and Base layouts keep independent positions, so every screen can feel intentional.",
"manageTitle": "Manage",
"manageDescription": "Fine-tune apps, integrations, boards, users, and server settings.",
"workshopTitle": "Workshop",
@@ -6350,6 +6323,7 @@
"provider": {
"title": "Provider",
"groups": {
+ "free": "Free with Homarr",
"hosted": "Hosted providers",
"local": "Local providers",
"custom": "Custom"
@@ -6357,7 +6331,7 @@
"options": {
"homarr": {
"label": "Homarr",
- "description": "Community-funded access to the model selected by the Homarr team. Each user signs in with the Community Workshop and receives a daily allowance."
+ "description": "A limited number of Assistant uses are free each day, paid for by the Homarr team. Sign in with the Community Workshop to use your allowance."
},
"openrouter": {
"label": "OpenRouter",
@@ -6409,8 +6383,8 @@
}
},
"homarrAuth": {
- "title": "No administrator API key required",
- "description": "Homarr keeps the upstream OpenRouter credential on the Community Workshop server. Each user authorizes requests with their own Workshop GitHub session, so their daily allowance stays separate."
+ "title": "Free daily Assistant uses included",
+ "description": "No API key is needed. The Homarr team covers a limited number of Assistant uses for you each day. Sign in with the Community Workshop to keep your allowance separate."
}
},
"baseUrl": {
diff --git a/packages/ui/src/components/board-color-input.tsx b/packages/ui/src/components/board-color-input.tsx
new file mode 100644
index 000000000..018465257
--- /dev/null
+++ b/packages/ui/src/components/board-color-input.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { ColorInput, useMantineTheme } from "@mantine/core";
+import type { ColorInputProps } from "@mantine/core";
+
+export const BoardColorInput = (props: ColorInputProps) => {
+ const theme = useMantineTheme();
+
+ return color[6])} />;
+};
diff --git a/packages/ui/src/components/color-scheme-combobox.tsx b/packages/ui/src/components/color-scheme-combobox.tsx
new file mode 100644
index 000000000..2ad00f027
--- /dev/null
+++ b/packages/ui/src/components/color-scheme-combobox.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import React from "react";
+import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
+import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
+
+import type { ColorScheme } from "@homarr/definitions";
+import { colorSchemes } from "@homarr/definitions";
+import { useScopedI18n } from "@homarr/translation/client";
+
+const schemeIcons: Record = {
+ auto: IconDeviceDesktop,
+ light: IconSun,
+ dark: IconMoon,
+};
+
+interface ColorSchemeComboboxProps {
+ label?: string;
+ value: ColorScheme;
+ onChange: (value: ColorScheme) => void;
+ width?: string;
+}
+
+export const ColorSchemeCombobox = ({ label, value, onChange, width }: ColorSchemeComboboxProps) => {
+ const tOptions = useScopedI18n("common.colorScheme.options");
+ const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() });
+
+ return (
+ {
+ onChange(nextValue as ColorScheme);
+ combobox.closeDropdown();
+ }}
+ >
+
+ }
+ rightSectionPointerEvents="none"
+ onClick={() => combobox.toggleDropdown()}
+ variant="filled"
+ w={width}
+ >
+
+
+
+
+
+ {colorSchemes.map((scheme) => (
+
+
+
+ ))}
+
+
+
+ );
+};
+
+const OptionItem = ({
+ scheme,
+ currentScheme,
+ tOptions,
+ showCheck,
+}: {
+ scheme: ColorScheme;
+ currentScheme: ColorScheme;
+ tOptions: (key: ColorScheme) => string;
+ showCheck?: boolean;
+}) => {
+ const Icon = schemeIcons[scheme];
+
+ return (
+
+
+
+ {tOptions(scheme)}
+
+ {showCheck && scheme === currentScheme && }
+
+ );
+};
diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx
index 9bc663a46..91a55ed57 100644
--- a/packages/ui/src/components/index.tsx
+++ b/packages/ui/src/components/index.tsx
@@ -15,6 +15,9 @@ export { BetaBadge } from "./beta-badge";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";
+export { LanguageCombobox } from "./language-combobox";
+export { ColorSchemeCombobox } from "./color-scheme-combobox";
+export { BoardColorInput } from "./board-color-input";
export { Link } from "./link";
export {
SelectGridLayout,
diff --git a/packages/ui/src/components/language-combobox.tsx b/packages/ui/src/components/language-combobox.tsx
new file mode 100644
index 000000000..53907d027
--- /dev/null
+++ b/packages/ui/src/components/language-combobox.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import React from "react";
+import { Combobox, Group, InputBase, Loader, ScrollArea, Text, useCombobox } from "@mantine/core";
+import { IconCheck } from "@tabler/icons-react";
+
+import type { SupportedLanguage } from "@homarr/translation";
+import { localeConfigurations, supportedLanguages } from "@homarr/translation";
+
+import { LanguageIcon } from "./language-icon";
+
+interface LanguageComboboxProps {
+ label?: string;
+ value: SupportedLanguage;
+ onChange: (value: SupportedLanguage) => void;
+ isPending?: boolean;
+ width?: string;
+ withinPortal?: boolean;
+}
+
+export const LanguageCombobox = ({
+ label,
+ value,
+ onChange,
+ isPending,
+ width,
+ withinPortal = false,
+}: LanguageComboboxProps) => {
+ const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() });
+
+ return (
+ {
+ onChange(nextValue as SupportedLanguage);
+ combobox.closeDropdown();
+ }}
+ >
+
+ : null}
+ rightSection={}
+ rightSectionPointerEvents="none"
+ onClick={() => combobox.toggleDropdown()}
+ variant="filled"
+ w={width}
+ >
+
+
+
+
+
+
+ {supportedLanguages.map((locale) => (
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+const OptionItem = ({
+ currentLocale,
+ localeKey,
+ showCheck,
+}: {
+ currentLocale: SupportedLanguage;
+ localeKey: SupportedLanguage;
+ showCheck?: boolean;
+}) => (
+
+
+
+
+ {localeConfigurations[localeKey].name}
+
+ ({localeConfigurations[localeKey].translatedName})
+
+
+
+ {showCheck && localeKey === currentLocale && }
+
+);
diff --git a/packages/validation/src/onboarding.spec.ts b/packages/validation/src/onboarding.spec.ts
index af6cc47f7..ebe8f4b32 100644
--- a/packages/validation/src/onboarding.spec.ts
+++ b/packages/validation/src/onboarding.spec.ts
@@ -102,4 +102,28 @@ describe("onboarding URL validation", () => {
onboardingCompleteSetupSchema.safeParse({ ...input, selectedDockerSourceIds: ["x".repeat(513)] }).success,
).toBe(false);
});
+
+ test("accepts board column counts from 8 through 24", () => {
+ const input = {
+ server: { defaultLocale: "en", defaultColorScheme: "dark" },
+ board: {
+ name: "dashboard",
+ primaryColor: "#228BE6",
+ secondaryColor: "#15AABF",
+ itemRadius: "md",
+ columnCount: 8,
+ },
+ };
+
+ expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(true);
+ expect(
+ onboardingCompleteSetupSchema.safeParse({ ...input, board: { ...input.board, columnCount: 24 } }).success,
+ ).toBe(true);
+ expect(
+ onboardingCompleteSetupSchema.safeParse({ ...input, board: { ...input.board, columnCount: 7 } }).success,
+ ).toBe(false);
+ expect(
+ onboardingCompleteSetupSchema.safeParse({ ...input, board: { ...input.board, columnCount: 25 } }).success,
+ ).toBe(false);
+ });
});
diff --git a/packages/validation/src/onboarding.ts b/packages/validation/src/onboarding.ts
index 152a1da40..add80399e 100644
--- a/packages/validation/src/onboarding.ts
+++ b/packages/validation/src/onboarding.ts
@@ -85,6 +85,7 @@ export const onboardingCompleteSetupSchema = z
secondaryColor: hexColorSchema,
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
+ columnCount: z.number().int().min(8).max(24).optional(),
leftSidebar: z.boolean().default(false),
rightSidebar: z.boolean().default(false),
}),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 85158904e..79b240682 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2444,9 +2444,6 @@ importers:
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
- motion:
- specifier: 'catalog:'
- version: 13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
specifier: 'catalog:'
version: 19.2.7