From 4ca02a91334d48a3ae9d84b510fe84d3d752d7cc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 13 Aug 2026 20:46:54 +0200 Subject: [PATCH] feat(onboarding): polish setup experience --- .../after-the-installation.mdx | 2 +- apps/docs/docs/management/assistant.mdx | 4 +- .../public/logo/homarr-wordmark-light.svg | 72 ++ .../boards/[name]/settings/_appereance.tsx | 15 +- apps/nextjs/src/app/[locale]/init/page.tsx | 2 + apps/nextjs/src/app/[locale]/layout.tsx | 1 + .../_components/assistant-configuration.tsx | 13 +- .../color-scheme/color-scheme-combobox.tsx | 93 +- .../components/language/language-combobox.tsx | 104 +- e2e/onboarding.spec.ts | 8 +- packages/api/src/router/assistant.ts | 4 +- .../api/src/router/onboard/onboard-router.ts | 2 +- .../api/src/router/test/assistant.spec.ts | 12 + packages/api/src/router/test/onboard.spec.ts | 3 +- packages/definitions/src/assistant.ts | 4 +- packages/onboarding/package.json | 1 - packages/onboarding/src/account.tsx | 3 + .../src/discovery-selection.spec.ts | 27 +- .../onboarding/src/discovery-selection.ts | 12 + packages/onboarding/src/finish.tsx | 25 +- .../src/onboarding-studio.module.css | 342 +++--- packages/onboarding/src/onboarding-studio.tsx | 3 +- .../onboarding/src/onboarding-wordmark.tsx | 9 + packages/onboarding/src/setup-studio.tsx | 1027 +++++++---------- packages/onboarding/src/types.ts | 1 + packages/onboarding/src/welcome.tsx | 67 +- packages/translation/src/lang/en.json | 102 +- .../ui/src/components/board-color-input.tsx | 10 + .../src/components/color-scheme-combobox.tsx | 86 ++ packages/ui/src/components/index.tsx | 3 + .../ui/src/components/language-combobox.tsx | 92 ++ packages/validation/src/onboarding.spec.ts | 24 + packages/validation/src/onboarding.ts | 1 + pnpm-lock.yaml | 3 - 34 files changed, 1005 insertions(+), 1172 deletions(-) create mode 100644 apps/nextjs/public/logo/homarr-wordmark-light.svg create mode 100644 packages/onboarding/src/onboarding-wordmark.tsx create mode 100644 packages/ui/src/components/board-color-input.tsx create mode 100644 packages/ui/src/components/color-scheme-combobox.tsx create mode 100644 packages/ui/src/components/language-combobox.tsx diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index cd9432a1d..4068fbb75 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -48,7 +48,7 @@ The studio keeps related choices together: 1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, domain, or reverse-proxy path used by your services. URL suggestions can use separate ports, subdomains, or one path per service. 2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup. 3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with 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 protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. A fast setup still adds the integration-free Clock, Weather, and Bookmarks starter widgets. +4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets. 5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint. 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. diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index be7e9d096..2ac43a19c 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -25,8 +25,8 @@ Open **Management → Assistant**. 4. Enable the assistant and save. Choose a provider preset or use **Custom endpoint** with an OpenAI-compatible Chat Completions API. The -[Homarr provider](../workshop/homarr-provider) needs no administrator API key; users sign in to the Community Workshop -and receive a daily allowance. +[Homarr provider](../workshop/homarr-provider) is the default and needs no administrator API key. The Homarr team pays +for a limited number of Assistant uses for each user every day; users sign in to the Community Workshop to use them. :::info Local model servers diff --git a/apps/nextjs/public/logo/homarr-wordmark-light.svg b/apps/nextjs/public/logo/homarr-wordmark-light.svg new file mode 100644 index 000000000..eeaf82f8a --- /dev/null +++ b/apps/nextjs/public/logo/homarr-wordmark-light.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 b03168912..b0e75d34d 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx @@ -21,6 +21,7 @@ import { IconX } from "@tabler/icons-react"; import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; +import { BoardColorInput } from "@homarr/ui"; import { SectionCard } from "~/components/manage/section-card"; import { generateColors } from "../../(content)/_theme"; @@ -44,21 +45,11 @@ export const ColorSettingsContent = ({ form }: Props) => { - color[6])} - {...form.getInputProps("primaryColor")} - /> + - color[6])} - {...form.getInputProps("secondaryColor")} - /> + {showPreview ? t("common.preview.hide") : t("common.preview.show")} diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index d1b4ffec1..ad03d889a 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -15,6 +15,7 @@ import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { DatabaseRestoreFlow } from "~/components/backup"; import { env } from "~/env"; +import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration"; export default async function InitPage() { const [state, session, requestHeaders, firstUser] = await Promise.all([ @@ -68,6 +69,7 @@ export default async function InitPage() { availableBoards, }} sqliteRestore={databaseDriver === "sqlite" ? : undefined} + assistantConfiguration={canConfigurePrivileged ? : undefined} /> ); } diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 401259c06..d7a5636f3 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; import "@homarr/ui/styles.css"; +import "flag-icons/css/flag-icons.min.css"; import "mantine-datatable/styles.css"; import "~/styles/color-scheme.scss"; import "~/styles/scroll-area.scss"; diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index 5f75c9227..1d05e1ed7 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -142,8 +142,8 @@ export const AssistantConfiguration = () => { const t = useScopedI18n("management.page.settings.section.assistant"); const utils = clientApi.useUtils(); const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery(); - const [provider, setProvider] = useState("openrouter"); - const [baseUrl, setBaseUrl] = useState(assistantProviderPresets.openrouter.baseUrl); + const [provider, setProvider] = useState("homarr"); + const [baseUrl, setBaseUrl] = useState(assistantProviderPresets.homarr.baseUrl); const [modelDiscoveryPath, setModelDiscoveryPath] = useState("/models"); const [apiKey, setApiKey] = useState(""); const [credentialFlow, setCredentialFlow] = useState("idle"); @@ -155,7 +155,7 @@ export const AssistantConfiguration = () => { const [webSearchEnabled, setWebSearchEnabled] = useState(false); useEffect(() => { - const configuredProvider = configuration?.provider ?? "openrouter"; + const configuredProvider = configuration?.provider ?? "homarr"; setProvider(configuredProvider); setBaseUrl( configuredProvider === "homarr" @@ -201,7 +201,7 @@ export const AssistantConfiguration = () => { ); const providerOptions = useMemo( () => - (["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => { + (["free", "hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => { const items = assistantProviderIds .filter((providerId) => assistantProviderPresets[providerId].category === category) .map((providerId) => ({ @@ -438,6 +438,11 @@ export const AssistantConfiguration = () => { {option.label} + {option.value === "homarr" && ( + + {t("provider.options.homarr.description")} + + )} )} diff --git a/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx b/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx index 207dcb913..ab3628e10 100644 --- a/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx +++ b/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx @@ -1,94 +1,3 @@ "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 { - value: ColorScheme; - onChange: (value: ColorScheme) => void; - width?: string; -} - -export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => { - const tOptions = useScopedI18n("common.colorScheme.options"); - - const combobox = useCombobox({ - onDropdownClose: () => combobox.resetSelectedOption(), - }); - - const handleOnOptionSubmit = React.useCallback( - (val: string) => { - if (!val) return; - onChange(val as ColorScheme); - combobox.closeDropdown(); - }, - [onChange, combobox], - ); - - const handleOnClick = React.useCallback(() => { - combobox.toggleDropdown(); - }, [combobox]); - - return ( - - - } - rightSectionPointerEvents="none" - onClick={handleOnClick} - 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 && } - - ); -}; +export { ColorSchemeCombobox } from "@homarr/ui"; diff --git a/apps/nextjs/src/components/language/language-combobox.tsx b/apps/nextjs/src/components/language/language-combobox.tsx index f6eaaac69..e58b28ec2 100644 --- a/apps/nextjs/src/components/language/language-combobox.tsx +++ b/apps/nextjs/src/components/language/language-combobox.tsx @@ -1,105 +1,3 @@ "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 "@homarr/ui"; - -import "flag-icons/css/flag-icons.min.css"; - -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(), - }); - - const handleOnOptionSubmit = React.useCallback( - (value: string) => { - if (!value) { - return; - } - onChange(value as SupportedLanguage); - combobox.closeDropdown(); - }, - [onChange, combobox], - ); - - const handleOnClick = React.useCallback(() => { - combobox.toggleDropdown(); - }, [combobox]); - - return ( - - - : null} - rightSection={} - rightSectionPointerEvents="none" - onClick={handleOnClick} - variant="filled" - w={width} - > - - - - - - - {supportedLanguages.map((languageKey) => ( - - - - ))} - - - - - ); -}; - -const OptionItem = ({ - currentLocale, - localeKey, - showCheck, -}: { - currentLocale: SupportedLanguage; - localeKey: SupportedLanguage; - showCheck?: boolean; -}) => { - return ( - - - - - {localeConfigurations[localeKey].name} - - ({localeConfigurations[localeKey].translatedName}) - - - - {showCheck && localeKey === currentLocale && } - - ); -}; +export { LanguageCombobox } from "@homarr/ui"; diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index c86e833e2..d69125f50 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -141,11 +141,9 @@ describe("Onboarding", () => { const boardPreview = page.locator('figure[aria-label="Live preview of the first board layout"]'); expect(await boardPreview.getAttribute("data-layout-role")).toBe("base"); expect(await boardPreview.getAttribute("data-layout-columns")).toBe("10"); - await page.getByText("Mobile", { exact: true }).click(); - expect(await boardPreview.getAttribute("data-layout-role")).toBe("mobile"); - expect(await boardPreview.getAttribute("data-layout-columns")).toBe("3"); - await page.getByText("Wide", { exact: true }).click(); - await page.getByText("Base", { exact: true }).click(); + const columnSlider = page.getByRole("slider", { name: "Board columns" }); + await columnSlider.press("ArrowRight"); + await columnSlider.press("ArrowRight"); expect(await boardPreview.getAttribute("data-layout-columns")).toBe("12"); await page.getByRole("button", { name: /^Connect/ }).click(); diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 38e1570ac..03540ef0a 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -534,8 +534,8 @@ export const assistantRouter = createTRPCRouter({ connectionConfigured: Boolean(configuration), enabled: configuration?.enabled ?? false, webSearchEnabled: configuration?.webSearchEnabled ?? false, - provider: configuration?.provider ?? "openrouter", - baseUrl: configuration?.baseUrl ?? "https://openrouter.ai/api/v1", + provider: configuration?.provider ?? "homarr", + baseUrl: configuration?.baseUrl ?? getHomarrProviderBaseUrl(), modelDiscoveryPath: configuration?.modelDiscoveryPath ?? "/models", apiKeyConfigured: Boolean(configuration?.encryptedApiKey), customHeadersConfigured: Boolean(configuration?.encryptedHeaders), diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index f149a2a40..4135f7cc6 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -585,7 +585,7 @@ export const onboardRouter = createTRPCRouter({ ); let leftGutterColumnCount = requestedGutters.left; let rightGutterColumnCount = requestedGutters.right; - const desiredColumns = getLayoutPresetColumnCount(input.board.layoutPreset); + const desiredColumns = input.board.columnCount ?? getLayoutPresetColumnCount(input.board.layoutPreset); if (requiredColumns + leftGutterColumnCount + rightGutterColumnCount > 24) rightGutterColumnCount = 0; if (requiredColumns + leftGutterColumnCount + rightGutterColumnCount > 24) leftGutterColumnCount = 0; const baseColumnCount = Math.min( diff --git a/packages/api/src/router/test/assistant.spec.ts b/packages/api/src/router/test/assistant.spec.ts index 672912563..a9c699c55 100644 --- a/packages/api/src/router/test/assistant.spec.ts +++ b/packages/api/src/router/test/assistant.spec.ts @@ -59,6 +59,18 @@ const createConfiguredAssistantAsync = async () => { return db; }; +describe("assistantRouter.getAdminConfiguration", () => { + test("defaults an unconfigured Assistant to the Homarr provider", async () => { + const caller = assistantRouter.createCaller({ db: createDb(), deviceType: undefined, session: adminSession }); + + await expect(caller.getAdminConfiguration()).resolves.toMatchObject({ + connectionConfigured: false, + provider: "homarr", + baseUrl: "https://homarr.dev/api/ai/v1", + }); + }); +}); + describe("assistantRouter.updateConnection", () => { test("clears the persisted model when the discovery path changes without clearing credentials", async () => { const db = await createConfiguredAssistantAsync(); diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts index 3be580a30..aab8a42b5 100644 --- a/packages/api/src/router/test/onboard.spec.ts +++ b/packages/api/src/router/test/onboard.spec.ts @@ -307,6 +307,7 @@ describe("onboard.completeSetup", () => { ...completionInput("missing").board, id: undefined, name: "Recovered-board", + columnCount: 14, leftSidebar: true, rightSidebar: true, }, @@ -328,7 +329,7 @@ describe("onboard.completeSetup", () => { expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }), expect.objectContaining({ role: "base", - columnCount: 10, + columnCount: 14, breakpoint: 768, leftGutterColumnCount: 1, rightGutterColumnCount: 1, diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts index 5c0c4f89d..0f2468c91 100644 --- a/packages/definitions/src/assistant.ts +++ b/packages/definitions/src/assistant.ts @@ -15,7 +15,7 @@ export const assistantProviderIds = [ ] as const; export type AssistantProvider = (typeof assistantProviderIds)[number]; -export type AssistantProviderCategory = "hosted" | "local" | "custom"; +export type AssistantProviderCategory = "free" | "hosted" | "local" | "custom"; export const assistantHomarrProviderTokenHeader = "X-Homarr-Provider-Token"; export const assistantReasoningModes = ["auto", "none", "minimal", "low", "medium", "high"] as const; @@ -50,7 +50,7 @@ export const assistantProviderPresets = { baseUrl: "https://homarr.dev/api/ai/v1", modelDiscoveryPath: "/models", requiresApiKey: false, - category: "hosted", + category: "free", discoveryAuthentication: "bearer", iconUrl: "https://homarr.dev/img/logo.png", }, diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index 93962200d..8000df6b2 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -36,7 +36,6 @@ "@mantine/core": "catalog:", "@mantine/hooks": "catalog:", "@tabler/icons-react": "catalog:", - "motion": "catalog:", "react": "catalog:", "zod": "catalog:" }, diff --git a/packages/onboarding/src/account.tsx b/packages/onboarding/src/account.tsx index 724b9dd9f..3b30620ad 100644 --- a/packages/onboarding/src/account.tsx +++ b/packages/onboarding/src/account.tsx @@ -126,6 +126,9 @@ const CredentialsSetup = () => { passwordInputProps={{ ...form.getInputProps("password"), autoComplete: "new-password" }} confirmPasswordInputProps={{ ...form.getInputProps("confirmPassword"), autoComplete: "new-password" }} /> + + {t("passwordHint")} + {submitError ? ( {submitError} diff --git a/packages/onboarding/src/discovery-selection.spec.ts b/packages/onboarding/src/discovery-selection.spec.ts index 5d3c31465..17ba80f80 100644 --- a/packages/onboarding/src/discovery-selection.spec.ts +++ b/packages/onboarding/src/discovery-selection.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { isHttpUrl, normalizeServiceUrl, resolveDiscoveredAppUrl, takeNewSourceIds } from "./discovery-selection"; +import { + isHttpUrl, + normalizeServiceUrl, + resolveDiscoveredAppUrl, + resolveIntegrationDraftUrl, + takeNewSourceIds, +} from "./discovery-selection"; describe("takeNewSourceIds", () => { it("keeps same-kind service instances independent and does not reselect them after refresh", () => { @@ -22,6 +28,25 @@ describe("takeNewSourceIds", () => { expect(resolveDiscoveredAppUrl(undefined, null, null)).toBe(""); }); + it("regenerates integration URLs from the usual server address without replacing manual edits", () => { + expect( + resolveIntegrationDraftUrl({ + currentUrl: "http://docker-host:8989", + overridden: false, + serverUrl: "http://home.lan:8989", + fallbackUrl: "http://docker-host:8989", + }), + ).toBe("http://home.lan:8989"); + expect( + resolveIntegrationDraftUrl({ + currentUrl: "https://sonarr.custom.example", + overridden: true, + serverUrl: "http://home.lan:8989", + fallbackUrl: "http://docker-host:8989", + }), + ).toBe("https://sonarr.custom.example"); + }); + it("normalizes HTTP, HTTPS, and bare self-hosted service addresses", () => { expect(isHttpUrl("http://home.lan:8989")).toBe(true); expect(isHttpUrl("https://home.example/sonarr")).toBe(true); diff --git a/packages/onboarding/src/discovery-selection.ts b/packages/onboarding/src/discovery-selection.ts index 3ab35bbd9..3cf2e3cb1 100644 --- a/packages/onboarding/src/discovery-selection.ts +++ b/packages/onboarding/src/discovery-selection.ts @@ -11,6 +11,18 @@ export const resolveDiscoveredAppUrl = ( generatedUrl: string | null, ) => override ?? suggestedUrl ?? generatedUrl ?? ""; +export const resolveIntegrationDraftUrl = ({ + currentUrl, + overridden, + serverUrl, + fallbackUrl, +}: { + currentUrl: string; + overridden: boolean; + serverUrl: string | null; + fallbackUrl: string | null; +}) => (overridden ? currentUrl : serverUrl || fallbackUrl || ""); + export const normalizeServiceUrl = (value: string) => { try { const trimmed = value.trim(); diff --git a/packages/onboarding/src/finish.tsx b/packages/onboarding/src/finish.tsx index 081be8d82..1064a0f74 100644 --- a/packages/onboarding/src/finish.tsx +++ b/packages/onboarding/src/finish.tsx @@ -1,8 +1,7 @@ "use client"; -import { Alert, Button, Group, Paper, SimpleGrid, Stack, Text, ThemeIcon, Title } from "@mantine/core"; -import { IconArrowRight, IconBook2, IconLayoutDashboard, IconSparkles, IconTool } from "@tabler/icons-react"; -import { motion, useReducedMotion } from "motion/react"; +import { Button, Group, Paper, SimpleGrid, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconArrowRight, IconBook2, IconSparkles, IconTool } from "@tabler/icons-react"; import { createDocumentationLink } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; @@ -13,7 +12,6 @@ import classes from "./onboarding-studio.module.css"; export const Finish = ({ environment }: OnboardingStudioProps) => { const t = useScopedI18n("init.studio.finish"); - const reduceMotion = useReducedMotion(); const boardHref = `/boards/${encodeURIComponent(environment.initialBoard?.name ?? "dashboard")}`; const destination = environment.canConfigurePrivileged ? boardHref : "/auth/login?callbackUrl=%2Finit"; @@ -21,22 +19,14 @@ export const Finish = ({ environment }: OnboardingStudioProps) => {
- +
- +
- +
{t("title")} @@ -46,9 +36,6 @@ export const Finish = ({ environment }: OnboardingStudioProps) => { - }> - {t("tutorial")} - {
- +
diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css index e563217da..62c2a909a 100644 --- a/packages/onboarding/src/onboarding-studio.module.css +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -1,17 +1,19 @@ .page { --studio-surface: color-mix(in srgb, var(--mantine-color-body) 92%, transparent); + --studio-glow-color: var(--mantine-primary-color-filled); + --studio-secondary-glow-color: var(--mantine-color-orange-6); min-height: 100dvh; overflow-x: clip; position: relative; background: radial-gradient( circle at 10% 10%, - color-mix(in srgb, var(--mantine-primary-color-filled) 14%, transparent), + color-mix(in srgb, var(--studio-glow-color) 14%, transparent), transparent 34rem ), radial-gradient( circle at 90% 5%, - color-mix(in srgb, var(--mantine-color-orange-6) 10%, transparent), + color-mix(in srgb, var(--studio-secondary-glow-color) 10%, transparent), transparent 30rem ), var(--mantine-color-body); @@ -20,19 +22,22 @@ .page::before { content: ""; position: fixed; - inset: 0; + inset: -2rem; pointer-events: none; - opacity: 0.22; - background-image: radial-gradient(circle, var(--mantine-color-dimmed) 0.7px, transparent 0.7px); - background-size: 24px 24px; - mask-image: linear-gradient(to bottom, black, transparent 75%); + opacity: 0.28; + background-image: + linear-gradient(color-mix(in srgb, var(--mantine-color-dimmed) 28%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--mantine-color-dimmed) 28%, transparent) 1px, transparent 1px); + background-size: 32px 32px; + filter: blur(0.35px); + mask-image: radial-gradient(ellipse 66% 86% at center, transparent 22%, black 88%); } .shell { position: relative; width: min(100% - 2rem, 76rem); margin-inline: auto; - padding-block: clamp(1rem, 3vw, 2.5rem) 5rem; + padding-block: clamp(1rem, 3vw, 2.5rem) calc(6rem + env(safe-area-inset-bottom)); } .topbar { @@ -42,31 +47,33 @@ .wordmark { display: block; - width: clamp(9.5rem, 22vw, 14rem); - height: auto; - padding: 0.4rem 0.55rem; - border-radius: var(--mantine-radius-md); - background: rgba(12, 12, 14, 0.86); - filter: drop-shadow(0 0.8rem 1.6rem color-mix(in srgb, black 22%, transparent)); + width: auto; + height: 3.5rem; +} + +.onboardingWordmark { + height: clamp(5rem, 12vw, 7rem); + max-width: min(70vw, 28rem); +} + +:global([data-mantine-color-scheme="light"]) .wordmark { + filter: brightness(0); } .hero { - min-height: calc(100dvh - 8rem); - display: grid; - grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr); - align-items: center; - gap: clamp(2rem, 6vw, 6rem); + margin-inline: auto; + max-width: 42rem; + padding-block: 5rem 3rem; + text-align: center; } .heroTitle { - font-size: clamp(2.5rem, 7vw, 5.8rem); - line-height: 0.94; - letter-spacing: -0.04em; + font-size: 3.5rem; + line-height: 1.05; + letter-spacing: -0.03em; text-wrap: balance; - max-width: 11ch; } -.heroPanel, .studio { background: var(--studio-surface); backdrop-filter: blur(18px); @@ -74,54 +81,13 @@ box-shadow: var(--mantine-shadow-sm); } -.heroPanel { - position: relative; - min-height: 27rem; - overflow: hidden; -} - -.orbit { - position: absolute; - inset: 50% auto auto 50%; - width: 18rem; - height: 18rem; - margin: -9rem; - border-radius: 50%; - border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent); -} - -.orbitItem { - position: absolute; - width: 3.25rem; - height: 3.25rem; - display: grid; - place-items: center; - border-radius: var(--mantine-radius-lg); - background: var(--mantine-color-body); - border: 1px solid var(--mantine-color-default-border); - box-shadow: var(--mantine-shadow-md); -} - -.orbitItem:nth-child(1) { - inset: -1.6rem auto auto calc(50% - 1.6rem); -} -.orbitItem:nth-child(2) { - inset: calc(50% - 1.6rem) -1.6rem auto auto; -} -.orbitItem:nth-child(3) { - inset: auto auto -1.6rem calc(50% - 1.6rem); -} -.orbitItem:nth-child(4) { - inset: calc(50% - 1.6rem) auto auto -1.6rem; -} - .studio { overflow: clip; } .studioGrid { display: grid; - grid-template-columns: 17rem minmax(0, 1fr); + grid-template-columns: 15rem minmax(0, 1fr); min-height: 40rem; } @@ -131,30 +97,38 @@ background: color-mix(in srgb, var(--mantine-color-default) 40%, transparent); } -.railButton { +.timeline { width: 100%; - min-height: 3.25rem; - padding: 0.65rem 0.75rem; +} + +.timelineBullet { + pointer-events: none; +} + +.timelineButton { + width: calc(100% + 2.75rem); + min-height: 2.75rem; + margin-inline-start: -2.75rem; + padding: 0.15rem 0.25rem; + padding-inline-start: 2.75rem; border-radius: var(--mantine-radius-md); color: var(--mantine-color-dimmed); - transition: - background-color 150ms ease, - color 150ms ease, - transform 150ms ease; } -.railButton[data-active="true"] { +.timelineButton[data-active="true"] { color: var(--mantine-color-text); - background: var(--mantine-color-default-hover); } -.railButton:focus-visible { +.timelineButton:focus-visible { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 2px; } .content { + display: flex; + flex-direction: column; min-width: 0; + min-height: 100%; padding: clamp(1rem, 3vw, 2rem); } @@ -163,12 +137,6 @@ border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent); } -.discoveryChoice { - transition: - border-color 150ms ease, - background-color 150ms ease; -} - .discoveryChoice[data-checked] { border-color: var(--mantine-primary-color-filled); background: color-mix(in srgb, var(--mantine-primary-color-light) 62%, var(--mantine-color-body)); @@ -179,6 +147,10 @@ outline-offset: 2px; } +.integrationStatus { + flex: 0 0 auto; +} + .choiceGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -192,10 +164,6 @@ border: 1px solid var(--mantine-color-default-border); background: var(--mantine-color-body); padding: var(--mantine-spacing-md); - transition: - border-color 150ms ease, - background-color 150ms ease, - transform 150ms ease; } .choice[data-selected="true"] { @@ -203,10 +171,6 @@ background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body)); } -.choice:hover { - transform: translateY(-2px); -} - .choice:focus-visible { outline: 3px solid var(--mantine-primary-color-light-color); outline-offset: 2px; @@ -220,21 +184,15 @@ padding: 0.6rem; overflow: hidden; border-radius: var(--mantine-radius-lg); - background: color-mix(in srgb, var(--mantine-color-default) 66%, transparent); + background: var(--mantine-color-default); border: 1px solid var(--mantine-color-default-border); } -.preview[data-layout-role="mobile"] { - width: min(100%, 28rem); - margin-inline: auto; - aspect-ratio: 4 / 3; -} - .previewLane { min-width: 0; border-radius: calc(var(--mantine-radius-lg) * 0.65); - background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); - border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent); + background: var(--mantine-color-body); + border: 1px solid var(--mantine-color-default-border); padding: 0.35rem; } @@ -248,62 +206,28 @@ .previewTile { border-radius: calc(var(--mantine-radius-lg) * 0.45); - background: linear-gradient( - 135deg, - var(--mantine-primary-color-light), - color-mix(in srgb, var(--mantine-color-orange-light) 60%, transparent) - ); - border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 22%, transparent); -} - -.assistantStage { - position: relative; - min-height: 13rem; - overflow: hidden; - border-radius: var(--mantine-radius-lg); - background: color-mix(in srgb, var(--mantine-primary-color-light) 44%, var(--mantine-color-body)); -} - -.assistantGlow { - position: absolute; - inset: 50% auto auto 50%; - width: 12rem; - height: 12rem; - margin: -6rem; - border-radius: 50%; - background: radial-gradient( - circle, - color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), - transparent 68% - ); + background: var(--mantine-color-default); + border: 1px solid var(--mantine-color-default-border); } .stickyActions { position: sticky; z-index: 2; bottom: 0; - margin: 1.5rem -2rem -2rem; + margin: auto -2rem -2rem; padding: 1rem 2rem; background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent); border-top: 1px solid var(--mantine-color-default-border); backdrop-filter: blur(16px); } -.applyError { - margin-top: var(--mantine-spacing-lg); -} - .nextCard { color: inherit; text-decoration: none; - transition: - background-color 150ms ease, - transform 150ms ease; } .nextCard:hover { background: var(--mantine-color-default-hover); - transform: translateY(-2px); } .nextCard:focus-visible { @@ -312,12 +236,54 @@ } .content :global(.mantine-Button-root), -.content :global(.mantine-SegmentedControl-label), .page :global(.mantine-Input-input), .page :global(.mantine-Tabs-tab) { min-height: 2.75rem; } +.floatingControlScroller { + max-width: 100%; + overflow-x: auto; +} + +.floatingControlRoot { + position: relative; + display: flex; + width: max-content; + min-width: 100%; + padding: 0.2rem; + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-default); +} + +.floatingControlIndicator { + border: 1px solid var(--mantine-color-default-border); + border-radius: calc(var(--mantine-radius-md) - 0.2rem); + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-xs); +} + +.floatingControl { + position: relative; + z-index: 1; + flex: 1 0 auto; + min-height: 2.25rem; + padding-inline: var(--mantine-spacing-sm); + border-radius: calc(var(--mantine-radius-md) - 0.2rem); + color: var(--mantine-color-dimmed); + text-align: center; + white-space: nowrap; +} + +.floatingControl[data-active="true"] { + color: var(--mantine-color-text); +} + +.floatingControl:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; +} + .page :global(.mantine-Switch-body) { min-height: 2.75rem; align-items: center; @@ -325,32 +291,15 @@ @media (max-width: 62em) { .hero { - grid-template-columns: 1fr; padding-block: 3rem; } - .heroPanel { - min-height: 22rem; - } - .studioGrid { - grid-template-columns: 1fr; + grid-template-columns: 12rem minmax(0, 1fr); } .rail { - border-right: 0; - border-bottom: 1px solid var(--mantine-color-default-border); - overflow-x: auto; - } - - .railList { - min-width: max-content; - flex-direction: row; - } - - .railButton { - width: auto; - min-width: 10rem; + padding-inline: 1rem; } } @@ -360,7 +309,7 @@ } .heroTitle { - font-size: clamp(2.35rem, 14vw, 4.5rem); + font-size: 2.75rem; } .choiceGrid { @@ -371,8 +320,25 @@ padding: 1rem; } + .studioGrid { + grid-template-columns: 3.75rem minmax(0, 1fr); + } + + .rail { + padding: 1rem 0.5rem; + } + + .timelineLabel { + display: none; + } + + .timelineButton { + min-height: 2rem; + padding-inline-start: 2.75rem; + } + .stickyActions { - margin: 1rem -1rem -1rem; + margin: auto -1rem -1rem; padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); } @@ -384,10 +350,6 @@ flex: 1 1 12rem; } - .content :global(.mantine-SegmentedControl-root) { - overflow-x: auto; - } - .content :global(.mantine-Tabs-list) { flex-wrap: nowrap; overflow-x: auto; @@ -406,31 +368,13 @@ } .wordmark { - width: 9.5rem; + height: 3rem; } .hero { - min-height: auto; - gap: 2rem; padding-block: 2rem 3rem; } - .heroPanel { - min-height: 19rem; - } - - .orbit { - scale: 0.78; - } - - .rail { - padding: 0.75rem; - } - - .railButton { - min-width: 8.5rem; - } - .stickyActions { gap: var(--mantine-spacing-xs); } @@ -459,12 +403,6 @@ min-width: 0; } - .fastAction { - flex: 1.15 1 0; - min-width: 0; - padding-inline: 0.25rem; - } - .primaryActions > :global(.mantine-Button-root):last-child { flex: 1 1 0; min-width: 0; @@ -475,36 +413,20 @@ font-size: var(--mantine-font-size-sm); } - .content :global(.mantine-SegmentedControl-label) { - min-width: 0; - height: auto; - padding: 0.45rem 0.3rem; - line-height: 1.15; - white-space: normal; - } - .sectionCard :global(.mantine-Group-root) { flex-wrap: wrap; } } @media (prefers-reduced-motion: reduce) { - .page { - scroll-behavior: auto; + .page * { + animation: none !important; } - .choice, - .railButton, - .nextCard, - .discoveryChoice { - transition: none; - } - - .choice:hover { - transform: none; - } - - .nextCard:hover { - transform: none; + .page *:not(.integrationAccordion *), + .page *:not(.integrationAccordion *)::before, + .page *:not(.integrationAccordion *)::after { + transition: none !important; + scroll-behavior: auto !important; } } diff --git a/packages/onboarding/src/onboarding-studio.tsx b/packages/onboarding/src/onboarding-studio.tsx index ffd01ebc0..519d15e95 100644 --- a/packages/onboarding/src/onboarding-studio.tsx +++ b/packages/onboarding/src/onboarding-studio.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Alert, Button, Center, Loader, Paper, Stack, Text, Title } from "@mantine/core"; +import { Alert, Button, Center, Paper, Stack, Text, Title } from "@mantine/core"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -61,7 +61,6 @@ export const OnboardingStudio = (props: OnboardingStudioProps) => {
- {claimState === "checking" ? : null} {claimState === "checking" ? t("checkingTitle") diff --git a/packages/onboarding/src/onboarding-wordmark.tsx b/packages/onboarding/src/onboarding-wordmark.tsx new file mode 100644 index 000000000..d7ebcdfc5 --- /dev/null +++ b/packages/onboarding/src/onboarding-wordmark.tsx @@ -0,0 +1,9 @@ +import classes from "./onboarding-studio.module.css"; + +export const OnboardingWordmark = () => ( + <img + src="/logo/homarr-wordmark-light.svg" + alt="Homarr" + className={`${classes.wordmark} ${classes.onboardingWordmark}`} + /> +); diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index c042cce95..103471688 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -12,62 +12,64 @@ import { Button, Checkbox, Code, - ColorInput, + ColorSwatch, CopyButton, - Divider, Fieldset, + FloatingIndicator, Group, - Image, - Loader, Paper, PasswordInput, + Popover, Progress, - SegmentedControl, Select, SimpleGrid, + Slider, Stack, Switch, Tabs, Text, TextInput, ThemeIcon, + Timeline, Title, UnstyledButton, useMantineColorScheme, - useMantineTheme, } from "@mantine/core"; import type { MantineSize } from "@mantine/core"; import { IconAdjustments, + IconAlertCircle, IconApps, IconArrowLeft, IconArrowRight, IconBrandDocker, - IconBrain, + IconBorderRadius, IconCheck, IconCode, IconCopy, IconDatabase, IconDeviceMobile, IconExternalLink, + IconInfoCircle, IconKey, + IconLanguage, + IconLayoutColumns, IconLayoutDashboard, + IconLayoutSidebar, IconPalette, IconPlugConnected, IconRefresh, + IconRobot, IconSearch, IconServer, IconSparkles, - IconTool, - IconWorld, + IconSunMoon, } from "@tabler/icons-react"; -import { motion, useReducedMotion } from "motion/react"; import { clientApi } from "@homarr/api/client"; import type { RouterOutputs } from "@homarr/api"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { - AssistantProvider, ColorScheme, IntegrationKind, IntegrationSecretKind, @@ -75,9 +77,6 @@ import type { WidgetKind, } from "@homarr/definitions"; import { - assistantProviderCanUseOpenRouterServerTools, - assistantProviderIds, - assistantProviderPresets, buildAppUrl, buildIntegrationUrl, getAllSecretKindOptions, @@ -89,26 +88,36 @@ import { getWidgetKindsForIntegration, generalWidgets, } from "@homarr/definitions"; -import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications"; +import { showErrorNotification, showWarningNotification } from "@homarr/notifications"; import type { SupportedLanguage } from "@homarr/translation"; -import { localeConfigurations, supportedLanguages } from "@homarr/translation"; import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; -import { IntegrationAvatar, Link } from "@homarr/ui"; +import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui"; import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid"; import type { OnboardingStudioProps } from "./types"; -import { isHttpUrl, normalizeServiceUrl, resolveDiscoveredAppUrl, takeNewSourceIds } from "./discovery-selection"; +import { OnboardingWordmark } from "./onboarding-wordmark"; +import { + isHttpUrl, + normalizeServiceUrl, + resolveDiscoveredAppUrl, + resolveIntegrationDraftUrl, + takeNewSourceIds, +} from "./discovery-selection"; import classes from "./onboarding-studio.module.css"; type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review"; type LayoutPreset = "balanced" | "wide" | "focused"; +const getLayoutPresetForColumnCount = (columnCount: number): LayoutPreset => + columnCount <= 8 ? "focused" : columnCount <= 10 ? "balanced" : "wide"; + interface IntegrationDraft { id: string; sourceId?: string; kind: IntegrationKind; name: string; url: string; + urlOverridden: boolean; source: "manual" | "docker"; secretOption: number; secrets: { kind: IntegrationSecretKind; value: string }[]; @@ -135,12 +144,61 @@ const sectionDefinitions = [ const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const; const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = []; const emptyDiscoveredApps: DockerDiscoveryData["apps"] = []; -export const SetupStudio = ({ environment }: OnboardingStudioProps) => { + +interface OnboardingFloatingControlProps<T extends string> { + value: T; + onChange: (value: T) => void; + options: readonly { value: T; label: ReactNode }[]; + ariaLabel: string; +} + +const OnboardingFloatingControl = <T extends string>({ + value, + onChange, + options, + ariaLabel, +}: OnboardingFloatingControlProps<T>) => { + const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); + const controlRefs = useRef<Record<string, HTMLButtonElement | null>>({}); + + return ( + <div className={classes.floatingControlScroller}> + <div ref={setRootRef} className={classes.floatingControlRoot} role="group" aria-label={ariaLabel}> + <FloatingIndicator + target={controlRefs.current[value] ?? null} + parent={rootRef} + className={classes.floatingControlIndicator} + /> + {options.map((option) => { + const selected = value === option.value; + return ( + <UnstyledButton + key={option.value} + ref={(node) => { + controlRefs.current[option.value] = node; + }} + type="button" + className={classes.floatingControl} + data-active={selected} + aria-pressed={selected} + onClick={() => onChange(option.value)} + > + {option.label} + </UnstyledButton> + ); + })} + </div> + </div> + ); +}; + +export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => { const t = useScopedI18n("init.studio"); + const tCommon = useScopedI18n("common.action"); const currentLocale = useCurrentLocale(); const { colorScheme, setColorScheme } = useMantineColorScheme(); - const reduceMotion = useReducedMotion(); const [activeSection, setActiveSection] = useState<StudioSection>("essentials"); + const [incompleteIntegrationConfirmationOpened, setIncompleteIntegrationConfirmationOpened] = useState(false); const [selectedLocale, setSelectedLocale] = useState(currentLocale); const [serverOrigin, setServerOrigin] = useState(""); const [urlMode, setUrlMode] = useState<UrlTemplateMode>("hostPort"); @@ -158,12 +216,11 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { const [primaryColor, setPrimaryColor] = useState("#fa5252"); const [secondaryColor, setSecondaryColor] = useState("#fd7e14"); const [itemRadius, setItemRadius] = useState<MantineSize>("lg"); - const [layoutPreset, setLayoutPreset] = useState<LayoutPreset>("balanced"); + const [columnCount, setColumnCount] = useState(10); const [leftSidebar, setLeftSidebar] = useState(false); const [rightSidebar, setRightSidebar] = useState(false); const [applyProgress, setApplyProgress] = useState(0); const [applyMessage, setApplyMessage] = useState(""); - const [applyError, setApplyError] = useState<string | null>(null); const [appError, setAppError] = useState<string | null>(null); const sectionHeadingRef = useRef<HTMLHeadingElement>(null); const sectionButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); @@ -235,17 +292,18 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { for (const discovered of discoveredIntegrations) { if (!selectedIntegrationSourceIds.includes(discovered.sourceId)) continue; const id = discovered.sourceId; - const generatedUrl = - discovered.suggestedUrl || - (serverOrigin - ? buildIntegrationUrl(discovered.kind, serverOrigin, urlMode, discovered.publishedPort ?? undefined) - : ""); const existingDraft = existing.get(id); + const generatedUrl = resolveIntegrationDraftUrl({ + currentUrl: existingDraft?.url ?? "", + overridden: existingDraft?.urlOverridden ?? false, + serverUrl: serverOrigin + ? buildIntegrationUrl(discovered.kind, serverOrigin, urlMode, discovered.publishedPort ?? undefined) + : null, + fallbackUrl: discovered.suggestedUrl, + }); next.push( existingDraft - ? existingDraft.url || !generatedUrl - ? existingDraft - : { ...existingDraft, url: generatedUrl } + ? { ...existingDraft, url: generatedUrl } : createDraft({ id, sourceId: discovered.sourceId, @@ -258,15 +316,16 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { } for (const kind of selectedKinds) { const id = `manual:${kind}`; - const generatedUrl = serverOrigin - ? buildIntegrationUrl(kind, serverOrigin, urlMode) - : getIntegrationDefaultUrl(kind); const existingDraft = existing.get(id); + const generatedUrl = resolveIntegrationDraftUrl({ + currentUrl: existingDraft?.url ?? "", + overridden: existingDraft?.urlOverridden ?? false, + serverUrl: serverOrigin ? buildIntegrationUrl(kind, serverOrigin, urlMode) : null, + fallbackUrl: getIntegrationDefaultUrl(kind) ?? null, + }); next.push( existingDraft - ? existingDraft.url || !generatedUrl - ? existingDraft - : { ...existingDraft, url: generatedUrl } + ? { ...existingDraft, url: generatedUrl } : createDraft({ id, sourceId: id, @@ -300,7 +359,6 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { }; const selectSection = (section: StudioSection) => { - setApplyError(null); setActiveSection(section); }; @@ -339,8 +397,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { }); }; - const applySetupAsync = async (includeSelections = true) => { - setApplyError(null); + const applySetupAsync = async () => { setAppError(null); setAppErrorSourceId(null); setApplyProgress(5); @@ -353,7 +410,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { setActiveSection("board"); throw new Error(t("board.nameDescription")); } - const draftsToApply = includeSelections ? drafts : []; + const draftsToApply = drafts; const invalidDraft = draftsToApply.find( (draft) => !isHttpUrl(draft.url) || draft.secrets.some((secret) => secret.value.trim().length === 0), ); @@ -363,9 +420,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { setActiveSection("connect"); throw new Error(message); } - const appsToCreate = includeSelections - ? discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId)) - : []; + const appsToCreate = discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId)); const appWithoutAddress = appsToCreate.find((app) => !isHttpUrl(discoveredAppUrls[app.sourceId] ?? "")); if (appWithoutAddress) { const message = t("connect.appAddressRequired", { name: appWithoutAddress.containerName }); @@ -390,7 +445,8 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { primaryColor, secondaryColor, itemRadius, - layoutPreset, + layoutPreset: getLayoutPresetForColumnCount(columnCount), + columnCount, leftSidebar, rightSidebar, }, @@ -461,8 +517,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { setApplyMessage(t("review.progress.done")); setApplyProgress(100); await revalidatePathActionAsync("/init"); - } catch (error) { - setApplyError(error instanceof Error ? error.message : t("common.unknownError")); + } catch { setApplyProgress(0); } }; @@ -470,6 +525,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { const sectionProps: StudioSectionProps = { headingRef: sectionHeadingRef, environment, + assistantConfiguration, serverOrigin, setServerOrigin, urlMode, @@ -513,102 +569,101 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { setSecondaryColor, itemRadius, setItemRadius, - layoutPreset, - setLayoutPreset, + columnCount, + setColumnCount, leftSidebar, setLeftSidebar, rightSidebar, setRightSidebar, applyProgress, applyMessage, - applyError, appError, appErrorSourceId, }; - + const hasIntegrationsWithoutUrl = drafts.some((draft) => draft.url.trim().length === 0); + const continueDisabled = isApplying || (activeSection === "essentials" && serverOrigin.trim().length === 0); + const continueOnboarding = () => { + if (continueDisabled) return; + if (activeSection === "review") { + void applySetupAsync(); + } else if (activeSection === "connect" && hasIntegrationsWithoutUrl) { + setIncompleteIntegrationConfirmationOpened(true); + } else { + move(1); + } + }; return ( - <main className={classes.page}> + <main + className={classes.page} + style={ + { + "--studio-glow-color": primaryColor, + "--studio-secondary-glow-color": secondaryColor, + } as CSSProperties + } + > <div className={classes.shell}> - <Group className={classes.topbar} justify="space-between" mb="lg"> - <Image - src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg" - alt="Homarr" - className={classes.wordmark} - /> - <Group gap="xs"> - <Badge variant="light">{t("setupBadge")}</Badge> - <Badge variant="dot" color={environment.databaseDriver === "sqlite" ? "blue" : "grape"}> - {environment.databaseDriver} - </Badge> - </Group> + <Group className={classes.topbar} mb="lg"> + <OnboardingWordmark /> </Group> <Paper className={classes.studio} radius="lg"> <div className={classes.studioGrid}> <nav className={classes.rail} aria-label={t("navigationLabel")}> - <Stack className={classes.railList} gap={4}> - <Stack gap={4} mb="md" px="xs" visibleFrom="md"> - <Text fw={700}>{t("title")}</Text> - <Text size="xs" c="dimmed"> - {t("subtitle")} - </Text> - </Stack> + <Timeline + active={activeIndex} + bulletSize={32} + lineWidth={2} + color={primaryColor} + className={classes.timeline} + classNames={{ itemBullet: classes.timelineBullet }} + > {sectionDefinitions.map((section, index) => { const Icon = section.icon; const active = section.id === activeSection; return ( - <UnstyledButton + <Timeline.Item key={section.id} - ref={(node) => { - sectionButtonRefs.current[index] = node; - }} - className={classes.railButton} - data-active={active} - onClick={() => selectSection(section.id)} - onKeyDown={(event) => handleSectionKeyDown(event, index)} - aria-current={active ? "step" : undefined} - aria-controls="onboarding-studio-section" - tabIndex={active ? 0 : -1} - > - <Group wrap="nowrap" gap="sm"> - <ThemeIcon variant={active ? "light" : "transparent"} size="md"> - <Icon size={17} /> - </ThemeIcon> - <Stack gap={0} align="flex-start"> - <Text size="sm" fw={active ? 650 : 500}> - {t(`navigation.${section.id}`)} - </Text> - <Text size="xs" c="dimmed"> - {index + 1} / {sectionDefinitions.length} - </Text> - </Stack> - </Group> - </UnstyledButton> + bullet={<Icon size={16} aria-hidden />} + title={ + <UnstyledButton + ref={(node) => { + sectionButtonRefs.current[index] = node; + }} + className={classes.timelineButton} + data-active={active} + onClick={() => selectSection(section.id)} + onKeyDown={(event) => handleSectionKeyDown(event, index)} + aria-label={`${t(`navigation.${section.id}`)} (${index + 1}/${sectionDefinitions.length})`} + aria-current={active ? "step" : undefined} + aria-controls="onboarding-studio-section" + tabIndex={active ? 0 : -1} + > + <Stack gap={0} align="flex-start"> + <Text className={classes.timelineLabel} size="sm" fw={active ? 650 : 500}> + {t(`navigation.${section.id}`)} + </Text> + <Text className={classes.timelineLabel} size="xs" c="dimmed"> + {index + 1} / {sectionDefinitions.length} + </Text> + </Stack> + </UnstyledButton> + } + /> ); })} - </Stack> + </Timeline> </nav> <section id="onboarding-studio-section" className={classes.content} aria-labelledby="studio-section-title"> - <motion.div - key={activeSection} - initial={reduceMotion ? false : { x: 12 }} - animate={{ x: 0 }} - transition={{ duration: reduceMotion ? 0 : 0.18 }} - > + <div key={activeSection}> <StudioSectionContent section={activeSection} {...sectionProps} /> - </motion.div> - - {applyError ? ( - <Alert className={classes.applyError} color="red" title={t("review.errorTitle")} role="alert"> - {applyError} - </Alert> - ) : null} + </div> <Group className={classes.stickyActions} justify="space-between" wrap="nowrap"> <Button className={classes.backAction} - size="md" + size="sm" variant="default" leftSection={<IconArrowLeft size={16} />} aria-label={t("back")} @@ -619,34 +674,72 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { </Button> {activeSection === "review" ? ( <Button - size="md" + size="sm" loading={isApplying} rightSection={<IconSparkles size={16} />} - onClick={() => void applySetupAsync()} + onClick={continueOnboarding} > {t("buildBoard")} </Button> ) : ( <Group className={classes.primaryActions} gap="xs" wrap="nowrap"> - {activeSection === "essentials" ? ( - <Button - className={classes.fastAction} - size="md" - variant="subtle" - loading={isApplying} - onClick={() => void applySetupAsync(false)} + {activeSection === "connect" && hasIntegrationsWithoutUrl ? ( + <Popover + opened={incompleteIntegrationConfirmationOpened} + onDismiss={() => setIncompleteIntegrationConfirmationOpened(false)} + transitionProps={{ duration: 0 }} + position="top-end" + width={320} + shadow="md" + withArrow + trapFocus > - {t("buildBoard")} + <Popover.Target> + <Button + size="sm" + disabled={continueDisabled} + rightSection={<IconArrowRight size={16} />} + aria-haspopup="dialog" + aria-expanded={incompleteIntegrationConfirmationOpened} + onClick={() => setIncompleteIntegrationConfirmationOpened((opened) => !opened)} + > + {t("continue")} + </Button> + </Popover.Target> + <Popover.Dropdown role="dialog" aria-label={t("connect.incompleteConfirmation")}> + <Stack gap="sm"> + <Text size="sm">{t("connect.incompleteConfirmation")}</Text> + <Group justify="flex-end" gap="xs"> + <Button + size="xs" + variant="default" + onClick={() => setIncompleteIntegrationConfirmationOpened(false)} + > + {tCommon("cancel")} + </Button> + <Button + size="xs" + onClick={() => { + setIncompleteIntegrationConfirmationOpened(false); + move(1); + }} + > + {tCommon("confirm")} + </Button> + </Group> + </Stack> + </Popover.Dropdown> + </Popover> + ) : ( + <Button + size="sm" + disabled={continueDisabled} + rightSection={<IconArrowRight size={16} />} + onClick={continueOnboarding} + > + {t("continue")} </Button> - ) : null} - <Button - size="md" - disabled={isApplying} - rightSection={<IconArrowRight size={16} />} - onClick={() => move(1)} - > - {t("continue")} - </Button> + )} </Group> )} </Group> @@ -661,6 +754,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { interface StudioSectionProps { headingRef: React.RefObject<HTMLHeadingElement | null>; environment: OnboardingStudioProps["environment"]; + assistantConfiguration: ReactNode; serverOrigin: string; setServerOrigin: (value: string) => void; urlMode: UrlTemplateMode; @@ -698,15 +792,14 @@ interface StudioSectionProps { setSecondaryColor: (value: string) => void; itemRadius: MantineSize; setItemRadius: (value: MantineSize) => void; - layoutPreset: LayoutPreset; - setLayoutPreset: (value: LayoutPreset) => void; + columnCount: number; + setColumnCount: (value: number) => void; leftSidebar: boolean; setLeftSidebar: (value: boolean) => void; rightSidebar: boolean; setRightSidebar: (value: boolean) => void; applyProgress: number; applyMessage: string; - applyError: string | null; appError: string | null; appErrorSourceId: string | null; } @@ -753,65 +846,47 @@ const Essentials = (props: StudioSectionProps) => { <Stack gap="lg"> <SectionHeading headingRef={props.headingRef} title={t("title")} description={t("description")} /> <SimpleGrid cols={{ base: 1, sm: 2 }}> - <Select + <LanguageCombobox label={t("language")} - data={supportedLanguages.map((locale) => ({ - value: locale, - label: `${localeConfigurations[locale].name} · ${localeConfigurations[locale].translatedName}`, - }))} value={props.selectedLocale} - onChange={(value) => value && props.setSelectedLocale(value as SupportedLanguage)} - searchable - allowDeselect={false} + onChange={props.setSelectedLocale} + width="100%" /> - <SegmentedControl - aria-label={t("theme")} + <ColorSchemeCombobox + label={t("theme")} value={props.colorScheme} - onChange={(value) => props.setColorScheme(value as ColorScheme)} - data={[ - { value: "auto", label: t("themeAuto") }, - { value: "light", label: t("themeLight") }, - { value: "dark", label: t("themeDark") }, - ]} - fullWidth + onChange={props.setColorScheme} + width="100%" /> </SimpleGrid> - <Paper className={classes.sectionCard} radius="lg" p="lg"> - <Stack> - <Group wrap="nowrap" align="flex-start"> - <ThemeIcon variant="light" size="lg"> - <IconServer size={20} /> - </ThemeIcon> - <Stack gap={2}> - <Text fw={650}>{t("serverTitle")}</Text> - <Text size="sm" c="dimmed"> - {t("serverDescription")} - </Text> - </Stack> - </Group> - <TextInput - label={t("serverOrigin")} - description={t("serverOriginDescription")} - placeholder="home.lan · 192.168.1.10 · https://homarr.example.com" - value={props.serverOrigin} - onChange={(event) => props.setServerOrigin(event.currentTarget.value)} - /> - <SegmentedControl - fullWidth - value={props.urlMode} - onChange={(value) => props.setUrlMode(value as UrlTemplateMode)} - data={[ - { value: "hostPort", label: t("hostPort") }, - { value: "subdomain", label: t("subdomain") }, - { value: "path", label: t("path") }, - ]} - /> - {props.serverOrigin ? ( - <Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code> - ) : null} - </Stack> - </Paper> + <Stack gap="sm"> + <Alert variant="light" icon={<IconInfoCircle size={18} />} my="xs"> + {t("serverOriginHelp")} + </Alert> + <TextInput + label={t("serverTitle")} + placeholder="home.lan · 192.168.1.10 · https://homarr.example.com" + value={props.serverOrigin} + onChange={(event) => props.setServerOrigin(event.currentTarget.value)} + required + withAsterisk + autoFocus + /> + <OnboardingFloatingControl + ariaLabel={t("urlModeLabel")} + value={props.urlMode} + onChange={props.setUrlMode} + options={[ + { value: "hostPort", label: t("hostPort") }, + { value: "subdomain", label: t("subdomain") }, + { value: "path", label: t("path") }, + ]} + /> + {props.serverOrigin ? ( + <Code block>{buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)}</Code> + ) : null} + </Stack> <Switch checked={props.analyticsEnabled} @@ -887,19 +962,6 @@ const Discovery = (props: StudioSectionProps) => { }) : t(`status.${dockerStatus}`) } - action={ - props.environment.dockerConfigured ? ( - <ActionIcon - size={44} - variant="subtle" - aria-label={t("retryDocker")} - onClick={() => void props.docker.refetch()} - loading={props.docker.isFetching} - > - <IconRefresh size={18} /> - </ActionIcon> - ) : undefined - } /> <CapabilityRow icon={IconServer} @@ -946,10 +1008,6 @@ const Discovery = (props: StudioSectionProps) => { </Stack> </Alert> ) : null} - - <Alert variant="light" icon={<IconWorld size={18} />} title={t("platformTitle")}> - {t("platformDescription")} - </Alert> </Stack> ); }; @@ -991,7 +1049,7 @@ const CapabilityRow = ({ <Group justify="space-between" wrap="nowrap"> <Group wrap="nowrap" miw={0}> <ThemeIcon variant="light" color={color} size="lg"> - {status === "checking" ? <Loader size="sm" /> : <Icon size={20} />} + <Icon size={20} /> </ThemeIcon> <Stack gap={1} miw={0}> <Text fw={650}>{title}</Text> @@ -1130,7 +1188,15 @@ const Connections = (props: StudioSectionProps) => { ) : null} {props.drafts.length > 0 ? ( - <Accordion variant="separated" multiple defaultValue={props.drafts.slice(0, 1).map((draft) => draft.id)}> + <Accordion + className={classes.integrationAccordion} + variant="separated" + multiple + transitionDuration={200} + order={3} + mb="md" + defaultValue={props.drafts.slice(0, 1).map((draft) => draft.id)} + > {props.drafts.map((draft) => ( <IntegrationEditor key={draft.id} @@ -1141,7 +1207,7 @@ const Connections = (props: StudioSectionProps) => { ))} </Accordion> ) : ( - <Alert variant="light" icon={<IconPlugConnected size={18} />}> + <Alert variant="light" icon={<IconPlugConnected size={18} />} my="xs"> {t("empty")} </Alert> )} @@ -1162,9 +1228,14 @@ const IntegrationEditor = ({ const options = getAllSecretKindOptions(draft.kind); const apiKeyUrl = getIntegrationApiKeyUrl(draft.url, draft.kind); const documentationUrl = getIntegrationDocumentationUrl(draft.kind); + const needsConfiguration = + !isHttpUrl(draft.url) || draft.secrets.some((secret) => secret.value.trim().length === 0) || Boolean(draft.error); return ( <Accordion.Item value={draft.id}> - <Accordion.Control icon={<IntegrationAvatar kind={draft.kind} size="sm" />}> + <Accordion.Control + icon={<IntegrationAvatar kind={draft.kind} size="sm" />} + aria-label={needsConfiguration ? `${draft.name}: ${t("needsConfiguration")}` : draft.name} + > <Group justify="space-between" wrap="nowrap" pr="sm"> <Stack gap={0} miw={0}> <Text fw={650} truncate> @@ -1174,6 +1245,16 @@ const IntegrationEditor = ({ {getIntegrationName(draft.kind)} · {draft.source === "docker" ? t("dockerSource") : t("manualSource")} </Text> </Stack> + <ThemeIcon + className={classes.integrationStatus} + color={needsConfiguration ? "orange" : "green"} + variant="light" + size="sm" + radius="xl" + aria-label={t(needsConfiguration ? "needsConfiguration" : "configured")} + > + {needsConfiguration ? <IconAlertCircle size={14} /> : <IconCheck size={14} />} + </ThemeIcon> </Group> </Accordion.Control> <Accordion.Panel> @@ -1187,14 +1268,15 @@ const IntegrationEditor = ({ <TextInput label={t("url")} value={draft.url} - onChange={(event) => update({ url: event.currentTarget.value, error: null })} + onChange={(event) => update({ url: event.currentTarget.value, urlOverridden: true, error: null })} /> </SimpleGrid> {options.length > 1 ? ( - <SegmentedControl + <OnboardingFloatingControl + ariaLabel={t("credentialMethod")} value={String(draft.secretOption)} onChange={(value) => selectSecretOption(Number(value))} - data={options.map((secretKinds, index) => ({ + options={options.map((secretKinds, index) => ({ value: String(index), label: secretKinds.length === 0 ? t("noCredentials") : secretKinds.map(formatSecretKind).join(" + "), }))} @@ -1249,9 +1331,6 @@ const formatSecretKind = (kind: IntegrationSecretKind) => const BoardBuilder = (props: StudioSectionProps) => { const t = useScopedI18n("init.studio.board"); - const reduceMotion = useReducedMotion(); - const theme = useMantineTheme(); - const colorSwatches = Object.values(theme.colors).map((color) => color[6]); const previewWidgetKinds = getPreviewWidgetKinds(props); const previewAppCount = getPreviewAppCount(props); return ( @@ -1285,50 +1364,34 @@ const BoardBuilder = (props: StudioSectionProps) => { withAsterisk /> <SimpleGrid cols={2}> - <ColorInput - label={t("primaryColor")} - value={props.primaryColor} - onChange={props.setPrimaryColor} - format="hex" - swatches={colorSwatches} - withPicker={false} - disallowInput - /> - <ColorInput + <BoardColorInput label={t("primaryColor")} value={props.primaryColor} onChange={props.setPrimaryColor} /> + <BoardColorInput label={t("secondaryColor")} value={props.secondaryColor} onChange={props.setSecondaryColor} - format="hex" - swatches={colorSwatches} - withPicker={false} - disallowInput /> </SimpleGrid> <Stack gap="xs"> <Text size="sm" fw={500}> {t("radius")} </Text> - <SegmentedControl - aria-label={t("radius")} - fullWidth + <OnboardingFloatingControl + ariaLabel={t("radius")} value={props.itemRadius} - onChange={(value) => props.setItemRadius(value as MantineSize)} - data={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))} + onChange={props.setItemRadius} + options={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))} /> <Group gap="xs" aria-hidden> - {radiusValues.map((radius, index) => ( - <motion.div + {radiusValues.map((radius) => ( + <div key={radius} - animate={{ - borderRadius: props.itemRadius === radius ? `var(--mantine-radius-${radius})` : "4px", - scale: reduceMotion ? 1 : props.itemRadius === radius ? 1.06 : 1, - }} - transition={{ duration: reduceMotion ? 0 : 0.18 }} style={{ flex: 1, height: 34, - background: index % 2 === 0 ? props.primaryColor : props.secondaryColor, - opacity: props.itemRadius === radius ? 1 : 0.28, + borderRadius: `var(--mantine-radius-${radius})`, + background: "var(--mantine-color-default)", + border: "1px solid var(--mantine-color-default-border)", + opacity: props.itemRadius === radius ? 1 : 0.55, }} /> ))} @@ -1336,24 +1399,36 @@ const BoardBuilder = (props: StudioSectionProps) => { </Stack> </Stack> - <Stack> - <SegmentedControl - aria-label={t("layoutPreset")} - fullWidth - value={props.layoutPreset} - onChange={(value) => props.setLayoutPreset(value as LayoutPreset)} - data={[ - { value: "focused", label: t("layoutFocused") }, - { value: "balanced", label: t("layoutBalanced") }, - { value: "wide", label: t("layoutWide") }, - ]} - /> + <Stack gap="lg"> + <Stack gap="xs" pb="md"> + <Group justify="space-between" align="flex-start"> + <Stack gap={2}> + <Text size="sm" fw={500}> + {t("columns")} + </Text> + <Text size="xs" c="dimmed" maw="44ch"> + {t("columnsDescription")} + </Text> + </Stack> + <Badge variant="light">{t("columnCount", { count: props.columnCount })}</Badge> + </Group> + <Slider + mt="xs" + min={8} + max={24} + step={1} + value={props.columnCount} + onChange={props.setColumnCount} + marks={[8, 10, 12, 18, 24].map((value) => ({ value, label: String(value) }))} + thumbLabel={t("columns")} + thumbValueText={(value) => t("columnCount", { count: value })} + label={(value) => t("columnCount", { count: value })} + /> + </Stack> <BoardPreview ariaLabel={t("previewLabel")} - primaryColor={props.primaryColor} - secondaryColor={props.secondaryColor} itemRadius={props.itemRadius} - layoutPreset={props.layoutPreset} + baseColumnCount={props.columnCount} leftSidebar={props.leftSidebar} rightSidebar={props.rightSidebar} widgetKinds={previewWidgetKinds} @@ -1373,154 +1448,81 @@ const BoardBuilder = (props: StudioSectionProps) => { </SimpleGrid> </Stack> </SimpleGrid> - - <Divider label={t("widgetPrimer")} labelPosition="left" /> - <Stack gap="lg"> - <PrimerRow icon={IconApps} title={t("primerAppsTitle")} description={t("primerAppsDescription")} /> - <PrimerRow - icon={IconPlugConnected} - title={t("primerWidgetsTitle")} - description={t("primerWidgetsDescription")} - /> - <PrimerRow icon={IconTool} title={t("primerEditTitle")} description={t("primerEditDescription")} /> - </Stack> </Stack> ); }; const BoardPreview = ({ ariaLabel, - primaryColor, - secondaryColor, itemRadius, - layoutPreset, + baseColumnCount, leftSidebar, rightSidebar, widgetKinds, appCount, }: { ariaLabel: string; - primaryColor: string; - secondaryColor: string; itemRadius: MantineSize; - layoutPreset: LayoutPreset; + baseColumnCount: number; leftSidebar: boolean; rightSidebar: boolean; widgetKinds: WidgetKind[]; appCount: number; }) => { - const t = useScopedI18n("init.studio.board"); - const reduceMotion = useReducedMotion(); - const [layoutRole, setLayoutRole] = useState<"mobile" | "base">("base"); - const baseColumnCount = { focused: 8, balanced: 10, wide: 12 }[layoutPreset]; - const columnCount = - layoutRole === "mobile" ? 3 : Math.max(1, baseColumnCount - Number(leftSidebar) - Number(rightSidebar)); - const showLeftSidebar = layoutRole === "base" && leftSidebar; - const showRightSidebar = layoutRole === "base" && rightSidebar; + const columnCount = Math.max(1, baseColumnCount - Number(leftSidebar) - Number(rightSidebar)); const previewItems = [ ...widgetKinds.map((kind) => { const config = getDefaultWidgetConfig(kind); - return { - id: `widget:${kind}`, - width: config.width, - height: config.height, - }; + return { id: `widget:${kind}`, width: config.width, height: config.height }; }), ...Array.from({ length: appCount }, (_, index) => ({ id: `app:${index}`, width: 1, height: 1 })), ].slice(0, 12); return ( - <Stack gap="xs"> - <SegmentedControl<"mobile" | "base"> - aria-label={t("previewLayout")} - value={layoutRole} - onChange={setLayoutRole} - data={[ - { value: "mobile", label: t("previewMobile") }, - { value: "base", label: t("previewBase") }, - ]} - fullWidth - /> - <figure - className={classes.preview} - aria-label={ariaLabel} - data-layout-role={layoutRole} - data-layout-columns={columnCount} - style={ - { - gridTemplateColumns: [ - showLeftSidebar ? "0.24fr" : null, - "minmax(0, 1fr)", - showRightSidebar ? "0.24fr" : null, - ] - .filter(Boolean) - .join(" "), - } as CSSProperties - } - > - {showLeftSidebar ? <div className={classes.previewLane} /> : null} - <div className={classes.previewLane}> - <div - className={classes.previewTiles} - style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }} - > - {previewItems.map((item, index) => ( - <motion.div - key={item.id} - className={classes.previewTile} - aria-hidden - initial={reduceMotion ? false : { opacity: 0.65, scale: 0.96 }} - animate={{ opacity: 1, scale: 1, borderRadius: `var(--mantine-radius-${itemRadius})` }} - transition={{ - duration: reduceMotion ? 0 : 0.18, - delay: reduceMotion ? 0 : Math.min(index * 0.015, 0.12), - }} - style={{ - gridColumn: `span ${Math.min(columnCount, item.width)}`, - gridRow: `span ${Math.min(3, item.height)}`, - borderRadius: `var(--mantine-radius-${itemRadius})`, - background: `linear-gradient(135deg, color-mix(in srgb, ${primaryColor} 45%, transparent), color-mix(in srgb, ${secondaryColor} 38%, transparent))`, - }} - /> - ))} - </div> + <figure + className={classes.preview} + aria-label={ariaLabel} + data-layout-columns={columnCount} + style={ + { + gridTemplateColumns: [leftSidebar ? "0.24fr" : null, "minmax(0, 1fr)", rightSidebar ? "0.24fr" : null] + .filter(Boolean) + .join(" "), + } as CSSProperties + } + > + {leftSidebar ? <div className={classes.previewLane} /> : null} + <div className={classes.previewLane}> + <div className={classes.previewTiles} style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}> + {previewItems.map((item) => ( + <div + key={item.id} + className={classes.previewTile} + aria-hidden + style={{ + gridColumn: `span ${Math.min(columnCount, item.width)}`, + gridRow: `span ${Math.min(3, item.height)}`, + borderRadius: `var(--mantine-radius-${itemRadius})`, + }} + /> + ))} </div> - {showRightSidebar ? <div className={classes.previewLane} /> : null} - </figure> - </Stack> + </div> + {rightSidebar ? <div className={classes.previewLane} /> : null} + </figure> ); }; -const PrimerRow = ({ - icon: Icon, - title, - description, -}: { - icon: typeof IconApps; - title: string; - description: string; -}) => ( - <Group wrap="nowrap" align="flex-start"> - <ThemeIcon variant="light" size="lg" flex="0 0 auto"> - <Icon size={20} /> - </ThemeIcon> - <Stack gap={3}> - <Text fw={650}>{title}</Text> - <Text size="sm" c="dimmed" maw="65ch"> - {description} - </Text> - </Stack> - </Group> -); - const Extensions = (props: StudioSectionProps) => { const t = useScopedI18n("init.studio.extend"); return ( - <Stack gap="lg"> - <SectionHeading headingRef={props.headingRef} title={t("title")} description={t("description")} /> - <Tabs defaultValue="assistant" variant="outline"> + <Stack gap={0}> + <Title id="studio-section-title" ref={props.headingRef} order={2} tabIndex={-1} mb="xs"> + {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")} - - -
- - setModelId(value ?? "")} - placeholder={models.isFetching ? t("discovering") : t("modelPlaceholder")} - disabled={modelControlsDisabled || models.isFetching} - allowDeselect={false} - /> - )} - - setEnabled(event.currentTarget.checked)} label={t("enable")} /> - setWebSearch(event.currentTarget.checked)} - label={t("webSearch")} - disabled={!assistantProviderCanUseOpenRouterServerTools(provider)} - /> - - - - {t("advanced")} - - - -
- ); + 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")} - - + <Stack gap="xl" align="center"> + <Stack gap="sm" align="center"> + <Title id="onboarding-welcome-title" className={classes.heroTitle} ta="center"> {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 ee2a42e33..bea32cb7f 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", @@ -6355,6 +6328,7 @@ "provider": { "title": "Provider", "groups": { + "free": "Free with Homarr", "hosted": "Hosted providers", "local": "Local providers", "custom": "Custom" @@ -6362,7 +6336,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", @@ -6414,8 +6388,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