diff --git a/apps/nextjs/src/components/layout/header/tour-target.tsx b/apps/nextjs/src/components/layout/header/tour-target.tsx
new file mode 100644
index 000000000..b23572d1e
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/tour-target.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
+import { Box } from "@mantine/core";
+
+export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
+
+ {children}
+
+);
diff --git a/apps/nextjs/src/components/onboarding/skip-hold-button.tsx b/apps/nextjs/src/components/onboarding/skip-hold-button.tsx
deleted file mode 100644
index f512744b5..000000000
--- a/apps/nextjs/src/components/onboarding/skip-hold-button.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"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 | 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 (
-
-
- {progress > 0 && }
-
- );
-};
diff --git a/apps/nextjs/src/components/onboarding/tour-fork-step.tsx b/apps/nextjs/src/components/onboarding/tour-fork-step.tsx
deleted file mode 100644
index 4a1ce4ffe..000000000
--- a/apps/nextjs/src/components/onboarding/tour-fork-step.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"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 (
-
- {description && {description}}
-
- {question}
-
-
-
-
-
-
-
- {t("onboardingTour.learnMore")}
-
-
- );
-};
diff --git a/apps/nextjs/src/components/onboarding/tour-shell.tsx b/apps/nextjs/src/components/onboarding/tour-shell.tsx
new file mode 100644
index 000000000..702c28634
--- /dev/null
+++ b/apps/nextjs/src/components/onboarding/tour-shell.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { useCallback, useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
+import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
+import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
+import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
+import type { FloatingPosition } from "@mantine/core";
+
+import { useI18n } from "@homarr/translation/client";
+
+import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
+
+interface TourShellProps extends PropsWithChildren {
+ steps: OnboardingTourStep[];
+ started: boolean;
+ onEnd: () => void;
+ stepRoutes?: Record;
+ position?: FloatingPosition | Record;
+}
+
+type TourController = Parameters["nextStepNavigation"]>>[0];
+
+const pollForElement = (targetId: string, callback: () => void) => {
+ const interval = setInterval(() => {
+ const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
+ if (targetElement) {
+ clearInterval(interval);
+ callback();
+ }
+ }, 50);
+ const timeout = setTimeout(() => clearInterval(interval), 5000);
+ return () => {
+ clearInterval(interval);
+ clearTimeout(timeout);
+ };
+};
+
+const isTypingTarget = (target: EventTarget | null) => {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
+};
+
+interface TourForwardButtonProps {
+ label: string;
+ onClick: () => void;
+}
+
+const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
+ return (
+
+ );
+};
+
+interface TourDoneButtonProps {
+ label: string;
+ onClick: () => void;
+}
+
+const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
+ return (
+
+ );
+};
+
+export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
+ const t = useI18n();
+ const router = useRouter();
+ const forwardActionRef = useRef<(() => void) | null>(null);
+
+ const navigateAndAdvance = useCallback(
+ (currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
+ if (!stepRoutes || !targetStepId) {
+ advance();
+ return;
+ }
+
+ const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
+ const targetRoute = stepRoutes[targetStepId];
+
+ if (targetRoute && targetRoute !== currentRoute) {
+ router.push(targetRoute);
+ pollForElement(targetStepId, advance);
+ } else {
+ advance();
+ }
+ },
+ [router, stepRoutes],
+ );
+
+ const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
+ forwardActionRef.current = action;
+ return action;
+ }, []);
+
+ useEffect(() => {
+ if (!started) {
+ forwardActionRef.current = null;
+ return;
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
+ if (isTypingTarget(event.target)) return;
+ event.preventDefault();
+ forwardActionRef.current?.();
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [started]);
+
+ return (
+ (
+ <>
+
+
+ {(controller.currentStepIndex ?? 0) + 1}
+
+
+ /
+
+
+ {controller.tour.length}
+
+
+ {controller.currentStepIndex === 0 && (
+
+
+
+ )}
+ >
+ )}
+ nextStepNavigation={(controller) => {
+ const nextStep = controller.tour[controller.currentStepIndex + 1];
+ const action = bindForwardAction(controller, () => {
+ navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
+ });
+ return ;
+ }}
+ endStepNavigation={(controller) => {
+ const action = bindForwardAction(controller, () => controller.endTour());
+ return ;
+ }}
+ prevStepNavigation={(controller) => (
+
+ )}
+ skipNavigation={(controller) => (
+
+ )}
+ withPrevButton
+ focusRevealProps={{
+ disableTargetInteraction: true,
+ popoverProps: {
+ position: position ?? { base: "bottom", sm: "right" },
+ width: 420,
+ shadow: "xl",
+ radius: "lg",
+ middlewares: { shift: { padding: 16 }, flip: true },
+ },
+ }}
+ cutoutPadding={12}
+ cutoutRadius={12}
+ >
+ {children}
+
+ );
+};
diff --git a/apps/nextjs/src/components/onboarding/tour-step-content.tsx b/apps/nextjs/src/components/onboarding/tour-step-content.tsx
index 8c8248594..05b7b4bd3 100644
--- a/apps/nextjs/src/components/onboarding/tour-step-content.tsx
+++ b/apps/nextjs/src/components/onboarding/tour-step-content.tsx
@@ -1,27 +1,38 @@
"use client";
-import { Anchor, Stack, Text } from "@mantine/core";
+import type { ReactNode } from "react";
+import { Anchor, Divider, Group, Stack, Text, ThemeIcon } 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}`;
+ documentationHref: string;
+ icon?: ReactNode;
}
-export const TourStepContent = ({ description, docPath, docHash }: TourStepContentProps) => {
+export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
const t = useI18n();
return (
-
- {description}
-
-
- {t("onboardingTour.learnMore")}
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {description}
+
+
+
+
+
+
+ {t("onboardingTour.learnMore")}
+
);