From 2283ec7113739b71433ae0ca756a35599a8db802 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 13 Aug 2026 21:11:39 +0200 Subject: [PATCH] feat: enhance onboarding --- apps/nextjs/src/app/[locale]/init/page.tsx | 2 + .../manage/_components/hero-banner.module.css | 22 ----- .../manage/_components/hero-banner.tsx | 50 +----------- .../api/src/router/onboard/onboard-router.ts | 7 +- packages/api/src/router/test/onboard.spec.ts | 31 +++++++ packages/onboarding/package.json | 2 + packages/onboarding/src/account.tsx | 2 + packages/onboarding/src/finish.tsx | 38 ++++++++- .../onboarding/src/onboarding-backdrop.tsx | 14 ++++ .../src/onboarding-studio.module.css | 70 +++++++++++++--- .../onboarding/src/onboarding-wordmark.tsx | 73 +++++++++++++++-- packages/onboarding/src/setup-studio.tsx | 49 +++++++++--- packages/onboarding/src/types.ts | 1 + packages/onboarding/src/welcome.tsx | 15 ++-- .../onboarding/src/wordmark-colors.spec.ts | 18 +++++ packages/onboarding/src/wordmark-colors.ts | 23 ++++++ packages/translation/src/lang/en.json | 9 ++- packages/ui/src/components/index.tsx | 1 + .../components/integration-marquee.module.css | 41 ++++++++++ .../integration-marquee.module.d.css.ts | 2 + .../ui/src/components/integration-marquee.tsx | 33 ++++++++ pnpm-lock.yaml | 80 ++++++------------- pnpm-workspace.yaml | 2 + 23 files changed, 415 insertions(+), 170 deletions(-) create mode 100644 packages/onboarding/src/onboarding-backdrop.tsx create mode 100644 packages/onboarding/src/wordmark-colors.spec.ts create mode 100644 packages/onboarding/src/wordmark-colors.ts create mode 100644 packages/ui/src/components/integration-marquee.module.css create mode 100644 packages/ui/src/components/integration-marquee.module.d.css.ts create mode 100644 packages/ui/src/components/integration-marquee.tsx diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index ad03d889a..b219d1c33 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 { getPackageVersion } from "~/versions/package-reader"; import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration"; export default async function InitPage() { @@ -55,6 +56,7 @@ export default async function InitPage() { return ( int.name !== "Mock") - .map((int) => int.iconUrl); - -const countIconGroups = 3; -const animationDurationInSeconds = icons.length; -const arrayInChunks = splitToNChunks(icons, countIconGroups); -const gridSpan = 12 / countIconGroups; - export const HeroBanner = async () => { const t = await getScopedI18n("management.page.home"); @@ -30,41 +20,7 @@ export const HeroBanner = async () => { - - {Array(countIconGroups) - .fill(0) - .map((_, columnIndex) => ( - - - {arrayInChunks[columnIndex]?.map((icon, index) => ( - - ))} - - {/* This is used for making the animation seem seamless */} - {arrayInChunks[columnIndex]?.map((icon, index) => ( - - ))} - - - ))} - + ); diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index e4eb293e1..1fc3972da 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -411,9 +411,10 @@ export const onboardRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); }); if (!result.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unable to connect to ${integration.name}: ${result.error.message}`, + logger.warn("Creating onboarding integration after its connection test failed", { + name: integration.name, + kind: integration.kind, + error: result.error.message, }); } }), diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts index 6f85e6fe4..4b765792a 100644 --- a/packages/api/src/router/test/onboard.spec.ts +++ b/packages/api/src/router/test/onboard.spec.ts @@ -528,6 +528,37 @@ describe("onboard.completeSetup", () => { } }); + it("creates a selected integration when its connection test fails", async () => { + const db = createDb(); + const claimToken = await seedOnboardingAsync(db); + await seedBoardAsync(db, { boardId: "target" }); + const input = completionInput("target"); + input.integrations = [ + { + sourceId: "manual:sonarr", + name: "Sonarr", + url: "http://sonarr:8989", + kind: "sonarr", + secrets: [{ kind: "apiKey", value: "invalid" }], + }, + ]; + const testConnection = vi + .spyOn(integrationConnection, "testConnectionAsync") + .mockResolvedValue({ success: false, error: new Error("Connection refused") as never }); + + try { + await expect(createCaller(db, claimToken).completeSetup(input)).resolves.toMatchObject({ boardId: "target" }); + expect( + await db.query.integrations.findFirst({ + where: eq(integrations.id, stableIntegrationIdForSource("manual:sonarr")), + }), + ).toMatchObject({ name: "Sonarr", kind: "sonarr" }); + expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish" }); + } finally { + testConnection.mockRestore(); + } + }); + it("rolls draft records back with board writes and reuses stable IDs on retry", async () => { const db = createDb(); const claimToken = await seedOnboardingAsync(db); diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index 8000df6b2..cfb577e0d 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -36,11 +36,13 @@ "@mantine/core": "catalog:", "@mantine/hooks": "catalog:", "@tabler/icons-react": "catalog:", + "canvas-confetti": "catalog:", "react": "catalog:", "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", + "@types/canvas-confetti": "catalog:", "@types/react": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/onboarding/src/account.tsx b/packages/onboarding/src/account.tsx index 3b30620ad..7e80926e2 100644 --- a/packages/onboarding/src/account.tsx +++ b/packages/onboarding/src/account.tsx @@ -17,6 +17,7 @@ import { groupCreateSchema } from "@homarr/validation/group"; import { userInitSchema } from "@homarr/validation/user"; import { didCredentialsSignInFail } from "./account-recovery"; +import { OnboardingBackdrop } from "./onboarding-backdrop"; import type { OnboardingStudioProps } from "./types"; import classes from "./onboarding-studio.module.css"; @@ -30,6 +31,7 @@ const AccountShell = ({ children: React.ReactNode; }) => (
+
diff --git a/packages/onboarding/src/finish.tsx b/packages/onboarding/src/finish.tsx index 1064a0f74..5e20eb425 100644 --- a/packages/onboarding/src/finish.tsx +++ b/packages/onboarding/src/finish.tsx @@ -1,12 +1,15 @@ "use client"; +import type { MouseEvent as ReactMouseEvent } from "react"; import { Button, Group, Paper, SimpleGrid, Stack, Text, ThemeIcon, Title } from "@mantine/core"; import { IconArrowRight, IconBook2, IconSparkles, IconTool } from "@tabler/icons-react"; +import confetti from "canvas-confetti"; import { createDocumentationLink } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; +import { OnboardingBackdrop } from "./onboarding-backdrop"; import type { OnboardingStudioProps } from "./types"; import classes from "./onboarding-studio.module.css"; @@ -14,9 +17,36 @@ export const Finish = ({ environment }: OnboardingStudioProps) => { const t = useScopedI18n("init.studio.finish"); const boardHref = `/boards/${encodeURIComponent(environment.initialBoard?.name ?? "dashboard")}`; const destination = environment.canConfigurePrivileged ? boardHref : "/auth/login?callbackUrl=%2Finit"; + const openBoard = (event: ReactMouseEvent) => { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + + event.preventDefault(); + const button = event.currentTarget.getBoundingClientRect(); + void confetti({ + particleCount: 120, + spread: 75, + startVelocity: 45, + origin: { + x: (button.left + button.width / 2) / window.innerWidth, + y: (button.top + button.height / 2) / window.innerHeight, + }, + disableForReducedMotion: true, + }); + window.setTimeout(() => window.location.assign(destination), 450); + }; return (
+
@@ -33,7 +63,13 @@ export const Finish = ({ environment }: OnboardingStudioProps) => { {t("description")} - diff --git a/packages/onboarding/src/onboarding-backdrop.tsx b/packages/onboarding/src/onboarding-backdrop.tsx new file mode 100644 index 000000000..9682d65d8 --- /dev/null +++ b/packages/onboarding/src/onboarding-backdrop.tsx @@ -0,0 +1,14 @@ +import { IntegrationMarquee } from "@homarr/ui"; + +import classes from "./onboarding-studio.module.css"; + +export const OnboardingBackdrop = () => ( +
+
+ +
+
+ +
+
+); diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css index 62c2a909a..7678aec1d 100644 --- a/packages/onboarding/src/onboarding-studio.module.css +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -19,22 +19,44 @@ var(--mantine-color-body); } -.page::before { - content: ""; +.marqueeBackdrop { position: fixed; - inset: -2rem; + z-index: 0; + inset: 0; + overflow: hidden; pointer-events: none; - 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%); + opacity: 0.42; + filter: blur(1.25px) saturate(0.92); + mask-image: linear-gradient(to right, black 0%, black 18%, transparent 40%, transparent 60%, black 82%, black 100%); +} + +.marqueeRail { + position: absolute; + top: -14rem; + bottom: -14rem; + width: clamp(22rem, 26vw, 32rem); + mask-image: linear-gradient(to bottom, transparent, black 10%, black 90%, transparent); +} + +.marqueeRail > * { + height: 100%; + overflow: visible; +} + +.marqueeRailLeft { + left: -7rem; + --integration-marquee-accent: var(--studio-glow-color); +} + +.marqueeRailRight { + right: -7rem; + top: -22rem; + --integration-marquee-accent: var(--studio-secondary-glow-color); } .shell { position: relative; + z-index: 1; width: min(100% - 2rem, 76rem); margin-inline: auto; padding-block: clamp(1rem, 3vw, 2.5rem) calc(6rem + env(safe-area-inset-bottom)); @@ -56,7 +78,12 @@ max-width: min(70vw, 28rem); } -:global([data-mantine-color-scheme="light"]) .wordmark { +.wordmark.welcomeWordmark { + height: clamp(8rem, 20vw, 12rem); + max-width: min(92vw, 42rem); +} + +:global([data-mantine-color-scheme="light"]) .wordmark:not([data-recolored="true"]) { filter: brightness(0); } @@ -304,6 +331,23 @@ } @media (max-width: 48em) { + .marqueeBackdrop { + opacity: 0.24; + mask-image: linear-gradient(to right, black, transparent 42%, transparent 58%, black); + } + + .marqueeRail { + width: 17rem; + } + + .marqueeRailLeft { + left: -8rem; + } + + .marqueeRailRight { + right: -8rem; + } + .shell { width: min(100% - 1rem, 76rem); } @@ -371,6 +415,10 @@ height: 3rem; } + .wordmark.welcomeWordmark { + height: clamp(7rem, 34vw, 10rem); + } + .hero { padding-block: 2rem 3rem; } diff --git a/packages/onboarding/src/onboarding-wordmark.tsx b/packages/onboarding/src/onboarding-wordmark.tsx index d7ebcdfc5..437583e9e 100644 --- a/packages/onboarding/src/onboarding-wordmark.tsx +++ b/packages/onboarding/src/onboarding-wordmark.tsx @@ -1,9 +1,66 @@ -import classes from "./onboarding-studio.module.css"; +"use client"; -export const OnboardingWordmark = () => ( - Homarr -); +import type { MouseEvent as ReactMouseEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useComputedColorScheme } from "@mantine/core"; +import confetti from "canvas-confetti"; + +import classes from "./onboarding-studio.module.css"; +import { recolorWordmark } from "./wordmark-colors"; + +interface OnboardingWordmarkProps { + primaryColor?: string; + secondaryColor?: string; + large?: boolean; +} + +const wordmarkUrl = "/logo/homarr-wordmark-light.svg"; + +export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => { + const [source, setSource] = useState(null); + const colorScheme = useComputedColorScheme("light"); + const recolored = Boolean(source && primaryColor && secondaryColor); + + useEffect(() => { + const controller = new AbortController(); + void fetch(wordmarkUrl, { signal: controller.signal }) + .then((response) => (response.ok ? response.text() : null)) + .then((svg) => { + if (svg) setSource(svg); + }) + .catch(() => undefined); + return () => controller.abort(); + }, []); + + const src = useMemo(() => { + if (!source || !primaryColor || !secondaryColor) return wordmarkUrl; + return `data:image/svg+xml,${encodeURIComponent( + recolorWordmark(source, primaryColor, secondaryColor, colorScheme === "light" ? "#1A1B1E" : undefined), + )}`; + }, [colorScheme, primaryColor, secondaryColor, source]); + + const celebrate = (event: ReactMouseEvent) => { + const logo = event.currentTarget.getBoundingClientRect(); + void confetti({ + particleCount: 60, + spread: 65, + startVelocity: 32, + origin: { + x: (logo.left + logo.width / 2) / window.innerWidth, + y: (logo.top + logo.height / 2) / window.innerHeight, + }, + colors: primaryColor && secondaryColor ? [primaryColor, secondaryColor] : undefined, + disableForReducedMotion: true, + }); + }; + + return ( + Homarr + ); +}; diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index 472125f8c..80f1b91c9 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -64,6 +64,7 @@ import { IconServer, IconSparkles, IconSunMoon, + IconX, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -95,6 +96,7 @@ import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombob import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid"; import type { OnboardingStudioProps } from "./types"; +import { OnboardingBackdrop } from "./onboarding-backdrop"; import { OnboardingWordmark } from "./onboarding-wordmark"; import { isHttpUrl, @@ -217,6 +219,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard"); const [primaryColor, setPrimaryColor] = useState("#fa5252"); const [secondaryColor, setSecondaryColor] = useState("#fd7e14"); + const [boardColorsDirty, setBoardColorsDirty] = useState(false); const [itemRadius, setItemRadius] = useState("lg"); const [columnCount, setColumnCount] = useState(10); const [leftSidebar, setLeftSidebar] = useState(false); @@ -425,6 +428,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS setApplyMessage(t("review.progress.applying")); setApplyProgress(20); + await new Promise((resolve) => setTimeout(resolve, 300)); const selectedSourceIds = new Set(draftsToApply.flatMap((draft) => (draft.sourceId ? [draft.sourceId] : []))); const completion = await complete.mutateAsync({ server: { @@ -557,9 +561,15 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS selectedBoardId, setSelectedBoardId, primaryColor, - setPrimaryColor, + setPrimaryColor: (value) => { + setPrimaryColor(value); + setBoardColorsDirty(true); + }, secondaryColor, - setSecondaryColor, + setSecondaryColor: (value) => { + setSecondaryColor(value); + setBoardColorsDirty(true); + }, itemRadius, setItemRadius, columnCount, @@ -595,9 +605,13 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS } as CSSProperties } > +
- + @@ -1238,11 +1252,20 @@ const IntegrationEditor = ({ testConnection.reset(); update(patch); }; + const testFailed = testConnection.data?.success === false || testConnection.isError; + const testSucceeded = testConnection.data?.success === true; + const status = needsConfiguration + ? { color: "orange", label: t("needsConfiguration"), icon: } + : testSucceeded + ? { color: "green", label: t("tested"), icon: } + : testFailed + ? { color: "red", label: t("testFailed"), icon: } + : { color: "gray", label: t("testConnection"), icon: }; return ( } - aria-label={needsConfiguration ? `${draft.name}: ${t("needsConfiguration")}` : draft.name} + aria-label={`${draft.name}: ${status.label}`} > @@ -1255,13 +1278,13 @@ const IntegrationEditor = ({ - {needsConfiguration ? : } + {status.icon} @@ -1337,10 +1360,16 @@ const IntegrationEditor = ({ size="xs" disabled={needsConfiguration} loading={testConnection.isPending} - color={ - testConnection.data?.success ? "green" : testConnection.data?.success === false ? "red" : undefined + color={testSucceeded ? "green" : testFailed ? "red" : undefined} + leftSection={ + testSucceeded ? ( + + ) : testFailed ? ( + + ) : ( + + ) } - leftSection={testConnection.data?.success ? : } onClick={() => testConnection.mutate({ sourceId: draft.sourceId ?? draft.id, diff --git a/packages/onboarding/src/types.ts b/packages/onboarding/src/types.ts index b9a6cba07..e47a06a1a 100644 --- a/packages/onboarding/src/types.ts +++ b/packages/onboarding/src/types.ts @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import type { OnboardingStep } from "@homarr/definitions"; export interface OnboardingEnvironment { + version: string; currentStep: OnboardingStep; databaseDriver: "sqlite" | "mysql" | "postgresql"; externalAuthEnabled: boolean; diff --git a/packages/onboarding/src/welcome.tsx b/packages/onboarding/src/welcome.tsx index 8c5acd581..5fc19c330 100644 --- a/packages/onboarding/src/welcome.tsx +++ b/packages/onboarding/src/welcome.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Alert, Badge, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { Alert, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; import { IconArrowRight, IconDatabaseImport } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -9,6 +9,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client"; import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; +import { OnboardingBackdrop } from "./onboarding-backdrop"; import { OnboardingWordmark } from "./onboarding-wordmark"; import type { OnboardingStudioProps } from "./types"; import classes from "./onboarding-studio.module.css"; @@ -75,20 +76,18 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) = return (
+
- - - - {t("badge")} - - -
+ {t("title")} + + {t("version", { version: environment.version })} + {t("description")} diff --git a/packages/onboarding/src/wordmark-colors.spec.ts b/packages/onboarding/src/wordmark-colors.spec.ts new file mode 100644 index 000000000..0295ff995 --- /dev/null +++ b/packages/onboarding/src/wordmark-colors.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { recolorWordmark } from "./wordmark-colors"; + +describe("recolorWordmark", () => { + it("recolors red fills while preserving white details", () => { + const svg = ''; + + expect(recolorWordmark(svg, "#228BE6", "#15AABF", "#1A1B1E")).toBe( + '', + ); + }); + + it("leaves the source unchanged for incomplete colors", () => { + const svg = ''; + expect(recolorWordmark(svg, "#228BE6", "")).toBe(svg); + }); +}); diff --git a/packages/onboarding/src/wordmark-colors.ts b/packages/onboarding/src/wordmark-colors.ts new file mode 100644 index 000000000..a85248208 --- /dev/null +++ b/packages/onboarding/src/wordmark-colors.ts @@ -0,0 +1,23 @@ +const hexColorPattern = /^#[0-9a-f]{6}$/iu; + +export const recolorWordmark = ( + svg: string, + primaryColor: string, + secondaryColor: string, + foregroundColor?: string, +) => { + if (!hexColorPattern.test(primaryColor) || !hexColorPattern.test(secondaryColor)) return svg; + + return svg.replace(/fill="(#[0-9a-f]{6})"/giu, (fill, color: string) => { + const red = Number.parseInt(color.slice(1, 3), 16); + const green = Number.parseInt(color.slice(3, 5), 16); + const blue = Number.parseInt(color.slice(5, 7), 16); + const redDifference = red - Math.max(green, blue); + + if (foregroundColor && hexColorPattern.test(foregroundColor) && Math.min(red, green, blue) >= 235) { + return `fill="${foregroundColor}"`; + } + if (redDifference < 15) return fill; + return `fill="${Math.min(green, blue) >= 150 ? secondaryColor : primaryColor}"`; + }); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0c308b011..fe42c3858 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -28,9 +28,9 @@ "review": "Review" }, "welcome": { - "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.", + "title": "Welcome home", + "version": "Welcome to Homarr v{version}", + "description": "Let's get you started with homarr", "start": "Get started", "restore": "Come from a backup", "restoreTitle": "Restore this Homarr", @@ -133,7 +133,8 @@ "testError": "Connection failed", "testErrorDescription": "Check the service address and credentials, then try again.", "needsConfiguration": "Needs configuration", - "configured": "Configured", + "tested": "Connection tested", + "testFailed": "Connection test failed", "incompleteConfirmation": "Unfinished integrations will be skipped. Continue without them?" }, "board": { diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 91a55ed57..50878a4f8 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -18,6 +18,7 @@ export { LanguageIcon } from "./language-icon"; export { LanguageCombobox } from "./language-combobox"; export { ColorSchemeCombobox } from "./color-scheme-combobox"; export { BoardColorInput } from "./board-color-input"; +export { IntegrationMarquee } from "./integration-marquee"; export { Link } from "./link"; export { SelectGridLayout, diff --git a/packages/ui/src/components/integration-marquee.module.css b/packages/ui/src/components/integration-marquee.module.css new file mode 100644 index 000000000..a3cb0daa2 --- /dev/null +++ b/packages/ui/src/components/integration-marquee.module.css @@ -0,0 +1,41 @@ +.root { + --integration-marquee-accent: var(--mantine-primary-color-filled); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--mantine-spacing-xl); + height: 100%; + overflow: hidden; + transform: rotate(10deg); +} + +.track { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--mantine-spacing-xl); + animation: integrationMarquee var(--marquee-duration) linear infinite; +} + +.iconFrame { + display: grid; + flex: 0 0 4.5rem; + width: 4.5rem; + height: 4.5rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--integration-marquee-accent) 24%, transparent); + border-radius: var(--mantine-radius-lg); + background: color-mix(in srgb, var(--integration-marquee-accent) 8%, var(--mantine-color-body)); + box-shadow: var(--mantine-shadow-sm); +} + +@keyframes integrationMarquee { + to { + transform: translateY(-50%); + } +} + +@media (prefers-reduced-motion: reduce) { + .track { + animation: none; + } +} diff --git a/packages/ui/src/components/integration-marquee.module.d.css.ts b/packages/ui/src/components/integration-marquee.module.d.css.ts new file mode 100644 index 000000000..94293cffd --- /dev/null +++ b/packages/ui/src/components/integration-marquee.module.d.css.ts @@ -0,0 +1,2 @@ +declare const classes: Record; +export default classes; diff --git a/packages/ui/src/components/integration-marquee.tsx b/packages/ui/src/components/integration-marquee.tsx new file mode 100644 index 000000000..e29ce87ef --- /dev/null +++ b/packages/ui/src/components/integration-marquee.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties } from "react"; +import { Image } from "@mantine/core"; + +import { splitToNChunks } from "@homarr/common"; +import { integrationDefs } from "@homarr/definitions"; + +import classes from "./integration-marquee.module.css"; + +const iconGroups = splitToNChunks( + Object.values(integrationDefs) + .filter((integration) => integration.name !== "Mock") + .map((integration) => integration.iconUrl), + 3, +); +const animationDuration = iconGroups.flat().length * 2; + +export const IntegrationMarquee = ({ className }: { className?: string }) => ( +
+ {iconGroups.map((icons, columnIndex) => ( +
+ {[...icons, ...icons].map((icon, iconIndex) => ( +
+ +
+ ))} +
+ ))} +
+); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79b240682..9aa1f5a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ catalogs: '@types/better-sqlite3': specifier: 7.6.13 version: 7.6.13 + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/chroma-js': specifier: 3.1.2 version: 3.1.2 @@ -366,6 +369,9 @@ catalogs: better-sqlite3: specifier: ^12.11.1 version: 12.11.1 + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 chroma-js: specifier: ^3.2.0 version: 3.2.0 @@ -495,9 +501,6 @@ catalogs: mermaid: specifier: ^11.15.0 version: 11.15.0 - motion: - specifier: ^13.0.0 - version: 13.0.0 mysql2: specifier: 3.22.6 version: 3.22.6 @@ -2444,6 +2447,9 @@ importers: '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) + canvas-confetti: + specifier: 'catalog:' + version: 1.9.4 react: specifier: 'catalog:' version: 19.2.7 @@ -2454,6 +2460,9 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/canvas-confetti': + specifier: 'catalog:' + version: 1.9.0 '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -9167,6 +9176,9 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -10376,6 +10388,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -11889,17 +11904,6 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@13.0.0: - resolution: {integrity: sha512-nQGZXlsiigN48nzvE7AL1GLekql+etcphp8v+PQ0X04oxO20yVmV9rU9XQ25c136RdeIYoaWlKT9oAwvJza3mg==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -13481,23 +13485,6 @@ packages: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} - motion-dom@13.0.0: - resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} - - motion-utils@13.0.0: - resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} - - motion@13.0.0: - resolution: {integrity: sha512-RYZjEpgHUCKjBNqjmUQzW93VJWLvEZGTPdKRFSqOdOl07IIMvz+WrsZmD1XXAkoFLruW/8bO1L7UF+ViQTxtLg==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - mpd-parser@1.4.0: resolution: {integrity: sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==} @@ -24270,6 +24257,8 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@types/canvas-confetti@1.9.0': {} + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -24777,7 +24766,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -24833,7 +24822,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/utils@4.1.10': dependencies: @@ -25581,6 +25570,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + canvas-confetti@1.9.4: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -27268,15 +27259,6 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - motion-dom: 13.0.0 - motion-utils: 13.0.0 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - fresh@0.5.2: {} fs-constants@1.0.0: {} @@ -29223,20 +29205,6 @@ snapshots: mkdirp@3.0.1: {} - motion-dom@13.0.0: - dependencies: - motion-utils: 13.0.0 - - motion-utils@13.0.0: {} - - motion@13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - framer-motion: 13.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - tslib: 2.8.1 - optionalDependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - mpd-parser@1.4.0: dependencies: '@babel/runtime': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index adffad833..a551b83be 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -227,6 +227,8 @@ catalog: remark-directive: ^3.0.1 remark-gfm: ^4.0.1 react-shiki: ^0.11.0 + canvas-confetti: ^1.9.4 + '@types/canvas-confetti': ^1.9.0 catalogMode: strict nodeLinker: hoisted