From 4e1bbf2ae64e951c62bb3a86787cecc1e2d22da9 Mon Sep 17 00:00:00 2001
From: Manuel <30572287+manuel-rw@users.noreply.github.com>
Date: Sat, 29 Jun 2024 17:28:22 +0200
Subject: [PATCH] feat: add dynamic breadcrumb (#706)
* feat: add dynamic breadcrumb
* feat: pr feedback
---
.../src/app/[locale]/manage/about/page.tsx | 2 +
.../[locale]/manage/apps/edit/[id]/page.tsx | 16 ++--
.../src/app/[locale]/manage/apps/new/page.tsx | 16 ++--
.../src/app/[locale]/manage/apps/page.tsx | 2 +
.../src/app/[locale]/manage/boards/page.tsx | 2 +
.../manage/integrations/edit/[id]/page.tsx | 22 +++--
.../[locale]/manage/integrations/new/page.tsx | 22 +++--
.../app/[locale]/manage/integrations/page.tsx | 2 +
apps/nextjs/src/app/[locale]/manage/page.tsx | 2 +
.../src/app/[locale]/manage/settings/page.tsx | 12 ++-
.../app/[locale]/manage/tools/docker/page.tsx | 12 ++-
.../app/[locale]/manage/tools/logs/page.tsx | 10 ++-
.../[locale]/manage/users/[userId]/layout.tsx | 30 +++----
.../app/[locale]/manage/users/create/page.tsx | 8 +-
.../app/[locale]/manage/users/groups/page.tsx | 2 +
.../[locale]/manage/users/invites/page.tsx | 8 +-
.../src/app/[locale]/manage/users/page.tsx | 8 +-
.../navigation/dynamic-breadcrumb.tsx | 82 +++++++++++++++++++
packages/translation/src/index.ts | 1 +
packages/translation/src/lang.ts | 8 ++
packages/translation/src/lang/en.ts | 49 +++++++++++
21 files changed, 258 insertions(+), 58 deletions(-)
create mode 100644 apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx
index a29e44e0f..3724cb668 100644
--- a/apps/nextjs/src/app/[locale]/manage/about/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx
@@ -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 (
+
diff --git a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx
index eddb8bb3b..9b3551e43 100644
--- a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/page.tsx
@@ -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 (
-
-
- {t("app.page.edit.title")}
-
-
-
+ <>
+
+
+
+ {t("app.page.edit.title")}
+
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx
index 5734c9f0e..9fe1a2de6 100644
--- a/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/apps/new/page.tsx
@@ -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 (
-
-
- {t("app.page.create.title")}
-
-
-
+ <>
+
+
+
+ {t("app.page.create.title")}
+
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
index 30344ccab..2e6983b6e 100644
--- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
@@ -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 (
+
{t("page.list.title")}
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
index b117a6d82..506af6a99 100644
--- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
@@ -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 (
+
{t("title")}
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx
index bebf34626..3fe30771e 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx
@@ -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 (
-
-
-
-
- {t("title", { name: getIntegrationName(integration.kind) })}
-
-
-
-
+ <>
+
+
+
+
+
+ {t("title", { name: getIntegrationName(integration.kind) })}
+
+
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx
index 0db877a11..c817f1c0e 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx
@@ -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 (
-
-
-
-
- {t("title", { name: getIntegrationName(currentKind) })}
-
-
-
-
+ <>
+
+
+
+
+
+ {t("title", { name: getIntegrationName(currentKind) })}
+
+
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
index 8bdcd0329..f3d1cc0af 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
@@ -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 (
+
{t("page.list.title")}
diff --git a/apps/nextjs/src/app/[locale]/manage/page.tsx b/apps/nextjs/src/app/[locale]/manage/page.tsx
index 3e45269e9..cd9f69d23 100644
--- a/apps/nextjs/src/app/[locale]/manage/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/page.tsx
@@ -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 (
<>
+
diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx
index bf5c92f34..c90261c37 100644
--- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx
@@ -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 (
-
- {t("title")}
-
-
+ <>
+
+
+ {t("title")}
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
index 9a18f5ba5..cfd5da570 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx
@@ -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 (
-
- {tDocker("title")}
-
-
+ <>
+
+
+ {tDocker("title")}
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx
index 4b6373764..7822da49c 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx
@@ -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 (
-
-
-
+ <>
+
+
+
+
+ >
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx
index b74e6d780..4821ac090 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx
@@ -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
+
-
-
-
- {user.name}
- {t("user.name")}
-
-
- {session?.user.permissions.includes("admin") && (
-
- )}
+
+
+ {user.name}
+ {t("user.name")}
+
diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx
index b44c17b6c..730f3e426 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx
@@ -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 ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
index ec46596bc..da8ce6967 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
@@ -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 (
+
{t("group.title")}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx
index 12daa10c4..1e7b8de1e 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx
@@ -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 ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx
index 61a143f5a..79048e506 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx
@@ -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 ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
new file mode 100644
index 000000000..f6310d481
--- /dev/null
+++ b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx
@@ -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;
+ 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 (
+
+ }
+ variant="default"
+ tt="initial"
+ h="auto"
+ >
+ {tNavbar("home")}
+
+ {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 {t(`${translationKey}.label` as TranslationKeys)};
+ }
+
+ if (dynamicMappings?.has(pathnamePart)) {
+ return (
+
+ {dynamicMappings.get(pathnamePart)}
+
+ );
+ }
+
+ return (
+
+ {t(`${translationKey}.label` as TranslationKeys)}
+
+ );
+ })}
+
+ );
+};
diff --git a/packages/translation/src/index.ts b/packages/translation/src/index.ts
index 805b3e873..89f14b401 100644
--- a/packages/translation/src/index.ts
+++ b/packages/translation/src/index.ts
@@ -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") {
diff --git a/packages/translation/src/lang.ts b/packages/translation/src/lang.ts
index 7765d1cce..ebaaca03c 100644
--- a/packages/translation/src/lang.ts
+++ b/packages/translation/src/lang.ts
@@ -11,3 +11,11 @@ export const languageMapping = () => {
return mapping as Record<(typeof supportedLanguages)[number], () => ReturnType>;
};
+
+type NestedKeyOf = {
+ [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
+ ? `${Key}` | `${Key}.${NestedKeyOf}`
+ : `${Key}`;
+}[keyof ObjectType & (string | number)];
+
+export type TranslationKeys = NestedKeyOf;
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index ef44dba64..98071e128 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -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;