Add breadcrumbs
This commit is contained in:
@@ -7,13 +7,21 @@ import { Repertoire } from "@/routes/Repertoire";
|
|||||||
import { Repertoires } from "@/routes/Repertoires";
|
import { Repertoires } from "@/routes/Repertoires";
|
||||||
import { Root } from "@/routes/Root";
|
import { Root } from "@/routes/Root";
|
||||||
import { Settings } from "@/routes/Settings";
|
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 { 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";
|
import "./style.css";
|
||||||
|
|
||||||
const Attachment = lazy(() => import("@/routes/Attachment"));
|
const Attachment = lazy(() => import("@/routes/Attachment"));
|
||||||
|
|
||||||
|
export interface Handle {
|
||||||
|
readonly breadcrumb?: (params: Params) => Effect.Effect<ReactNode>;
|
||||||
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -25,6 +33,12 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "piece",
|
path: "piece",
|
||||||
|
handle: {
|
||||||
|
breadcrumb: () => Effect.succeed(<>
|
||||||
|
<Music3 className="size-4" absoluteStrokeWidth />
|
||||||
|
<div className="self-baseline">Utwory</div>
|
||||||
|
</>),
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
@@ -32,6 +46,13 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":pieceId",
|
path: ":pieceId",
|
||||||
|
handle: {
|
||||||
|
breadcrumb: (params: Params) => pipe(
|
||||||
|
PieceId.make(params.pieceId!),
|
||||||
|
pieceCache.get,
|
||||||
|
Effect.map((piece) => piece.name),
|
||||||
|
),
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
@@ -39,6 +60,17 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "attachment/:attachmentId",
|
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,
|
Component: Attachment,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -47,6 +79,12 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "repertoire",
|
path: "repertoire",
|
||||||
|
handle: {
|
||||||
|
breadcrumb: () => Effect.succeed(<>
|
||||||
|
<ListMusic className="size-4" absoluteStrokeWidth />
|
||||||
|
<div className="self-baseline">Repertuary</div>
|
||||||
|
</>),
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
@@ -54,16 +92,35 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":repertoireId",
|
path: ":repertoireId",
|
||||||
|
handle: {
|
||||||
|
breadcrumb: (params: Params) => pipe(
|
||||||
|
RepertoireId.make(params.repertoireId!),
|
||||||
|
repertoireCache.get,
|
||||||
|
Effect.map((repertoire) => repertoire.name),
|
||||||
|
),
|
||||||
|
},
|
||||||
Component: Repertoire,
|
Component: Repertoire,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "settings",
|
path: "settings",
|
||||||
|
handle: {
|
||||||
|
breadcrumb: () => Effect.succeed(<>
|
||||||
|
<SettingsIcon className="size-4" absoluteStrokeWidth />
|
||||||
|
<div className="self-baseline">Ustawienia</div>
|
||||||
|
</>),
|
||||||
|
},
|
||||||
Component: Settings,
|
Component: Settings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "admin",
|
path: "admin",
|
||||||
|
handle: {
|
||||||
|
breadcrumb: () => Effect.succeed(<>
|
||||||
|
<Users className="size-4" absoluteStrokeWidth />
|
||||||
|
<div className="self-baseline">Administracja</div>
|
||||||
|
</>),
|
||||||
|
},
|
||||||
Component: Admin,
|
Component: Admin,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
111
packages/frontend/src/components/ui/breadcrumb.tsx
Normal file
111
packages/frontend/src/components/ui/breadcrumb.tsx
Normal 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
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import { type Handle } from "@/app";
|
||||||
import { API_URL_PREFIX, client } from "@/client";
|
import { API_URL_PREFIX, client } from "@/client";
|
||||||
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
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 { buttonVariants } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { setUser, useStore } from "@/hooks/useStore";
|
import { setUser, useStore } from "@/hooks/useStore";
|
||||||
import { Role } from "common/the_api";
|
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 { LogOut, Settings, Users } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { Fragment, useEffect, useMemo } from "react";
|
||||||
import { Link, Outlet, useNavigate } from "react-router-dom";
|
import { Link, Outlet, UIMatch, useMatches, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
export function Root() {
|
export function Root() {
|
||||||
|
|
||||||
@@ -49,9 +53,12 @@ export function Root() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full h-full overflow-hidden flex flex-col items-stretch">
|
<div className="w-full h-full overflow-hidden flex flex-col items-stretch">
|
||||||
<div className="flex p-4 justify-between items-center border-b">
|
<div className="flex p-4 justify-between items-center border-b">
|
||||||
|
<div className="flex gap-4 items-baseline">
|
||||||
<Link className={buttonVariants({ variant: "outline" })} to="/">
|
<Link className={buttonVariants({ variant: "outline" })} to="/">
|
||||||
Repozytorium muzyczne
|
Repozytorium muzyczne
|
||||||
</Link>
|
</Link>
|
||||||
|
<Breadcrumbs />
|
||||||
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Avatar className="shadow">
|
<Avatar className="shadow">
|
||||||
@@ -81,3 +88,44 @@ export function Root() {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user