mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
public commit
This commit is contained in:
362
app/src/ui/layouts/AppShell/AppShell.tsx
Normal file
362
app/src/ui/layouts/AppShell/AppShell.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useClickOutside, useDisclosure, useHotkeys, useViewportSize } from "@mantine/hooks";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { ucFirst } from "core/utils";
|
||||
import { throttle } from "lodash-es";
|
||||
import { type ComponentProps, createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import type { IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { Link } from "wouter";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export function Root({ children }) {
|
||||
return (
|
||||
<AppShellProvider>
|
||||
<div data-shell="root" className="flex flex-1 flex-col select-none h-dvh">
|
||||
{children}
|
||||
</div>
|
||||
</AppShellProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type NavLinkProps<E extends React.ElementType> = {
|
||||
Icon?: IconType;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
to?: string; // @todo: workaround
|
||||
as?: E;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const NavLink = <E extends React.ElementType = "a">({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
Icon,
|
||||
disabled,
|
||||
...otherProps
|
||||
}: NavLinkProps<E> & Omit<React.ComponentProps<E>, keyof NavLinkProps<E>>) => {
|
||||
const Tag = as || "a";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...otherProps}
|
||||
className={twMerge(
|
||||
"px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={18} />}
|
||||
{typeof children === "string" ? <span className="text-lg">{children}</span> : children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export function Content({ children, center }: { children: React.ReactNode; center?: boolean }) {
|
||||
return (
|
||||
<main
|
||||
data-shell="content"
|
||||
className={twMerge(
|
||||
"flex flex-1 flex-row w-dvw h-full",
|
||||
center && "justify-center items-center"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function Main({ children }) {
|
||||
return (
|
||||
<div data-shell="main" className="flex flex-col flex-grow w-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ children }) {
|
||||
const ctx = useAppShell();
|
||||
|
||||
const ref = useClickOutside(ctx.sidebar?.handler?.close);
|
||||
|
||||
const onClickBackdrop = useEvent((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx?.sidebar?.handler.close();
|
||||
});
|
||||
|
||||
const onEscape = useEvent(() => {
|
||||
if (ctx?.sidebar?.open) {
|
||||
ctx?.sidebar?.handler.close();
|
||||
}
|
||||
});
|
||||
|
||||
// @todo: potentially has to be added to the root, as modals could be opened
|
||||
useHotkeys([["Escape", onEscape]]);
|
||||
|
||||
if (!ctx) {
|
||||
console.warn("AppShell.Sidebar: missing AppShellContext");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
data-shell="sidebar"
|
||||
className="hidden md:flex flex-col basis-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-muted/10"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
<div
|
||||
data-open={ctx?.sidebar?.open}
|
||||
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm"
|
||||
onClick={onClickBackdrop}
|
||||
>
|
||||
<aside
|
||||
/*ref={ref}*/
|
||||
data-shell="sidebar"
|
||||
className="flex-col w-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeaderTitle({ children, className, ...props }: ComponentProps<"h2">) {
|
||||
return (
|
||||
<h2
|
||||
{...props}
|
||||
className={twMerge("text-lg dark:font-bold font-semibold select-text", className)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeader({ children, right, className, scrollable, sticky }: any = {}) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row h-14 flex-shrink-0 py-2 pl-5 pr-3 border-muted border-b items-center justify-between bg-muted/10",
|
||||
sticky && "sticky top-0 bottom-10 z-10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"",
|
||||
scrollable && "overflow-x-scroll overflow-y-visible app-scrollbar"
|
||||
)}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<SectionHeaderTitle>{children}</SectionHeaderTitle>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
{right && !scrollable && <div className="flex flex-row gap-2.5">{right}</div>}
|
||||
{right && scrollable && (
|
||||
<div className="flex flex-row sticky z-10 right-0 h-full">
|
||||
<div className="h-full w-5 bg-gradient-to-l from-background" />
|
||||
<div className="flex flex-row gap-2.5 bg-background">{right}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarLinkProps<E extends React.ElementType> = {
|
||||
children: React.ReactNode;
|
||||
as?: E;
|
||||
to?: string; // @todo: workaround
|
||||
params?: Record<string, string>; // @todo: workaround
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarLink = <E extends React.ElementType = "a">({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
disabled = false,
|
||||
...otherProps
|
||||
}: SidebarLinkProps<E> & Omit<React.ComponentProps<E>, keyof SidebarLinkProps<E>>) => {
|
||||
const Tag = as || "a";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...otherProps}
|
||||
className={twMerge(
|
||||
"flex flex-row px-4 py-2.5 items-center gap-2",
|
||||
!disabled &&
|
||||
"cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 link",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionHeaderLinkProps<E extends React.ElementType> = {
|
||||
children: React.ReactNode;
|
||||
as?: E;
|
||||
to?: string; // @todo: workaround
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
badge?: string | number;
|
||||
};
|
||||
|
||||
export const SectionHeaderLink = <E extends React.ElementType = "a">({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
disabled = false,
|
||||
active = false,
|
||||
badge,
|
||||
...props
|
||||
}: SectionHeaderLinkProps<E> & Omit<React.ComponentProps<E>, keyof SectionHeaderLinkProps<E>>) => {
|
||||
const Tag = as || "a";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"hover:bg-primary/5 flex flex-row items-center justify-center gap-2.5 px-5 h-12 leading-none font-medium text-primary/80 rounded-tr-lg rounded-tl-lg",
|
||||
active
|
||||
? "bg-background hover:bg-background text-primary border border-muted border-b-0"
|
||||
: "link",
|
||||
badge && "pr-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{badge ? (
|
||||
<span className="px-3 py-1 rounded-full font-mono bg-primary/5 text-sm leading-none">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export type SectionHeaderTabsProps = {
|
||||
title?: string;
|
||||
items?: (Omit<SectionHeaderLinkProps<any>, "children"> & {
|
||||
label: string;
|
||||
})[];
|
||||
};
|
||||
export const SectionHeaderTabs = ({ title, items }: SectionHeaderTabsProps) => {
|
||||
return (
|
||||
<SectionHeader className="mt-10 border-t pl-3 pb-0 items-end">
|
||||
<div className="flex flex-row items-center gap-6 -mb-px">
|
||||
{title && (
|
||||
<SectionHeaderTitle className="pl-2 hidden md:block">{title}</SectionHeaderTitle>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
{items?.map(({ label, ...item }, key) => (
|
||||
<SectionHeaderLink key={key} {...item}>
|
||||
{label}
|
||||
</SectionHeaderLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export function Scrollable({
|
||||
children,
|
||||
initialOffset = 64
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialOffset?: number;
|
||||
}) {
|
||||
const scrollRef = useRef<React.ElementRef<"div">>(null);
|
||||
const [offset, setOffset] = useState(initialOffset);
|
||||
|
||||
function updateHeaderHeight() {
|
||||
if (scrollRef.current) {
|
||||
setOffset(scrollRef.current.offsetTop);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(updateHeaderHeight, []);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("resize", throttle(updateHeaderHeight, 500));
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}>
|
||||
<ScrollArea.Viewport className="w-full h-full ">{children}</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-transparent w-0.5"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-muted flex-col h-0.5"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50 " />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
title,
|
||||
open,
|
||||
toggle,
|
||||
ActiveIcon = IconChevronUp,
|
||||
children,
|
||||
renderHeaderRight
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{ minHeight: 49 }}
|
||||
className={twMerge(
|
||||
"flex flex-col flex-animate overflow-hidden",
|
||||
open
|
||||
? "flex-open border-b border-b-muted"
|
||||
: "flex-initial cursor-pointer hover:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2"
|
||||
)}
|
||||
onClick={toggle}
|
||||
>
|
||||
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
|
||||
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
|
||||
<div className="flex flex-grow" />
|
||||
{renderHeaderRight?.({ open })}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-y-scroll transition-all",
|
||||
open ? " flex-grow" : "h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { Header } from "./Header";
|
||||
116
app/src/ui/layouts/AppShell/Breadcrumbs.tsx
Normal file
116
app/src/ui/layouts/AppShell/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { useMemo } from "react";
|
||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { IconButton } from "../../components/buttons/IconButton";
|
||||
import { Dropdown } from "../../components/overlay/Dropdown";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export type BreadcrumbsProps = {
|
||||
path: string | string[];
|
||||
backTo?: string;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export const Breadcrumbs = ({ path: _path, backTo, onBack }: BreadcrumbsProps) => {
|
||||
const [_, navigate] = useLocation();
|
||||
const location = window.location.pathname;
|
||||
const path = Array.isArray(_path) ? _path : [_path];
|
||||
const loc = location.split("/").filter((v) => v !== "");
|
||||
const hasBack = path.length > 1;
|
||||
|
||||
const goBack = onBack
|
||||
? onBack
|
||||
: useEvent(() => {
|
||||
if (backTo) {
|
||||
navigate(backTo, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const href = loc.slice(0, path.length + 1).join("/");
|
||||
navigate(`~/${href}`, { replace: true });
|
||||
});
|
||||
|
||||
const crumbs = useMemo(
|
||||
() =>
|
||||
path.map((p, key) => {
|
||||
const last = key === path.length - 1;
|
||||
const index = loc.indexOf(p);
|
||||
const href = loc.slice(0, index + 1).join("/");
|
||||
const string = ucFirstAllSnakeToPascalWithSpaces(p);
|
||||
|
||||
return {
|
||||
last,
|
||||
href,
|
||||
string
|
||||
};
|
||||
}),
|
||||
[path, loc]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow items-center gap-2 text-lg">
|
||||
{hasBack && (
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
Icon={TbArrowLeft}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
<div className="hidden md:flex gap-2">
|
||||
<CrumbsDesktop crumbs={crumbs} />
|
||||
</div>
|
||||
<div className="flex md:hidden gap-2">{<CrumbsMobile crumbs={crumbs} />}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbsDesktop = ({ crumbs }) => {
|
||||
return crumbs.map((crumb, key) => {
|
||||
return crumb.last ? <CrumbLast key={key} {...crumb} /> : <CrumbLink key={key} {...crumb} />;
|
||||
});
|
||||
};
|
||||
|
||||
const CrumbsMobile = ({ crumbs }) => {
|
||||
const [, navigate] = useLocation();
|
||||
if (crumbs.length <= 2) return <CrumbsDesktop crumbs={crumbs} />;
|
||||
const first = crumbs[0];
|
||||
const last = crumbs[crumbs.length - 1];
|
||||
const items = useMemo(
|
||||
() =>
|
||||
crumbs.slice(1, -1).map((c) => ({
|
||||
label: c.string,
|
||||
href: c.href
|
||||
})),
|
||||
[crumbs]
|
||||
);
|
||||
const onClick = useEvent((item) => navigate(`~/${item.href}`));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CrumbLink {...first} />
|
||||
<Dropdown onClickItem={onClick} items={items}>
|
||||
<IconButton Icon={TbDots} variant="ghost" />
|
||||
</Dropdown>
|
||||
<span className="opacity-25 dark:font-bold font-semibold">/</span>
|
||||
<CrumbLast {...last} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbLast = ({ string }) => {
|
||||
return <span className="text-nowrap dark:font-bold font-semibold">{string}</span>;
|
||||
};
|
||||
|
||||
const CrumbLink = ({ href, string }) => {
|
||||
return (
|
||||
<div className="opacity-50 flex flex-row gap-2 dark:font-bold font-semibold">
|
||||
<Link to={`~/${href}`} className="text-nowrap">
|
||||
{string}
|
||||
</Link>
|
||||
<span className="opacity-50">/</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
app/src/ui/layouts/AppShell/Breadcrumbs2.tsx
Normal file
120
app/src/ui/layouts/AppShell/Breadcrumbs2.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { useMemo } from "react";
|
||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { IconButton } from "../../components/buttons/IconButton";
|
||||
import { Dropdown } from "../../components/overlay/Dropdown";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
type Breadcrumb = {
|
||||
label: string | JSX.Element;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
export type Breadcrumbs2Props = {
|
||||
path: Breadcrumb[];
|
||||
backTo?: string;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export const Breadcrumbs2 = ({ path: _path, backTo, onBack }: Breadcrumbs2Props) => {
|
||||
const [_, navigate] = useLocation();
|
||||
const location = window.location.pathname;
|
||||
const path = Array.isArray(_path) ? _path : [_path];
|
||||
const loc = location.split("/").filter((v) => v !== "");
|
||||
const hasBack = path.length > 1;
|
||||
|
||||
const goBack = onBack
|
||||
? onBack
|
||||
: useEvent(() => {
|
||||
if (backTo) {
|
||||
navigate(backTo, { replace: true });
|
||||
return;
|
||||
} else if (_path.length > 0 && _path[0]?.href) {
|
||||
navigate(_path[0].href, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const href = loc.slice(0, path.length + 1).join("/");
|
||||
navigate(`~/${href}`, { replace: true });
|
||||
});
|
||||
|
||||
const crumbs = useMemo(
|
||||
() =>
|
||||
path.map((p, key) => {
|
||||
const last = key === path.length - 1;
|
||||
|
||||
return {
|
||||
last,
|
||||
...p
|
||||
};
|
||||
}),
|
||||
[path]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow items-center gap-2 text-lg">
|
||||
{hasBack && (
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
Icon={TbArrowLeft}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
<div className="hidden md:flex gap-1.5">
|
||||
<CrumbsDesktop crumbs={crumbs} />
|
||||
</div>
|
||||
<div className="flex md:hidden gap-2">{<CrumbsMobile crumbs={crumbs} />}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbsDesktop = ({ crumbs }) => {
|
||||
return crumbs.map((crumb, key) => {
|
||||
return crumb.last ? <CrumbLast key={key} {...crumb} /> : <CrumbLink key={key} {...crumb} />;
|
||||
});
|
||||
};
|
||||
|
||||
const CrumbsMobile = ({ crumbs }) => {
|
||||
const [, navigate] = useLocation();
|
||||
if (crumbs.length <= 2) return <CrumbsDesktop crumbs={crumbs} />;
|
||||
const first = crumbs[0];
|
||||
const last = crumbs[crumbs.length - 1];
|
||||
const items = useMemo(
|
||||
() =>
|
||||
crumbs.slice(1, -1).map((c) => ({
|
||||
label: c.string,
|
||||
href: c.href
|
||||
})),
|
||||
[crumbs]
|
||||
);
|
||||
const onClick = useEvent((item) => navigate(`~/${item.href}`));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CrumbLink {...first} />
|
||||
<Dropdown onClickItem={onClick} items={items}>
|
||||
<IconButton Icon={TbDots} variant="ghost" />
|
||||
</Dropdown>
|
||||
<span className="opacity-25 dark:font-bold font-semibold">/</span>
|
||||
<CrumbLast {...last} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CrumbLast = ({ label }) => {
|
||||
return <span className="text-nowrap dark:font-bold font-semibold">{label}</span>;
|
||||
};
|
||||
|
||||
const CrumbLink = ({ href, label }) => {
|
||||
return (
|
||||
<div className="opacity-50 flex flex-row gap-1.5 dark:font-bold font-semibold">
|
||||
<Link to={href} className="text-nowrap">
|
||||
{label}
|
||||
</Link>
|
||||
<span className="opacity-50">/</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
app/src/ui/layouts/AppShell/Header.tsx
Normal file
203
app/src/ui/layouts/AppShell/Header.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Menu, Popover, SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
TbFingerprint,
|
||||
TbHierarchy2,
|
||||
TbMenu2,
|
||||
TbMoon,
|
||||
TbPhoto,
|
||||
TbSelector,
|
||||
TbSun,
|
||||
TbUser,
|
||||
TbX
|
||||
} from "react-icons/tb";
|
||||
import { Button } from "ui";
|
||||
import { useAuth, useBknd } from "ui/client";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { NavLink } from "./AppShell";
|
||||
|
||||
function HeaderNavigation() {
|
||||
const [location, navigate] = useLocation();
|
||||
|
||||
const items: {
|
||||
label: string;
|
||||
href: string;
|
||||
Icon: any;
|
||||
exact?: boolean;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
/*{
|
||||
label: "Base",
|
||||
href: "#",
|
||||
exact: true,
|
||||
Icon: TbLayoutDashboard,
|
||||
disabled: true,
|
||||
tooltip: "Coming soon"
|
||||
},*/
|
||||
{ label: "Data", href: "/data", Icon: TbDatabase },
|
||||
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
||||
{ label: "Media", href: "/media", Icon: TbPhoto },
|
||||
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 }
|
||||
];
|
||||
const activeItem = items.find((item) =>
|
||||
item.exact ? location === item.href : location.startsWith(item.href)
|
||||
);
|
||||
|
||||
const handleItemClick = useEvent((item) => {
|
||||
navigate(item.href);
|
||||
});
|
||||
|
||||
const renderDropdownItem = (item, { key, onClick }) => (
|
||||
<NavLink key={key} onClick={onClick} as="button" className="rounded-md">
|
||||
<div
|
||||
data-active={activeItem?.label === item.label}
|
||||
className="flex flex-row items-center gap-2.5 data-[active=true]:opacity-50"
|
||||
>
|
||||
<item.Icon size={18} />
|
||||
<span className="text-lg">{item.label}</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||
{items.map((item) => (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={item.tooltip}
|
||||
disabled={typeof item.tooltip === "undefined"}
|
||||
position="bottom"
|
||||
>
|
||||
<div>
|
||||
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</nav>
|
||||
<nav className="flex md:hidden flex-row items-center">
|
||||
{activeItem && (
|
||||
<Dropdown
|
||||
items={items}
|
||||
onClickItem={handleItemClick}
|
||||
renderItem={renderDropdownItem}
|
||||
>
|
||||
<NavLink as="button" Icon={activeItem.Icon} className="active pl-6 pr-3.5">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span className="text-lg">{activeItem.label}</span>
|
||||
<TbSelector size={18} className="opacity-70" />
|
||||
</div>
|
||||
</NavLink>
|
||||
</Dropdown>
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarToggler() {
|
||||
const { sidebar } = useAppShell();
|
||||
return (
|
||||
<IconButton size="lg" Icon={sidebar.open ? TbX : TbMenu2} onClick={sidebar.handler.toggle} />
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ hasSidebar = true }) {
|
||||
//const logoReturnPath = "";
|
||||
const { app } = useBknd();
|
||||
const logoReturnPath = app.getAdminConfig().logo_return_path ?? "/";
|
||||
|
||||
return (
|
||||
<header
|
||||
data-shell="header"
|
||||
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||
>
|
||||
<Link
|
||||
href={logoReturnPath}
|
||||
replace
|
||||
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
<HeaderNavigation />
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex md:hidden flex-row items-center pr-2 gap-2">
|
||||
<SidebarToggler />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-row items-center px-4 gap-2">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu() {
|
||||
const auth = useAuth();
|
||||
const [navigate] = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
navigate("/auth/login", { replace: true });
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
navigate("/auth/login");
|
||||
}
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
||||
];
|
||||
|
||||
if (!auth.user) {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
} else {
|
||||
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
|
||||
}
|
||||
|
||||
items.push(() => <UserMenuThemeToggler />);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown items={items} position="bottom-end">
|
||||
{auth.user ? (
|
||||
<Button className="rounded-full w-12 h-12 justify-center p-0 text-lg">
|
||||
{auth.user.email[0]?.toUpperCase()}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="rounded-full w-12 h-12 justify-center p-0" IconLeft={TbUser} />
|
||||
)}
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenuThemeToggler() {
|
||||
const { theme, toggle } = useBkndSystemTheme();
|
||||
return (
|
||||
<div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5">
|
||||
<SegmentedControl
|
||||
className="w-full"
|
||||
data={[
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" }
|
||||
]}
|
||||
value={theme}
|
||||
onChange={toggle}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
app/src/ui/layouts/AppShell/index.ts
Normal file
1
app/src/ui/layouts/AppShell/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as AppShell from "./AppShell";
|
||||
28
app/src/ui/layouts/AppShell/use-appshell.tsx
Normal file
28
app/src/ui/layouts/AppShell/use-appshell.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useDisclosure, useViewportSize } from "@mantine/hooks";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type AppShellContextType = {
|
||||
sidebar: {
|
||||
open: boolean;
|
||||
handler: ReturnType<typeof useDisclosure>[1];
|
||||
};
|
||||
};
|
||||
|
||||
const AppShellContext = createContext<AppShellContextType>(undefined as any);
|
||||
|
||||
export function AppShellProvider({ children }) {
|
||||
const { width } = useViewportSize(); // @todo: maybe with throttle, not a problem atm
|
||||
const [sidebarOpen, sidebarHandlers] = useDisclosure(width > 768);
|
||||
|
||||
return (
|
||||
<AppShellContext.Provider
|
||||
value={{ sidebar: { open: sidebarOpen, handler: sidebarHandlers } }}
|
||||
>
|
||||
{children}
|
||||
</AppShellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppShell() {
|
||||
return useContext(AppShellContext);
|
||||
}
|
||||
Reference in New Issue
Block a user