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;