wip
This commit is contained in:
31 files changed
+7559
-22
No files matched your search
@@ -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