Files
fluxer/packages/admin/src/components/Layout.tsx
2026-02-17 12:22:36 +00:00

389 lines
12 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {getAccessibleSections} from '@fluxer/admin/src/Navigation';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {FlashMessage} from '@fluxer/ui/src/components/Flash';
import {formatDiscriminator, getUserAvatarUrl} from '@fluxer/ui/src/utils/FormatUser';
import type {FC, PropsWithChildren} from 'hono/jsx';
interface LayoutProps {
title: string;
activePage: string;
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
autoRefresh?: boolean;
assetVersion: string;
csrfToken: string;
extraScripts?: string;
inspectedVoiceRegionId?: string;
}
function cacheBustedAsset(basePath: string, assetVersion: string, path: string): string {
return `${basePath}${path}?t=${assetVersion}`;
}
const Head: FC<{
title: string;
basePath: string;
staticCdnEndpoint: string;
assetVersion: string;
autoRefresh: boolean | undefined;
}> = ({title, basePath, staticCdnEndpoint, assetVersion, autoRefresh}) => (
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{autoRefresh && <meta http-equiv="refresh" content="3" />}
<title>{title} ~ Fluxer Admin</title>
<link rel="stylesheet" href={`${staticCdnEndpoint}/fonts/ibm-plex.css`} />
<link rel="stylesheet" href={`${staticCdnEndpoint}/fonts/bricolage.css`} />
<link rel="stylesheet" href={cacheBustedAsset(basePath, assetVersion, '/static/app.css')} />
<link rel="icon" type="image/x-icon" href={`${staticCdnEndpoint}/web/favicon.ico`} />
<link rel="apple-touch-icon" href={`${staticCdnEndpoint}/web/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${staticCdnEndpoint}/web/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${staticCdnEndpoint}/web/favicon-16x16.png`} />
</head>
);
const SidebarSection: FC<PropsWithChildren<{title: string}>> = ({title, children}) => (
<div>
<div class="mb-2 text-neutral-400 text-xs uppercase">{title}</div>
<div class="space-y-1">{children}</div>
</div>
);
const SidebarItem: FC<{title: string; path: string; active: boolean; basePath: string}> = ({
title,
path,
active,
basePath,
}) => {
const classes = active
? 'block px-3 py-2 rounded bg-neutral-800 text-white text-sm transition-colors'
: 'block px-3 py-2 rounded text-neutral-300 hover:bg-neutral-800 hover:text-white text-sm transition-colors';
return (
<a href={`${basePath}${path}`} class={classes} {...(active ? {'data-active': ''} : {})}>
{title}
</a>
);
};
const Sidebar: FC<{
activePage: string;
adminAcls: Array<string>;
basePath: string;
selfHosted: boolean;
inspectedVoiceRegionId?: string;
}> = ({activePage, adminAcls, basePath, selfHosted, inspectedVoiceRegionId}) => {
const sections = getAccessibleSections(adminAcls, {selfHosted, inspectedVoiceRegionId});
return (
<div
data-sidebar=""
class="fixed inset-y-0 left-0 z-40 flex h-screen w-64 -translate-x-full transform flex-col bg-neutral-900 text-white shadow-xl transition-transform duration-200 ease-in-out lg:static lg:inset-auto lg:translate-x-0 lg:shadow-none"
>
<div class="flex items-center justify-between gap-3 border-neutral-800 border-b p-6">
<a href={`${basePath}/users`}>
<h1 class="font-semibold text-base">Fluxer Admin</h1>
</a>
<button
type="button"
data-sidebar-close=""
class="inline-flex items-center justify-center rounded-md p-2 text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40 lg:hidden"
aria-label="Close sidebar"
>
Close
</button>
</div>
<nav class="sidebar-scrollbar flex-1 space-y-4 overflow-y-auto p-4">
{sections.map((section) => (
<SidebarSection title={section.title}>
{section.items.map((item) => (
<SidebarItem
title={item.title}
path={item.path}
active={activePage === item.activeKey}
basePath={basePath}
/>
))}
</SidebarSection>
))}
</nav>
<script
defer
dangerouslySetInnerHTML={{
__html: SIDEBAR_ACTIVE_SCROLL_SCRIPT,
}}
/>
</div>
);
};
const Header: FC<{
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
assetVersion: string;
csrfToken: string;
}> = ({config, session, currentAdmin, assetVersion, csrfToken}) => (
<header class="sticky top-0 z-10 flex items-center justify-between gap-4 border-neutral-200 border-b bg-white px-4 py-4 sm:px-6 lg:px-8">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
data-sidebar-toggle=""
class="inline-flex items-center justify-center rounded-md border border-neutral-300 p-2 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400 lg:hidden"
aria-label="Toggle sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{currentAdmin ? (
<a
href={`${config.basePath}/users/${session.userId}`}
class="flex items-center gap-3 transition-opacity hover:opacity-80"
>
<img
src={getUserAvatarUrl(
config.mediaEndpoint,
config.staticCdnEndpoint,
currentAdmin.id,
currentAdmin.avatar,
true,
assetVersion,
)}
alt={`${currentAdmin.username}'s avatar`}
class="h-10 w-10 rounded-full"
/>
<div class="flex flex-col">
<div class="text-neutral-900 text-sm">
{currentAdmin.username}#{formatDiscriminator(currentAdmin.discriminator)}
</div>
<div class="text-neutral-500 text-xs">Admin</div>
</div>
</a>
) : (
<div class="text-neutral-600 text-sm">
Logged in as:{' '}
<a
href={`${config.basePath}/users/${session.userId}`}
class="text-blue-600 hover:text-blue-800 hover:underline"
>
{session.userId}
</a>
</div>
)}
</div>
<form method="post" action={`${config.basePath}/logout`}>
<CsrfInput token={csrfToken} />
<button
type="submit"
class="rounded border border-neutral-300 px-4 py-2 font-medium text-neutral-700 text-sm transition-colors hover:border-neutral-400 hover:text-neutral-900"
>
Logout
</button>
</form>
</header>
);
const SIDEBAR_ACTIVE_SCROLL_SCRIPT = `
(function () {
var el = document.querySelector('[data-active]');
if (el) el.scrollIntoView({block: 'nearest'});
})();
`;
const SIDEBAR_SCRIPT = `
(function () {
var sidebar = document.querySelector('[data-sidebar]');
var overlay = document.querySelector('[data-sidebar-overlay]');
var toggles = document.querySelectorAll('[data-sidebar-toggle]');
var closes = document.querySelectorAll('[data-sidebar-close]');
if (!sidebar || !overlay) return;
function open() {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
}
function close() {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
}
toggles.forEach(function (btn) {
btn.addEventListener('click', function () {
if (sidebar.classList.contains('-translate-x-full')) {
open();
} else {
close();
}
});
});
closes.forEach(function (btn) {
btn.addEventListener('click', close);
});
overlay.addEventListener('click', close);
window.addEventListener('keydown', function (event) {
if (event.key === 'Escape') close();
});
function syncForDesktop() {
if (window.innerWidth >= 1024) {
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
sidebar.classList.remove('-translate-x-full');
} else {
sidebar.classList.add('-translate-x-full');
}
}
window.addEventListener('resize', syncForDesktop);
syncForDesktop();
})();
`;
const SH_LINK_REWRITE_SCRIPT = `
(function () {
if (window.location.search.indexOf('sh=1') === -1) return;
function rewriteHref(el) {
var href = el.getAttribute('href');
if (!href || href.indexOf('sh=1') >= 0) return;
if (href.charAt(0) === '#' || href.indexOf('javascript:') === 0 || href.indexOf('data:') === 0 || href.indexOf('mailto:') === 0) return;
if (href.indexOf('://') >= 0) {
try {
var url = new URL(href);
if (url.origin !== window.location.origin) return;
} catch (e) {
return;
}
}
var sep = href.indexOf('?') >= 0 ? '&' : '?';
el.setAttribute('href', href + sep + 'sh=1');
}
function rewriteAction(form) {
var action = form.getAttribute('action');
if (!action || action.indexOf('sh=1') >= 0) return;
var sep = action.indexOf('?') >= 0 ? '&' : '?';
form.setAttribute('action', action + sep + 'sh=1');
}
document.querySelectorAll('a[href]').forEach(rewriteHref);
document.querySelectorAll('form[action]').forEach(rewriteAction);
document.addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (a) rewriteHref(a);
}, true);
document.addEventListener('submit', function (e) {
var form = e.target.closest('form[action]');
if (form) rewriteAction(form);
}, true);
})();
`;
export function Layout({
title,
activePage,
config,
session,
currentAdmin,
flash,
autoRefresh,
assetVersion,
csrfToken,
extraScripts,
inspectedVoiceRegionId,
children,
}: PropsWithChildren<LayoutProps>) {
const adminAcls = currentAdmin?.acls ?? [];
return (
<html lang="en" data-base-path={config.basePath}>
<Head
title={title}
basePath={config.basePath}
staticCdnEndpoint={config.staticCdnEndpoint}
assetVersion={assetVersion}
autoRefresh={autoRefresh}
/>
<body class="min-h-screen overflow-hidden bg-neutral-50">
<div class="flex h-screen">
<Sidebar
activePage={activePage}
adminAcls={adminAcls}
basePath={config.basePath}
selfHosted={config.selfHosted}
inspectedVoiceRegionId={inspectedVoiceRegionId}
/>
<div data-sidebar-overlay="" class="fixed inset-0 z-30 hidden bg-black/50 lg:hidden" />
<div class="flex h-screen w-full flex-1 flex-col overflow-y-auto">
<Header
config={config}
session={session}
currentAdmin={currentAdmin}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>
<main class="flex-1 p-4 sm:p-6 lg:p-8">
<div class="mx-auto w-full max-w-7xl">
{flash && (
<div class="mb-6">
<FlashMessage flash={flash} />
</div>
)}
{children}
</div>
</main>
</div>
</div>
<script defer dangerouslySetInnerHTML={{__html: SIDEBAR_SCRIPT}} />
<script defer dangerouslySetInnerHTML={{__html: SH_LINK_REWRITE_SCRIPT}} />
{extraScripts && <script defer dangerouslySetInnerHTML={{__html: extraScripts}} />}
</body>
</html>
);
}