This commit is contained in:
Thomas Camlong committed 2026-05-19 16:21:32 +02:00
1 parent 97b7c417ab
commit f773b4a15e
31 files changed
+7559 -22

No files matched your search

@@ -53,7 +53,7 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu />
<HeaderButton href={`/boards/${board.name}/settings`}>
<HeaderButton href={`/boards/${board.name}/settings`} data-onboarding-tour-id="board-settings">
<IconSettings stroke={1.5} />
</HeaderButton>
@@ -173,7 +173,7 @@ const EditModeMenu = () => {
usePreventLeaveWithDirty(isEditMode);
return (
<HeaderButton onClick={toggle} loading={isPending}>
<HeaderButton onClick={toggle} loading={isPending} data-onboarding-tour-id="board-edit-mode">
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
);
@@ -185,7 +185,7 @@ const SelectBoardsMenu = () => {
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<HeaderButton w="auto" px={4} data-onboarding-tour-id="board-switcher">
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -20,6 +21,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -61,14 +67,16 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<BoardTourWrapper hasSession={!!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
+1
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
+16 -1
View File
@@ -36,6 +36,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
@@ -45,11 +46,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
"data-onboarding-tour-id": "manage-welcome",
},
{
icon: IconLayoutDashboardFilled,
href: "/manage/boards",
label: t("items.boards"),
"data-onboarding-tour-id": "manage-boards",
},
{
icon: IconBox,
@@ -59,12 +62,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-apps",
},
{
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconSearch,
@@ -74,17 +79,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-search-engines",
},
{
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
icon: IconUserFilled,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [
{
label: t("items.users.items.manage"),
@@ -153,6 +161,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/settings",
icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-settings",
},
{
label: t("items.help.label"),
@@ -191,11 +200,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
];
return (
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
}
@@ -0,0 +1,57 @@
"use client";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface ResetToursProps {
completedManageTour: boolean;
completedBoardTour: boolean;
}
export const ResetTours = ({ completedManageTour, completedBoardTour }: ResetToursProps) => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
title: t("resetSuccess.title"),
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
},
});
const tourStatusMap: Record<string, { key: "management" | "dashboard"; completed: boolean }> = {
management: { key: "management", completed: completedManageTour },
dashboard: { key: "dashboard", completed: completedBoardTour },
};
return (
<Stack gap="xs">
<Group gap="xs">
{Object.values(tourStatusMap).map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
))}
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
size="sm"
w="fit-content"
onClick={() => resetTours()}
loading={isPending}
>
{t("reset")}
</Button>
</Stack>
);
};
@@ -18,6 +18,7 @@ import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
params: Promise<{
@@ -118,6 +119,16 @@ export default async function EditUserPage(props: Props) {
<PingIconsEnabled user={user} />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours
completedManageTour={user.completedManageTour}
completedBoardTour={user.completedBoardTour}
/>
</Stack>
)}
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
+7 -3
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { AppShellHeader, Box, Group, UnstyledButton } from "@mantine/core";
import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui";
@@ -25,11 +25,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<DesktopSearchInput />
<Box data-onboarding-tour-id="board-search">
<DesktopSearchInput />
</Box>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<UserButton />
<Box data-onboarding-tour-id="board-user-menu">
<UserButton />
</Box>
</Group>
</Group>
<Spotlight />
@@ -11,6 +11,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname();
const tourId = props["data-onboarding-tour-id"];
return props.external ? (
<NavLink
component="a"
@@ -21,6 +22,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
borderRadius: 5,
}}
target="_blank"
data-onboarding-tour-id={tourId}
/>
) : (
<NavLink
@@ -32,6 +34,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
style={{
borderRadius: 5,
}}
data-onboarding-tour-id={tourId}
/>
);
};
@@ -47,6 +50,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
label={props.label}
leftSection={props.icon}
defaultOpened={isActive}
data-onboarding-tour-id={props["data-onboarding-tour-id"]}
>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} />
@@ -58,6 +62,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
interface CommonNavigationLinkProps {
label: string;
icon: ReactNode;
"data-onboarding-tour-id"?: string;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null;
}
const { icon: TablerIcon, iconProps, ...props } = link;
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
const Icon =
typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} />
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
if ("items" in props) {
clientLink = {
...props,
"data-onboarding-tour-id": tourId,
items: props.items
.filter((item) => !item.hidden)
.map((item) => {
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
return {
...item,
...rest,
"data-onboarding-tour-id": itemTourId,
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
};
}),
} as ClientNavigationLink;
} else {
clientLink = props as ClientNavigationLink;
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
}
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
})}
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
icon: TablerIcon | string;
iconProps?: TablerIconProps;
hidden?: boolean;
"data-onboarding-tour-id"?: string;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -0,0 +1,97 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { SkipHoldButton } from "./skip-hold-button";
import { TourStepContent } from "./tour-step-content";
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
const handleEnd = () => {
completeTour({ tour: "board" });
};
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
{
id: "board-search",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
docPath="/docs/getting-started/after-the-installation"
docHash="#creating-your-first-board"
/>
),
footer: (controller: { skipTour: () => void }) => (
<SkipHoldButton onSkip={() => controller.skipTour()} />
),
},
{
id: "board-edit-mode",
title: t("editMode.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("editMode.description")}
docPath="/docs/getting-started/after-the-installation"
docHash="#arrange-and-organize-your-board"
/>
),
},
{
id: "board-settings",
title: t("settings.title"),
requiresChangeAccess: true,
content: (
<TourStepContent description={t("settings.description")} docPath="/docs/management/boards" />
),
},
{
id: "board-switcher",
title: t("switcher.title"),
content: (
<TourStepContent description={t("switcher.description")} docPath="/docs/management/boards" />
),
},
{
id: "board-user-menu",
title: t("userMenu.title"),
content: (
<TourStepContent description={t("userMenu.description")} docPath="/docs/management/users" />
),
},
];
return allSteps.filter(
(step) => !step.requiresChangeAccess || hasChangeAccess,
) as OnboardingTourStep[];
}, [hasChangeAccess, t]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourComplete={handleEnd}
onOnboardingTourSkip={handleEnd}
maw={420}
>
{children}
</OnboardingTour>
);
};
@@ -0,0 +1,144 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { SkipHoldButton } from "./skip-hold-button";
import { TourForkStep } from "./tour-fork-step";
import { TourStepContent } from "./tour-step-content";
interface ManageTourProviderProps extends PropsWithChildren {
isAdmin: boolean;
}
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const router = useRouter();
const t = useScopedI18n("onboardingTour.manage");
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const started = tourStatus !== undefined && !tourStatus.completedManageTour;
const handleEnd = () => {
completeTour({ tour: "manage" });
};
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
{
id: "manage-welcome",
title: t("welcome.title"),
content: (
<TourStepContent description={t("welcome.description")} docPath="/docs/category/management" />
),
footer: (controller: { skipTour: () => void }) => (
<SkipHoldButton onSkip={() => controller.skipTour()} />
),
},
{
id: "manage-boards",
title: t("boards.title"),
content: (
<TourStepContent description={t("boards.description")} docPath="/docs/management/boards" />
),
},
{
id: "manage-apps",
title: t("apps.title"),
content: (
<TourStepContent description={t("apps.description")} docPath="/docs/management/apps" />
),
},
{
id: "manage-integrations",
title: t("integrations.title"),
content: (controller: { endTour: () => void; nextStep: () => void }) => (
<TourForkStep
question={t("integrationsFork.question")}
description={t("integrations.description")}
docPath="/docs/management/integrations"
onYes={() => {
controller.endTour();
router.push("/manage/integrations/new");
}}
onNo={() => controller.nextStep()}
/>
),
},
{
id: "manage-search-engines",
title: t("searchEngines.title"),
content: (
<TourStepContent description={t("searchEngines.description")} docPath="/docs/management/search-engines" />
),
},
{
id: "manage-medias",
title: t("medias.title"),
content: (
<TourStepContent description={t("medias.description")} docPath="/docs/management/media" />
),
},
{
id: "manage-users",
title: t("users.title"),
adminOnly: true,
content: (controller: { endTour: () => void; nextStep: () => void }) => (
<TourForkStep
question={t("usersFork.question")}
description={t("users.description")}
docPath="/docs/management/users"
onYes={() => {
controller.endTour();
router.push("/manage/users/create");
}}
onNo={() => controller.nextStep()}
/>
),
},
{
id: "manage-settings",
title: t("settings.title"),
adminOnly: true,
content: (
<TourStepContent description={t("settings.description")} docPath="/docs/management/settings" />
),
},
{
id: "manage-complete",
title: t("complete.title"),
content: (
<TourStepContent
description={t("complete.description")}
docPath="/docs/getting-started/after-the-installation"
/>
),
},
];
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
}, [isAdmin, router, t]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourComplete={handleEnd}
onOnboardingTourSkip={handleEnd}
maw={420}
>
<Box
data-onboarding-tour-id="manage-complete"
style={{ position: "fixed", opacity: 0, pointerEvents: "none" }}
/>
{children}
</OnboardingTour>
);
};
@@ -0,0 +1,64 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Progress, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
const HOLD_DURATION_MS = 2000;
const TICK_INTERVAL_MS = 50;
interface SkipHoldButtonProps {
onSkip: () => void;
}
export const SkipHoldButton = ({ onSkip }: SkipHoldButtonProps) => {
const t = useI18n();
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearHoldInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => clearHoldInterval, [clearHoldInterval]);
const handlePointerDown = useCallback(() => {
clearHoldInterval();
setProgress(0);
let elapsed = 0;
intervalRef.current = setInterval(() => {
elapsed += TICK_INTERVAL_MS;
const pct = Math.min((elapsed / HOLD_DURATION_MS) * 100, 100);
setProgress(pct);
if (elapsed >= HOLD_DURATION_MS) {
clearHoldInterval();
onSkip();
}
}, TICK_INTERVAL_MS);
}, [onSkip, clearHoldInterval]);
const handlePointerUp = useCallback(() => {
clearHoldInterval();
setProgress(0);
}, [clearHoldInterval]);
return (
<Stack gap={4} mt="xs">
<Button
size="xs"
variant="subtle"
color="dimmed"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<Text size="xs">{t("onboardingTour.skipHold")}</Text>
</Button>
{progress > 0 && <Progress value={progress} size="xs" color="red" animated />}
</Stack>
);
};
@@ -0,0 +1,41 @@
"use client";
import { Anchor, Button, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { HomarrDocumentationPath } from "@homarr/definitions";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
interface TourForkStepProps {
question: string;
description?: string;
docPath: HomarrDocumentationPath;
onYes: () => void;
onNo: () => void;
}
export const TourForkStep = ({ question, description, docPath, onYes, onNo }: TourForkStepProps) => {
const t = useI18n();
return (
<Stack gap="sm">
{description && <Text size="sm">{description}</Text>}
<Text size="sm" fw={500}>
{question}
</Text>
<Group gap="sm">
<Button size="xs" onClick={onYes}>
{t("onboardingTour.forkYes")}
</Button>
<Button size="xs" variant="light" onClick={onNo}>
{t("onboardingTour.forkNo")}
</Button>
</Group>
<Anchor href={createDocumentationLink(docPath)} target="_blank" size="xs" c="dimmed">
<IconExternalLink size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("onboardingTour.learnMore")}
</Anchor>
</Stack>
);
};
@@ -0,0 +1,28 @@
"use client";
import { Anchor, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { HomarrDocumentationPath } from "@homarr/definitions";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
interface TourStepContentProps {
description: string;
docPath: HomarrDocumentationPath;
docHash?: `#${string}`;
}
export const TourStepContent = ({ description, docPath, docHash }: TourStepContentProps) => {
const t = useI18n();
return (
<Stack gap="xs">
<Text size="sm">{description}</Text>
<Anchor href={createDocumentationLink(docPath, docHash)} target="_blank" size="xs" c="dimmed">
<IconExternalLink size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("onboardingTour.learnMore")}
</Anchor>
</Stack>
);
};