First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ArchiveRestore,
|
||||
DatabaseBackup,
|
||||
FileText,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Mail,
|
||||
Receipt,
|
||||
ScrollText,
|
||||
Settings,
|
||||
SquarePen,
|
||||
UserRound,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type AdminAsideLinkItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
Icon: LucideIcon;
|
||||
strict?: boolean;
|
||||
super_admin_only?: boolean;
|
||||
/**
|
||||
* Only shown when `AUTH_LOGIN_METHOD` is `password`.
|
||||
*/
|
||||
password_method_only?: boolean;
|
||||
/**
|
||||
* Sub pages rendered as a collapsible list under this link. With fewer
|
||||
* than two visible children the item renders as a plain link.
|
||||
*/
|
||||
children?: AdminAsideLinkItem[];
|
||||
};
|
||||
|
||||
export type AdminAsideGroup = {
|
||||
label: string;
|
||||
links: AdminAsideLinkItem[];
|
||||
};
|
||||
|
||||
export const AdminAsideGroups: AdminAsideGroup[] = [
|
||||
{
|
||||
label: "Workspace",
|
||||
links: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin",
|
||||
Icon: LayoutDashboard,
|
||||
strict: true,
|
||||
},
|
||||
{ title: "People", url: "/admin/people", Icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Documents",
|
||||
links: [
|
||||
{ title: "Letters", url: "/admin/letters", Icon: FileText },
|
||||
{ title: "Emails", url: "/admin/emails", Icon: Mail },
|
||||
{ title: "Invoices", url: "/admin/invoices", Icon: Receipt },
|
||||
{ title: "Quotes", url: "/admin/quotes", Icon: SquarePen },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
links: [
|
||||
{
|
||||
title: "Logs",
|
||||
url: "/admin/logs",
|
||||
Icon: ScrollText,
|
||||
super_admin_only: true,
|
||||
},
|
||||
{
|
||||
title: "Backups",
|
||||
url: "/admin/backups",
|
||||
Icon: DatabaseBackup,
|
||||
super_admin_only: true,
|
||||
children: [
|
||||
{
|
||||
title: "Backups",
|
||||
url: "/admin/backups",
|
||||
Icon: DatabaseBackup,
|
||||
strict: true,
|
||||
},
|
||||
{
|
||||
title: "Restore",
|
||||
url: "/admin/backups/restore",
|
||||
Icon: ArchiveRestore,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
Icon: Settings,
|
||||
children: [
|
||||
{
|
||||
title: "Profile",
|
||||
url: "/admin/settings",
|
||||
Icon: UserRound,
|
||||
strict: true,
|
||||
},
|
||||
{
|
||||
title: "Password",
|
||||
url: "/admin/settings/password",
|
||||
Icon: KeyRound,
|
||||
password_method_only: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ title: "Logout", url: "/auth/logout", Icon: LogOut },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TWUI_LINK_LIST_LINK_OBJECT } from "@moduletrace/twui/elements/LinkList";
|
||||
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
|
||||
|
||||
type Params = {
|
||||
link: AdminAsideLinkItem;
|
||||
};
|
||||
|
||||
export default function adminAsideLinkToTwuiLink({
|
||||
link,
|
||||
}: Params): TWUI_LINK_LIST_LINK_OBJECT {
|
||||
const { title, url, strict, Icon } = link;
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
strict,
|
||||
icon: <Icon size={16} strokeWidth={1.75} />,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LoginMethod } from "@/src/types";
|
||||
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
|
||||
|
||||
type Params = {
|
||||
links: AdminAsideLinkItem[];
|
||||
is_super_admin: boolean;
|
||||
login_method: LoginMethod;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops links (and nested children) the current user/site config should not
|
||||
* see.
|
||||
*/
|
||||
export default function filterAdminAsideLinks({
|
||||
links,
|
||||
is_super_admin,
|
||||
login_method,
|
||||
}: Params): AdminAsideLinkItem[] {
|
||||
return links
|
||||
.filter((link) => is_super_admin || !link.super_admin_only)
|
||||
.filter(
|
||||
(link) => login_method == "password" || !link.password_method_only,
|
||||
)
|
||||
.map((link) =>
|
||||
link.children
|
||||
? {
|
||||
...link,
|
||||
children: filterAdminAsideLinks({
|
||||
links: link.children,
|
||||
is_super_admin,
|
||||
login_method,
|
||||
}),
|
||||
}
|
||||
: link,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import AdminAsideLinks from "./admin-aside-links-dict";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import MobileNavThemeRow from "@/src/components/general/mobile-nav-theme-row";
|
||||
|
||||
export default function MobileNav() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="flex xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
className="cursor-pointer p-1"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Menu size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-200 bg-dark/40 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<aside
|
||||
data-admin-nav
|
||||
className={twMerge(
|
||||
"fixed top-0 left-0 bottom-0 z-210",
|
||||
"w-[min(280px,85vw)] bg-light dark:bg-black",
|
||||
"flex flex-col shadow-xl",
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Admin navigation"
|
||||
>
|
||||
<Row className="w-full justify-between items-center px-6 h-16 flex-nowrap">
|
||||
<a href="/" onClick={() => setOpen(false)}>
|
||||
<LogoIcon icon_size={28} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="cursor-pointer p-1"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
</Row>
|
||||
<Stack
|
||||
className="w-full grow overflow-y-auto px-3 py-4"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a")) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AdminAsideLinks />
|
||||
</Stack>
|
||||
<MobileNavThemeRow className="px-6 py-4" />
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import LinkList from "@moduletrace/twui/elements/LinkList";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
|
||||
import adminAsideLinkToTwuiLink from "../(functions)/admin-aside-link-to-twui-link";
|
||||
|
||||
type Props = {
|
||||
link: AdminAsideLinkItem;
|
||||
};
|
||||
|
||||
export default function AdminAsideLinkDropdown({ link }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { title, url, Icon, children } = link;
|
||||
|
||||
const is_current = Boolean(pageProps?.url?.pathname?.startsWith(url));
|
||||
const [open, setOpen] = useState(is_current);
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-0.5!">
|
||||
<Button
|
||||
title={title}
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
aria-expanded={open}
|
||||
className={twMerge(
|
||||
"w-full justify-start px-3 py-2 font-normal",
|
||||
"text-slate-600 hover:bg-slate-900/5 hover:text-slate-900 hover:opacity-100",
|
||||
"dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-white",
|
||||
is_current ? "text-slate-900 dark:text-white" : "",
|
||||
)}
|
||||
buttonContentProps={{ className: "w-full" }}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Row className="w-full flex-nowrap">
|
||||
<Icon
|
||||
size={16}
|
||||
strokeWidth={1.75}
|
||||
className={
|
||||
is_current
|
||||
? "text-accent"
|
||||
: "text-slate-400 dark:text-slate-500"
|
||||
}
|
||||
/>
|
||||
{title}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
className={twMerge(
|
||||
"ml-auto text-slate-400 transition-transform",
|
||||
open ? "rotate-180" : "",
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
</Button>
|
||||
{open ? (
|
||||
<LinkList
|
||||
links={(children || []).map((child) =>
|
||||
adminAsideLinkToTwuiLink({ link: child }),
|
||||
)}
|
||||
className="flex-col items-stretch gap-0.5 ml-5 pl-2 border-l border-slate-200 dark:border-white/10"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import LinkList from "@moduletrace/twui/elements/LinkList";
|
||||
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
|
||||
import adminAsideLinkToTwuiLink from "../(functions)/admin-aside-link-to-twui-link";
|
||||
import AdminAsideLinkDropdown from "./admin-aside-link-dropdown";
|
||||
|
||||
type Props = {
|
||||
link: AdminAsideLinkItem;
|
||||
};
|
||||
|
||||
export default function AdminAsideLinkEntry({ link }: Props) {
|
||||
if (link.children && link.children.length > 1) {
|
||||
return <AdminAsideLinkDropdown link={link} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkList
|
||||
links={[adminAsideLinkToTwuiLink({ link })]}
|
||||
className="flex-col items-stretch"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import useLoginMethod from "@/src/hooks/use-login-method";
|
||||
import type { AdminAsideGroup } from "../(dict)/admin-aside-groups-dict";
|
||||
import filterAdminAsideLinks from "../(functions)/filter-admin-aside-links";
|
||||
import AdminAsideLinkEntry from "./admin-aside-link-entry";
|
||||
|
||||
type Props = AdminAsideGroup & {
|
||||
is_super_admin: boolean;
|
||||
};
|
||||
|
||||
export default function AdminAsideLinkGroup({
|
||||
label,
|
||||
links,
|
||||
is_super_admin,
|
||||
}: Props) {
|
||||
const login_method = useLoginMethod();
|
||||
|
||||
const visible_links = filterAdminAsideLinks({
|
||||
links,
|
||||
is_super_admin,
|
||||
login_method,
|
||||
});
|
||||
|
||||
if (!visible_links[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-2!">
|
||||
<Span className="px-3 text-xs font-medium text-slate-400">
|
||||
{label}
|
||||
</Span>
|
||||
<Stack className="w-full items-stretch gap-0.5!">
|
||||
{visible_links.map((link) => (
|
||||
<AdminAsideLinkEntry key={link.title} link={link} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { useContext } from "react";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import { AdminAsideGroups } from "../(dict)/admin-aside-groups-dict";
|
||||
import AdminAsideLinkGroup from "./admin-aside-link-group";
|
||||
|
||||
export default function AdminAsideLinks() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { user, user_types } = pageProps || {};
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const is_super_admin = Boolean(checkUserAccess({ user_types }).success);
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-7!">
|
||||
{AdminAsideGroups.map((group) => (
|
||||
<AdminAsideLinkGroup
|
||||
key={group.label}
|
||||
{...group}
|
||||
is_super_admin={is_super_admin}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { useContext } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import type { User } from "@/src/types";
|
||||
|
||||
export default function AdminAsideUser() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const user = pageProps?.user as User | undefined;
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const full_name = [user.first_name, user.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<a href="/admin/settings" className="block w-full">
|
||||
<Row className="w-full flex-nowrap gap-3! rounded-default px-3 py-2.5 hover:bg-slate-900/5 dark:hover:bg-white/5 transition-colors">
|
||||
<Img
|
||||
alt={full_name}
|
||||
src={user.image_thumbnail ?? "/icons/man.png"}
|
||||
size={32}
|
||||
circle
|
||||
className="bg-slate-300 dark:bg-slate-700 shrink-0"
|
||||
/>
|
||||
<Stack className="gap-0! min-w-0">
|
||||
<Span className="text-sm font-medium text-foreground-light dark:text-foreground-dark truncate max-w-[150px]">
|
||||
{full_name}
|
||||
</Span>
|
||||
<Span className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[150px]">
|
||||
{user.email}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import AdminAsideLinks from "./admin-aside-links-dict";
|
||||
import AdminAsideUser from "./admin-aside-user";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminAside({ className }: Props) {
|
||||
return (
|
||||
<aside
|
||||
data-admin-nav
|
||||
className={twMerge(
|
||||
"w-64 shrink-0 sticky top-0 h-screen flex-col",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Row className="h-16 px-6 flex-nowrap">
|
||||
<Logo icon_size={26} />
|
||||
</Row>
|
||||
<nav className="w-full grow overflow-y-auto px-3 py-4">
|
||||
<AdminAsideLinks />
|
||||
</nav>
|
||||
<Stack className="w-full items-stretch p-3 border-t border-slate-200 dark:border-white/10">
|
||||
<AdminAsideUser />
|
||||
</Stack>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
export default function Footer() {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<footer className="w-full px-5 xl:px-10 py-5 border-t border-slate-200 dark:border-white/10">
|
||||
<Row className="w-full justify-between">
|
||||
<Span className="text-xs text-slate-400">
|
||||
© {new Date().getFullYear()} {SiteData["SiteName"]}
|
||||
</Span>
|
||||
<Row className="items-center gap-4!">
|
||||
<Span className="text-xs text-slate-400">
|
||||
Admin console
|
||||
</Span>
|
||||
<ColorSchemeSelector
|
||||
className="text-slate-400 hover:text-foreground-light dark:hover:text-foreground-dark transition-colors"
|
||||
size="small"
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import HeaderUser from "../../main/(partials)/header-user";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import MobileNav from "../(partials)/MobileNav";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export default function Header({}: Props) {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<header className="h-16 border-b border-slate-200 dark:border-white/10">
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full justify-between h-full px-4 xl:px-10",
|
||||
"flex-nowrap",
|
||||
)}
|
||||
>
|
||||
<Row className="flex-nowrap gap-3!">
|
||||
<MobileNav />
|
||||
<a href="/" className="flex xl:hidden">
|
||||
<LogoIcon icon_size={28} />
|
||||
</a>
|
||||
</Row>
|
||||
<Row className="flex-nowrap gap-3!">
|
||||
<Button
|
||||
title="View site"
|
||||
href="/"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
afterIcon={<ArrowUpRight size={14} />}
|
||||
className="text-sm hidden sm:flex"
|
||||
>
|
||||
View site
|
||||
</Button>
|
||||
<HeaderUser />
|
||||
</Row>
|
||||
</Row>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import Header from "./(sections)/Header";
|
||||
import Footer from "./(sections)/Footer";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import AdminAside from "./(partials)/admin-aside";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main Layout Functional Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// Main Layout Component
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Row className="w-full items-stretch gap-0! flex-nowrap min-h-screen bg-light dark:bg-black">
|
||||
<AdminAside className="hidden xl:flex" />
|
||||
<Stack className="grow min-w-0 items-stretch gap-0! xl:py-2 xl:pr-2">
|
||||
<Stack className="grow items-stretch gap-0! bg-white dark:bg-background-dark xl:rounded-default-xl xl:border border-slate-200 dark:border-white/10 shadow-sm shadow-slate-900/5 dark:shadow-none">
|
||||
<Header />
|
||||
<main className="grow items-start" id="admin-main">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AuthLegalLinkType = {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const AuthLegalLinks: AuthLegalLinkType[] = [
|
||||
{ title: "Terms", url: "/terms" },
|
||||
{ title: "Privacy", url: "/privacy" },
|
||||
{ title: "Contact", url: "/contact" },
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { AuthLegalLinks } from "../(data)/auth-legal-links";
|
||||
import AuthLegalLink from "./auth-legal-link";
|
||||
|
||||
export default function AuthBottomBar() {
|
||||
return (
|
||||
<Row className="w-full justify-center flex-wrap gap-x-6! gap-y-2! px-4 py-6">
|
||||
<Span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
© {new Date().getFullYear()} {SiteData["SiteName"]}
|
||||
</Span>
|
||||
{AuthLegalLinks.map((link) => (
|
||||
<AuthLegalLink key={link.url} {...link} />
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import type { AuthLegalLinkType } from "../(data)/auth-legal-links";
|
||||
|
||||
export default function AuthLegalLink({ title, url }: AuthLegalLinkType) {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
className="text-xs border-none text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
|
||||
|
||||
export default function AuthTopBar() {
|
||||
return (
|
||||
<Row className="w-full justify-between flex-nowrap px-4 sm:px-10 py-5">
|
||||
<Logo icon_size={32} />
|
||||
<ColorSchemeSelector
|
||||
className="text-slate-500 hover:text-foreground-light dark:text-slate-400 dark:hover:text-foreground-dark transition-colors"
|
||||
size="small"
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Main from "@moduletrace/twui/layout/Main";
|
||||
import AuthTopBar from "./(partials)/auth-top-bar";
|
||||
import AuthBottomBar from "./(partials)/auth-bottom-bar";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Distraction-free layout for `/auth/*`: logo and theme switch on top,
|
||||
* centered content, legal links at the bottom. No site header or footer.
|
||||
*/
|
||||
export default function AuthLayout({ children }: Props): React.JSX.Element {
|
||||
return (
|
||||
<Stack className="w-full min-h-screen items-stretch gap-0! bg-light dark:bg-black">
|
||||
<AuthTopBar />
|
||||
<Main className="grow justify-center">{children}</Main>
|
||||
<AuthBottomBar />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type FooterColumnLink = {
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type FooterColumn = {
|
||||
label: string;
|
||||
links: FooterColumnLink[];
|
||||
};
|
||||
|
||||
export const FooterColumns: FooterColumn[] = [
|
||||
{
|
||||
label: "Product",
|
||||
links: [
|
||||
{ title: "Features", url: "/#features" },
|
||||
{ title: "About", url: "/about" },
|
||||
{ title: "Contact", url: "/contact" },
|
||||
{ title: "Log in", url: "/auth/login" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Legal",
|
||||
links: [
|
||||
{ title: "Terms and Conditions", url: "/terms" },
|
||||
{ title: "Privacy Policy", url: "/privacy" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MainLinks } from "@/src/dict/main-links-dict";
|
||||
import LinkList from "@moduletrace/twui/elements/LinkList";
|
||||
import HeaderCTA from "./header-cta";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import MobileNavThemeRow from "@/src/components/general/mobile-nav-theme-row";
|
||||
|
||||
export default function MobileNav() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="flex xl:hidden relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
className="cursor-pointer p-1 text-dark dark:text-white"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Menu size={26} />
|
||||
</button>
|
||||
|
||||
{open
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
className={twMerge(
|
||||
"fixed inset-0 z-200 flex flex-col",
|
||||
"bg-light text-dark dark:bg-background-dark dark:text-white",
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a")) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full justify-between items-center flex-nowrap",
|
||||
"px-4 sm:px-10 py-4 border-b",
|
||||
"border-slate-200/70 dark:border-white/10",
|
||||
)}
|
||||
>
|
||||
<Logo icon_size={28} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="cursor-pointer p-1 text-dark dark:text-white"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X size={26} />
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
<Stack className="grow w-full items-stretch px-4 sm:px-10 py-6 gap-8! overflow-y-auto">
|
||||
<nav className="w-full">
|
||||
<LinkList
|
||||
links={MainLinks}
|
||||
className="flex-col items-stretch gap-1 w-full"
|
||||
linkProps={{
|
||||
className: twMerge(
|
||||
"px-0 py-4 w-full text-lg border-0",
|
||||
"text-slate-600 hover:text-dark",
|
||||
"dark:text-slate-300 dark:hover:text-white",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</nav>
|
||||
<HeaderCTA className="flex flex-col w-full items-stretch" />
|
||||
</Stack>
|
||||
|
||||
<MobileNavThemeRow className="px-4 sm:px-10 py-4" />
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import LinkList from "@moduletrace/twui/elements/LinkList";
|
||||
import type { FooterColumn } from "../(data)/footer-columns";
|
||||
|
||||
type Props = FooterColumn;
|
||||
|
||||
export default function FooterLinkColumn({ label, links }: Props) {
|
||||
return (
|
||||
<Stack className="gap-5! min-w-[160px]">
|
||||
<Span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{label}
|
||||
</Span>
|
||||
<LinkList
|
||||
links={links}
|
||||
className="flex-col items-start gap-3"
|
||||
linkProps={{ className: "p-0 text-sm border-0" }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useAuthStatus } from "@/src/hooks/use-auth-status";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function HeaderCTA({ className }: Props) {
|
||||
const { is_logged_in } = useAuthStatus();
|
||||
|
||||
if (is_logged_in) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className={twMerge("flex-col", className)}>
|
||||
<Button
|
||||
title="Log in"
|
||||
href="/auth/login"
|
||||
color="gray"
|
||||
variant="outlined"
|
||||
className="w-full"
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
<Button title="Get Started" href="/auth/signup" className="w-full">
|
||||
Get Started
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Dropdown from "@moduletrace/twui/elements/Dropdown";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import LinkList from "@moduletrace/twui/elements/LinkList";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { useLocalUser } from "@/src/hooks/use-local-user";
|
||||
|
||||
export default function HeaderUser() {
|
||||
const { user } = useLocalUser();
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="">
|
||||
<Dropdown
|
||||
target={
|
||||
<Img
|
||||
alt="Logged in user image"
|
||||
src={user.image_thumbnail ?? "/icons/man.png"}
|
||||
size={32}
|
||||
circle
|
||||
className="bg-slate-300 dark:bg-slate-700 ring-1 ring-slate-200 dark:ring-white/10 cursor-pointer"
|
||||
title={
|
||||
user.first_name +
|
||||
(user.last_name ? ` ${user.last_name}` : "")
|
||||
}
|
||||
/>
|
||||
}
|
||||
position="bottom-right"
|
||||
>
|
||||
<Paper className="min-w-[200px] px-0! py-2 header-user-dropdown">
|
||||
<LinkList
|
||||
links={[
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin",
|
||||
},
|
||||
{
|
||||
component: <Divider />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
},
|
||||
{
|
||||
title: "Logout",
|
||||
url: "/auth/logout",
|
||||
},
|
||||
]}
|
||||
className="flex-col items-stretch w-full"
|
||||
/>
|
||||
</Paper>
|
||||
</Dropdown>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { AppContextObject } from "@/src/types";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
|
||||
import { FooterColumns } from "../(data)/footer-columns";
|
||||
import FooterLinkColumn from "../(partials)/footer-link-column";
|
||||
|
||||
export default function Footer() {
|
||||
const appContext: AppContextObject = React.useContext(AppContext);
|
||||
|
||||
const { SITE_DATA } = appContext;
|
||||
|
||||
return (
|
||||
<footer className="bg-white dark:bg-background-dark border-t border-slate-200 dark:border-white/10">
|
||||
<Section className="pt-20! pb-0!">
|
||||
<Container>
|
||||
<Stack className="w-full gap-16! items-stretch">
|
||||
<Row className="w-full items-start justify-between gap-14! flex-wrap">
|
||||
<Stack className="gap-5! max-w-md">
|
||||
<Logo icon_size={32} />
|
||||
<Span className="text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
A considered starting point for Bun, Bunext
|
||||
and SQLite applications.
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Row className="gap-16! justify-start flex-wrap items-start">
|
||||
{FooterColumns.map((column) => (
|
||||
<FooterLinkColumn
|
||||
key={column.label}
|
||||
{...column}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Row className="w-full items-center justify-between flex-wrap py-8 gap-4! border-t border-slate-200 dark:border-white/10">
|
||||
<Span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
© {new Date().getFullYear()}{" "}
|
||||
{SITE_DATA.siteName}. All rights reserved.
|
||||
</Span>
|
||||
<Row className="items-center gap-6!">
|
||||
<Span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Built with Bunext + SQLite
|
||||
</Span>
|
||||
<ColorSchemeSelector
|
||||
className="text-slate-500 hover:text-foreground-light dark:text-slate-400 dark:hover:text-foreground-dark transition-colors"
|
||||
size="small"
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
import MobileNav from "../(partials)/MobileNav";
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import HeaderNav from "@moduletrace/twui/elements/HeaderNav";
|
||||
import { MainLinks } from "@/src/dict/main-links-dict";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import HeaderUser from "../(partials)/header-user";
|
||||
import { useAuthStatus } from "@/src/hooks/use-auth-status";
|
||||
|
||||
export default function Header() {
|
||||
const { is_logged_in } = useAuthStatus();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-slate-200/70 bg-light/80 backdrop-blur-md dark:border-white/10 dark:bg-background-dark/80">
|
||||
<Section className="py-0!">
|
||||
<Container className="w-full justify-between">
|
||||
<Row className="w-full items-center justify-between flex-nowrap py-4">
|
||||
<Logo icon_size={28} />
|
||||
|
||||
<div className="hidden xl:flex">
|
||||
<HeaderNav headerLinks={MainLinks} />
|
||||
</div>
|
||||
|
||||
<Row className="flex-nowrap items-center gap-2!">
|
||||
{!is_logged_in ? (
|
||||
<>
|
||||
<Button
|
||||
title="Log in"
|
||||
href="/auth/login"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
<Button
|
||||
title="Get started"
|
||||
href="/auth/signup"
|
||||
size="small"
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<MobileNav />
|
||||
<HeaderUser />
|
||||
</Row>
|
||||
</Row>
|
||||
</Container>
|
||||
</Section>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import Header from "./(sections)/Header";
|
||||
import Footer from "./(sections)/Footer";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
blank?: boolean;
|
||||
};
|
||||
|
||||
export default function Layout({ children, blank }: Props): React.JSX.Element {
|
||||
const {} = React.useContext(AppContext);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack className="w-full items-stretch gap-0">
|
||||
{blank ? null : <Header />}
|
||||
<main>{children}</main>
|
||||
{blank ? null : <Footer />}
|
||||
</Stack>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user