chore: reconcile performance layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-13 20:01:46 +02:00
1 parent 1088f058e3
commit c92ca2af5f
235 files changed
+18492 -15756

No files matched your search

@@ -1,84 +0,0 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardAddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: { id: "new", name: "" },
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{ title: (translate) => translate("section.category.create.title") },
),
[addCategoryToEnd, openCategoryEditModal, t],
);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
withCreate: session?.user.permissions.includes("app-create") ?? false,
})
}
>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -4,6 +4,7 @@ import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
@@ -20,6 +21,7 @@ export default function BoardEditActions() {
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
@@ -28,7 +30,7 @@ const AddMenu = () => {
const t = useI18n();
const handleSelectItem = () => {
openItemSelectModal();
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
@@ -1,6 +1,6 @@
"use client";
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { Alert, Input } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
@@ -8,65 +8,44 @@ import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import type { FormValues } from "./_settings-form";
import classes from "./customcss.module.css";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const CustomCssSettingsContent = ({ board }: Props) => {
export const CustomCssSettingsContent = ({ form }: Props) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="space-between">
{isAdmin ? <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} /> : <span />}
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
{isAdmin && <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />}
</SectionCard>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
@@ -76,6 +55,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
@@ -59,7 +59,7 @@
}
}
.dynamicTile {
.containerTile {
border-style: dashed;
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
}
@@ -1,14 +1,38 @@
import { describe, expect, test } from "vitest";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const };
const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const };
const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const };
const mobile = {
id: "mobile",
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
};
const custom = {
id: "tablet",
name: "Tablet",
columnCount: 6,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 480,
role: "custom" as const,
};
const base = {
id: "base",
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
};
describe("layout representative widths", () => {
test("uses a phone width, activation midpoint, and desktop minimum", () => {
@@ -22,30 +46,30 @@ describe("layout representative widths", () => {
describe("projectBoardLayout", () => {
test("returns exact saved positions without recursively re-projecting the active layout", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
.build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, dynamicSection]).build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
expect.objectContaining({ id: dynamicSection.id, sectionId: rootSection.id, width: 3, height: 2 }),
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
]);
});
test("uses production ordering and sizing for items and dynamic sections", () => {
test("uses production ordering and sizing for items and containers", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
.build();
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
.build();
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
.build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSections([rootSection, dynamicSection])
.addSections([rootSection, container])
.addItems([rootItem, nestedItem])
.build();
@@ -59,7 +83,7 @@ describe("projectBoardLayout", () => {
yOffset: 0,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({
expect(projected.find((element) => element.id === container.id)).toMatchObject({
type: "section",
width: 3,
xOffset: 0,
@@ -69,7 +93,7 @@ describe("projectBoardLayout", () => {
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
type: "item",
width: 3,
sectionId: dynamicSection.id,
sectionId: container.id,
});
});
@@ -1,9 +1,12 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { BoardLane } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board } from "./_types";
export type BoardLayout = Board["layouts"][number];
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
if (layout.role === "mobile") return 390;
@@ -17,28 +20,69 @@ export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: Bo
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
};
export const projectBoardLayout = (
board: Board,
sourceLayout: Pick<BoardLayout, "id" | "columnCount">,
targetLayout: Pick<BoardLayout, "id" | "columnCount">,
) => {
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
const elements = getElementsForLayout(board, sourceLayout.id);
if (sourceLayout.id === targetLayout.id && sourceLayout.columnCount === targetLayout.columnCount) {
if (
sourceLayout.id === targetLayout.id &&
sourceLayout.columnCount === targetLayout.columnCount &&
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
) {
return elements;
}
return board.sections
.filter((section) => section.kind !== "dynamic")
.flatMap(
(section) =>
generateResponsiveGridFor({
items: elements,
previousWidth: sourceLayout.columnCount,
width: targetLayout.columnCount,
sectionId: section.id,
}).items,
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
for (const root of emptyRoots) {
const lane = getRootSectionLane(root.xOffset);
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
}
const mainRoot = rootByLane.get("main");
if (!mainRoot) return [];
const sourceRootById = new Map(
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
);
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
boardLanes.map((lane) => [
lane,
lane !== "main" && getBoardLaneColumnCount(targetLayout, lane) === 0 ? "main" : lane,
]),
);
const remappedElements = elements.map((element) => {
const sourceLane = sourceRootById.get(element.sectionId);
if (!sourceLane) return element;
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
return { ...element, sectionId: (rootByLane.get(targetLane) ?? mainRoot).id };
});
return boardLanes.flatMap((lane) => {
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(targetLayout, lane);
if (!root || width === 0) return [];
const sourceLanes = boardLanes.filter(
(sourceLane) =>
rootByLane.has(sourceLane) &&
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
targetLaneBySourceLane.get(sourceLane) === lane,
);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return generateResponsiveGridFor({
items: remappedElements,
previousWidth,
width,
sectionId: root.id,
}).items;
});
};
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
@@ -59,7 +103,7 @@ const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "dynamic") return [];
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId
? [
@@ -11,10 +11,18 @@ import type { Board } from "./_types";
interface Props {
initialBoard: Board;
initialLayoutId: string;
initialViewportWidth: number;
canModify: boolean;
}
export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren<Props>) => {
export const BoardProviders = ({
children,
initialBoard,
initialLayoutId,
initialViewportWidth,
canModify,
}: PropsWithChildren<Props>) => {
const searchParams = useSearchParams();
const isDesktop = useMediaQuery("(min-width: 64em)");
const requestedLayoutId = searchParams.get("layout");
@@ -23,7 +31,12 @@ export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithC
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
return (
<BoardProvider initialBoard={initialBoard} layoutOverrideId={layoutOverrideId}>
<BoardProvider
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
layoutOverrideId={layoutOverrideId}
>
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
</BoardProvider>
);
+32 -20
View File
@@ -15,9 +15,10 @@ import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { env as authEnv } from "@homarr/auth/env";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
@@ -48,6 +49,8 @@ const fontSans = Inter({
variable: "--font-sans",
});
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
@@ -83,25 +86,36 @@ export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>;
}) {
if (!isLocaleSupported((await props.params).locale)) {
const { locale } = await props.params;
if (!isLocaleSupported(locale)) {
notFound();
}
const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
// Resolved on the server so the assistant runtime chunk is never referenced by a page unless the
// assistant is actually usable. Reuses the availability check the management page invalidates.
// The states stay distinct so a signed-out visitor is told to sign in rather than that the
// instance is unconfigured, which the server cannot know for them.
const assistantAvailability: AssistantAvailability = !session
? "unauthenticated"
: await api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? ("enabled" as const) : ("unconfigured" as const)))
.catch(() => "error" as const);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const sessionPromise = auth();
const userPromise = sessionPromise.then((session) =>
session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
return null;
})
: null,
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
!session
? "unauthenticated"
: api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error"),
);
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
assistantAvailabilityPromise,
]);
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const publicUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
@@ -146,8 +160,6 @@ export default async function Layout(props: {
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
]);
const { locale } = await props.params;
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
@@ -1,55 +0,0 @@
import { describe, expect, test } from "vitest";
import { buildAiPrompt } from "./_copy-ai-prompt-button";
const schema = {
type: "object",
required: ["name", "url", "displayType", "displayConfig"],
};
describe("buildAiPrompt", () => {
test("asks Homarr Assistant to create a definition and includes the user's request", () => {
const prompt = buildAiPrompt(
schema,
'{"status":"online"}',
{
$schema: "homarr-custom-widget-v2",
name: "Status",
url: "https://example.com/status",
},
"Show a compact green status when the service is online.",
);
expect(prompt).toContain("`customWidget_create`");
expect(prompt).toContain("`secrets: []`");
expect(prompt).toContain("do not ask for typed confirmation");
expect(prompt).toContain('```json\n{"status":"online"}\n```');
expect(prompt).toContain('"name": "Status"');
expect(prompt).toContain("Show a compact green status when the service is online.");
});
test("asks Homarr Assistant to update the selected definition without replacing its secrets", () => {
const prompt = buildAiPrompt(
schema,
null,
{
name: "Existing widget",
authType: "bearer",
},
"Use a progress bar.",
"widget-definition-id",
);
expect(prompt).toContain("custom widget definition `widget-definition-id`");
expect(prompt).toContain("`customWidget_update`");
expect(prompt).toContain("Preserve configured secrets by omitting the `secrets` field");
expect(prompt).not.toContain('"secrets"');
});
test("keeps copy-to-clipboard prompts useful before a request is entered", () => {
const prompt = buildAiPrompt(schema);
expect(prompt).toContain("PASTE_YOUR_API_RESPONSE_HERE");
expect(prompt).toContain("Describe what you want the widget to show or change here.");
});
});
+32 -10
View File
@@ -29,8 +29,10 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
@@ -41,11 +43,29 @@ 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";
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
import { env as nextEnv } from "~/env";
const logger = createLogger({ module: "manageLayout" });
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
const session = await auth();
const sessionPromise = auth();
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
if (!session || nextEnv.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedManageTour;
} catch (error) {
logger.error(new Error("Failed to load the management tour status", { cause: error }));
return false;
}
});
const [t, session, shouldRunManageTour] = await Promise.all([
getScopedI18n("management.navbar"),
sessionPromise,
shouldRunManageTourPromise,
]);
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
@@ -63,7 +83,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
hidden: !session?.user.permissions.includes("app-create"),
iconProps: {
strokeWidth: 2.5,
},
@@ -73,7 +93,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
hidden: !session?.user.permissions.includes("integration-create"),
"data-onboarding-tour-id": "manage-integrations",
},
{
@@ -92,7 +112,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session,
hidden: !session?.user.permissions.includes("search-engine-create"),
iconProps: {
strokeWidth: 2.5,
},
@@ -102,7 +122,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
hidden: !session?.user.permissions.includes("media-upload"),
"data-onboarding-tour-id": "manage-medias",
},
{
@@ -239,7 +259,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
</ClientShell>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
return (
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
{shell}
</ManageTourGate>
);
}