mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-30 03:09:19 +01:00
feat: add dynamic breadcrumb (#706)
* feat: add dynamic breadcrumb * feat: pr feedback
This commit is contained in:
@@ -22,6 +22,7 @@ import { setStaticParamsLocale } from "next-international/server";
|
||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||
import contributorsData from "../../../../../../../static-data/contributors.json";
|
||||
@@ -48,6 +49,7 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||
const attributes = await getPackageAttributesAsync();
|
||||
return (
|
||||
<div>
|
||||
<DynamicBreadcrumb />
|
||||
<Center w="100%">
|
||||
<Group py="lg">
|
||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container, Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppEditForm } from "./_app-edit-form";
|
||||
|
||||
interface AppEditPageProps {
|
||||
@@ -14,11 +15,14 @@ export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.edit.title")}</Title>
|
||||
<AppEditForm app={app} />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, app.name]])} nonInteractable={["edit"]} />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.edit.title")}</Title>
|
||||
<AppEditForm app={app} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,21 @@ import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default async function AppNewPage() {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.create.title")}</Title>
|
||||
<AppNewForm />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.create.title")}</Title>
|
||||
<AppNewForm />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
@@ -16,6 +17,7 @@ export default async function AppsPage() {
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
@@ -34,6 +35,7 @@ export default async function ManageBoardsPage() {
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "@homarr/api/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
@@ -16,14 +17,17 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
|
||||
const integration = await api.integration.byId({ id: params.id });
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||
</Group>
|
||||
<EditIntegrationForm integration={integration} />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, integration.name]])} nonInteractable={["edit"]} />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||
</Group>
|
||||
<EditIntegrationForm integration={integration} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { IntegrationAvatar } from "../_integration-avatar";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
@@ -28,14 +29,17 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
||||
const currentKind = result.data;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { IntegrationAvatar } from "./_integration-avatar";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
@@ -54,6 +55,7 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
@@ -67,6 +68,7 @@ export default async function ManagementPage() {
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<HeroBanner />
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -18,9 +19,12 @@ export default async function SettingsPage() {
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const t = await getScopedI18n("management.page.settings");
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
</Stack>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerTable } from "./DockerTable";
|
||||
|
||||
export default async function DockerPage() {
|
||||
@@ -10,9 +11,12 @@ export default async function DockerPage() {
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
</Stack>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
|
||||
@@ -23,8 +24,11 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
|
||||
|
||||
export default function LogsManagementPage() {
|
||||
return (
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
</Box>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconSettings, IconShieldLock } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -10,6 +9,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
@@ -30,21 +30,23 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb
|
||||
dynamicMappings={
|
||||
new Map([
|
||||
[params.userId, user.name ?? ""],
|
||||
["general", t("navigationStructure.manage.users.general")],
|
||||
["security", t("navigationStructure.manage.users.security")],
|
||||
])
|
||||
}
|
||||
/>
|
||||
<Grid>
|
||||
<GridCol span={12}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group>
|
||||
<UserAvatar user={user} size="lg" />
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{user.name}</Title>
|
||||
<Text c="gray.5">{t("user.name")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{session?.user.permissions.includes("admin") && (
|
||||
<Button component={Link} href="/manage/users" color="gray" variant="light">
|
||||
{tUser("back")}
|
||||
</Button>
|
||||
)}
|
||||
<UserAvatar user={user} size="lg" />
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{user.name}</Title>
|
||||
<Text c="gray.5">{t("user.name")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</GridCol>
|
||||
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||
|
||||
@@ -12,5 +13,10 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default function CreateUserPage() {
|
||||
return <UserCreateStepperComponent />;
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<UserCreateStepperComponent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AddGroup } from "./_add-group";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -31,6 +32,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
<Group justify="space-between">
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { InviteListComponent } from "./_components/invite-list";
|
||||
|
||||
export default async function InvitesOverviewPage() {
|
||||
const initialInvites = await api.invite.getAll();
|
||||
return <InviteListComponent initialInvites={initialInvites} />;
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<InviteListComponent initialInvites={initialInvites} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserListComponent } from "./_components/user-list.component";
|
||||
|
||||
@@ -14,5 +15,10 @@ export async function generateMetadata() {
|
||||
|
||||
export default async function UsersPage() {
|
||||
const userList = await api.user.getAll();
|
||||
return <UserListComponent initialUserList={userList} />;
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
82
apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
Normal file
82
apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Anchor, Badge, Breadcrumbs, Text } from "@mantine/core";
|
||||
import { IconHomeFilled } from "@tabler/icons-react";
|
||||
|
||||
import type { TranslationKeys } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DynamicBreadcrumbProps {
|
||||
customHome?: string | null;
|
||||
customHomeLink?: string;
|
||||
dynamicMappings?: Map<string, string>;
|
||||
nonInteractable?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb is client side rendered. Elements are automatically
|
||||
* calculated and translated using dynamic keys.
|
||||
* For dynamic routes (e.g. UIDs, names , ...),
|
||||
* you can pass dynamic mappings to define their values
|
||||
* in your parent component.
|
||||
* @constructor
|
||||
*/
|
||||
export const DynamicBreadcrumb = ({
|
||||
dynamicMappings,
|
||||
customHome = "manage",
|
||||
customHomeLink = "/manage",
|
||||
nonInteractable,
|
||||
}: DynamicBreadcrumbProps) => {
|
||||
const pathname = usePathname();
|
||||
const pathnameParts = pathname.split("/").filter((part) => part.length > 0);
|
||||
const t = useScopedI18n("navigationStructure");
|
||||
const tNavbar = useScopedI18n("management.navbar.items");
|
||||
|
||||
const length = pathnameParts.filter((part) => part !== customHome).length;
|
||||
|
||||
if (length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumbs w="100%" mb="md">
|
||||
<Badge
|
||||
styles={{ root: { cursor: "pointer" } }}
|
||||
component={"a"}
|
||||
href={customHomeLink}
|
||||
leftSection={<IconHomeFilled size="1rem" />}
|
||||
variant="default"
|
||||
tt="initial"
|
||||
h="auto"
|
||||
>
|
||||
<Text fw="bold">{tNavbar("home")}</Text>
|
||||
</Badge>
|
||||
{pathnameParts.map((pathnamePart, index) => {
|
||||
if (pathnamePart === customHome) {
|
||||
return null;
|
||||
}
|
||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
if (dynamicMappings?.has(pathnamePart)) {
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{dynamicMappings.get(pathnamePart)}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const defaultLocale = "en";
|
||||
export { languageMapping } from "./lang";
|
||||
export type { TranslationKeys } from "./lang";
|
||||
|
||||
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
|
||||
if (typeof value === "function") {
|
||||
|
||||
@@ -11,3 +11,11 @@ export const languageMapping = () => {
|
||||
|
||||
return mapping as Record<(typeof supportedLanguages)[number], () => ReturnType<typeof enTranslations>>;
|
||||
};
|
||||
|
||||
type NestedKeyOf<ObjectType extends object> = {
|
||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
|
||||
: `${Key}`;
|
||||
}[keyof ObjectType & (string | number)];
|
||||
|
||||
export type TranslationKeys = NestedKeyOf<typeof enTranslations>;
|
||||
|
||||
@@ -1508,4 +1508,53 @@ export default {
|
||||
remove: "Remove",
|
||||
},
|
||||
},
|
||||
navigationStructure: {
|
||||
manage: {
|
||||
label: "Manage",
|
||||
boards: {
|
||||
label: "Boards",
|
||||
},
|
||||
integrations: {
|
||||
label: "Integrations",
|
||||
edit: {
|
||||
label: "Edit",
|
||||
},
|
||||
new: {
|
||||
label: "New",
|
||||
},
|
||||
},
|
||||
apps: {
|
||||
label: "Apps",
|
||||
new: {
|
||||
label: "New App",
|
||||
},
|
||||
edit: {
|
||||
label: "Edit App",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
label: "Users",
|
||||
create: {
|
||||
label: "Create",
|
||||
},
|
||||
general: "General",
|
||||
security: "Security",
|
||||
},
|
||||
tools: {
|
||||
label: "Tools",
|
||||
docker: {
|
||||
label: "Docker",
|
||||
},
|
||||
logs: {
|
||||
label: "Logs",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
label: "Settings",
|
||||
},
|
||||
about: {
|
||||
label: "About",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user