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