feat: enhance onboarding

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:12 +02:00
1 parent ece6757979
commit 775ae0147c
23 files changed
+415 -170

No files matched your search

@@ -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 (
<OnboardingStudio
environment={{
version: getPackageVersion(),
currentStep,
databaseDriver,
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
@@ -21,26 +21,4 @@
.scrollContainer {
height: 100%;
transform: rotateZ(10deg);
}
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -1,20 +1,10 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui";
import classes from "./hero-banner.module.css";
const icons = Object.values(integrationDefs)
.filter((int) => 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 () => {
</Group>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
<Stack
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))}
</Stack>
</GridCol>
))}
</Grid>
<IntegrationMarquee />
</Box>
</Box>
);
@@ -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,
});
}
}),
@@ -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);
+2
View File
@@ -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:"
+2
View File
@@ -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;
}) => (
<main className={classes.page}>
<OnboardingBackdrop />
<div className={classes.shell}>
<Stack mih="calc(100dvh - 6rem)" justify="center" align="center">
<Paper className={classes.studio} radius="lg" p={{ base: "lg", sm: "xl" }} w="100%" maw="36rem">
+37 -1
View File
@@ -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<HTMLAnchorElement>) => {
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 (
<main className={classes.page}>
<OnboardingBackdrop />
<div className={classes.shell}>
<Stack mih="calc(100dvh - 6rem)" justify="center" align="center">
<div style={{ width: "100%", maxWidth: "52rem" }}>
@@ -33,7 +63,13 @@ export const Finish = ({ environment }: OnboardingStudioProps) => {
{t("description")}
</Text>
</Stack>
<Button component={Link} href={destination} size="lg" rightSection={<IconArrowRight size={18} />}>
<Button
component={Link}
href={destination}
size="lg"
rightSection={<IconArrowRight size={18} />}
onClick={openBoard}
>
{t("openBoard")}
</Button>
<SimpleGrid cols={{ base: 1, sm: 3 }} w="100%">
@@ -0,0 +1,14 @@
import { IntegrationMarquee } from "@homarr/ui";
import classes from "./onboarding-studio.module.css";
export const OnboardingBackdrop = () => (
<div className={classes.marqueeBackdrop} aria-hidden>
<div className={`${classes.marqueeRail} ${classes.marqueeRailLeft}`}>
<IntegrationMarquee />
</div>
<div className={`${classes.marqueeRail} ${classes.marqueeRailRight}`}>
<IntegrationMarquee />
</div>
</div>
);
@@ -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;
}
@@ -1,9 +1,66 @@
import classes from "./onboarding-studio.module.css";
"use client";
export const OnboardingWordmark = () => (
<img
src="/logo/homarr-wordmark-light.svg"
alt="Homarr"
className={`${classes.wordmark} ${classes.onboardingWordmark}`}
/>
);
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<string | null>(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<HTMLImageElement>) => {
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 (
<img
src={src}
alt="Homarr"
data-recolored={recolored || undefined}
className={`${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""}`}
onMouseEnter={celebrate}
/>
);
};
+39 -10
View File
@@ -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<MantineSize>("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
}
>
<OnboardingBackdrop />
<div className={classes.shell}>
<Group className={classes.topbar} mb="lg">
<OnboardingWordmark />
<OnboardingWordmark
primaryColor={boardColorsDirty ? primaryColor : undefined}
secondaryColor={boardColorsDirty ? secondaryColor : undefined}
/>
</Group>
<Paper className={classes.studio} radius="lg">
@@ -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: <IconAlertCircle size={14} /> }
: testSucceeded
? { color: "green", label: t("tested"), icon: <IconCheck size={14} /> }
: testFailed
? { color: "red", label: t("testFailed"), icon: <IconX size={14} /> }
: { color: "gray", label: t("testConnection"), icon: <IconPlugConnected size={14} /> };
return (
<Accordion.Item value={draft.id}>
<Accordion.Control
icon={<IntegrationAvatar kind={draft.kind} size="sm" />}
aria-label={needsConfiguration ? `${draft.name}: ${t("needsConfiguration")}` : draft.name}
aria-label={`${draft.name}: ${status.label}`}
>
<Group justify="space-between" wrap="nowrap" pr="sm">
<Stack gap={0} miw={0}>
@@ -1255,13 +1278,13 @@ const IntegrationEditor = ({
</Stack>
<ThemeIcon
className={classes.integrationStatus}
color={needsConfiguration ? "orange" : "green"}
color={status.color}
variant="light"
size="sm"
radius="xl"
aria-label={t(needsConfiguration ? "needsConfiguration" : "configured")}
aria-label={status.label}
>
{needsConfiguration ? <IconAlertCircle size={14} /> : <IconCheck size={14} />}
{status.icon}
</ThemeIcon>
</Group>
</Accordion.Control>
@@ -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 ? (
<IconCheck size={14} />
) : testFailed ? (
<IconX size={14} />
) : (
<IconPlugConnected size={14} />
)
}
leftSection={testConnection.data?.success ? <IconCheck size={14} /> : <IconPlugConnected size={14} />}
onClick={() =>
testConnection.mutate({
sourceId: draft.sourceId ?? draft.id,
+1
View File
@@ -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;
+7 -8
View File
@@ -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 (
<main className={classes.page}>
<OnboardingBackdrop />
<div className={classes.shell}>
<Group className={classes.topbar} justify="space-between">
<OnboardingWordmark />
<Badge variant="light" size="lg">
{t("badge")}
</Badge>
</Group>
<section className={classes.hero} aria-labelledby="onboarding-welcome-title">
<Stack gap="xl" align="center">
<OnboardingWordmark large />
<Stack gap="sm" align="center">
<Title id="onboarding-welcome-title" className={classes.heroTitle} ta="center">
{t("title")}
</Title>
<Text fw={600} size="lg" ta="center">
{t("version", { version: environment.version })}
</Text>
<Text size="lg" c="dimmed" maw="38rem" ta="center">
{t("description")}
</Text>
@@ -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 = '<path fill="#FA5252"/><path fill="#FDD7D7"/><path fill="#FEFDFD"/>';
expect(recolorWordmark(svg, "#228BE6", "#15AABF", "#1A1B1E")).toBe(
'<path fill="#228BE6"/><path fill="#15AABF"/><path fill="#1A1B1E"/>',
);
});
it("leaves the source unchanged for incomplete colors", () => {
const svg = '<path fill="#FA5252"/>';
expect(recolorWordmark(svg, "#228BE6", "")).toBe(svg);
});
});
@@ -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}"`;
});
};
+5 -4
View File
@@ -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": {
+1
View File
@@ -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,
@@ -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;
}
}
@@ -0,0 +1,2 @@
declare const classes: Record<string, string>;
export default classes;
@@ -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 }) => (
<div className={[classes.root, className].filter(Boolean).join(" ")} aria-hidden>
{iconGroups.map((icons, columnIndex) => (
<div
key={columnIndex}
className={classes.track}
style={{ "--marquee-duration": `${animationDuration - columnIndex * 3}s` } as CSSProperties}
>
{[...icons, ...icons].map((icon, iconIndex) => (
<div key={`${icon}-${iconIndex}`} className={classes.iconFrame}>
<Image src={icon} alt="" fit="contain" w={52} h={52} />
</div>
))}
</div>
))}
</div>
);
+24 -56
View File
@@ -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
+2
View File
@@ -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