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) { - - - - - - - + + + + + + + + + )} - + + + ); 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} - + ); };