This commit is contained in:
Jamie Curnow
2025-09-02 23:56:00 +10:00
parent 330993f028
commit fadec9751e
355 changed files with 9308 additions and 17813 deletions

View File

@@ -0,0 +1,64 @@
import cn from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
type?: "button" | "submit";
actionType?: "primary" | "secondary" | "success" | "warning" | "danger" | "info" | "light" | "dark";
variant?: "ghost" | "outline" | "pill" | "square" | "action";
size?: "sm" | "md" | "lg" | "xl";
fullWidth?: boolean;
isLoading?: boolean;
disabled?: boolean;
color?:
| "blue"
| "azure"
| "indigo"
| "purple"
| "pink"
| "red"
| "orange"
| "yellow"
| "lime"
| "green"
| "teal"
| "cyan";
onClick?: () => void;
}
function Button({
children,
className,
onClick,
type,
actionType,
variant,
size,
color,
fullWidth,
isLoading,
disabled,
}: Props) {
const myOnClick = () => {
!isLoading && onClick && onClick();
};
const cns = cn(
"btn",
className,
actionType && `btn-${actionType}`,
variant && `btn-${variant}`,
size && `btn-${size}`,
color && `btn-${color}`,
fullWidth && "w-100",
isLoading && "btn-loading",
);
return (
<button type={type || "button"} className={cns} onClick={myOnClick} disabled={disabled}>
{children}
</button>
);
}
export { Button };

View File

@@ -0,0 +1,23 @@
import { intl } from "src/locale";
import { useNavigate } from "react-router-dom";
import { Button } from "src/components";
export function ErrorNotFound() {
const navigate = useNavigate();
return (
<div className="container-tight py-4">
<div className="empty">
<p className="empty-title">{intl.formatMessage({ id: "notfound.title" })}</p>
<p className="empty-subtitle text-secondary">
{intl.formatMessage({ id: "notfound.text" })}
</p>
<div className="empty-action">
<Button type="button" size="md" onClick={() => navigate("/")}>
{intl.formatMessage({ id: "notfound.action" })}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { IconWorld } from "@tabler/icons-react";
import { hasFlag } from "country-flag-icons";
// @ts-expect-error Creating a typing for a subfolder is not easily possible
import Flags from "country-flag-icons/react/3x2";
interface FlagProps {
className?: string;
countryCode: string;
}
function Flag({ className, countryCode }: FlagProps) {
countryCode = countryCode.toUpperCase();
if (countryCode === "EN") {
return <IconWorld className={className} width={20} />;
}
if (hasFlag(countryCode)) {
const FlagElement = Flags[countryCode] as any;
return <FlagElement title={countryCode} className={className} width={20} />;
}
console.error(`No flag for country ${countryCode} found!`);
return null;
}
export { Flag };

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import Alert from "react-bootstrap/Alert";
import { useUser } from "src/hooks";
import { intl } from "src/locale";
interface Props {
permission: string;
type: "manage" | "view";
hideError?: boolean;
children?: ReactNode;
}
function HasPermission({ permission, type, children, hideError = false }: Props) {
const { data } = useUser("me");
const perms = data?.permissions;
let allowed = permission === "";
const acceptable = ["manage", type];
switch (permission) {
case "admin":
allowed = data?.roles?.includes("admin") || false;
break;
case "proxyHosts":
allowed = acceptable.indexOf(perms?.proxyHosts || "") !== -1;
break;
case "redirectionHosts":
allowed = acceptable.indexOf(perms?.redirectionHosts || "") !== -1;
break;
case "deadHosts":
allowed = acceptable.indexOf(perms?.deadHosts || "") !== -1;
break;
case "streams":
allowed = acceptable.indexOf(perms?.streams || "") !== -1;
break;
case "accessLists":
allowed = acceptable.indexOf(perms?.accessLists || "") !== -1;
break;
case "certificates":
allowed = acceptable.indexOf(perms?.certificates || "") !== -1;
break;
}
if (allowed) {
return <>{children}</>;
}
return !hideError ? <Alert variant="danger">{intl.formatMessage({ id: "no-permission-error" })}</Alert> : null;
}
export { HasPermission };

View File

@@ -0,0 +1,3 @@
.logo {
max-height: 100px;
}

View File

@@ -0,0 +1,27 @@
import { Page } from "src/components";
import { intl } from "src/locale";
import styles from "./LoadingPage.module.css";
interface Props {
label?: string;
noLogo?: boolean;
}
export function LoadingPage({ label, noLogo }: Props) {
return (
<Page className="page-center">
<div className="container-tight py-4">
<div className="empty text-center">
{noLogo ? null : (
<div className="mb-3">
<img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
</div>
)}
<div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
<div className="progress progress-sm">
<div className="progress-bar progress-bar-indeterminate" />
</div>
</div>
</div>
</Page>
);
}

View File

@@ -0,0 +1,15 @@
.darkBtn {
color: var(--tblr-light) !important;
&:hover {
border: var(--tblr-btn-border-width) solid transparent !important;
background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important;
}
}
.lightBtn {
color: var(--tblr-dark) !important;
&:hover {
border: var(--tblr-btn-border-width) solid transparent !important;
background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important;
}
}

View File

@@ -0,0 +1,71 @@
import cn from "classnames";
import { Flag } from "src/components";
import { useLocaleState } from "src/context";
import { useTheme } from "src/hooks";
import { changeLocale, getFlagCodeForLocale, intl, localeOptions } from "src/locale";
import styles from "./LocalePicker.module.css";
function LocalePicker() {
const { locale, setLocale } = useLocaleState();
const { getTheme } = useTheme();
const changeTo = (lang: string) => {
changeLocale(lang);
setLocale(lang);
location.reload();
};
const classes = ["btn", "dropdown-toggle", "btn-sm"];
let cns = cn(...classes, "btn-ghost-light", styles.lightBtn);
if (getTheme() === "dark") {
cns = cn(...classes, "btn-ghost-dark", styles.darkBtn);
}
return (
<div className="dropdown">
<button type="button" className={cns} data-bs-toggle="dropdown">
<Flag countryCode={getFlagCodeForLocale(locale)} />
</button>
<div className="dropdown-menu">
{localeOptions.map((item) => {
return (
<a
className="dropdown-item"
href={`/locale/${item[0]}`}
key={`locale-${item[0]}`}
onClick={(e) => {
e.preventDefault();
changeTo(item[0]);
}}
>
<Flag countryCode={getFlagCodeForLocale(item[0])} />{" "}
{intl.formatMessage({ id: `locale-${item[1]}` })}
</a>
);
})}
</div>
</div>
);
// <div className={className}>
// <Menu>
// <MenuButton as={Button} {...additionalProps}>
// <Flag countryCode={getFlagCodeForLocale(locale)} />
// </MenuButton>
// <MenuList>
// {localeOptions.map((item) => {
// return (
// <MenuItem
// icon={<Flag countryCode={getFlagCodeForLocale(item[0])} />}
// onClick={() => changeTo(item[0])}
// key={`locale-${item[0]}`}>
// <span>{intl.formatMessage({ id: `locale-${item[1]}` })}</span>
// </MenuItem>
// );
// })}
// </MenuList>
// </Menu>
// </Box>
}
export { LocalePicker };

View File

@@ -0,0 +1,29 @@
import { useNavigate } from "react-router-dom";
interface Props {
children: React.ReactNode;
to?: string;
isDropdownItem?: boolean;
onClick?: () => void;
}
export function NavLink({ children, to, isDropdownItem, onClick }: Props) {
const navigate = useNavigate();
return (
<a
className={isDropdownItem ? "dropdown-item" : "nav-link"}
href={to}
onClick={(e) => {
e.preventDefault();
if (onClick) {
onClick();
}
if (to) {
navigate(to);
}
}}
>
{children}
</a>
);
}

View File

@@ -0,0 +1,5 @@
.page {
display: grid;
grid-template-rows: auto 1fr auto; /* Header, Main Content, Footer */
min-height: 100vh;
}

View File

@@ -0,0 +1,10 @@
import cn from "classnames";
import styles from "./Page.module.css";
interface Props {
children: React.ReactNode;
className?: string;
}
export function Page({ children, className }: Props) {
return <div className={cn(className, styles.page)}>{children}</div>;
}

View File

@@ -0,0 +1,6 @@
interface Props {
children: React.ReactNode;
}
export function SiteContainer({ children }: Props) {
return <div className="container-xl py-3">{children}</div>;
}

View File

@@ -0,0 +1,64 @@
import { useHealth } from "src/hooks";
import { intl } from "src/locale";
export function SiteFooter() {
const health = useHealth();
const getVersion = () => {
if (!health.data) {
return "";
}
const v = health.data.version;
return `v${v.major}.${v.minor}.${v.revision}`;
};
return (
<footer className="footer d-print-none py-3">
<div className="container-xl">
<div className="row text-center align-items-center flex-row-reverse">
<div className="col-lg-auto ms-lg-auto">
<ul className="list-inline list-inline-dots mb-0">
<li className="list-inline-item">
<a
href="https://github.com/NginxProxyManager/nginx-proxy-manager"
target="_blank"
className="link-secondary"
rel="noopener"
>
{intl.formatMessage({ id: "footer.github-fork" })}
</a>
</li>
</ul>
</div>
<div className="col-12 col-lg-auto mt-3 mt-lg-0">
<ul className="list-inline list-inline-dots mb-0">
<li className="list-inline-item">
© 2025{" "}
<a href="https://jc21.com" rel="noreferrer" target="_blank" className="link-secondary">
jc21.com
</a>
</li>
<li className="list-inline-item">
Theme by{" "}
<a href="https://tabler.io" rel="noreferrer" target="_blank" className="link-secondary">
Tabler
</a>
</li>
<li className="list-inline-item">
<a
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${getVersion()}`}
className="link-secondary"
target="_blank"
rel="noopener"
>
{" "}
{getVersion()}{" "}
</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,8 @@
.logo {
font-size: 1.1rem;
font-weight: 500;
img {
margin-right: 0.8rem;
}
}

View File

@@ -0,0 +1,119 @@
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react";
import { useState } from "react";
import { LocalePicker, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context";
import { useUser } from "src/hooks";
import { intl } from "src/locale";
import { ChangePasswordModal, UserModal } from "src/modals";
import styles from "./SiteHeader.module.css";
export function SiteHeader() {
const { data: currentUser } = useUser("me");
const isAdmin = currentUser?.roles.includes("admin");
const { logout } = useAuthState();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
return (
<header className="navbar navbar-expand-md d-print-none">
<div className="container-xl">
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar-menu"
aria-controls="navbar-menu"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<span className={styles.logo}>
<img
src="/images/logo-no-text.svg"
width={40}
height={40}
className="navbar-brand-image"
alt="Logo"
/>
Nginx Proxy Manager
</span>
</div>
<div className="navbar-nav flex-row order-md-last">
<div className="d-none d-md-flex">
<div className="nav-item">
<LocalePicker />
</div>
<div className="nav-item">
<ThemeSwitcher />
</div>
</div>
<div className="nav-item d-none d-md-flex me-3">
<div className="nav-item dropdown">
<a
href="/"
className="nav-link d-flex lh-1 p-0 px-2"
data-bs-toggle="dropdown"
aria-label="Open user menu"
>
<span
className="avatar avatar-sm"
style={{
backgroundImage: `url(${currentUser?.avatar || "/images/default-avatar.jpg"})`,
}}
/>
<div className="d-none d-xl-block ps-2">
<div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary">
{intl.formatMessage({ id: isAdmin ? "administrator" : "standard-user" })}
</div>
</div>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a
href="?"
className="dropdown-item"
onClick={(e) => {
e.preventDefault();
setShowProfileEdit(true);
}}
>
<IconUser width={18} />
{intl.formatMessage({ id: "user.edit-profile" })}
</a>
<a
href="?"
className="dropdown-item"
onClick={(e) => {
e.preventDefault();
setShowChangePassword(true);
}}
>
<IconLock width={18} />
{intl.formatMessage({ id: "user.change-password" })}
</a>
<div className="dropdown-divider" />
<a
href="?"
className="dropdown-item"
onClick={(e) => {
e.preventDefault();
logout();
}}
>
<IconLogout width={18} />
{intl.formatMessage({ id: "user.logout" })}
</a>
</div>
</div>
</div>
</div>
</div>
{showProfileEdit ? <UserModal userId="me" onClose={() => setShowProfileEdit(false)} /> : null}
{showChangePassword ? (
<ChangePasswordModal userId="me" onClose={() => setShowChangePassword(false)} />
) : null}
</header>
);
}

View File

@@ -0,0 +1,195 @@
import {
IconBook,
IconDeviceDesktop,
IconHome,
IconLock,
IconSettings,
IconShield,
IconUser,
} from "@tabler/icons-react";
import cn from "classnames";
import React from "react";
import { HasPermission, NavLink } from "src/components";
import { intl } from "src/locale";
interface MenuItem {
label: string;
icon?: React.ElementType;
to?: string;
items?: MenuItem[];
permission?: string;
permissionType?: "view" | "manage";
}
const menuItems: MenuItem[] = [
{
to: "/",
icon: IconHome,
label: "dashboard.title",
},
{
icon: IconDeviceDesktop,
label: "hosts.title",
items: [
{
to: "/nginx/proxy",
label: "proxy-hosts.title",
permission: "proxyHosts",
permissionType: "view",
},
{
to: "/nginx/redirection",
label: "redirection-hosts.title",
permission: "redirectionHosts",
permissionType: "view",
},
{
to: "/nginx/stream",
label: "streams.title",
permission: "streams",
permissionType: "view",
},
{
to: "/nginx/404",
label: "dead-hosts.title",
permission: "deadHosts",
permissionType: "view",
},
],
},
{
to: "/access",
icon: IconLock,
label: "access.title",
permission: "accessLists",
permissionType: "view",
},
{
to: "/certificates",
icon: IconShield,
label: "certificates.title",
permission: "certificates",
permissionType: "view",
},
{
to: "/users",
icon: IconUser,
label: "users.title",
permission: "admin",
},
{
to: "/audit-log",
icon: IconBook,
label: "auditlog.title",
permission: "admin",
},
{
to: "/settings",
icon: IconSettings,
label: "settings.title",
permission: "admin",
},
];
const getMenuItem = (item: MenuItem, onClick?: () => void) => {
if (item.items && item.items.length > 0) {
return getMenuDropown(item, onClick);
}
return (
<HasPermission
key={`item-${item.label}`}
permission={item.permission || ""}
type={item.permissionType || "view"}
hideError
>
<li className="nav-item">
<NavLink to={item.to} onClick={onClick}>
<span className="nav-link-icon d-md-none d-lg-inline-block">
{item.icon && React.createElement(item.icon, { height: 24, width: 24 })}
</span>
<span className="nav-link-title">{intl.formatMessage({ id: item.label })}</span>
</NavLink>
</li>
</HasPermission>
);
};
const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
const cns = cn("nav-item", "dropdown");
return (
<HasPermission
key={`item-${item.label}`}
permission={item.permission || ""}
type={item.permissionType || "view"}
hideError
>
<li className={cns}>
<a
className="nav-link dropdown-toggle"
href={item.to}
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
role="button"
>
<span className="nav-link-icon d-md-none d-lg-inline-block">
<IconDeviceDesktop height={24} width={24} />
</span>
<span className="nav-link-title">{intl.formatMessage({ id: item.label })}</span>
</a>
<div className="dropdown-menu">
{item.items?.map((subitem, idx) => {
return (
<HasPermission
key={`${idx}-${subitem.to}`}
permission={subitem.permission || ""}
type={subitem.permissionType || "view"}
hideError
>
<NavLink to={subitem.to} isDropdownItem onClick={onClick}>
{intl.formatMessage({ id: subitem.label })}
</NavLink>
</HasPermission>
);
})}
</div>
</li>
</HasPermission>
);
};
export function SiteMenu() {
// This is hacky AF. But that's the price of using a non-react UI kit.
const closeMenus = () => {
const navMenus = document.querySelectorAll(".nav-item.dropdown");
navMenus.forEach((menu) => {
menu.classList.remove("show");
const dropdown = menu.querySelector(".dropdown-menu");
if (dropdown) {
dropdown.classList.remove("show");
}
});
};
return (
<header className="navbar-expand-md">
<div className="collapse navbar-collapse">
<div className="navbar">
<div className="container-xl">
<div className="row flex-column flex-md-row flex-fill align-items-center">
<div className="col">
<ul className="navbar-nav">
{menuItems.length > 0 &&
menuItems.map((item) => {
return getMenuItem(item, closeMenus);
})}
</ul>
</div>
</div>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,16 @@
import type { Table as ReactTable } from "@tanstack/react-table";
interface Props {
tableInstance: ReactTable<any>;
}
function EmptyRow({ tableInstance }: Props) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
<p className="text-center">There are no items</p>
</td>
</tr>
);
}
export { EmptyRow };

View File

@@ -0,0 +1,13 @@
import type { Certificate } from "src/api/backend";
import { intl } from "src/locale";
interface Props {
certificate?: Certificate;
}
export function CertificateFormatter({ certificate }: Props) {
if (certificate) {
return intl.formatMessage({ id: "lets-encrypt" });
}
return intl.formatMessage({ id: "http-only" });
}

View File

@@ -0,0 +1,25 @@
import { intlFormat, parseISO } from "date-fns";
import { intl } from "src/locale";
interface Props {
domains: string[];
createdOn?: string;
}
export function DomainsFormatter({ domains, createdOn }: Props) {
return (
<div className="flex-fill">
<div className="font-weight-medium">
{domains.map((domain: string) => (
<span key={domain} className="badge badge-lg domain-name">
{domain}
</span>
))}
</div>
{createdOn ? (
<div className="text-secondary mt-1">
{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,17 @@
interface Props {
url: string;
name?: string;
}
export function GravatarFormatter({ url, name }: Props) {
return (
<div className="d-flex py-1 align-items-center">
<span
title={name}
className="avatar avatar-2 me-2"
style={{
backgroundImage: `url(${url})`,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { intl } from "src/locale";
interface Props {
enabled: boolean;
}
export function StatusFormatter({ enabled }: Props) {
if (enabled) {
return <span className="badge bg-lime-lt">{intl.formatMessage({ id: "online" })}</span>;
}
return <span className="badge bg-red-lt">{intl.formatMessage({ id: "offline" })}</span>;
}

View File

@@ -0,0 +1,21 @@
import { intlFormat, parseISO } from "date-fns";
import { intl } from "src/locale";
interface Props {
value: string;
createdOn?: string;
}
export function ValueWithDateFormatter({ value, createdOn }: Props) {
return (
<div className="flex-fill">
<div className="font-weight-medium">
<div className="font-weight-medium">{value}</div>
</div>
{createdOn ? (
<div className="text-secondary mt-1">
{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export * from "./CertificateFormatter";
export * from "./DomainsFormatter";
export * from "./GravatarFormatter";
export * from "./StatusFormatter";
export * from "./ValueWithDateFormatter";

View File

@@ -0,0 +1,39 @@
import { flexRender } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
import { EmptyRow } from "./EmptyRow";
function TableBody<T>(props: TableLayoutProps<T>) {
const { tableInstance, extraStyles, emptyState } = props;
const rows = tableInstance.getRowModel().rows;
if (rows.length === 0) {
return emptyState ? (
emptyState
) : (
<tbody className="table-tbody">
<EmptyRow tableInstance={tableInstance} />
</tbody>
);
}
return (
<tbody className="table-tbody">
{rows.map((row: any) => {
return (
<tr key={row.id} {...extraStyles?.row(row.original)}>
{row.getVisibleCells().map((cell: any) => {
const { className } = (cell.column.columnDef.meta as any) ?? {};
return (
<td key={cell.id} className={className}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
);
}
export { TableBody };

View File

@@ -0,0 +1,26 @@
import type { TableLayoutProps } from "src/components";
function TableHeader<T>(props: TableLayoutProps<T>) {
const { tableInstance } = props;
const headerGroups = tableInstance.getHeaderGroups();
return (
<thead>
{headerGroups.map((headerGroup: any) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header: any) => {
const { column } = header;
const { className } = (column.columnDef.meta as any) ?? {};
return (
<th key={header.id} className={className}>
{typeof column.columnDef.header === "string" ? `${column.columnDef.header}` : null}
</th>
);
})}
</tr>
))}
</thead>
);
}
export { TableHeader };

View File

@@ -0,0 +1,64 @@
export interface TablePagination {
limit: number;
offset: number;
total: number;
}
export interface TableSortBy {
id: string;
desc: boolean;
}
export interface TableFilter {
id: string;
value: any;
}
const tableEvents = {
FILTERS_CHANGED: "FILTERS_CHANGED",
PAGE_CHANGED: "PAGE_CHANGED",
PAGE_SIZE_CHANGED: "PAGE_SIZE_CHANGED",
TOTAL_COUNT_CHANGED: "TOTAL_COUNT_CHANGED",
SORT_CHANGED: "SORT_CHANGED",
};
const tableEventReducer = (state: any, { type, payload }: any) => {
let offset = state.offset;
switch (type) {
case tableEvents.PAGE_CHANGED:
return {
...state,
offset: payload * state.limit,
};
case tableEvents.PAGE_SIZE_CHANGED:
return {
...state,
limit: payload,
};
case tableEvents.TOTAL_COUNT_CHANGED:
return {
...state,
total: payload,
};
case tableEvents.SORT_CHANGED:
return {
...state,
sortBy: payload,
};
case tableEvents.FILTERS_CHANGED:
if (state.filters !== payload) {
// this actually was a legit change
// sets to page 1 when filter is modified
offset = 0;
}
return {
...state,
filters: payload,
offset,
};
default:
throw new Error(`Unhandled action type: ${type}`);
}
};
export { tableEvents, tableEventReducer };

View File

@@ -0,0 +1,22 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import { TableBody } from "./TableBody";
import { TableHeader } from "./TableHeader";
interface TableLayoutProps<TFields> {
tableInstance: ReactTable<TFields>;
emptyState?: React.ReactNode;
extraStyles?: {
row: (rowData: TFields) => any | undefined;
};
}
function TableLayout<TFields>(props: TableLayoutProps<TFields>) {
const hasRows = props.tableInstance.getRowModel().rows.length > 0;
return (
<table className="table table-vcenter table-selectable mb-0">
{hasRows ? <TableHeader tableInstance={props.tableInstance} /> : null}
<TableBody {...props} />
</table>
);
}
export { TableLayout, type TableLayoutProps };

View File

@@ -0,0 +1,4 @@
export * from "./Formatter";
export * from "./TableHeader";
export * from "./TableHelpers";
export * from "./TableLayout";

View File

@@ -0,0 +1,15 @@
.darkBtn {
color: var(--tblr-light) !important;
&:hover {
border: var(--tblr-btn-border-width) solid transparent !important;
background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important;
}
}
.lightBtn {
color: var(--tblr-dark) !important;
&:hover {
border: var(--tblr-btn-border-width) solid transparent !important;
background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important;
}
}

View File

@@ -0,0 +1,41 @@
import { IconMoon, IconSun } from "@tabler/icons-react";
import cn from "classnames";
import { Button } from "src/components";
import { useTheme } from "src/hooks";
import styles from "./ThemeSwitcher.module.css";
interface Props {
className?: string;
}
function ThemeSwitcher({ className }: Props) {
const { setTheme } = useTheme();
return (
<div className={cn("d-print-none", "d-inline-block", className)}>
<Button
size="sm"
className={cn("btn-ghost-dark", "hide-theme-dark", styles.lightBtn)}
data-bs-toggle="tooltip"
data-bs-placement="bottom"
aria-label="Enable dark mode"
data-bs-original-title="Enable dark mode"
onClick={() => setTheme("dark")}
>
<IconMoon width={24} />
</Button>
<Button
size="sm"
className={cn("btn-ghost-light", "hide-theme-light", styles.darkBtn)}
data-bs-toggle="tooltip"
data-bs-placement="bottom"
aria-label="Enable dark mode"
data-bs-original-title="Enable dark mode"
onClick={() => setTheme("light")}
>
<IconSun width={24} />
</Button>
</div>
);
}
export { ThemeSwitcher };

View File

@@ -0,0 +1,17 @@
import { Page } from "src/components";
export function Unhealthy() {
return (
<Page className="page-center">
<div className="container-tight py-4">
<div className="empty">
<div className="empty-img">
<img src="/images/unhealthy.svg" alt="" />
</div>
<p className="empty-title">The API is not healthy.</p>
<p className="empty-subtitle text-secondary">We'll keep checking and hope to be back soon!</p>
</div>
</div>
</Page>
);
}

View File

@@ -0,0 +1,15 @@
export * from "./Button";
export * from "./ErrorNotFound";
export * from "./Flag";
export * from "./HasPermission";
export * from "./LoadingPage";
export * from "./LocalePicker";
export * from "./NavLink";
export * from "./Page";
export * from "./SiteContainer";
export * from "./SiteFooter";
export * from "./SiteHeader";
export * from "./SiteMenu";
export * from "./Table";
export * from "./ThemeSwitcher";
export * from "./Unhealthy";