Add breadcrumbs

This commit is contained in:
2025-10-15 16:59:39 +02:00
parent d4f225df34
commit 350ebc90c8
3 changed files with 224 additions and 8 deletions

View File

@@ -7,13 +7,21 @@ import { Repertoire } from "@/routes/Repertoire";
import { Repertoires } from "@/routes/Repertoires";
import { Root } from "@/routes/Root";
import { Settings } from "@/routes/Settings";
import { lazy, StrictMode } from "react";
import { AttachmentId, PieceId, RepertoireId } from "common";
import { Effect, pipe } from "effect";
import { ListMusic, Music3, SettingsIcon, Users } from "lucide-react";
import { lazy, ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { createBrowserRouter, Params, RouterProvider } from "react-router-dom";
import { pieceCache, repertoireCache } from "./cache";
import "./style.css";
const Attachment = lazy(() => import("@/routes/Attachment"));
export interface Handle {
readonly breadcrumb?: (params: Params) => Effect.Effect<ReactNode>;
}
const router = createBrowserRouter([
{
path: "/",
@@ -25,6 +33,12 @@ const router = createBrowserRouter([
},
{
path: "piece",
handle: {
breadcrumb: () => Effect.succeed(<>
<Music3 className="size-4" absoluteStrokeWidth />
<div className="self-baseline">Utwory</div>
</>),
},
children: [
{
index: true,
@@ -32,6 +46,13 @@ const router = createBrowserRouter([
},
{
path: ":pieceId",
handle: {
breadcrumb: (params: Params) => pipe(
PieceId.make(params.pieceId!),
pieceCache.get,
Effect.map((piece) => piece.name),
),
},
children: [
{
index: true,
@@ -39,6 +60,17 @@ const router = createBrowserRouter([
},
{
path: "attachment/:attachmentId",
handle: {
breadcrumb: (params: Params) => Effect.gen(function* () {
const pieceId = PieceId.make(params.pieceId!);
const attachmentId = AttachmentId.make(params.attachmentId!);
const piece = yield* pieceCache.get(pieceId);
const attachment = piece.attachments.find((attachment) => attachment.attachmentId === attachmentId)!;
return attachment.filename;
}),
},
Component: Attachment,
},
],
@@ -47,6 +79,12 @@ const router = createBrowserRouter([
},
{
path: "repertoire",
handle: {
breadcrumb: () => Effect.succeed(<>
<ListMusic className="size-4" absoluteStrokeWidth />
<div className="self-baseline">Repertuary</div>
</>),
},
children: [
{
index: true,
@@ -54,16 +92,35 @@ const router = createBrowserRouter([
},
{
path: ":repertoireId",
handle: {
breadcrumb: (params: Params) => pipe(
RepertoireId.make(params.repertoireId!),
repertoireCache.get,
Effect.map((repertoire) => repertoire.name),
),
},
Component: Repertoire,
},
],
},
{
path: "settings",
handle: {
breadcrumb: () => Effect.succeed(<>
<SettingsIcon className="size-4" absoluteStrokeWidth />
<div className="self-baseline">Ustawienia</div>
</>),
},
Component: Settings,
},
{
path: "admin",
handle: {
breadcrumb: () => Effect.succeed(<>
<Users className="size-4" absoluteStrokeWidth />
<div className="self-baseline">Administracja</div>
</>),
},
Component: Admin,
}
],

View File

@@ -0,0 +1,111 @@
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import * as React from "react";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem,
BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator
};

View File

@@ -1,13 +1,17 @@
import { type Handle } from "@/app";
import { API_URL_PREFIX, client } from "@/client";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { buttonVariants } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useLoading } from "@/hooks/useLoading";
import { setUser, useStore } from "@/hooks/useStore";
import { Role } from "common/the_api";
import { Effect, HashSet, Option, pipe } from "effect";
import { Array, Effect, HashSet, Option, pipe } from "effect";
import { apply } from "effect/Function";
import { LogOut, Settings, Users } from "lucide-react";
import { useEffect } from "react";
import { Link, Outlet, useNavigate } from "react-router-dom";
import { Fragment, useEffect, useMemo } from "react";
import { Link, Outlet, UIMatch, useMatches, useNavigate, useParams } from "react-router-dom";
export function Root() {
@@ -49,9 +53,12 @@ export function Root() {
return (
<div className="w-full h-full overflow-hidden flex flex-col items-stretch">
<div className="flex p-4 justify-between items-center border-b">
<Link className={buttonVariants({ variant: "outline" })} to="/">
Repozytorium muzyczne
</Link>
<div className="flex gap-4 items-baseline">
<Link className={buttonVariants({ variant: "outline" })} to="/">
Repozytorium muzyczne
</Link>
<Breadcrumbs />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="shadow">
@@ -81,3 +88,44 @@ export function Root() {
</div>
);
}
function Breadcrumbs() {
const params = useParams();
const matches = useMatches() as UIMatch<unknown, Handle | undefined>[];
const breadcrumbEffect = useMemo(() => pipe(
matches,
Array.filterMap(({ handle, pathname }) => pipe(
handle,
Option.fromNullable,
Option.flatMapNullable((handle) => handle.breadcrumb),
Option.map(apply(params)),
Option.map(Effect.map((title) => ({ title, pathname })))
)),
Effect.allWith({ concurrency: "unbounded" })
), [params, matches]);
const { data: breadcrumbs } = useLoading(breadcrumbEffect, [breadcrumbEffect]);
return (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs !== null && breadcrumbs?.map(({ title, pathname }, i) => (
<Fragment key={i}>
<BreadcrumbItem>
{i < breadcrumbs.length - 1 ? (
<BreadcrumbLink asChild>
<Link to={pathname} className="flex items-center gap-2">{title}</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage className="flex items-center gap-2">{title}</BreadcrumbPage>
)}
</BreadcrumbItem>
{i < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}