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

@@ -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>
);
};