Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e742ef5d0f | ||
|
|
11865cbe8a | ||
|
|
fcdc36766a | ||
|
|
d5373f1c58 | ||
|
|
e267a1bb72 | ||
|
|
eb7c161caf | ||
|
|
4b8956449f | ||
|
|
3d1f2e7764 | ||
|
|
75a45c2b71 | ||
|
|
62a0bae989 | ||
|
|
1220fbf6ad | ||
|
|
3da104256f | ||
|
|
60bf867203 | ||
|
|
1279168538 | ||
|
|
97e9c3258e | ||
|
|
8e6f31e92a | ||
|
|
acd248cca2 | ||
|
|
2190436d9f | ||
|
|
48d157b4f4 | ||
|
|
f773b4a15e | ||
|
|
cb5b73ddbd | ||
|
|
df56eeab95 |
No files matched your search
@@ -18,6 +18,7 @@
|
|||||||
"@dnd-kit/modifiers": "catalog:",
|
"@dnd-kit/modifiers": "catalog:",
|
||||||
"@dnd-kit/sortable": "catalog:",
|
"@dnd-kit/sortable": "catalog:",
|
||||||
"@dnd-kit/utilities": "catalog:",
|
"@dnd-kit/utilities": "catalog:",
|
||||||
|
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
withTour: true,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Group, Menu, ScrollArea } from "@mantine/core";
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
@@ -57,9 +58,11 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<EditModeMenu demoMode={demoMode ?? false} />
|
<EditModeMenu demoMode={demoMode ?? false} />
|
||||||
|
|
||||||
{!demoMode && (
|
{!demoMode && (
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
<OnboardingTour.Target id="board-settings">
|
||||||
<IconSettings stroke={1.5} />
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
</HeaderButton>
|
<IconSettings stroke={1.5} />
|
||||||
|
</HeaderButton>
|
||||||
|
</OnboardingTour.Target>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
@@ -195,9 +198,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
|||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<OnboardingTour.Target id="board-edit-mode">
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
</HeaderButton>
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
|
</HeaderButton>
|
||||||
|
</OnboardingTour.Target>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,27 +210,31 @@ const SelectBoardsMenu = () => {
|
|||||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end">
|
<OnboardingTour.Target id="board-switcher">
|
||||||
<Menu.Target>
|
<Box>
|
||||||
<HeaderButton w="auto" px={4}>
|
<Menu position="bottom-end">
|
||||||
<IconReplace stroke={1.5} />
|
<Menu.Target>
|
||||||
</HeaderButton>
|
<HeaderButton w="auto" px={4}>
|
||||||
</Menu.Target>
|
<IconReplace stroke={1.5} />
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
</HeaderButton>
|
||||||
<ScrollArea.Autosize mah={300}>
|
</Menu.Target>
|
||||||
{boards.map((board) => (
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
<Menu.Item
|
<ScrollArea.Autosize mah={300}>
|
||||||
key={board.id}
|
{boards.map((board) => (
|
||||||
component={Link}
|
<Menu.Item
|
||||||
href={`/boards/${board.name}`}
|
key={board.id}
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
component={Link}
|
||||||
>
|
href={`/boards/${board.name}`}
|
||||||
{board.name}
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
</Menu.Item>
|
>
|
||||||
))}
|
{board.name}
|
||||||
</ScrollArea.Autosize>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
))}
|
||||||
</Menu>
|
</ScrollArea.Autosize>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
</OnboardingTour.Target>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
if (env.NODE_ENV === "development") return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "createBoardLayout" });
|
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> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
|
withTour?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
|
withTour = false,
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<ClientShell hasNavigation={false}>
|
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||||
<MainHeader
|
<ClientShell hasNavigation={false}>
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
<MainHeader
|
||||||
actions={headerActions}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
hasNavigation={false}
|
actions={headerActions}
|
||||||
/>
|
hasNavigation={false}
|
||||||
<AppShellMain>{children}</AppShellMain>
|
/>
|
||||||
</ClientShell>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
|
</ClientShell>
|
||||||
|
</BoardTourWrapper>
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</EditModeProvider>
|
</EditModeProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
title={t("page.list.title")}
|
title={t("page.list.title")}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
canCreate ? (
|
canCreate ? (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<TourTarget id="manage-apps-create">
|
||||||
{t("page.create.title")}
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
</MobileAffixButton>
|
{t("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</TourTarget>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||||
@@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
>
|
>
|
||||||
{apps.length === 0 && <AppNoResults />}
|
{apps.length === 0 && <AppNoResults />}
|
||||||
{apps.length > 0 && (
|
{apps.length > 0 && (
|
||||||
<Stack gap="sm">
|
<TourTarget id="manage-apps-list">
|
||||||
{apps.map((app) => (
|
<Stack gap="sm">
|
||||||
<AppCard key={app.id} app={app} />
|
{apps.map((app) => (
|
||||||
))}
|
<AppCard key={app.id} app={app} />
|
||||||
</Stack>
|
))}
|
||||||
|
</Stack>
|
||||||
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
|
|||||||
return (
|
return (
|
||||||
<ManagePageLayout
|
<ManagePageLayout
|
||||||
title={t("title")}
|
title={t("title")}
|
||||||
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
primaryAction={
|
||||||
|
canCreateBoards ? (
|
||||||
|
<TourTarget id="manage-boards-create">
|
||||||
|
<CreateBoardButton />
|
||||||
|
</TourTarget>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
floatingPrimaryAction={canCreateBoards}
|
floatingPrimaryAction={canCreateBoards}
|
||||||
>
|
>
|
||||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||||
{boards.length > 0 && (
|
{boards.length > 0 && (
|
||||||
<Grid>
|
<TourTarget id="manage-boards-list">
|
||||||
{boards.map((board) => (
|
<Grid>
|
||||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
{boards.map((board) => (
|
||||||
<BoardCard board={board} />
|
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||||
</GridCol>
|
<BoardCard board={board} />
|
||||||
))}
|
</GridCol>
|
||||||
</Grid>
|
))}
|
||||||
|
</Grid>
|
||||||
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { getIntegrationName } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
@@ -60,14 +61,18 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
title={t("page.list.title")}
|
title={t("page.list.title")}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
canCreateIntegrations ? (
|
canCreateIntegrations ? (
|
||||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
<TourTarget id="manage-integrations-create">
|
||||||
{t("action.create")}
|
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||||
</MobileAffixButton>
|
{t("action.create")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</TourTarget>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
floatingPrimaryAction={canCreateIntegrations}
|
floatingPrimaryAction={canCreateIntegrations}
|
||||||
>
|
>
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<TourTarget id="manage-integrations-list">
|
||||||
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
|
</TourTarget>
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
|||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
@@ -45,11 +46,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
icon: IconHomeFilled,
|
icon: IconHomeFilled,
|
||||||
href: "/manage",
|
href: "/manage",
|
||||||
|
"data-onboarding-tour-id": "manage-welcome",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconLayoutDashboardFilled,
|
icon: IconLayoutDashboardFilled,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
label: t("items.boards"),
|
label: t("items.boards"),
|
||||||
|
"data-onboarding-tour-id": "manage-boards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
@@ -59,12 +62,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
|
"data-onboarding-tour-id": "manage-apps",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconAffiliateFilled,
|
icon: IconAffiliateFilled,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
|
"data-onboarding-tour-id": "manage-integrations",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
@@ -74,17 +79,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
|
"data-onboarding-tour-id": "manage-search-engines",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPhotoFilled,
|
icon: IconPhotoFilled,
|
||||||
href: "/manage/medias",
|
href: "/manage/medias",
|
||||||
label: t("items.medias"),
|
label: t("items.medias"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
|
"data-onboarding-tour-id": "manage-medias",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconUserFilled,
|
icon: IconUserFilled,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
"data-onboarding-tour-id": "manage-users",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -153,6 +161,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettingsFilled,
|
icon: IconSettingsFilled,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
"data-onboarding-tour-id": "manage-settings",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
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>
|
<ClientShell hasNavigation>
|
||||||
<MainHeader></MainHeader>
|
<MainHeader></MainHeader>
|
||||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</ClientShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!session) return shell;
|
||||||
|
|
||||||
|
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||||
}
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
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({
|
||||||
|
title: t("resetSuccess.title"),
|
||||||
|
message: t("resetSuccess.message"),
|
||||||
|
});
|
||||||
|
void utils.user.getTourStatus.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tours = [
|
||||||
|
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
|
||||||
|
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs">
|
||||||
|
{tours.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access";
|
|||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
|
import { ResetTours } from "./_components/_reset-tours";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{session?.user.id === user.id && (
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
||||||
|
<ResetTours />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
label={t("user.action.delete.label")}
|
label={t("user.action.delete.label")}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
@@ -38,14 +39,18 @@ export default async function UsersPage() {
|
|||||||
title={t("list.title")}
|
title={t("list.title")}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
credentialsProviderEnabled ? (
|
credentialsProviderEnabled ? (
|
||||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
<TourTarget id="manage-users-create">
|
||||||
{t("create.title")}
|
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||||
</MobileAffixButton>
|
{t("create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</TourTarget>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
floatingPrimaryAction={credentialsProviderEnabled}
|
floatingPrimaryAction={credentialsProviderEnabled}
|
||||||
>
|
>
|
||||||
<UserListComponent initialUserList={userList} />
|
<TourTarget id="manage-users-list">
|
||||||
|
<UserListComponent initialUserList={userList} />
|
||||||
|
</TourTarget>
|
||||||
</ManagePageLayout>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Link } from "@homarr/ui";
|
|||||||
|
|
||||||
import { ClientBurger } from "./header/burger";
|
import { ClientBurger } from "./header/burger";
|
||||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||||
|
import { TourTarget } from "./header/tour-target";
|
||||||
import { UserButton } from "./header/user";
|
import { UserButton } from "./header/user";
|
||||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
|||||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Group>
|
</Group>
|
||||||
<DesktopSearchInput />
|
<TourTarget id="board-search">
|
||||||
|
<DesktopSearchInput />
|
||||||
|
</TourTarget>
|
||||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||||
{actions}
|
{actions}
|
||||||
<MobileSearchButton />
|
<MobileSearchButton />
|
||||||
<UserButton />
|
<TourTarget id="board-user-menu">
|
||||||
|
<UserButton />
|
||||||
|
</TourTarget>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Spotlight />
|
<Spotlight />
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
|
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||||
|
<OnboardingTour.Target id={id}>
|
||||||
|
<Box data-tour-target={id}>{children}</Box>
|
||||||
|
</OnboardingTour.Target>
|
||||||
|
);
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
import { NavLink } from "@mantine/core";
|
import { NavLink } from "@mantine/core";
|
||||||
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
@@ -9,9 +10,15 @@ import { Link } from "@homarr/ui";
|
|||||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||||
|
|
||||||
|
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||||
|
if (!id) return <>{children}</>;
|
||||||
|
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||||
|
};
|
||||||
|
|
||||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return props.external ? (
|
const tourId = props["data-onboarding-tour-id"];
|
||||||
|
const link = props.external ? (
|
||||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||||
) : (
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -22,23 +29,26 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
active={pathname === props.href}
|
active={pathname === props.href}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isActive = props.items.some((item) => item.href === pathname);
|
const isActive = props.items.some((item) => item.href === pathname);
|
||||||
return (
|
const nav = (
|
||||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||||
{props.items.map((item) => (
|
{props.items.map((item) => (
|
||||||
<NavLinkHref key={item.label} {...item} />
|
<NavLinkHref key={item.label} {...item} />
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CommonNavigationLinkProps {
|
interface CommonNavigationLinkProps {
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
"data-onboarding-tour-id"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { icon: TablerIcon, iconProps, ...props } = link;
|
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
|
||||||
const Icon =
|
const Icon =
|
||||||
typeof TablerIcon === "string" ? (
|
typeof TablerIcon === "string" ? (
|
||||||
<Image src={TablerIcon} w={20} h={20} />
|
<Image src={TablerIcon} w={20} h={20} />
|
||||||
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
|||||||
if ("items" in props) {
|
if ("items" in props) {
|
||||||
clientLink = {
|
clientLink = {
|
||||||
...props,
|
...props,
|
||||||
|
"data-onboarding-tour-id": tourId,
|
||||||
items: props.items
|
items: props.items
|
||||||
.filter((item) => !item.hidden)
|
.filter((item) => !item.hidden)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
|
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
|
||||||
return {
|
return {
|
||||||
...item,
|
...rest,
|
||||||
|
"data-onboarding-tour-id": itemTourId,
|
||||||
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
} as ClientNavigationLink;
|
} as ClientNavigationLink;
|
||||||
} else {
|
} else {
|
||||||
clientLink = props as ClientNavigationLink;
|
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
|
||||||
}
|
}
|
||||||
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
||||||
})}
|
})}
|
||||||
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
|
|||||||
icon: TablerIcon | string;
|
icon: TablerIcon | string;
|
||||||
iconProps?: TablerIconProps;
|
iconProps?: TablerIconProps;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
"data-onboarding-tour-id"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
import { TourShell } from "./tour-shell";
|
||||||
|
import { TourStepContent } from "./tour-step-content";
|
||||||
|
|
||||||
|
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const t = useScopedI18n("onboardingTour.board");
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||||
|
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
void utils.user.getTourStatus.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
utils.user.getTourStatus.setData(undefined, {
|
||||||
|
completedManageTour: tourStatus?.completedManageTour ?? false,
|
||||||
|
completedBoardTour: true,
|
||||||
|
});
|
||||||
|
completeTour({ tour: "board" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = useMemo(() => {
|
||||||
|
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
|
||||||
|
{
|
||||||
|
id: "board-search",
|
||||||
|
title: t("welcome.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("welcome.description")}
|
||||||
|
documentationHref={createDocumentationLink(
|
||||||
|
"/docs/getting-started/after-the-installation",
|
||||||
|
"#creating-your-first-board",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "board-edit-mode",
|
||||||
|
title: t("editMode.title"),
|
||||||
|
requiresChangeAccess: true,
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("editMode.description")}
|
||||||
|
documentationHref={createDocumentationLink(
|
||||||
|
"/docs/getting-started/after-the-installation",
|
||||||
|
"#arrange-and-organize-your-board",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "board-settings",
|
||||||
|
title: t("settings.title"),
|
||||||
|
requiresChangeAccess: true,
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("settings.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "board-switcher",
|
||||||
|
title: t("switcher.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("switcher.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "board-user-menu",
|
||||||
|
title: t("userMenu.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("userMenu.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
|
||||||
|
}, [hasChangeAccess, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||||
|
{children}
|
||||||
|
</TourShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
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 { TourShell } from "./tour-shell";
|
||||||
|
import { TourStepContent } from "./tour-step-content";
|
||||||
|
|
||||||
|
interface ManageTourProviderProps extends PropsWithChildren {
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepRoutes: Record<string, string> = {
|
||||||
|
"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: OnboardingTourFocusRevealProps = {
|
||||||
|
withReveal: false,
|
||||||
|
popoverProps: {
|
||||||
|
position: "bottom",
|
||||||
|
middlewares: { shift: { padding: 16 }, flip: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||||
|
const t = useScopedI18n("onboardingTour.manage");
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||||
|
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 isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
|
||||||
|
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
|
||||||
|
|
||||||
|
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 })[] = [
|
||||||
|
{
|
||||||
|
id: "manage-welcome",
|
||||||
|
title: t("welcome.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("welcome.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-boards-list",
|
||||||
|
title: t("boardsList.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("boardsList.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||||
|
icon={<IconLayoutDashboardFilled size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-boards-create",
|
||||||
|
title: t("boardsCreate.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("boardsCreate.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||||
|
icon={<IconPlus size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-apps-list",
|
||||||
|
title: t("appsList.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("appsList.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||||
|
icon={<IconAppsFilled size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-apps-create",
|
||||||
|
title: t("appsCreate.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("appsCreate.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||||
|
icon={<IconPlus size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-integrations-list",
|
||||||
|
title: t("integrationsList.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("integrationsList.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||||
|
icon={<IconAffiliateFilled size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-integrations-create",
|
||||||
|
title: t("integrationsCreate.title"),
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("integrationsCreate.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||||
|
icon={<IconPlus size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-users-list",
|
||||||
|
title: t("usersList.title"),
|
||||||
|
adminOnly: true,
|
||||||
|
focusRevealProps: usersStepFocusRevealProps,
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("usersList.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||||
|
icon={<IconUserFilled size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-users-create",
|
||||||
|
title: t("usersCreate.title"),
|
||||||
|
adminOnly: true,
|
||||||
|
focusRevealProps: usersStepFocusRevealProps,
|
||||||
|
content: (
|
||||||
|
<TourStepContent
|
||||||
|
description={t("usersCreate.description")}
|
||||||
|
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||||
|
icon={<IconPlus size={18} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
|
||||||
|
}, [isAdmin, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourShell
|
||||||
|
steps={steps}
|
||||||
|
started={tourActive}
|
||||||
|
onEnd={handleEnd}
|
||||||
|
stepRoutes={stepRoutes}
|
||||||
|
position={{ base: "bottom", sm: "right" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TourShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||||
|
import type { FloatingPosition } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
|
|
||||||
|
interface TourShellProps extends PropsWithChildren {
|
||||||
|
steps: OnboardingTourStep[];
|
||||||
|
started: boolean;
|
||||||
|
onEnd: () => void;
|
||||||
|
stepRoutes?: Record<string, string>;
|
||||||
|
position?: FloatingPosition | Record<string, FloatingPosition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TourController = OnboardingTourController;
|
||||||
|
|
||||||
|
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
|
||||||
|
|
||||||
|
const stepAtOffset = (controller: TourController, offset: number) => {
|
||||||
|
const index = stepIndexFromController(controller) + offset;
|
||||||
|
return controller.tour[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollForElement = (targetId: string, callback: () => void) => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
|
||||||
|
if (targetElement) {
|
||||||
|
clearInterval(interval);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
const timeout = setTimeout(() => clearInterval(interval), 5000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTypingTarget = (target: EventTarget | null) => {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TourForwardButtonProps {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
|
||||||
|
return (
|
||||||
|
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TourDoneButtonProps {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
||||||
|
return (
|
||||||
|
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const navigateAndAdvance = useCallback(
|
||||||
|
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
|
||||||
|
if (!stepRoutes || !targetStepId) {
|
||||||
|
advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
|
||||||
|
const targetRoute = stepRoutes[targetStepId];
|
||||||
|
|
||||||
|
if (targetRoute && targetRoute !== currentRoute) {
|
||||||
|
router.push(targetRoute);
|
||||||
|
pollForElement(targetStepId, advance);
|
||||||
|
} else {
|
||||||
|
advance();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, stepRoutes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
|
||||||
|
forwardActionRef.current = action;
|
||||||
|
return action;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!started) {
|
||||||
|
forwardActionRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
if (isTypingTarget(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
forwardActionRef.current?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [started]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingTour
|
||||||
|
tour={steps}
|
||||||
|
started={started}
|
||||||
|
onOnboardingTourEnd={onEnd}
|
||||||
|
withStepper={false}
|
||||||
|
header={(controller) => (
|
||||||
|
<>
|
||||||
|
<Group justify="center" gap={4} py={4}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{stepIndexFromController(controller) + 1}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
/
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{controller.tour.length}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{stepIndexFromController(controller) === 0 && (
|
||||||
|
<Center py="xs">
|
||||||
|
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
nextStepNavigation={(controller) => {
|
||||||
|
const nextStep = stepAtOffset(controller, 1);
|
||||||
|
const action = bindForwardAction(controller, () => {
|
||||||
|
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
|
||||||
|
});
|
||||||
|
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
|
||||||
|
}}
|
||||||
|
endStepNavigation={(controller) => {
|
||||||
|
const action = bindForwardAction(controller, () => controller.endTour());
|
||||||
|
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
|
||||||
|
}}
|
||||||
|
prevStepNavigation={(controller) => (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
const prevStep = stepAtOffset(controller, -1);
|
||||||
|
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("onboardingTour.prev")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
skipNavigation={(controller) => (
|
||||||
|
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
|
||||||
|
{t("onboardingTour.skip")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
withPrevButton
|
||||||
|
focusRevealProps={{
|
||||||
|
disableTargetInteraction: true,
|
||||||
|
popoverProps: {
|
||||||
|
position: position ?? { base: "bottom", sm: "right" },
|
||||||
|
width: 420,
|
||||||
|
shadow: "xl",
|
||||||
|
radius: "lg",
|
||||||
|
middlewares: { shift: { padding: 16 }, flip: true },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cutoutPadding={12}
|
||||||
|
cutoutRadius={12}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</OnboardingTour>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
|
import { IconExternalLink } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface TourStepContentProps {
|
||||||
|
description: string;
|
||||||
|
documentationHref: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
|
{icon && (
|
||||||
|
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<IconExternalLink size={14} />
|
||||||
|
{t("onboardingTour.learnMore")}
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -278,6 +278,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
defaultSearchEngineId: true,
|
defaultSearchEngineId: true,
|
||||||
openSearchInNewTab: true,
|
openSearchInNewTab: true,
|
||||||
ddgBangs: true,
|
ddgBangs: true,
|
||||||
|
completedManageTour: true,
|
||||||
|
completedBoardTour: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||||
@@ -304,6 +306,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
defaultSearchEngineId: true,
|
defaultSearchEngineId: true,
|
||||||
openSearchInNewTab: true,
|
openSearchInNewTab: true,
|
||||||
ddgBangs: true,
|
ddgBangs: true,
|
||||||
|
completedManageTour: true,
|
||||||
|
completedBoardTour: true,
|
||||||
},
|
},
|
||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
@@ -605,6 +609,39 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, input.id));
|
.where(eq(users.id, input.id));
|
||||||
}),
|
}),
|
||||||
|
completeTour: protectedProcedure
|
||||||
|
.input(z.object({ tour: z.enum(["manage", "board"]) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const columnByTour = {
|
||||||
|
manage: { completedManageTour: true },
|
||||||
|
board: { completedBoardTour: true },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id));
|
||||||
|
}),
|
||||||
|
resetTours: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
completedManageTour: false,
|
||||||
|
completedBoardTour: false,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
}),
|
||||||
|
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
completedManageTour: true,
|
||||||
|
completedBoardTour: true,
|
||||||
|
},
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
completedManageTour: user?.completedManageTour ?? false,
|
||||||
|
completedBoardTour: user?.completedBoardTour ?? false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user` ADD `completed_manage_tour` boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `completed_board_tour` boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -274,6 +274,13 @@
|
|||||||
"when": 1775400000000,
|
"when": 1775400000000,
|
||||||
"tag": "0038_add_user_ddg_bangs",
|
"tag": "0038_add_user_ddg_bangs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 39,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1779199104477,
|
||||||
|
"tag": "0039_add_user_onboarding_tour_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "completed_manage_tour" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "completed_board_tour" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1775400000000,
|
"when": 1775400000000,
|
||||||
"tag": "0004_add_user_ddg_bangs",
|
"tag": "0004_add_user_ddg_bangs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779199097716,
|
||||||
|
"tag": "0005_add_user_onboarding_tour_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user` ADD `completed_manage_tour` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `completed_board_tour` integer DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -260,6 +260,13 @@
|
|||||||
"when": 1775400000000,
|
"when": 1775400000000,
|
||||||
"tag": "0036_add_user_ddg_bangs",
|
"tag": "0036_add_user_ddg_bangs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1779199046805,
|
||||||
|
"tag": "0037_add_user_onboarding_tour_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,8 @@ export const users = mysqlTable("user", {
|
|||||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: boolean().default(false).notNull(),
|
pingIconsEnabled: boolean().default(false).notNull(),
|
||||||
|
completedManageTour: boolean().default(false).notNull(),
|
||||||
|
completedBoardTour: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = mysqlTable(
|
export const accounts = mysqlTable(
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export const users = pgTable("user", {
|
|||||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: boolean().default(false).notNull(),
|
pingIconsEnabled: boolean().default(false).notNull(),
|
||||||
|
completedManageTour: boolean().default(false).notNull(),
|
||||||
|
completedBoardTour: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = pgTable(
|
export const accounts = pgTable(
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export const users = sqliteTable("user", {
|
|||||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
|
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
|
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = sqliteTable(
|
export const accounts = sqliteTable(
|
||||||
|
|||||||
@@ -3611,7 +3611,24 @@
|
|||||||
},
|
},
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"firstDayOfWeek": "First day of the week",
|
"firstDayOfWeek": "First day of the week",
|
||||||
"accessibility": "Accessibility"
|
"accessibility": "Accessibility",
|
||||||
|
"onboardingTours": {
|
||||||
|
"title": "Onboarding Tours",
|
||||||
|
"description": "Reset the onboarding tours to see them again on your next visit to the management area or dashboard.",
|
||||||
|
"reset": "Reset onboarding tours",
|
||||||
|
"resetSuccess": {
|
||||||
|
"title": "Tours reset",
|
||||||
|
"message": "Onboarding tours will show again on your next visit"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"completed": "Completed",
|
||||||
|
"notStarted": "Not started"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"management": "Management",
|
||||||
|
"dashboard": "Dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
@@ -5192,5 +5209,72 @@
|
|||||||
"error": "Error"
|
"error": "Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"onboardingTour": {
|
||||||
|
"learnMore": "Read the docs",
|
||||||
|
"next": "Next",
|
||||||
|
"prev": "Back",
|
||||||
|
"done": "Done",
|
||||||
|
"skip": "Skip tour",
|
||||||
|
"manage": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "Welcome to Homarr!",
|
||||||
|
"description": "This is your control center. From here you manage boards, apps, integrations, users, and server settings. Let's take a tour of each section."
|
||||||
|
},
|
||||||
|
"boardsList": {
|
||||||
|
"title": "Your Boards",
|
||||||
|
"description": "Here are all your dashboards. Each board has its own layout, background, and access settings. You can see who created each one and whether it's public or private."
|
||||||
|
},
|
||||||
|
"boardsCreate": {
|
||||||
|
"title": "Create a Board",
|
||||||
|
"description": "Click here to create a new dashboard or import one from a previous Homarr installation. Each board gets its own URL you can share."
|
||||||
|
},
|
||||||
|
"appsList": {
|
||||||
|
"title": "Your Apps",
|
||||||
|
"description": "These are all the apps you've added — links to your self-hosted services. Each app has an icon, optional health check, and a status badge on the dashboard."
|
||||||
|
},
|
||||||
|
"appsCreate": {
|
||||||
|
"title": "Add an App",
|
||||||
|
"description": "Register a new service here. Give it a name, URL, and icon — it will appear as a tile on your boards with live status monitoring."
|
||||||
|
},
|
||||||
|
"integrationsList": {
|
||||||
|
"title": "Your Integrations",
|
||||||
|
"description": "These are the services connected to Homarr — Sonarr, Radarr, Plex, Jellyfin, and 50+ more. All credentials are encrypted at rest."
|
||||||
|
},
|
||||||
|
"integrationsCreate": {
|
||||||
|
"title": "Add an Integration",
|
||||||
|
"description": "Connect a new service here. Pick the type, enter the URL and API key — Homarr validates the connection before saving."
|
||||||
|
},
|
||||||
|
"usersList": {
|
||||||
|
"title": "Your Users",
|
||||||
|
"description": "See everyone who has access. Organize users into groups and control who can view or edit each board."
|
||||||
|
},
|
||||||
|
"usersCreate": {
|
||||||
|
"title": "Create a User",
|
||||||
|
"description": "Add a new user account here. You can also use invite links for easy onboarding. That's the tour — you're all set!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "This is your board",
|
||||||
|
"description": "A drag-and-drop dashboard where you arrange apps and widgets. Press Ctrl+K anytime to search for apps, boards, and actions."
|
||||||
|
},
|
||||||
|
"editMode": {
|
||||||
|
"title": "Edit Mode",
|
||||||
|
"description": "Click here to enter edit mode. You can then add widgets, apps, and sections — drag to reposition, resize with the corner handle. Click again to save your changes."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Board Settings",
|
||||||
|
"description": "Change this board's name, columns, background, colors, and who can access it. Each board can be public or private."
|
||||||
|
},
|
||||||
|
"switcher": {
|
||||||
|
"title": "Switch Boards",
|
||||||
|
"description": "Jump between boards. Create separate dashboards for different purposes — media, networking, per-user, or anything you like."
|
||||||
|
},
|
||||||
|
"userMenu": {
|
||||||
|
"title": "Your Profile",
|
||||||
|
"description": "Change your color scheme, language, home board, and preferences. You can also jump to the management area from here."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "@mantine/core/styles.css";
|
@import "@mantine/core/styles.css";
|
||||||
@import "@mantine/charts/styles.css";
|
@import "@mantine/charts/styles.css";
|
||||||
@import "@mantine/dates/styles.css";
|
@import "@mantine/dates/styles.css";
|
||||||
|
@import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||||
@import "mantine-react-table/styles.css";
|
@import "mantine-react-table/styles.css";
|
||||||
Generated
+23
@@ -39,6 +39,9 @@ catalogs:
|
|||||||
'@extractus/feed-extractor':
|
'@extractus/feed-extractor':
|
||||||
specifier: 7.2.1
|
specifier: 7.2.1
|
||||||
version: 7.2.1
|
version: 7.2.1
|
||||||
|
'@gfazioli/mantine-onboarding-tour':
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.2
|
||||||
'@gitbeaker/rest':
|
'@gitbeaker/rest':
|
||||||
specifier: ^43.8.0
|
specifier: ^43.8.0
|
||||||
version: 43.8.0
|
version: 43.8.0
|
||||||
@@ -646,6 +649,9 @@ importers:
|
|||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.2.2(react@19.2.6)
|
version: 3.2.2(react@19.2.6)
|
||||||
|
'@gfazioli/mantine-onboarding-tour':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 4.0.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@homarr/analytics':
|
'@homarr/analytics':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/analytics
|
version: link:../../packages/analytics
|
||||||
@@ -3310,6 +3316,15 @@ packages:
|
|||||||
'@formatjs/intl-localematcher@0.8.1':
|
'@formatjs/intl-localematcher@0.8.1':
|
||||||
resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==}
|
resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==}
|
||||||
|
|
||||||
|
'@gfazioli/mantine-onboarding-tour@4.0.2':
|
||||||
|
resolution: {integrity: sha512-x6mcsI6qyJEXeQfmeM9yMIyWoKpLzfrhYWU04LuShNPjvrF3NprTYzmxpUsRGpdSUNVnVXwEku4ZD5u7j0tx8Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@mantine/core': '>=7.0.0'
|
||||||
|
'@mantine/hooks': '>=7.0.0'
|
||||||
|
'@tabler/icons-react': ^3.30.0
|
||||||
|
react: ^18.x || ^19.x
|
||||||
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@gitbeaker/core@43.8.0':
|
'@gitbeaker/core@43.8.0':
|
||||||
resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==}
|
resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==}
|
||||||
engines: {node: '>=18.20.0'}
|
engines: {node: '>=18.20.0'}
|
||||||
@@ -10520,6 +10535,14 @@ snapshots:
|
|||||||
'@formatjs/fast-memoize': 3.1.0
|
'@formatjs/fast-memoize': 3.1.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@gfazioli/mantine-onboarding-tour@4.0.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.2.2(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/hooks': 9.2.2(react@19.2.6)
|
||||||
|
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
'@gitbeaker/core@43.8.0':
|
'@gitbeaker/core@43.8.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@gitbeaker/requester-utils': 43.8.0
|
'@gitbeaker/requester-utils': 43.8.0
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ catalog:
|
|||||||
"@dnd-kit/utilities": ^3.2.2
|
"@dnd-kit/utilities": ^3.2.2
|
||||||
"@drizzle-team/brocli": ^0.12.0
|
"@drizzle-team/brocli": ^0.12.0
|
||||||
"@extractus/feed-extractor": 7.2.1
|
"@extractus/feed-extractor": 7.2.1
|
||||||
|
'@gfazioli/mantine-onboarding-tour': ^4.0.1
|
||||||
"@gitbeaker/rest": ^43.8.0
|
"@gitbeaker/rest": ^43.8.0
|
||||||
"@homarr/gridstack": ^1.12.0
|
"@homarr/gridstack": ^1.12.0
|
||||||
"@homarr/node-unifi": ^2.6.0
|
"@homarr/node-unifi": ^2.6.0
|
||||||
|
|||||||
Reference in new issue
Block a user