Redesign the frontend UI on twui
Rebuild every page on a shared set of twui-based primitives with a dense ops-console look, WireGuard red accent and light/dark/system theming. - Add design tokens (WireGuard red accent, zinc neutrals, theme-flipping semantic colours) and a no-flash theme cookie + toggle - Add shared components: page header, panel, sub-nav, data table with mobile card list, stat tile, status dot, key-value list, copy button, inline alert, empty state, form section/actions, confirm dialog and toast providers - New admin shell (sidebar, sticky header, mobile drawer) and a centered shell for auth and 404; strip the public template pages - Unify adding clients at /admin/clients/add with a host picker; tabbed client detail (Overview / Rules / Config & QR) with config copy/download - Re-run client setup after rule changes on the detail page so the firewall matches the DB - Split Setup into status/maintenance/firewall and Settings into profile/password subpages - Replace window.confirm/alert and page reloads with in-app dialogs, toasts and in-place list updates - Fix nested rule form submitting the client form, prefilled signup passwords, hidden submit buttons, fields not updating after "next free" lookups, and TinyMCE white-on-white in dark mode Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type AdminNavItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
Icon: LucideIcon;
|
||||
/** Only active on an exact path match */
|
||||
strict?: boolean;
|
||||
super_admin_only?: boolean;
|
||||
};
|
||||
|
||||
export type AdminNavGroup = {
|
||||
group: string;
|
||||
items: AdminNavItem[];
|
||||
};
|
||||
|
||||
export const AdminNav: AdminNavGroup[] = [
|
||||
{
|
||||
group: "Overview",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/admin", Icon: LayoutDashboard, strict: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "WireGuard",
|
||||
items: [
|
||||
{ title: "Hosts", url: "/admin/hosts", Icon: Server },
|
||||
{ title: "Clients", url: "/admin/clients", Icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "System",
|
||||
items: [
|
||||
{
|
||||
title: "Setup",
|
||||
url: "/admin/setup",
|
||||
Icon: Wrench,
|
||||
super_admin_only: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Account",
|
||||
items: [
|
||||
{ title: "Settings", url: "/admin/settings", Icon: Settings },
|
||||
{ title: "Sign out", url: "/auth/logout", Icon: LogOut },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import AdminAsideLinks from "./admin-aside-links-dict";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AdminNavigation from "./admin-nav";
|
||||
|
||||
export default function MobileNav() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -34,21 +34,23 @@ export default function MobileNav() {
|
||||
|
||||
return (
|
||||
<div className="flex xl:hidden">
|
||||
<button
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
title="Open menu"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
aria-expanded={open}
|
||||
className="cursor-pointer p-1"
|
||||
className="p-1.5! -ml-1.5"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Menu size={28} />
|
||||
</button>
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-200 bg-dark/50"
|
||||
className="fixed inset-0 z-200 bg-dark/60"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -57,36 +59,35 @@ export default function MobileNav() {
|
||||
tabIndex={-1}
|
||||
className={twMerge(
|
||||
"fixed top-0 left-0 bottom-0 z-210",
|
||||
"w-[min(280px,85vw)] bg-background-light dark:bg-background-dark",
|
||||
"w-[min(272px,85vw)] bg-surface border-r border-line",
|
||||
"flex flex-col shadow-xl outline-none",
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Admin navigation"
|
||||
>
|
||||
<Row className="w-full justify-between items-center py-2 px-4 h-20 flex-nowrap">
|
||||
<a href="/" onClick={() => setOpen(false)}>
|
||||
<LogoIcon icon_size={40} />
|
||||
</a>
|
||||
<button
|
||||
<Row className="w-full justify-between px-4 h-14 flex-nowrap border-b border-line">
|
||||
<Logo />
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="cursor-pointer p-1"
|
||||
title="Close menu"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
className="p-1.5!"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X size={28} />
|
||||
</button>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Stack
|
||||
className="w-full grow overflow-y-auto"
|
||||
className="w-full grow overflow-y-auto px-2 py-4 items-stretch"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a")) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AdminAsideLinks />
|
||||
<AdminNavigation />
|
||||
</Stack>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { TWUILink } from "@/src/components/twui/elements/LinkList";
|
||||
import LinkList from "@/src/components/twui/elements/LinkList";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { useContext } from "react";
|
||||
import { LayoutDashboard, LogOut, Server, Settings, Users, Wrench } from "lucide-react";
|
||||
|
||||
const sectionLabel = (label: string) => (
|
||||
<div className="px-3 pt-6 pb-1.5 text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
const linkClassName =
|
||||
"px-3 py-2 w-full rounded-[5px] no-underline! border-b-0! " +
|
||||
"text-[14px] font-medium text-foreground-light/70 dark:text-foreground-dark/70 " +
|
||||
"hover:text-foreground-light! dark:hover:text-foreground-dark! " +
|
||||
"hover:bg-foreground-light/5 dark:hover:bg-foreground-dark/5 transition-colors";
|
||||
|
||||
const iconClassName = "shrink-0 opacity-60";
|
||||
|
||||
export default function AdminAsideLinks() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { user, user_types } = pageProps || {};
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const is_super_admin =
|
||||
user_types?.find((t) => t.user_type === "super_admin") !== undefined;
|
||||
|
||||
const links: TWUILink[] = [
|
||||
{ component: sectionLabel("Overview") },
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin",
|
||||
strict: true,
|
||||
icon: (
|
||||
<LayoutDashboard
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className={iconClassName}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ component: sectionLabel("WireGuard") },
|
||||
{
|
||||
title: "Hosts",
|
||||
url: "/admin/hosts",
|
||||
icon: (
|
||||
<Server size={16} strokeWidth={2} className={iconClassName} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Clients",
|
||||
url: "/admin/clients",
|
||||
icon: <Users size={16} strokeWidth={2} className={iconClassName} />,
|
||||
},
|
||||
{ component: sectionLabel("System") },
|
||||
...(is_super_admin
|
||||
? [
|
||||
{
|
||||
title: "Setup",
|
||||
url: "/admin/setup",
|
||||
icon: (
|
||||
<Wrench
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className={iconClassName}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ component: sectionLabel("Account") },
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
icon: (
|
||||
<Settings size={16} strokeWidth={2} className={iconClassName} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Logout",
|
||||
url: "/auth/logout",
|
||||
icon: (
|
||||
<LogOut size={16} strokeWidth={2} className={iconClassName} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch">
|
||||
<LinkList
|
||||
links={links}
|
||||
className="gap-0.5 flex-col items-start pb-6"
|
||||
linkProps={{ className: linkClassName }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,41 @@
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
import AdminAsideLinks from "./admin-aside-links-dict";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminAside({ className }: Props) {
|
||||
return (
|
||||
<aside className={twMerge("w-[220px] shrink-0", className)}>
|
||||
<Stack className="w-full items-stretch gap-0! sticky top-0 h-screen overflow-y-auto">
|
||||
<Row className="px-4 h-20 flex items-center">
|
||||
<a href="/" className="no-underline! border-b-0!">
|
||||
<LogoIcon icon_size={40} />
|
||||
</a>
|
||||
</Row>
|
||||
<Divider />
|
||||
<nav className="w-full grow">
|
||||
<AdminAsideLinks />
|
||||
</nav>
|
||||
<Divider />
|
||||
</Stack>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import Link from "@/src/components/twui/layout/Link";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AdminNavigation from "./admin-nav";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminAside({ className }: Props) {
|
||||
return (
|
||||
<aside
|
||||
className={twMerge(
|
||||
"w-[232px] shrink-0 border-r border-line bg-surface",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full items-stretch gap-0 sticky top-0 h-screen">
|
||||
<Row className="px-4 h-14 shrink-0 border-b border-line">
|
||||
<Logo />
|
||||
</Row>
|
||||
<nav aria-label="Admin" className="w-full grow overflow-y-auto px-2 py-4">
|
||||
<AdminNavigation />
|
||||
</nav>
|
||||
<Row className="px-4 h-11 shrink-0 border-t border-line">
|
||||
<Link
|
||||
href={SiteData["SiteURL"]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[13px] text-subtle! hover:text-fg! no-underline!"
|
||||
>
|
||||
Source & docs
|
||||
</Link>
|
||||
</Row>
|
||||
</Stack>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import SectionLabel from "@/src/components/general/section-label";
|
||||
import type { AdminNavItem as AdminNavItemType } from "../(data)/admin-nav-dict";
|
||||
import AdminNavItem from "./admin-nav-item";
|
||||
|
||||
type Props = {
|
||||
group: string;
|
||||
items: AdminNavItemType[];
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
export default function AdminNavGroup({ group, items, pathname }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-0.5 items-stretch">
|
||||
<SectionLabel className="px-2.5 pb-1">{group}</SectionLabel>
|
||||
{items.map((item) => (
|
||||
<AdminNavItem {...item} key={item.url} pathname={pathname} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from "@/src/components/twui/layout/Link";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { AdminNavItem as AdminNavItemType } from "../(data)/admin-nav-dict";
|
||||
|
||||
type Props = AdminNavItemType & {
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
export default function AdminNavItem({ title, url, Icon, strict, pathname }: Props) {
|
||||
const active = pathname
|
||||
? strict
|
||||
? pathname == url
|
||||
: pathname == url || pathname.startsWith(`${url}/`)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={twMerge(
|
||||
"w-full px-2.5 py-1.5 rounded-default no-underline! text-[15px] font-medium",
|
||||
"text-muted! hover:text-fg! hover:bg-gray dark:hover:bg-gray-dark transition-colors",
|
||||
active && "text-fg! bg-gray dark:bg-gray-dark",
|
||||
)}
|
||||
>
|
||||
<Row className="gap-2.5 flex-nowrap">
|
||||
<Icon
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className={twMerge("shrink-0", active ? "text-primary dark:text-primary-dark-text" : "opacity-70")}
|
||||
/>
|
||||
{title}
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { useContext } from "react";
|
||||
import { AdminNav } from "../(data)/admin-nav-dict";
|
||||
import AdminNavGroup from "./admin-nav-group";
|
||||
|
||||
export default function AdminNavigation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { user, user_types, url } = pageProps || {};
|
||||
|
||||
if (!user?.logged_in_status) return null;
|
||||
|
||||
const is_super_admin = Boolean(
|
||||
user_types?.find((t) => t.user_type === "super_admin"),
|
||||
);
|
||||
|
||||
const groups = AdminNav.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter(
|
||||
(item) => !item.super_admin_only || is_super_admin,
|
||||
),
|
||||
})).filter((group) => group.items.length);
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-5 items-stretch">
|
||||
{groups.map((group) => (
|
||||
<AdminNavGroup
|
||||
{...group}
|
||||
key={group.group}
|
||||
pathname={url?.pathname}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from "@/src/components/twui/layout/Link";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
url: string;
|
||||
Icon: LucideIcon;
|
||||
};
|
||||
|
||||
export default function HeaderUserMenuItem({ title, url, Icon }: Props) {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
role="menuitem"
|
||||
className="w-full px-2.5 py-1.5 rounded-default-sm no-underline! text-[15px] text-fg! hover:bg-gray dark:hover:bg-gray-dark"
|
||||
>
|
||||
<Row className="gap-2.5 flex-nowrap">
|
||||
<Icon size={14} className="opacity-70" />
|
||||
{title}
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useContext } from "react";
|
||||
import Dropdown from "@/src/components/twui/elements/Dropdown";
|
||||
import Img from "@/src/components/twui/layout/Img";
|
||||
import Paper from "@/src/components/twui/elements/Paper";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import MutedText from "@/src/components/general/muted-text";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { LayoutDashboard, LogOut, Settings } from "lucide-react";
|
||||
import HeaderUserMenuItem from "./header-user-menu-item";
|
||||
|
||||
const MenuItems = [
|
||||
{ title: "Dashboard", url: "/admin", Icon: LayoutDashboard },
|
||||
{ title: "Settings", url: "/admin/settings", Icon: Settings },
|
||||
{ title: "Sign out", url: "/auth/logout", Icon: LogOut },
|
||||
];
|
||||
|
||||
export default function HeaderUser() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { user } = pageProps || {};
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const full_name = [user.first_name, user.last_name].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottom-right"
|
||||
target={
|
||||
<Button
|
||||
title="Account menu"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
aria-haspopup="menu"
|
||||
className="p-0! rounded-full"
|
||||
>
|
||||
<Img
|
||||
alt=""
|
||||
src={user.image_thumbnail || "/icons/man.png"}
|
||||
size={30}
|
||||
circle
|
||||
className="bg-gray dark:bg-gray-dark ring-1 ring-line"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Paper role="menu" className="min-w-[220px] p-1! gap-0.5 shadow-lg rounded-default-lg">
|
||||
<Stack className="gap-0 px-2.5 py-2 mb-1 border-b border-line w-full">
|
||||
<Span className="text-[15px] font-medium text-fg truncate max-w-full">
|
||||
{full_name || user.username}
|
||||
</Span>
|
||||
<MutedText text_size="xs" className="truncate max-w-full">
|
||||
{user.email}
|
||||
</MutedText>
|
||||
</Stack>
|
||||
{MenuItems.map((item) => (
|
||||
<HeaderUserMenuItem {...item} key={item.url} />
|
||||
))}
|
||||
</Paper>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { AppContextObject } from "@/src/types";
|
||||
import React from "react";
|
||||
|
||||
export default function Footer() {
|
||||
const appContext: AppContextObject = React.useContext(AppContext);
|
||||
const { user, appState } = appContext;
|
||||
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<footer className="w-full p-4 h-20">
|
||||
<Row className="h-full">
|
||||
<Span size="smaller" variant="faded">
|
||||
Copyright © {new Date().getFullYear()}.{" "}
|
||||
<a
|
||||
href={SiteData["SiteURL"]}
|
||||
target="_blank"
|
||||
className="decoration-dotted underline"
|
||||
>
|
||||
{SiteData["SiteName"]}
|
||||
</a>
|
||||
. All rights reserved
|
||||
</Span>
|
||||
</Row>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,43 @@
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import HeaderUser from "../../main/(partials)/header-user";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import MobileNav from "../(partials)/MobileNav";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Breadcrumbs from "@/src/components/twui/elements/Breadcrumbs";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export default function Header({}: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<header className="h-20">
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full justify-between h-full py-4 px-4 xl:px-10",
|
||||
"flex-nowrap",
|
||||
)}
|
||||
>
|
||||
<Row className="flex-nowrap">
|
||||
<LogoIcon
|
||||
icon_size={60}
|
||||
img_props={{ className: "flex xl:hidden" }}
|
||||
/>
|
||||
<MobileNav />
|
||||
<Breadcrumbs
|
||||
dividerProps={{
|
||||
className: "-skew-10",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
className: "text-slate-400! pointer-events-none",
|
||||
}}
|
||||
pageUrl={pageProps?.url?.pathname}
|
||||
backButton
|
||||
skipHome
|
||||
/>
|
||||
</Row>
|
||||
<Row className="flex-nowrap">
|
||||
<HeaderUser />
|
||||
</Row>
|
||||
</Row>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Breadcrumbs from "@/src/components/twui/elements/Breadcrumbs";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import ThemeToggle from "@/src/components/general/theme/theme-toggle";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { useContext } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import HeaderUser from "../(partials)/header-user";
|
||||
import MobileNav from "../(partials)/MobileNav";
|
||||
|
||||
export default function Header() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-14 shrink-0 border-b border-line bg-surface/85 backdrop-blur">
|
||||
<Row className="w-full h-full justify-between gap-3 px-4 sm:px-6 flex-nowrap">
|
||||
<Row className="gap-2 flex-nowrap min-w-0">
|
||||
<MobileNav />
|
||||
<LogoIcon icon_size={24} className="xl:hidden md:hidden" />
|
||||
<Row className="hidden md:flex min-w-0">
|
||||
<Breadcrumbs
|
||||
divider={
|
||||
<ChevronRight size={13} className="text-subtle shrink-0 -mx-2" />
|
||||
}
|
||||
linkProps={{
|
||||
className: "text-[15px] text-muted! hover:text-fg! no-underline!",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
className: "text-fg! pointer-events-none",
|
||||
}}
|
||||
pageUrl={pageProps?.url?.pathname}
|
||||
skipHome
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
<Row className="gap-2 flex-nowrap">
|
||||
<ThemeToggle />
|
||||
<HeaderUser />
|
||||
</Row>
|
||||
</Row>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-40
@@ -1,40 +1,19 @@
|
||||
import React from "react";
|
||||
import Header from "./(sections)/Header";
|
||||
import Footer from "./(sections)/Footer";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { AppContextObject } from "@/src/types";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
import AdminAsideLinks from "./(partials)/admin-aside-links-dict";
|
||||
import AdminAside from "./(partials)/admin-aside";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main Layout Functional Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// Main Layout Component
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const appContext = React.useContext<AppContextObject>(AppContext);
|
||||
const { user } = appContext;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="w-full items-stretch gap-0! flex-nowrap">
|
||||
<AdminAside className="hidden xl:flex" />
|
||||
<Divider vertical className="h-auto" />
|
||||
<Stack className="grow items-stretch gap-0!">
|
||||
<Header />
|
||||
<Divider />
|
||||
<main className="grow items-start" id="admin-main">
|
||||
{children}
|
||||
</main>
|
||||
<Divider />
|
||||
<Footer />
|
||||
</Stack>
|
||||
</Row>
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from "react";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Header from "./(sections)/Header";
|
||||
import AdminAside from "./(partials)/admin-aside";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Row className="w-full min-h-screen items-stretch gap-0 flex-nowrap">
|
||||
<AdminAside className="hidden xl:block" />
|
||||
<Stack className="grow min-w-0 items-stretch gap-0">
|
||||
<Header />
|
||||
<main className="grow" id="admin-main">
|
||||
{children}
|
||||
</main>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Card from "@/src/components/twui/elements/Card";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import ThemeToggle from "@/src/components/general/theme/theme-toggle";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = PropsWithChildren & {
|
||||
width?: "sm" | "md";
|
||||
};
|
||||
|
||||
/** Auth pages, 404 and other pages outside the admin shell. */
|
||||
export default function CenteredShell({ children, width = "sm" }: Props) {
|
||||
return (
|
||||
<Stack className="relative min-h-screen w-full items-center justify-center gap-6 px-4 py-12">
|
||||
<ThemeToggle className="absolute top-4 right-4" />
|
||||
<Logo icon_size={32} href="/" />
|
||||
<Card
|
||||
noHover
|
||||
className={twMerge(
|
||||
"w-full flex-col items-stretch gap-0 p-6 sm:p-8 shadow-sm",
|
||||
width == "md" ? "max-w-[640px]" : "max-w-[400px]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import React from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import { MainLinks } from "@/src/dict/main-links-dict";
|
||||
import LinkList from "@/src/components/twui/elements/LinkList";
|
||||
import HeaderCTA from "./header-cta";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
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 text-white p-1"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Menu size={30} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className={twMerge(
|
||||
"fixed inset-0 z-200 flex flex-col",
|
||||
"bg-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="w-full justify-between items-center px-4 py-6 flex-nowrap">
|
||||
<Logo
|
||||
icon_size={60}
|
||||
text_wrapper_props={{
|
||||
className: "max-[400px]:hidden",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="cursor-pointer text-white p-1"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X size={30} />
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
<Stack className="grow w-full items-stretch px-6 py-4 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-2 py-4 w-full text-xl border-0",
|
||||
"text-white/80 hover:text-white",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</nav>
|
||||
<HeaderCTA className="flex flex-col w-full items-stretch" />
|
||||
</Stack>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export default function NavList({ transparent }: { transparent?: boolean }) {
|
||||
const className = transparent ? "light" : "";
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<div className="gap-10 flex-col xl:flex-row">
|
||||
<a className={className} href="/">
|
||||
Home
|
||||
</a>
|
||||
<a className={className} href="/subsidiaries">
|
||||
Subsidiaries
|
||||
</a>
|
||||
<a className={className} href="/about">
|
||||
About
|
||||
</a>
|
||||
<a className={className} href="/events">
|
||||
Events
|
||||
</a>
|
||||
<a className={className} href="/gallery">
|
||||
Gallery
|
||||
</a>
|
||||
<a className={className} href="/contact">
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function HeaderCTA({ className }: Props) {
|
||||
return (
|
||||
<Row className={twMerge("hidden xl:flex", className)}>
|
||||
<Button title="Login" className="w-full" href="/auth/login">
|
||||
Login
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Dropdown from "@/src/components/twui/elements/Dropdown";
|
||||
import Img from "@/src/components/twui/layout/Img";
|
||||
import Paper from "@/src/components/twui/elements/Paper";
|
||||
import LinkList from "@/src/components/twui/elements/LinkList";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
|
||||
export default function HeaderUser() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { user, user_types } = pageProps || {};
|
||||
|
||||
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={45}
|
||||
circle
|
||||
className="bg-slate-400"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { AppContextObject } from "@/src/types";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Section from "@/src/components/twui/layout/Section";
|
||||
import Container from "@/src/components/twui/layout/Container";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import H4 from "@/src/components/twui/layout/H4";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
export default function Footer() {
|
||||
const appContext: AppContextObject = React.useContext(AppContext);
|
||||
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<footer className="bg-dark text-white">
|
||||
<Section className="pt-20! pb-0!">
|
||||
<Container>
|
||||
<Stack className="w-full gap-10! items-center">
|
||||
<Row className="w-full items-start justify-start xl:justify-between gap-20!">
|
||||
<div className="flex flex-col items-start max-w-[380px] gap-4">
|
||||
<Logo
|
||||
text_props={{ className: "text-white" }}
|
||||
white
|
||||
/>
|
||||
<Span className="opacity-70">
|
||||
Wireguard UI is the ultimate dashboard for
|
||||
managing raw Wireguard
|
||||
</Span>
|
||||
</div>
|
||||
|
||||
<Row className="gap-20! justify-start flex-wrap items-start">
|
||||
<div className="flex items-start gap-10 justify-between flex-wrap xl:ml-auto">
|
||||
<Stack>
|
||||
<H4>Company</H4>
|
||||
<Divider className="opacity-20" />
|
||||
<a href="/about">Company</a>
|
||||
<a href="/terms">
|
||||
Terms and Conditions
|
||||
</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
</Stack>
|
||||
</div>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Divider className="opacity-20" />
|
||||
|
||||
<div
|
||||
className="container px-4 sm:px-6 flex w-full items-start justify-between flex-wrap"
|
||||
style={{
|
||||
gap: "40px 100px",
|
||||
}}
|
||||
>
|
||||
<span className="text-sm opacity-60">
|
||||
Copyright {new Date().getFullYear()}{" "}
|
||||
<a href={SiteData["SiteURL"]}>
|
||||
{SiteData["SiteName"]}
|
||||
</a>
|
||||
. All rights reserved
|
||||
</span>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
<div className="w-full h-20 relative z-100 overflow-hidden mt-20"></div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import MobileNav from "../(partials)/MobileNav";
|
||||
import Section from "@/src/components/twui/layout/Section";
|
||||
import Container from "@/src/components/twui/layout/Container";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Logo from "@/src/components/general/logo";
|
||||
import LinkList from "@/src/components/twui/elements/LinkList";
|
||||
import { MainLinks } from "@/src/dict/main-links-dict";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
import HeaderCTA from "../(partials)/header-cta";
|
||||
import HeaderUser from "../(partials)/header-user";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
no_header_divider?: boolean;
|
||||
};
|
||||
|
||||
export default function Header({ no_header_divider }: Props) {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<header className={"px-4 py-6 w-full z-100 -mb-[160px] relative"}>
|
||||
<Section className="p-0! relative z-10">
|
||||
<Container className="w-full justify-between">
|
||||
<Row className="w-full items-center gap-10 justify-between flex-nowrap">
|
||||
<Row className="flex xl:hidden">
|
||||
<Logo
|
||||
text_wrapper_props={{
|
||||
className: "max-[400px]:hidden",
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row className="gap-5! hidden xl:flex">
|
||||
<Logo />
|
||||
{/* <Row className="hidden xl:flex gap-3!">
|
||||
<Divider vertical className="-skew-12 h-10" />
|
||||
<nav>
|
||||
<LinkList
|
||||
links={MainLinks}
|
||||
className="gap-1"
|
||||
/>
|
||||
</nav>
|
||||
</Row> */}
|
||||
</Row>
|
||||
|
||||
<Row className="flex-nowrap">
|
||||
<HeaderCTA />
|
||||
<MobileNav />
|
||||
<HeaderUser />
|
||||
</Row>
|
||||
</Row>
|
||||
</Container>
|
||||
</Section>
|
||||
{no_header_divider ? null : <Divider className="mt-6 opacity-20" />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from "react";
|
||||
import Header from "./(sections)/Header";
|
||||
import Footer from "./(sections)/Footer";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
no_header_divider?: boolean;
|
||||
blank?: boolean;
|
||||
};
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
no_header_divider,
|
||||
blank,
|
||||
}: Props): React.JSX.Element {
|
||||
const {} = React.useContext(AppContext);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack className="w-full items-stretch gap-0">
|
||||
{blank ? null : (
|
||||
<Header no_header_divider={no_header_divider} />
|
||||
)}
|
||||
<main>{children}</main>
|
||||
{blank ? null : <Footer />}
|
||||
</Stack>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user