feat: enhance onboarding
This commit is contained in:
1 parent
e5d6b6be87
commit
2283ec7113
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);
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"`;
|
||||
});
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
Generated
+24
-56
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user