feat: add dynamic breadcrumb (#706)

* feat: add dynamic breadcrumb

* feat: pr feedback
This commit is contained in:
Manuel
2024-06-29 17:28:22 +02:00
committed by GitHub
parent be100b610e
commit 4e1bbf2ae6
21 changed files with 258 additions and 58 deletions

View File

@@ -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="" />

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>

View File

@@ -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 }}>

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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 }}>

View File

@@ -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 />
</>
);
}

View File

@@ -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">

View File

@@ -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} />
</>
);
}

View File

@@ -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} />
</>
);
}

View 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>
);
};

View File

@@ -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") {

View File

@@ -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>;

View File

@@ -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;