wip
This commit is contained in:
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,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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
+57
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user