This commit is contained in:
Thomas Camlong committed 2026-05-19 16:21:32 +02:00
1 parent 97b7c417ab
commit f773b4a15e
31 files changed
+7559 -22

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
@@ -53,7 +53,7 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu />
<HeaderButton href={`/boards/${board.name}/settings`}>
<HeaderButton href={`/boards/${board.name}/settings`} data-onboarding-tour-id="board-settings">
<IconSettings stroke={1.5} />
</HeaderButton>
@@ -173,7 +173,7 @@ const EditModeMenu = () => {
usePreventLeaveWithDirty(isEditMode);
return (
<HeaderButton onClick={toggle} loading={isPending}>
<HeaderButton onClick={toggle} loading={isPending} data-onboarding-tour-id="board-edit-mode">
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
);
@@ -185,7 +185,7 @@ const SelectBoardsMenu = () => {
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<HeaderButton w="auto" px={4} data-onboarding-tour-id="board-switcher">
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -20,6 +21,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -61,14 +67,16 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<BoardTourWrapper hasSession={!!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
+1
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
+16 -1
View File
@@ -36,6 +36,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
@@ -45,11 +46,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
"data-onboarding-tour-id": "manage-welcome",
},
{
icon: IconLayoutDashboardFilled,
href: "/manage/boards",
label: t("items.boards"),
"data-onboarding-tour-id": "manage-boards",
},
{
icon: IconBox,
@@ -59,12 +62,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-apps",
},
{
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconSearch,
@@ -74,17 +79,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-search-engines",
},
{
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
icon: IconUserFilled,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [
{
label: t("items.users.items.manage"),
@@ -153,6 +161,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/settings",
icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-settings",
},
{
label: t("items.help.label"),
@@ -191,11 +200,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
];
return (
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
}
@@ -0,0 +1,57 @@
"use client";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface ResetToursProps {
completedManageTour: boolean;
completedBoardTour: boolean;
}
export const ResetTours = ({ completedManageTour, completedBoardTour }: ResetToursProps) => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
title: t("resetSuccess.title"),
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
},
});
const tourStatusMap: Record<string, { key: "management" | "dashboard"; completed: boolean }> = {
management: { key: "management", completed: completedManageTour },
dashboard: { key: "dashboard", completed: completedBoardTour },
};
return (
<Stack gap="xs">
<Group gap="xs">
{Object.values(tourStatusMap).map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
))}
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
size="sm"
w="fit-content"
onClick={() => resetTours()}
loading={isPending}
>
{t("reset")}
</Button>
</Stack>
);
};
@@ -18,6 +18,7 @@ import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
params: Promise<{
@@ -118,6 +119,16 @@ export default async function EditUserPage(props: Props) {
<PingIconsEnabled user={user} />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours
completedManageTour={user.completedManageTour}
completedBoardTour={user.completedBoardTour}
/>
</Stack>
)}
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
+7 -3
View File
@@ -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>
);
};
+40
View File
@@ -278,6 +278,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -304,6 +306,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, input.userId),
});
@@ -605,6 +609,42 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
completeTour: protectedProcedure
.input(z.object({ tour: z.enum(["manage", "board"]) }))
.mutation(async ({ input, ctx }) => {
const columnMap: Record<string, Record<string, boolean>> = {
manage: { completedManageTour: true },
board: { completedBoardTour: true },
};
await ctx.db
.update(users)
.set(columnMap[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">) => {
@@ -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,
"tag": "0038_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1779199104477,
"tag": "0039_strong_lizard",
"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,
"tag": "0004_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779199097716,
"tag": "0005_same_manta",
"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,
"tag": "0036_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1779199046805,
"tag": "0037_equal_retro_girl",
"breakpoints": true
}
]
}
}
+2
View File
@@ -80,6 +80,8 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
export const accounts = mysqlTable(
+2
View File
@@ -79,6 +79,8 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
export const accounts = pgTable(
+2
View File
@@ -62,6 +62,8 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
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(
+92 -1
View File
@@ -3482,7 +3482,24 @@
},
"search": "Search",
"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": {
@@ -4931,5 +4948,79 @@
"error": "Error"
}
}
},
"onboardingTour": {
"skipHold": "Hold to skip — I know what I'm doing",
"learnMore": "Learn more in the docs",
"forkYes": "Yes, let's go!",
"forkNo": "Not now",
"manage": {
"welcome": {
"title": "Welcome to Management",
"description": "This is the management area where you configure everything in Homarr — boards, apps, integrations, users, and server settings. Let's walk through what each section does."
},
"boards": {
"title": "Boards",
"description": "Boards are your dashboards. Each board has a unique name that becomes part of its URL. Boards can be public or private and support responsive layouts, background images, custom CSS, and per-user access control."
},
"apps": {
"title": "Apps",
"description": "Apps represent links to your services. Each app needs a name and icon (choose from 11K+ built-in icons). You can set a separate ping URL for health checks and add apps to boards as widgets or bookmarks."
},
"integrations": {
"title": "Integrations",
"description": "Integrations connect Homarr to third-party apps you run — like Sonarr, Radarr, Plex, and 50+ more. Secrets are encrypted and never exposed to the client. Connections are validated before saving."
},
"integrationsFork": {
"title": "Try it now?",
"question": "Would you like to add your first integration?"
},
"searchEngines": {
"title": "Search Engines",
"description": "Configure custom search providers accessible via the search bar or Ctrl+K. Define a name, shortcut prefix, and URL template. Supports integration-based search for apps like Jellyseerr and Overseerr."
},
"medias": {
"title": "Media",
"description": "Upload custom images for app icons or board branding. Media is stored in the database and available in the icon picker when creating apps."
},
"users": {
"title": "Users & Groups",
"description": "Manage users, groups, and permissions. Users can belong to multiple groups. Groups control board access, integration permissions, and admin privileges. You can also create invite links for self-onboarding."
},
"usersFork": {
"title": "Try it now?",
"question": "Would you like to create a new user?"
},
"settings": {
"title": "Server Settings",
"description": "Configure global defaults — analytics, SEO/crawling directives, default home boards, color scheme, language, and app status behavior. These apply across the entire Homarr instance."
},
"complete": {
"title": "You're all set!",
"description": "You've seen everything in the management area. Next, head to your board to start adding apps and widgets, or explore the docs for advanced topics like the API, certificates, and background tasks."
}
},
"board": {
"welcome": {
"title": "Welcome to your Board",
"description": "This is your dashboard — a drag-and-drop canvas where you organize apps and widgets. Use the search bar or Ctrl+K to quickly find apps, integrations, boards, and actions."
},
"editMode": {
"title": "Edit Mode",
"description": "Toggle edit mode to add, move, and resize items. In edit mode you can add widgets, apps, categories, and dynamic sections. Drag items to reposition, use the corner handle to resize. Don't forget to exit edit mode to save!"
},
"settings": {
"title": "Board Settings",
"description": "Customize this board's name, layout columns, background image, colors, custom CSS, and access control. You can set boards as public or private and control who can view or modify them."
},
"switcher": {
"title": "Board Switcher",
"description": "Switch between your boards. You can create multiple boards for different purposes — one for media, one for networking, or one per user."
},
"userMenu": {
"title": "Your Profile",
"description": "Quick access to your preferences — change color scheme, language, home board, and search settings. You can also navigate to the management area from here."
}
}
}
}
+23
View File
@@ -42,6 +42,9 @@ catalogs:
'@extractus/feed-extractor':
specifier: 7.2.1
version: 7.2.1
'@gfazioli/mantine-onboarding-tour':
specifier: ^4.0.1
version: 4.0.1
'@gitbeaker/rest':
specifier: ^43.8.0
version: 43.8.0
@@ -682,6 +685,9 @@ importers:
'@dnd-kit/utilities':
specifier: 'catalog:'
version: 3.2.2(react@19.2.6)
'@gfazioli/mantine-onboarding-tour':
specifier: 'catalog:'
version: 4.0.1(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(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':
specifier: workspace:^0.1.0
version: link:../../packages/analytics
@@ -3785,6 +3791,15 @@ packages:
'@formatjs/intl-localematcher@0.8.1':
resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==}
'@gfazioli/mantine-onboarding-tour@4.0.1':
resolution: {integrity: sha512-LaeUlA1mgRFLwIuH7BQ66NffdGEhvKdPMKov6dDIDT97fp7+DCmQk++oAZkxPWey5GRUJ7+Sf+GbjFLkT6dHCA==}
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':
resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==}
engines: {node: '>=18.20.0'}
@@ -11401,6 +11416,14 @@ snapshots:
'@formatjs/fast-memoize': 3.1.0
tslib: 2.8.1
'@gfazioli/mantine-onboarding-tour@4.0.1(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(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': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 8.3.18(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':
dependencies:
'@gitbeaker/requester-utils': 43.8.0
+1
View File
@@ -15,6 +15,7 @@ catalog:
"@drizzle-team/brocli": ^0.12.0
"@eslint/js": 10.0.1
"@extractus/feed-extractor": 7.2.1
'@gfazioli/mantine-onboarding-tour': ^4.0.1
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0