refactor: breadcrumbs

This commit is contained in:
Nicolas Meienberger 2026-02-08 15:54:05 +01:00
parent 1f1d584eea
commit 5ad47cce1a
37 changed files with 99 additions and 201 deletions

View file

@ -1,4 +1,4 @@
import { Link, useMatches, type UIMatch } from "react-router";
import { useMatches, Link } from "@tanstack/react-router";
import {
Breadcrumb,
BreadcrumbItem,
@ -13,25 +13,22 @@ export interface BreadcrumbItemData {
href?: string;
}
interface RouteHandle {
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
}
type BreadcrumbFunction = (match: ReturnType<typeof useMatches>[number]) => BreadcrumbItemData[] | null;
export function AppBreadcrumb() {
const matches = useMatches();
// Find the last match with a breadcrumb handler
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
const handle = match.handle as RouteHandle | undefined;
return handle?.breadcrumb;
const breadcrumbFn = match.staticData?.breadcrumb as BreadcrumbFunction | undefined;
return breadcrumbFn;
});
if (!lastMatchWithBreadcrumb) {
return null;
}
const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
const breadcrumbFn = lastMatchWithBreadcrumb.staticData?.breadcrumb as BreadcrumbFunction;
const breadcrumbs = breadcrumbFn?.(lastMatchWithBreadcrumb);
if (!breadcrumbs || breadcrumbs.length === 0) {
return null;

View file

@ -8,6 +8,7 @@ import { AppSidebar } from "./app-sidebar";
import { authClient } from "../lib/auth-client";
import { DevPanelListener } from "./dev-panel-listener";
import { Outlet, useNavigate } from "@tanstack/react-router";
import { AppBreadcrumb } from "./app-breadcrumb";
type Props = {
loaderData: AppContext;
@ -37,7 +38,7 @@ export function Layout({ loaderData }: Props) {
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0">
<SidebarTrigger />
{/* <AppBreadcrumb /> */}
<AppBreadcrumb />
</div>
{loaderData.user && (
<div className="flex items-center gap-4">

View file

@ -40,12 +40,6 @@ import type {
} from "~/client/lib/types";
import { useNavigate } from "@tanstack/react-router";
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => {
// const data = match.loaderData;
// return [{ label: "Backups", href: "/backups" }, { label: data.schedule.name }];
// },
// };
type Props = {
loaderData: {
schedule: BackupSchedule;

View file

@ -23,10 +23,6 @@ import { SortableCard } from "~/client/components/sortable-card";
import { BackupCard } from "../components/backup-card";
import { Link } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Backups" }],
};
export const clientLoader = async () => {
const jobs = await listBackupSchedules();
if (jobs.data) return jobs.data;

View file

@ -16,10 +16,6 @@ import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import { Link, useNavigate } from "@tanstack/react-router";
// export const handle = {
// breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
// };
export function CreateBackupPage() {
const navigate = useNavigate();
const formId = useId();

View file

@ -10,10 +10,6 @@ import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
};
export function CreateNotificationPage() {
const navigate = useNavigate();
const formId = useId();

View file

@ -26,13 +26,6 @@ import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
import { useNavigate } from "@tanstack/react-router";
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Notifications", href: "/notifications" },
// { label: match.params.id },
// ],
// };
export function NotificationDetailsPage({ notificationId }: { notificationId: string }) {
const navigate = useNavigate();
const formId = useId();

View file

@ -11,10 +11,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Notifications" }],
};
export function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");

View file

@ -13,10 +13,6 @@ import { parseError } from "~/client/lib/errors";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Repositories", href: "/repositories" }, { label: "Create" }],
};
export function CreateRepositoryPage() {
const navigate = useNavigate();
const formId = useId();

View file

@ -12,10 +12,6 @@ import { cn } from "~/client/lib/utils";
import { EmptyState } from "~/client/components/empty-state";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Repositories" }],
};
export function RepositoriesPage() {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");

View file

@ -6,13 +6,6 @@ import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { useNavigate, useSearch } from "@tanstack/react-router";
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Repositories", href: "/repositories" },
// { label: match.loaderData?.name || match.params.id },
// ],
// };
export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) {
const queryClient = useQueryClient();

View file

@ -1,15 +1,6 @@
import { RestoreForm } from "~/client/components/restore-form";
import type { Repository, Snapshot } from "~/client/lib/types";
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Repositories", href: "/repositories" },
// { label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
// { label: match.params.snapshotId, href: `/repositories/${match.params.id}/${match.params.snapshotId}` },
// { label: "Restore" },
// ],
// };
type Props = {
snapshot: Snapshot;
repository: Repository;

View file

@ -12,14 +12,6 @@ import { useState } from "react";
import { Database } from "lucide-react";
import { Link, useParams } from "@tanstack/react-router";
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Repositories", href: "/repositories" },
// { label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
// { label: match.params.snapshotId },
// ],
// };
export const SnapshotError = () => {
const { repoId } = useParams({ from: "/(dashboard)/repositories/$repoId/$snapshotId" });

View file

@ -28,10 +28,6 @@ import { TwoFactorSection } from "../components/two-factor-section";
import { UserManagement } from "../components/user-management";
import { useNavigate, useSearch } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
};
type Props = {
appContext: AppContext;
};

View file

@ -10,10 +10,6 @@ import { parseError } from "~/client/lib/errors";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
};
export function CreateVolumePage() {
const navigate = useNavigate();
const formId = useId();

View file

@ -31,10 +31,6 @@ import {
} from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.id }],
};
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
const statusMap = {
mounted: "success" as const,

View file

@ -23,10 +23,6 @@ const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "
return statusMap[status];
};
export const handle = {
breadcrumb: () => [{ label: "Volumes" }],
};
export function VolumesPage() {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");

View file

@ -1,9 +0,0 @@
import { redirect } from "react-router";
export const clientLoader = async () => {
return redirect("/volumes");
};
export const loader = async () => {
return redirect("/volumes");
};

View file

@ -1,5 +1,3 @@
import { createContext } from "react-router";
type User = {
id: string;
email: string;
@ -13,8 +11,3 @@ export type AppContext = {
user: User | null;
hasUsers: boolean;
};
export const appContext = createContext<AppContext>({
user: null,
hasUsers: false,
});

View file

@ -1,96 +0,0 @@
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import { Toaster } from "./client/components/ui/sonner";
import { useServerEvents } from "./client/hooks/use-server-events";
import { client } from "./client/api-client/client.gen";
client.setConfig({
baseUrl: "/",
});
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&display=swap",
},
];
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: () => {
void queryClient.invalidateQueries();
},
onError: (error) => {
console.error("Mutation error:", error);
void queryClient.invalidateQueries();
},
}),
});
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="icon" type="image/png" href="/images/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<Meta />
<Links />
</head>
<QueryClientProvider client={queryClient}>
<body className="dark">
{children}
<Toaster />
<ScrollRestoration />
<Scripts />
</body>
</QueryClientProvider>
</html>
);
}
export default function App() {
useServerEvents();
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-4 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View file

@ -3,6 +3,7 @@ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query
import { routeTree } from "./routeTree.gen";
import { MutationCache, QueryClient } from "@tanstack/react-query";
import { client } from "./client/api-client/client.gen";
import type { BreadcrumbItemData } from "./client/components/app-breadcrumb";
client.setConfig({
baseUrl: "/",
@ -45,4 +46,7 @@ declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
interface StaticDataRouteOption {
breadcrumb?: (match: any) => BreadcrumbItemData[] | null;
}
}

View file

@ -19,7 +19,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }),
]);
return { snapshot, repository };
return { snapshot, repository, schedule: schedule.data };
},
staticData: {
breadcrumb: (match) => [
{ label: "Backup Jobs", href: "/backups" },
{ label: match.loaderData?.schedule?.name || "Job", href: `/backups/${match.params.backupId}` },
{ label: match.params.snapshotId },
{ label: "Restore" },
],
},
});

View file

@ -28,6 +28,12 @@ export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({
return { schedule, notifs, repos, scheduleNotifs, mirrors };
},
staticData: {
breadcrumb: (match) => [
{ label: "Backup Jobs", href: "/backups" },
{ label: match.loaderData?.schedule.name || "Job Details" },
],
},
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - ${loaderData?.schedule.name || "Backup Job Details"}` },

View file

@ -9,6 +9,12 @@ export const Route = createFileRoute("/(dashboard)/backups/create")({
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
]);
},
staticData: {
breadcrumb: () => [
{ label: "Backup Jobs", href: "/backups" },
{ label: "Create" },
],
},
component: RouteComponent,
});

View file

@ -9,6 +9,9 @@ export const Route = createFileRoute("/(dashboard)/backups/")({
...listBackupSchedulesOptions(),
});
},
staticData: {
breadcrumb: () => [{ label: "Backup Jobs" }],
},
head: () => ({
meta: [
{ title: "Zerobyte - Backup Jobs" },

View file

@ -11,6 +11,12 @@ export const Route = createFileRoute("/(dashboard)/notifications/$notificationId
return res;
},
staticData: {
breadcrumb: (match) => [
{ label: "Notifications", href: "/notifications" },
{ label: match.loaderData?.name || "Notification Details" },
],
},
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - ${loaderData?.name}` },

View file

@ -3,6 +3,12 @@ import { CreateNotificationPage } from "~/client/modules/notifications/routes/cr
export const Route = createFileRoute("/(dashboard)/notifications/create")({
component: RouteComponent,
staticData: {
breadcrumb: () => [
{ label: "Notifications", href: "/notifications" },
{ label: "Create" },
],
},
head: () => ({
meta: [
{ title: "Zerobyte - Create Notification" },

View file

@ -7,6 +7,9 @@ export const Route = createFileRoute("/(dashboard)/notifications/")({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData({ ...listNotificationDestinationsOptions() });
},
staticData: {
breadcrumb: () => [{ label: "Notifications" }],
},
head: () => ({
meta: [
{ title: "Zerobyte - Notifications" },

View file

@ -15,6 +15,14 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/
return { snapshot, repository };
},
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repoId}` },
{ label: match.params.snapId, href: `/repositories/${match.params.repoId}/${match.params.snapId}` },
{ label: "Restore" },
],
},
head: ({ params }) => ({
meta: [
{ title: `Zerobyte - Restore Snapshot ${params.snapId}` },

View file

@ -10,7 +10,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapsho
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData({
const res = await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { id: params.repoId } }),
});
@ -25,6 +25,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapsho
query: { path: "/" },
}),
});
return res;
},
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repoId}` },
{ label: match.params.snapshotId },
],
},
head: ({ params }) => ({
meta: [

View file

@ -25,6 +25,12 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId")(
return res;
},
validateSearch: type({ tab: "string?" }),
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.name || "Repository Details" },
],
},
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - ${loaderData?.name}` },

View file

@ -3,6 +3,12 @@ import { CreateRepositoryPage } from "~/client/modules/repositories/routes/creat
export const Route = createFileRoute("/(dashboard)/repositories/create")({
component: RouteComponent,
staticData: {
breadcrumb: () => [
{ label: "Repositories", href: "/repositories" },
{ label: "Create" },
],
},
head: () => ({
meta: [
{ title: "Zerobyte - Create Repository" },

View file

@ -10,6 +10,9 @@ export const Route = createFileRoute("/(dashboard)/repositories/")({
});
},
errorComponent: (e) => <div>{e.error.message}</div>,
staticData: {
breadcrumb: () => [{ label: "Repositories" }],
},
head: () => ({
meta: [
{ title: "Zerobyte - Repositories" },

View file

@ -11,6 +11,9 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
const authContext = await fetchUser();
return authContext as AppContext;
},
staticData: {
breadcrumb: () => [{ label: "Settings" }],
},
});
function RouteComponent() {

View file

@ -14,6 +14,12 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
return res;
},
validateSearch: type({ tab: "string?" }),
staticData: {
breadcrumb: (match) => [
{ label: "Volumes", href: "/volumes" },
{ label: match.loaderData?.volume.name || "Volume Details" },
],
},
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - ${loaderData?.volume.name}` },

View file

@ -3,6 +3,9 @@ import { CreateVolumePage } from "~/client/modules/volumes/routes/create-volume"
export const Route = createFileRoute("/(dashboard)/volumes/create")({
component: RouteComponent,
staticData: {
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
},
head: () => ({
meta: [
{ title: "Zerobyte - Create Volume" },

View file

@ -8,6 +8,9 @@ export const Route = createFileRoute("/(dashboard)/volumes/")({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(listVolumesOptions());
},
staticData: {
breadcrumb: () => [{ label: "Volumes" }],
},
head: () => ({
meta: [
{ title: "Zerobyte - Volumes" },