diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
index 27ab01fb5..4801a3275 100644
--- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
+import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -47,19 +48,21 @@ export default async function AppsPage(props: AppsPageProps) {
{session.user.permissions.includes("app-create") && (
-
- {t("page.create.title")}
-
+
+
+ {t("page.create.title")}
+
+
)}
- {apps.length === 0 && }
- {apps.length > 0 && (
+
+ {apps.length === 0 && }
{apps.map((app) => (
))}
- )}
+
{/* Added margin to not hide pagination behind affix-button */}
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
index 9c0a28344..7452cf3df 100644
--- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
@@ -23,6 +23,7 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
+import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
@@ -40,16 +41,22 @@ export default async function ManageBoardsPage() {
{t("title")}
- {canCreateBoards && }
+ {canCreateBoards && (
+
+
+
+ )}
-
- {boards.map((board) => (
-
-
-
- ))}
-
+
+
+ {boards.map((board) => (
+
+
+
+ ))}
+
+
);
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
index 1fe3a83e2..8b976cb45 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
@@ -37,6 +37,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
+import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
@@ -84,18 +85,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
-
-
-
- }>{t("action.create")}
-
-
-
+
+
+
+
+ }>{t("action.create")}
+
+
+
+
>
)}
-
+
+
+
);
diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx
index d638b624e..76f33a8db 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx
@@ -7,14 +7,10 @@ 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) => {
+export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
+ const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
@@ -25,15 +21,15 @@ export const ResetTours = ({ completedManageTour, completedBoardTour }: ResetTou
},
});
- const tourStatusMap: Record = {
- management: { key: "management", completed: completedManageTour },
- dashboard: { key: "dashboard", completed: completedBoardTour },
- };
+ const tours = [
+ { key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
+ { key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
+ ];
return (
- {Object.values(tourStatusMap).map(({ key, completed }) => (
+ {tours.map(({ key, completed }) => (
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx
index 665ab7d6c..453c2c486 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx
@@ -122,10 +122,7 @@ export default async function EditUserPage(props: Props) {
{session?.user.id === user.id && (
{tGeneral("item.onboardingTours.title")}
-
+
)}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx
index 22f4d1b88..881aec980 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.tsx
@@ -12,6 +12,8 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
+import { TourTarget } from "~/components/layout/header/tour-target";
+
interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"];
credentialsProviderEnabled: boolean;
@@ -69,12 +71,6 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
enableFullScreenToggle: false,
layoutMode: "grid-no-grow",
getRowId: (row) => row.id,
- renderTopToolbarCustomActions: () =>
- credentialsProviderEnabled ? (
-
- ) : null,
state: {
isLoading,
},
@@ -82,7 +78,18 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
return (
<>
- {tUserList("title")}
+
+
+ {tUserList("title")}
+
+ {credentialsProviderEnabled && (
+
+
+
+ )}
+
>
);
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx
index 53a1ed2dd..32ffa81d7 100644
--- a/apps/nextjs/src/components/layout/header.tsx
+++ b/apps/nextjs/src/components/layout/header.tsx
@@ -1,11 +1,12 @@
import type { ReactNode } from "react";
-import { AppShellHeader, Box, Group, UnstyledButton } from "@mantine/core";
+import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
+import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
@@ -25,15 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
{logo ?? }
-
+
-
+
{actions}
-
+
+
diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx
index 848f7d691..dcd8e5573 100644
--- a/apps/nextjs/src/components/layout/navigation-link.tsx
+++ b/apps/nextjs/src/components/layout/navigation-link.tsx
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
+import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
@@ -9,20 +10,22 @@ import { Link } from "@homarr/ui";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? : ;
+const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
+ if (!id) return <>{children}>;
+ return {children};
+};
+
const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname();
const tourId = props["data-onboarding-tour-id"];
- return props.external ? (
+ const link = props.external ? (
) : (
{
leftSection={props.icon}
href={props.href}
active={pathname === props.href}
- style={{
- borderRadius: 5,
- }}
- data-onboarding-tour-id={tourId}
+ style={{ borderRadius: 5 }}
/>
);
+ return {link};
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname);
- return (
-
+ const nav = (
+
{props.items.map((item) => (
))}
);
+ return {nav};
};
interface CommonNavigationLinkProps {
diff --git a/apps/nextjs/src/components/onboarding/manage-tour.tsx b/apps/nextjs/src/components/onboarding/manage-tour.tsx
index 5da9959a5..361a183dd 100644
--- a/apps/nextjs/src/components/onboarding/manage-tour.tsx
+++ b/apps/nextjs/src/components/onboarding/manage-tour.tsx
@@ -1,34 +1,101 @@
"use client";
import type { PropsWithChildren } from "react";
-import { useMemo } from "react";
-import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { usePathname, 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 { useMediaQuery } from "@mantine/hooks";
+import {
+ IconAffiliateFilled,
+ IconAppsFilled,
+ IconLayoutDashboardFilled,
+ IconPlus,
+ IconUserFilled,
+} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
+import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
-import { SkipHoldButton } from "./skip-hold-button";
-import { TourForkStep } from "./tour-fork-step";
+import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
interface ManageTourProviderProps extends PropsWithChildren {
isAdmin: boolean;
}
+const stepRoutes: Record = {
+ "manage-welcome": "/manage",
+ "manage-boards-list": "/manage/boards",
+ "manage-boards-create": "/manage/boards",
+ "manage-apps-list": "/manage/apps",
+ "manage-apps-create": "/manage/apps",
+ "manage-integrations-list": "/manage/integrations",
+ "manage-integrations-create": "/manage/integrations",
+ "manage-users-list": "/manage/users",
+ "manage-users-create": "/manage/users",
+};
+
+const usersStepFocusRevealProps = {
+ withReveal: false,
+ popoverProps: {
+ position: "bottom",
+ middlewares: { shift: { padding: 16 }, flip: true },
+ },
+};
+
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
- const router = useRouter();
const t = useScopedI18n("onboardingTour.manage");
+ const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
- const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
+ const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
+ onSuccess() {
+ void utils.user.getTourStatus.invalidate();
+ },
+ });
+ const isMobile = useMediaQuery("(max-width: 48em)");
+ const pathname = usePathname();
+ const router = useRouter();
- const started = tourStatus !== undefined && !tourStatus.completedManageTour;
+ const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
+ const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
- const handleEnd = () => {
+ const [tourActive, setTourActive] = useState(false);
+ const [tourDismissed, setTourDismissed] = useState(false);
+ const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
+
+ useEffect(() => {
+ if (tourDismissed) return;
+ if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
+ setTourActive(true);
+ }
+ }, [isManageHome, tourStatus, isMobile, tourDismissed]);
+
+ useEffect(() => {
+ if (!isManageSection) {
+ setTourActive(false);
+ }
+ }, [isManageSection]);
+
+ useEffect(() => {
+ const wasCompleted = wasManageTourCompletedRef.current;
+ const isCompleted = tourStatus?.completedManageTour;
+ if (wasCompleted === true && isCompleted === false) {
+ setTourDismissed(false);
+ }
+ wasManageTourCompletedRef.current = isCompleted;
+ }, [tourStatus?.completedManageTour]);
+
+ const handleEnd = useCallback(() => {
+ setTourDismissed(true);
+ setTourActive(false);
+ utils.user.getTourStatus.setData(undefined, {
+ completedManageTour: true,
+ completedBoardTour: tourStatus?.completedBoardTour ?? false,
+ });
completeTour({ tour: "manage" });
- };
+ router.push("/manage");
+ }, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
@@ -36,109 +103,118 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
id: "manage-welcome",
title: t("welcome.title"),
content: (
-
- ),
- footer: (controller: { skipTour: () => void }) => (
- controller.skipTour()} />
- ),
- },
- {
- id: "manage-boards",
- title: t("boards.title"),
- content: (
-
- ),
- },
- {
- id: "manage-apps",
- title: t("apps.title"),
- content: (
-
- ),
- },
- {
- id: "manage-integrations",
- title: t("integrations.title"),
- content: (controller: { endTour: () => void; nextStep: () => void }) => (
- {
- controller.endTour();
- router.push("/manage/integrations/new");
- }}
- onNo={() => controller.nextStep()}
+
),
},
{
- id: "manage-search-engines",
- title: t("searchEngines.title"),
- content: (
-
- ),
- },
- {
- id: "manage-medias",
- title: t("medias.title"),
- content: (
-
- ),
- },
- {
- id: "manage-users",
- title: t("users.title"),
- adminOnly: true,
- content: (controller: { endTour: () => void; nextStep: () => void }) => (
- {
- controller.endTour();
- router.push("/manage/users/create");
- }}
- onNo={() => controller.nextStep()}
- />
- ),
- },
- {
- id: "manage-settings",
- title: t("settings.title"),
- adminOnly: true,
- content: (
-
- ),
- },
- {
- id: "manage-complete",
- title: t("complete.title"),
+ id: "manage-boards-list",
+ title: t("boardsList.title"),
content: (
}
+ />
+ ),
+ },
+ {
+ id: "manage-boards-create",
+ title: t("boardsCreate.title"),
+ content: (
+ }
+ />
+ ),
+ },
+ {
+ id: "manage-apps-list",
+ title: t("appsList.title"),
+ content: (
+ }
+ />
+ ),
+ },
+ {
+ id: "manage-apps-create",
+ title: t("appsCreate.title"),
+ content: (
+ }
+ />
+ ),
+ },
+ {
+ id: "manage-integrations-list",
+ title: t("integrationsList.title"),
+ content: (
+ }
+ />
+ ),
+ },
+ {
+ id: "manage-integrations-create",
+ title: t("integrationsCreate.title"),
+ content: (
+ }
+ />
+ ),
+ },
+ {
+ id: "manage-users-list",
+ title: t("usersList.title"),
+ adminOnly: true,
+ focusRevealProps: usersStepFocusRevealProps,
+ content: (
+ }
+ />
+ ),
+ },
+ {
+ id: "manage-users-create",
+ title: t("usersCreate.title"),
+ adminOnly: true,
+ focusRevealProps: usersStepFocusRevealProps,
+ content: (
+ }
/>
),
},
];
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
- }, [isAdmin, router, t]);
+ }, [isAdmin, t]);
return (
-
-
{children}
-
+
);
};