refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,51 @@
/*
* 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 {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {Alert} from '@fluxer/ui/src/components/Alert';
import {Card} from '@fluxer/ui/src/components/Card';
import type {FC} from 'hono/jsx';
interface ErrorAlertProps {
error: string;
}
interface ErrorCardProps {
title: string;
message: string;
}
export const ErrorAlert: FC<ErrorAlertProps> = ({error}) => <Alert variant="error">{error}</Alert>;
export const ErrorCard: FC<ErrorCardProps> = ({title, message}) => (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
{title}
</Heading>
<Text size="sm" color="muted">
{message}
</Text>
</VStack>
</Card>
);

View File

@@ -0,0 +1,91 @@
/*
* 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 */
export function PaperclipIcon({color}: {color: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-3 w-3 ${color}`}>
<rect width="256" height="256" fill="none" />
<path
d="M108.71,197.23l-5.11,5.11a46.63,46.63,0,0,1-66-.05h0a46.63,46.63,0,0,1,.06-65.89L72.4,101.66a46.62,46.62,0,0,1,65.94,0h0A46.34,46.34,0,0,1,150.78,124"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
<path
d="M147.29,58.77l5.11-5.11a46.62,46.62,0,0,1,65.94,0h0a46.62,46.62,0,0,1,0,65.94L193.94,144,183.6,154.34a46.63,46.63,0,0,1-66-.05h0A46.46,46.46,0,0,1,105.22,132"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
}
export function CheckmarkIcon({color}: {color: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-4 w-4 ${color}`}>
<rect width="256" height="256" fill="none" />
<polyline
points="40 144 96 200 224 72"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
}
export function XIcon({color}: {color: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-4 w-4 ${color}`}>
<rect width="256" height="256" fill="none" />
<line
x1="200"
y1="56"
x2="56"
y2="200"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
<line
x1="200"
y1="200"
x2="56"
y2="56"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
}

View File

@@ -0,0 +1,388 @@
/*
* 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>
);
}

View File

@@ -0,0 +1,204 @@
/*
* 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 {PaperclipIcon} from '@fluxer/admin/src/components/Icons';
import {CSRF_FORM_FIELD} from '@fluxer/constants/src/Cookies';
import {formatUserTag} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
interface Attachment {
url: string;
filename: string;
}
export interface Message {
id: string;
content: string;
timestamp: string;
author_id: string;
author_username: string;
author_discriminator: string;
channel_id: string;
guild_id?: string | null;
attachments: Array<Attachment>;
}
interface MessageRowProps {
basePath: string;
message: Message;
includeDeleteButton: boolean;
}
function buildMessageLookupHref(basePath: string, channelId: string, messageId: string): string {
return `${basePath}/messages?channel_id=${channelId}&message_id=${messageId}&context_limit=50`;
}
const MessageRow: FC<MessageRowProps> = ({basePath, message, includeDeleteButton}) => (
<div
class="group flex items-start gap-3 px-4 py-2 transition-colors hover:bg-neutral-50"
data-message-id={message.id}
>
<div class="flex-shrink-0 pt-0.5">
<a
href={`${basePath}/users/${message.author_id}`}
class="cursor-pointer text-neutral-900 text-xs hover:underline"
title={message.author_id}
>
{formatUserTag(message.author_username, message.author_discriminator)}
</a>
<div class="text-neutral-500 text-xs">{message.timestamp}</div>
</div>
<div class="message-content min-w-0 flex-1">
<div class="whitespace-pre-wrap break-words text-neutral-900 text-sm">{message.content}</div>
{message.attachments.length > 0 && (
<div class="mt-2 space-y-1">
{message.attachments.map((att) => (
<div class="flex items-center gap-1 text-xs">
<PaperclipIcon color="text-neutral-500" />
<a href={att.url} target="_blank" class="text-blue-600 hover:underline">
{att.filename}
</a>
</div>
))}
</div>
)}
<div class="mt-1 flex flex-wrap items-center gap-2 text-neutral-400 text-xs">
<span>ID: {message.id}</span>
{message.channel_id && (
<>
<span>|</span>
<a
href={buildMessageLookupHref(basePath, message.channel_id, message.id)}
class="text-neutral-500 hover:text-neutral-700 hover:underline"
>
Channel: {message.channel_id}
</a>
</>
)}
{message.guild_id && (
<>
<span>|</span>
<a
href={`${basePath}/guilds/${message.guild_id}`}
class="text-neutral-500 hover:text-neutral-700 hover:underline"
>
Guild: {message.guild_id}
</a>
</>
)}
</div>
</div>
{includeDeleteButton && message.channel_id && (
<div class="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
class="rounded px-2 py-1 text-red-600 text-xs transition-colors hover:bg-red-50 hover:text-red-700"
title="Delete message"
onclick={`deleteMessage('${message.channel_id}', '${message.id}', this)`}
>
Delete
</button>
</div>
)}
</div>
);
export function MessageList({
basePath,
messages,
includeDeleteButton,
}: {
basePath: string;
messages: Array<Message>;
includeDeleteButton: boolean;
}) {
return (
<div class="space-y-1">
{messages.map((message) => (
<MessageRow basePath={basePath} message={message} includeDeleteButton={includeDeleteButton} />
))}
</div>
);
}
export function createMessageDeletionScriptBody(csrfToken: string): string {
return `
function deleteMessage(channelId, messageId, button) {
const csrfToken = ${JSON.stringify(csrfToken)};
if (!confirm('Are you sure you want to delete this message?')) {
return;
}
const formData = new FormData();
formData.append('channel_id', channelId);
formData.append('message_id', messageId);
formData.append('${CSRF_FORM_FIELD}', csrfToken);
button.disabled = true;
button.textContent = 'Deleting...';
const basePath = document.documentElement.dataset.basePath || '';
fetch(basePath + '/messages?action=delete', {
method: 'POST',
body: formData
})
.then(async response => {
if (response.ok) {
const messageRow = button.closest('[data-message-id]');
if (messageRow) {
messageRow.style.opacity = '0.5';
messageRow.style.pointerEvents = 'none';
const messageContent = messageRow.querySelector('.message-content');
if (messageContent) {
messageContent.style.textDecoration = 'line-through';
}
}
const buttonContainer = button.parentElement;
const deletedBadge = document.createElement('span');
deletedBadge.className = 'px-2 py-1 bg-red-100 text-red-800 text-xs rounded opacity-100';
deletedBadge.textContent = 'DELETED';
button.replaceWith(deletedBadge);
if (buttonContainer) {
buttonContainer.style.opacity = '1';
}
} else {
button.disabled = false;
button.textContent = 'Delete';
let errorMessage = 'Failed to delete message';
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
} catch (e) {}
alert(errorMessage);
}
})
.catch(error => {
console.error('Error:', error);
button.disabled = false;
button.textContent = 'Delete';
alert('Error deleting message: ' + (error.message || 'Unknown error'));
});
}
`;
}

View File

@@ -0,0 +1,124 @@
/*
* 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 type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {hasBigIntFlag, parseBigIntOrZero} from '@fluxer/admin/src/utils/Bigint';
import {cn} from '@fluxer/admin/src/utils/ClassNames';
import {UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {getFormattedShortDate} from '@fluxer/date_utils/src/DateFormatting';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {normalizeEndpoint} from '@fluxer/ui/src/utils/AvatarMediaUtils';
interface BadgeDefinition {
key: string;
iconUrl: string;
tooltip: string;
}
export interface UserProfileBadgesProps {
config: Config;
user: UserAdminResponse;
size?: 'sm' | 'md';
class?: string;
}
export function UserProfileBadges({config, user, size = 'sm', class: className}: UserProfileBadgesProps) {
const flags = parseBigIntOrZero(user.flags);
const badges: Array<BadgeDefinition> = [];
const isSelfHosted = config.selfHosted;
const staticCdnEndpoint = normalizeEndpoint(config.staticCdnEndpoint);
if (hasBigIntFlag(flags, UserFlags.STAFF)) {
badges.push({
key: 'staff',
iconUrl: `${staticCdnEndpoint}/badges/staff.svg`,
tooltip: 'Fluxer Staff',
});
}
if (!isSelfHosted && hasBigIntFlag(flags, UserFlags.CTP_MEMBER)) {
badges.push({
key: 'ctp',
iconUrl: `${staticCdnEndpoint}/badges/ctp.svg`,
tooltip: 'Fluxer Community Team',
});
}
if (!isSelfHosted && hasBigIntFlag(flags, UserFlags.PARTNER)) {
badges.push({
key: 'partner',
iconUrl: `${staticCdnEndpoint}/badges/partner.svg`,
tooltip: 'Fluxer Partner',
});
}
if (!isSelfHosted && hasBigIntFlag(flags, UserFlags.BUG_HUNTER)) {
badges.push({
key: 'bug_hunter',
iconUrl: `${staticCdnEndpoint}/badges/bug-hunter.svg`,
tooltip: 'Fluxer Bug Hunter',
});
}
if (!isSelfHosted && user.premium_type && user.premium_type !== UserPremiumTypes.NONE) {
let tooltip = 'Fluxer Plutonium';
if (user.premium_type === UserPremiumTypes.LIFETIME) {
if (user.premium_since) {
const premiumSince = getFormattedShortDate(user.premium_since);
tooltip = `Fluxer Visionary since ${premiumSince}`;
} else {
tooltip = 'Fluxer Visionary';
}
} else if (user.premium_since) {
const premiumSince = getFormattedShortDate(user.premium_since);
tooltip = `Fluxer Plutonium subscriber since ${premiumSince}`;
}
badges.push({
key: 'premium',
iconUrl: `${staticCdnEndpoint}/badges/plutonium.svg`,
tooltip,
});
}
if (badges.length === 0) {
return null;
}
const badgeSizeClass = size === 'md' ? 'h-5 w-5' : 'h-4 w-4';
const containerClass = cn('flex items-center', size === 'md' ? 'gap-2' : 'gap-1.5', className);
return (
<div class={containerClass}>
{badges.map((badge) => (
<img
key={badge.key}
src={badge.iconUrl}
alt={badge.tooltip}
title={badge.tooltip}
class={cn(badgeSizeClass, 'shrink-0')}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,103 @@
/*
* 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 {UnifiedBadge as Badge} from '@fluxer/ui/src/components/Badge';
import {Checkbox, Input} from '@fluxer/ui/src/components/Form';
import type {FC} from 'hono/jsx';
export interface VoiceRestrictions {
vip_only: boolean;
required_guild_features: Array<string>;
allowed_guild_ids: Array<string>;
}
export interface VoiceStatusBadgesProps {
vip_only: boolean;
has_features: boolean;
has_guild_ids: boolean;
}
export const VoiceStatusBadges: FC<VoiceStatusBadgesProps> = ({vip_only, has_features, has_guild_ids}) => (
<>
{vip_only && <Badge label="VIP ONLY" tone="purple" intensity="subtle" rounded="default" />}
{has_features && <Badge label="FEATURES" tone="orange" intensity="subtle" rounded="default" />}
{has_guild_ids && <Badge label="GUILD IDS" tone="warning" intensity="subtle" rounded="default" />}
</>
);
export interface VoiceFeaturesListProps {
features: Array<string>;
}
export const VoiceFeaturesList: FC<VoiceFeaturesListProps> = ({features}) =>
features.length > 0 ? (
<div>
<span class="font-medium text-neutral-600 text-xs">Required Features: </span>
<span class="text-neutral-700 text-xs">{features.join(', ')}</span>
</div>
) : null;
export interface VoiceGuildIdsListProps {
guild_ids: Array<string>;
}
export const VoiceGuildIdsList: FC<VoiceGuildIdsListProps> = ({guild_ids}) =>
guild_ids.length > 0 ? (
<div>
<span class="font-medium text-neutral-600 text-xs">Allowed Guilds: </span>
<span class="text-neutral-700 text-xs">{guild_ids.join(', ')}</span>
</div>
) : null;
export interface VoiceRestrictionFieldsProps {
id_prefix?: string;
restrictions: VoiceRestrictions;
}
export const VoiceRestrictionFields: FC<VoiceRestrictionFieldsProps> = ({id_prefix = '', restrictions}) => {
const {vip_only, required_guild_features, allowed_guild_ids} = restrictions;
return (
<div class="space-y-3 border-neutral-200 border-t pt-3">
<h4 class="font-medium text-neutral-700 text-sm">Access Restrictions</h4>
<Checkbox name="vip_only" value="true" label="VIP Only" checked={vip_only} />
<Input
label="Required Guild Features"
name="required_guild_features"
type="text"
value={required_guild_features.join(', ')}
placeholder="e.g. FEATURE_1, FEATURE_2"
id={id_prefix ? `${id_prefix}-required-guild-features` : undefined}
helper="Separate features with commas."
/>
<Input
label="Allowed Guild IDs"
name="allowed_guild_ids"
type="text"
value={allowed_guild_ids.join(', ')}
placeholder="e.g. 123456789, 987654321"
id={id_prefix ? `${id_prefix}-allowed-guild-ids` : undefined}
helper="Separate guild IDs with commas."
/>
</div>
);
};

View File

@@ -0,0 +1,47 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface AlertProps {
variant?: 'success' | 'warning' | 'error' | 'info';
title?: string;
children: Child;
class?: string;
}
export function Alert({variant = 'info', title, children, class: className}: AlertProps) {
const variantStyles = {
success: 'bg-green-50 border-green-200 text-green-700',
warning: 'bg-neutral-50 border-neutral-200 text-neutral-700',
error: 'bg-red-50 border-red-200 text-red-700',
info: 'bg-blue-50 border-blue-200 text-blue-700',
};
return (
<div class={cn('rounded-lg border p-4', variantStyles[variant], className)}>
{title && <div class="mb-2 font-bold">{title}</div>}
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface BadgeProps {
variant?: 'success' | 'danger' | 'warning' | 'info' | 'neutral';
size?: 'sm' | 'md';
}
export function Badge({variant = 'neutral', size = 'md', children}: PropsWithChildren<BadgeProps>) {
const classes = clsx(
'inline-flex items-center justify-center rounded-full font-medium',
{
'px-2 py-0.5 text-xs': size === 'sm',
'px-2.5 py-1 text-sm': size === 'md',
},
{
'bg-green-100 text-green-600': variant === 'success',
'bg-red-100 text-red-600': variant === 'danger',
'bg-neutral-100 text-neutral-700 border border-neutral-200': variant === 'warning',
'bg-blue-100 text-blue-600': variant === 'info',
'bg-neutral-100 text-neutral-600': variant === 'neutral',
},
);
return <span class={classes}>{children}</span>;
}

View File

@@ -0,0 +1,53 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export type CardPadding = 'none' | 'sm' | 'md' | 'lg' | 'xl';
export type CardVariant = 'default' | 'bordered' | 'elevated';
export interface CardProps {
padding?: CardPadding;
variant?: CardVariant;
className?: string;
}
const paddingClasses: Record<CardPadding, string> = {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
xl: 'p-12',
};
const variantClasses: Record<CardVariant, string> = {
default: 'border border-neutral-200',
bordered: 'border-2 border-neutral-300',
elevated: 'border border-neutral-200 shadow-md',
};
export function Card({padding = 'md', variant = 'default', className, children}: PropsWithChildren<CardProps>) {
const classes = clsx('rounded-lg bg-white', variantClasses[variant], paddingClasses[padding], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,34 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface CardBodyProps {
className?: string;
}
export function CardBody({className, children}: PropsWithChildren<CardBodyProps>) {
const classes = clsx('p-6', className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,34 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface CardFooterProps {
className?: string;
}
export function CardFooter({className, children}: PropsWithChildren<CardFooterProps>) {
const classes = clsx('border-neutral-200 border-t p-6', className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,34 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface CardHeaderProps {
className?: string;
}
export function CardHeader({className, children}: PropsWithChildren<CardHeaderProps>) {
const classes = clsx('border-neutral-200 border-b p-6', className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface ChipProps {
active?: boolean;
href?: string;
}
export function Chip({active = false, href, children}: PropsWithChildren<ChipProps>) {
const chipClasses = clsx(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 font-medium text-sm transition-colors no-underline',
{
'border-brand-primary bg-brand-primary text-white': active,
'border-neutral-300 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50': !active,
},
);
if (href) {
return (
<a href={href} class={chipClasses}>
{children}
</a>
);
}
return <span class={chipClasses}>{children}</span>;
}

View File

@@ -0,0 +1,63 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface CodeBlockProps {
children: Child;
copiable?: boolean;
class?: string;
}
const COPY_CODE_SCRIPT = `
function copyCode(button) {
var code = button.previousElementSibling.textContent;
navigator.clipboard.writeText(code).then(function () {
var original = button.textContent;
button.textContent = 'Copied!';
setTimeout(function () { button.textContent = original; }, 2000);
});
}
`;
export function CodeBlock({children, copiable = false, class: className}: CodeBlockProps) {
return (
<div class={cn('relative', className)}>
<pre class="overflow-x-auto rounded border border-neutral-200 bg-neutral-100 p-3 font-mono text-sm">
<code>{children}</code>
</pre>
{copiable && (
<>
<button
type="button"
onclick="copyCode(this)"
class="absolute top-2 right-2 rounded border border-neutral-300 bg-white px-2 py-1 text-xs hover:bg-neutral-50"
>
Copy
</button>
<script dangerouslySetInnerHTML={{__html: COPY_CODE_SCRIPT}} />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export type ContainerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export interface ContainerProps {
size?: ContainerSize;
}
const sizeClasses: Record<ContainerSize, string> = {
sm: 'max-w-2xl',
md: 'max-w-4xl',
lg: 'max-w-6xl',
xl: 'max-w-7xl',
full: 'max-w-full',
};
export function Container({size = 'xl', children}: PropsWithChildren<ContainerProps>) {
const classes = clsx('mx-auto w-full px-4 sm:px-6 lg:px-8', sizeClasses[size]);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,40 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface EmptyStateProps {
variant?: 'empty' | 'loading' | 'error';
children: Child;
class?: string;
}
export function EmptyState({variant = 'empty', children, class: className}: EmptyStateProps) {
const variantStyles = {
empty: 'text-neutral-500 text-center py-8',
loading: 'text-neutral-500 text-center py-8',
error: 'text-red-600 text-center py-8',
};
return <div class={cn(variantStyles[variant], className)}>{children}</div>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import {Button} from '@fluxer/ui/src/components/Button';
export interface FormActionsProps {
submitText?: string;
cancelText?: string;
loading?: boolean;
cancelHref?: string;
class?: string;
}
export function FormActions(props: FormActionsProps) {
const {submitText = 'Submit', cancelText = 'Cancel', loading = false, cancelHref, class: className} = props;
return (
<div class={cn('flex items-center justify-end gap-3 border-gray-200 border-t pt-4', className)}>
{cancelHref && (
<Button variant="secondary" href={cancelHref} disabled={loading}>
{cancelText}
</Button>
)}
<Button type="submit" variant="primary" loading={loading}>
{submitText}
</Button>
</div>
);
}

View File

@@ -0,0 +1,47 @@
/*
* 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 {Card, type CardPadding, type CardVariant} from '@fluxer/admin/src/components/ui/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {PropsWithChildren} from 'hono/jsx';
export interface FormCardProps {
action: string;
method?: 'get' | 'post';
csrfToken: string;
padding?: CardPadding;
variant?: CardVariant;
className?: string;
}
export function FormCard(props: PropsWithChildren<FormCardProps>) {
const {action, method = 'post', csrfToken, padding = 'md', variant = 'default', className, children} = props;
return (
<Card padding={padding} variant={variant} className={className}>
<form method={method} action={action}>
<CsrfInput token={csrfToken} />
{children}
</form>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
/*
* 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 {Caption, Label} from '@fluxer/admin/src/components/ui/Typography';
import {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
export interface FormFieldGroupProps {
label: string;
htmlFor?: string;
required?: boolean;
error?: string;
helper?: Child;
children: Child;
class?: string;
}
export function FormFieldGroup(props: FormFieldGroupProps) {
const {label, htmlFor, required = false, error, helper, children, class: className} = props;
const helperView =
!error && helper !== undefined && helper !== null ? (
typeof helper === 'string' || typeof helper === 'number' ? (
<Caption>{helper}</Caption>
) : (
helper
)
) : null;
return (
<div class={cn('flex flex-col gap-2', className)}>
<div class="flex flex-col gap-1">
<Label htmlFor={htmlFor} required={required}>
{label}
</Label>
{helperView}
</div>
<div class="flex flex-col gap-1">
{children}
{error && (
<p class="flex items-center gap-1 text-red-600 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
{error}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export interface FormRowProps {
cols?: 1 | 2 | 3 | 4;
gap?: 2 | 3 | 4 | 5 | 6;
class?: string;
}
const colsStyles: Record<NonNullable<FormRowProps['cols']>, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
const gapStyles: Record<NonNullable<FormRowProps['gap']>, string> = {
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
};
export function FormRow(props: PropsWithChildren<FormRowProps>) {
const {cols = 2, gap = 4, children, class: className} = props;
return <div class={cn('grid', colsStyles[cols], gapStyles[gap], className)}>{children}</div>;
}

View File

@@ -0,0 +1,44 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export interface FormSectionProps {
title: string;
description?: string;
class?: string;
}
export function FormSection(props: PropsWithChildren<FormSectionProps>) {
const {title, description, children, class: className} = props;
return (
<div class={cn('space-y-4', className)}>
<div class="flex flex-col gap-1 border-gray-200 border-b pb-3">
<h3 class="font-semibold text-gray-900 text-lg">{title}</h3>
{description && <p class="text-gray-600 text-sm">{description}</p>}
</div>
<div class="space-y-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type GridCols = 1 | 2 | 3 | 4;
export type GridGap = 'sm' | 'md' | 'lg';
export interface GridProps {
cols?: GridCols;
gap?: GridGap;
class?: string;
}
const colsClasses: Record<GridCols, string> = {
1: 'md:grid-cols-1',
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4',
};
const gapClasses: Record<GridGap, string> = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
export function Grid({cols = 2, gap = 'md', class: className, children}: PropsWithChildren<GridProps>) {
const classes = cn('grid grid-cols-1', colsClasses[cols], gapClasses[gap], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface InlineStackProps {
gap?: number | string;
align?: 'start' | 'center' | 'end' | 'baseline';
children: Child;
class?: string;
}
export function InlineStack({gap = 2, align = 'center', children, class: className}: InlineStackProps) {
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
const alignClass = `items-${align}`;
return <div class={cn('flex', alignClass, gapClass, className)}>{children}</div>;
}

View File

@@ -0,0 +1,159 @@
/*
* 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 type {Child, FC} from 'hono/jsx';
export type InputSize = 'sm' | 'md' | 'lg';
export type InputType = 'text' | 'email' | 'password' | 'tel' | 'number' | 'date' | 'datetime-local' | 'url' | 'search';
export interface InputProps {
type?: InputType;
name: string;
id?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
readonly?: boolean;
size?: InputSize;
error?: boolean;
fullWidth?: boolean;
leftIcon?: Child;
rightIcon?: Child;
autocomplete?: string;
min?: string | number;
max?: string | number;
step?: string | number;
pattern?: string;
class?: string;
}
const sizeClasses: Record<InputSize, string> = {
sm: 'h-8 px-3 py-1.5 text-sm',
md: 'h-9 px-3 py-2 text-sm',
lg: 'h-10 px-4 py-2.5 text-base',
};
function toInputId(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
export const Input: FC<InputProps> = ({
type = 'text',
name,
id,
value,
placeholder,
disabled = false,
required = false,
readonly = false,
size = 'sm',
error = false,
fullWidth = true,
leftIcon,
rightIcon,
autocomplete,
min,
max,
step,
pattern,
class: extraClass,
}) => {
const inputId = id ?? toInputId(name);
const baseClasses = [
'rounded-lg',
'border',
'border-neutral-300',
'bg-white',
'text-neutral-900',
'placeholder:text-neutral-400',
'transition-all',
'focus:outline-none',
'focus:ring-2',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-neutral-50',
];
const stateClasses = [
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'focus:border-brand-primary focus:ring-brand-primary/20',
fullWidth ? 'w-full' : '',
leftIcon ? 'pl-10' : '',
rightIcon ? 'pr-10' : '',
].filter(Boolean);
const classes = [...baseClasses, sizeClasses[size], ...stateClasses, extraClass || ''].filter(Boolean).join(' ');
if (leftIcon || rightIcon) {
return (
<div class={`relative ${fullWidth ? 'w-full' : 'inline-flex'}`}>
{leftIcon && (
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-neutral-400">
{leftIcon}
</div>
)}
<input
type={type}
name={name}
id={inputId}
value={value}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
autocomplete={autocomplete}
min={min}
max={max}
step={step}
pattern={pattern}
class={classes}
/>
{rightIcon && (
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400">
{rightIcon}
</div>
)}
</div>
);
}
return (
<input
type={type}
name={name}
id={inputId}
value={value}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
autocomplete={autocomplete}
min={min}
max={max}
step={step}
pattern={pattern}
class={classes}
/>
);
};

View File

@@ -0,0 +1,87 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type BoxSpacing = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type BoxBackground = 'white' | 'gray-50' | 'gray-100' | 'transparent';
export type BoxBorder = 'none' | 'gray-200' | 'gray-300';
export type BoxRounded = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
export interface BoxProps {
p?: BoxSpacing;
m?: BoxSpacing;
bg?: BoxBackground;
border?: BoxBorder;
rounded?: BoxRounded;
}
const backgroundClasses: Record<BoxBackground, string> = {
white: 'bg-white',
'gray-50': 'bg-gray-50',
'gray-100': 'bg-gray-100',
transparent: 'bg-transparent',
};
const borderClasses: Record<BoxBorder, string> = {
none: '',
'gray-200': 'border border-gray-200',
'gray-300': 'border border-gray-300',
};
const roundedClasses: Record<BoxRounded, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
full: 'rounded-full',
};
function getPaddingClass(p: BoxSpacing): string {
return `p-${p}`;
}
function getMarginClass(m: BoxSpacing): string {
return `m-${m}`;
}
export function Box({
p,
m,
bg = 'transparent',
border = 'none',
rounded = 'none',
children,
}: PropsWithChildren<BoxProps>) {
const classes = cn(
p !== undefined && getPaddingClass(p),
m !== undefined && getMarginClass(m),
backgroundClasses[bg],
borderClasses[border],
roundedClasses[rounded],
);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,39 @@
/*
* 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 {PageContainer} from '@fluxer/admin/src/components/ui/Layout/PageContainer';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface DetailPageLayoutProps {
header: Child;
tabs?: Child;
}
export function DetailPageLayout({header, tabs, children}: PropsWithChildren<DetailPageLayoutProps>) {
return (
<PageContainer>
{header}
{tabs}
{children}
</PageContainer>
);
}

View File

@@ -0,0 +1,94 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type FlexDirection = 'row' | 'col' | 'row-reverse' | 'col-reverse';
export type FlexAlign = 'start' | 'center' | 'end' | 'baseline' | 'stretch';
export type FlexJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
export type FlexWrap = 'wrap' | 'nowrap' | 'wrap-reverse';
export interface FlexProps {
direction?: FlexDirection;
align?: FlexAlign;
justify?: FlexJustify;
gap?: number | string;
wrap?: FlexWrap;
}
const directionClasses: Record<FlexDirection, string> = {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
};
const alignClasses: Record<FlexAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
const justifyClasses: Record<FlexJustify, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
const wrapClasses: Record<FlexWrap, string> = {
wrap: 'flex-wrap',
nowrap: 'flex-nowrap',
'wrap-reverse': 'flex-wrap-reverse',
};
function getGapClass(gap: number | string): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gap;
}
export function Flex({
direction = 'row',
align = 'stretch',
justify = 'start',
gap,
wrap = 'nowrap',
children,
}: PropsWithChildren<FlexProps>) {
const classes = cn(
'flex',
directionClasses[direction],
alignClasses[align],
justifyClasses[justify],
gap !== undefined && getGapClass(gap),
wrapClasses[wrap],
);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,44 @@
/*
* 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 type {PropsWithChildren} from 'hono/jsx';
export interface FormGridProps {
cols?: 2 | 3 | 4;
gap?: 'sm' | 'md' | 'lg';
}
const colsClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4',
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
export function FormGrid({cols = 2, gap = 'md', children}: PropsWithChildren<FormGridProps>) {
return <div class={`grid grid-cols-1 ${colsClasses[cols]} ${gapClasses[gap]}`}>{children}</div>;
}

View File

@@ -0,0 +1,70 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type HStackAlign = 'start' | 'center' | 'end' | 'baseline' | 'stretch';
export type HStackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
export interface HStackProps {
gap?: number | string;
align?: HStackAlign;
justify?: HStackJustify;
class?: string;
}
const alignClasses: Record<HStackAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
const justifyClasses: Record<HStackJustify, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
function getGapClass(gap: number | string): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gap;
}
export function HStack({
gap = 4,
align = 'center',
justify = 'start',
class: className,
children,
}: PropsWithChildren<HStackProps>) {
const classes = cn('flex flex-row', getGapClass(gap), alignClasses[align], justifyClasses[justify], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 type {PropsWithChildren} from 'hono/jsx';
export interface PageContainerProps {
maxWidth?: 'full' | '7xl';
}
export function PageContainer({maxWidth = '7xl', children}: PropsWithChildren<PageContainerProps>) {
const widthClass = maxWidth === 'full' ? 'w-full' : 'max-w-7xl';
return <div class={`mx-auto ${widthClass} space-y-6`}>{children}</div>;
}

View File

@@ -0,0 +1,45 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface PageHeaderProps {
title: string;
description?: string;
actions?: Child;
}
export function PageHeader({title, description, actions, children}: PropsWithChildren<PageHeaderProps>) {
return (
<div>
<div class={cn('flex items-start justify-between', description ? 'mb-2' : 'mb-0')}>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<h1 class="font-bold text-3xl text-gray-900">{title}</h1>
{description && <p class="text-base text-gray-600">{description}</p>}
</div>
{actions && <div class="ml-4 flex-shrink-0">{actions}</div>}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,50 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type PageLayoutMaxWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl';
export interface PageLayoutProps {
maxWidth?: PageLayoutMaxWidth;
padding?: boolean;
}
const maxWidthClasses: Record<PageLayoutMaxWidth, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
};
export function PageLayout({maxWidth = '7xl', padding = false, children}: PropsWithChildren<PageLayoutProps>) {
const classes = cn('mx-auto w-full', maxWidthClasses[maxWidth], padding && 'px-4 sm:px-6 lg:px-8');
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,48 @@
/*
* 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 {PageContainer} from '@fluxer/admin/src/components/ui/Layout/PageContainer';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface SearchListPageLayoutProps {
title: string;
description?: string;
actions?: Child;
searchForm: Child;
}
export function SearchListPageLayout({
title,
description,
actions,
searchForm,
children,
}: PropsWithChildren<SearchListPageLayoutProps>) {
return (
<PageContainer>
<PageHeader title={title} description={description} actions={actions} />
{searchForm}
{children}
</PageContainer>
);
}

View File

@@ -0,0 +1,37 @@
/*
* 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 type {PropsWithChildren} from 'hono/jsx';
export interface TwoColumnGridProps {
gap?: 'sm' | 'md' | 'lg';
}
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
export function TwoColumnGrid({gap = 'md', children}: PropsWithChildren<TwoColumnGridProps>) {
return <div class={`grid grid-cols-1 md:grid-cols-2 ${gapClasses[gap]}`}>{children}</div>;
}

View File

@@ -0,0 +1,52 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type VStackAlign = 'start' | 'center' | 'end' | 'stretch';
export interface VStackProps {
gap?: number | string;
align?: VStackAlign;
class?: string;
}
const alignClasses: Record<VStackAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
};
function getGapClass(gap: number | string): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gap;
}
export function VStack({gap = 4, align = 'stretch', class: className, children}: PropsWithChildren<VStackProps>) {
const classes = cn('flex flex-col', getGapClass(gap), alignClasses[align], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
interface MetadataRowProps {
label: string;
value: string | number | any;
class?: string;
}
export function MetadataRow({label, value, class: className}: MetadataRowProps) {
return (
<div class={cn('flex gap-2', className)}>
<span class="text-neutral-500 text-sm">{label}:</span>
<span class="text-neutral-900 text-sm">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,39 @@
/*
* 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 type {Child, FC} from 'hono/jsx';
interface NavLinkProps {
href: string;
children: Child;
class?: string;
}
export const NavLink: FC<NavLinkProps> = ({href, children, class: className = ''}) => {
const baseClass = `label rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50 ${className}`;
return (
<a href={href} class={baseClass}>
{children}
</a>
);
};

View File

@@ -0,0 +1,40 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface PillProps {
tone?: 'neutral' | 'success' | 'danger' | 'warning' | 'info';
}
export function Pill({tone = 'neutral', children}: PropsWithChildren<PillProps>) {
const classes = clsx('inline-block rounded-lg border px-3 py-2 font-medium text-sm shadow-sm', {
'border-gray-200 bg-neutral-100 text-neutral-700': tone === 'neutral',
'border-green-200 bg-green-50 text-green-700': tone === 'success',
'border-red-200 bg-red-50 text-red-700': tone === 'danger',
'border-neutral-200 bg-neutral-50 text-neutral-700': tone === 'warning',
'border-blue-200 bg-blue-50 text-blue-700': tone === 'info',
});
return <span class={classes}>{children}</span>;
}

View File

@@ -0,0 +1,52 @@
/*
* 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 type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Child, FC} from 'hono/jsx';
interface ResourceLinkProps {
config: Config;
resourceType: 'user' | 'guild';
resourceId: string;
children: Child;
size?: 'sm' | 'md';
class?: string;
}
export const ResourceLink: FC<ResourceLinkProps> = ({
config,
resourceType,
resourceId,
children,
size = 'sm',
class: className = '',
}) => {
const sizeClass = size === 'sm' ? 'text-sm' : '';
const baseClass = `text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 ${sizeClass} ${className}`;
const href = `${config.basePath}/${resourceType === 'user' ? 'users' : 'guilds'}/${resourceId}`;
return (
<a href={href} class={baseClass}>
{children}
</a>
);
};

View File

@@ -0,0 +1,109 @@
/*
* 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 type {FC} from 'hono/jsx';
export type SelectSize = 'sm' | 'md' | 'lg';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface SelectProps {
name: string;
id?: string;
value?: string;
options: Array<SelectOption>;
placeholder?: string;
disabled?: boolean;
required?: boolean;
size?: SelectSize;
error?: boolean;
fullWidth?: boolean;
class?: string;
}
const sizeClasses: Record<SelectSize, string> = {
sm: 'h-8 px-3 py-1.5 text-sm',
md: 'h-9 px-3 py-2 text-sm',
lg: 'h-10 px-4 py-2.5 text-base',
};
export const Select: FC<SelectProps> = ({
name,
id,
value,
options,
placeholder,
disabled = false,
required = false,
size = 'sm',
error = false,
fullWidth = true,
class: extraClass,
}) => {
const baseClasses = [
'rounded-lg',
'border',
'border-neutral-300',
'bg-white',
'text-neutral-900',
'transition-all',
'focus:outline-none',
'focus:border-brand-primary',
'focus:ring-2',
'focus:ring-brand-primary/20',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-neutral-50',
'appearance-none',
"bg-[url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E\")]",
'bg-[length:1.1em_1.1em]',
'bg-[position:right_0.75rem_center]',
'bg-no-repeat',
'pr-10',
];
const stateClasses = [
error ? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20' : '',
fullWidth ? 'w-full' : '',
].filter(Boolean);
const classes = [...baseClasses, sizeClasses[size], ...stateClasses, extraClass || ''].filter(Boolean).join(' ');
return (
<select name={name} id={id} disabled={disabled} required={required} class={classes}>
{placeholder && (
<option value="" disabled selected={!value}>
{placeholder}
</option>
)}
{options.map((option) => (
<option value={option.value} selected={option.value === value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
};

View File

@@ -0,0 +1,59 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type StackGap = 'sm' | 'md' | 'lg' | number;
export type StackAlign = 'start' | 'center' | 'end' | 'stretch';
export interface StackProps {
gap?: StackGap;
align?: StackAlign;
class?: string;
}
const gapClasses: Record<string, string> = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
const alignClasses: Record<StackAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
};
function getGapClass(gap: StackGap): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gapClasses[gap] ?? gapClasses.md;
}
export function Stack({gap = 'md', align = 'stretch', class: className, children}: PropsWithChildren<StackProps>) {
const classes = cn('flex flex-col', getGapClass(gap), alignClasses[align], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,58 @@
/*
* 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 type {BadgeProps} from '@fluxer/admin/src/components/ui/Badge';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
export interface StatusBadgeProps {
status: 'active' | 'inactive' | 'pending' | 'approved' | 'rejected' | 'banned';
size?: BadgeProps['size'];
}
const statusVariantMap: Record<StatusBadgeProps['status'], BadgeProps['variant']> = {
active: 'success',
inactive: 'neutral',
pending: 'warning',
approved: 'success',
rejected: 'danger',
banned: 'danger',
};
const statusLabelMap: Record<StatusBadgeProps['status'], string> = {
active: 'Active',
inactive: 'Inactive',
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
banned: 'Banned',
};
export function StatusBadge({status, size}: StatusBadgeProps) {
const variant = statusVariantMap[status];
const label = statusLabelMap[status];
return (
<Badge variant={variant} size={size}>
{label}
</Badge>
);
}

View File

@@ -0,0 +1,36 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableProps {
className?: string;
}
export function Table({children, className}: PropsWithChildren<TableProps>) {
return (
<table class={clsx('min-w-full border-collapse rounded-lg border border-neutral-200 bg-white', className)}>
{children}
</table>
);
}

View File

@@ -0,0 +1,27 @@
/*
* 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 type {PropsWithChildren} from 'hono/jsx';
export function TableBody({children}: PropsWithChildren) {
return <tbody class="divide-y divide-neutral-200">{children}</tbody>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableCellProps {
align?: 'left' | 'center' | 'right';
variant?: 'default' | 'header';
colSpan?: number;
}
export function TableCell({align = 'left', variant = 'default', colSpan, children}: PropsWithChildren<TableCellProps>) {
const isHeader = variant === 'header';
return (
<td
class={clsx(
'px-6 py-4 text-sm',
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
isHeader ? 'bg-neutral-50 font-bold text-neutral-900' : 'text-neutral-900',
)}
colspan={colSpan}
>
{children}
</td>
);
}

View File

@@ -0,0 +1,27 @@
/*
* 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 type {PropsWithChildren} from 'hono/jsx';
export function TableContainer({children}: PropsWithChildren) {
return <div class="overflow-x-auto">{children}</div>;
}

View File

@@ -0,0 +1,27 @@
/*
* 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 type {PropsWithChildren} from 'hono/jsx';
export function TableHeader({children}: PropsWithChildren) {
return <thead class="bg-neutral-50">{children}</thead>;
}

View File

@@ -0,0 +1,43 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableHeaderCellProps {
align?: 'left' | 'center' | 'right';
}
export function TableHeaderCell({align = 'left', children}: PropsWithChildren<TableHeaderCellProps>) {
return (
<th
class={clsx(
'whitespace-nowrap px-6 py-3 font-medium text-neutral-500 text-xs uppercase tracking-wider',
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
)}
>
{children}
</th>
);
}

View File

@@ -0,0 +1,49 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableRowProps {
hover?: boolean;
selected?: boolean;
clickable?: boolean;
}
export function TableRow({
hover = true,
selected = false,
clickable = false,
children,
}: PropsWithChildren<TableRowProps>) {
return (
<tr
class={clsx(
hover && 'transition-colors hover:bg-neutral-50',
selected && 'bg-blue-50',
clickable && 'cursor-pointer',
)}
>
{children}
</tr>
);
}

View File

@@ -0,0 +1,48 @@
/*
* 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 type {Child, FC} from 'hono/jsx';
interface TextLinkProps {
href: string;
children: Child;
external?: boolean;
mono?: boolean;
class?: string;
}
export const TextLink: FC<TextLinkProps> = ({
href,
children,
external = false,
mono = false,
class: className = '',
}) => {
const baseClass = `text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 ${mono ? 'font-mono' : ''} ${className}`;
const externalProps = external ? {target: '_blank', rel: 'noopener noreferrer'} : {};
return (
<a href={href} class={baseClass} {...externalProps}>
{children}
</a>
);
};

View File

@@ -0,0 +1,121 @@
/*
* 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 type {FC} from 'hono/jsx';
export type TextareaSize = 'sm' | 'md' | 'lg';
export type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both';
export interface TextareaProps {
name: string;
id?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
readonly?: boolean;
rows?: number;
size?: TextareaSize;
error?: boolean;
fullWidth?: boolean;
maxlength?: number;
minlength?: number;
resize?: TextareaResize;
class?: string;
}
const sizeClasses: Record<TextareaSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-3 py-2 text-sm',
lg: 'px-4 py-3 text-base',
};
const resizeClasses: Record<TextareaResize, string> = {
none: 'resize-none',
vertical: 'resize-y',
horizontal: 'resize-x',
both: 'resize',
};
function toTextareaId(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
export const Textarea: FC<TextareaProps> = ({
name,
id,
value,
placeholder,
disabled = false,
required = false,
readonly = false,
rows = 4,
size = 'sm',
error = false,
fullWidth = true,
maxlength,
minlength,
resize = 'vertical',
class: extraClass,
}) => {
const textareaId = id ?? toTextareaId(name);
const baseClasses = [
'rounded-lg',
'border',
'bg-white',
'text-neutral-900',
'placeholder:text-neutral-400',
'transition-all',
'focus:outline-none',
'focus:ring-2',
'focus:ring-brand-primary/20',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-neutral-50',
];
const stateClasses = [
error ? 'border-red-500 focus:border-red-500' : 'border-neutral-300 focus:border-brand-primary',
fullWidth ? 'w-full' : '',
].filter(Boolean);
const classes = [...baseClasses, sizeClasses[size], resizeClasses[resize], ...stateClasses, extraClass || '']
.filter(Boolean)
.join(' ');
return (
<textarea
name={name}
id={textareaId}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
rows={rows}
maxlength={maxlength}
minlength={minlength}
class={classes}
>
{value}
</textarea>
);
};

View File

@@ -0,0 +1,158 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface HeadingProps {
level: 1 | 2 | 3 | 4 | 5 | 6;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
class?: string;
}
const headingSizes: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {
1: 'text-3xl font-bold',
2: 'text-2xl font-semibold',
3: 'text-xl font-semibold',
4: 'text-lg font-semibold',
5: 'text-base font-semibold',
6: 'text-sm font-semibold',
};
const customSizes: Record<NonNullable<HeadingProps['size']>, string> = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
};
export function Heading(props: PropsWithChildren<HeadingProps>) {
const {level, size, children, class: className} = props;
const classes = cn('text-gray-900 tracking-tight', size ? customSizes[size] : headingSizes[level], className);
if (level === 1) return <h1 class={classes}>{children}</h1>;
if (level === 2) return <h2 class={classes}>{children}</h2>;
if (level === 3) return <h3 class={classes}>{children}</h3>;
if (level === 4) return <h4 class={classes}>{children}</h4>;
if (level === 5) return <h5 class={classes}>{children}</h5>;
return <h6 class={classes}>{children}</h6>;
}
export interface TextProps {
size?: 'xs' | 'sm' | 'base' | 'lg';
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
color?: 'default' | 'muted' | 'primary' | 'danger' | 'success';
class?: string;
}
const textSizes: Record<NonNullable<TextProps['size']>, string> = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
};
const textWeights: Record<NonNullable<TextProps['weight']>, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const textColors: Record<NonNullable<TextProps['color']>, string> = {
default: 'text-gray-900',
muted: 'text-neutral-500',
primary: 'text-brand-primary',
danger: 'text-red-600',
success: 'text-green-600',
};
export function Text(props: PropsWithChildren<TextProps>) {
const {size = 'base', weight = 'normal', color = 'default', children, class: className} = props;
const classes = cn(textSizes[size], textWeights[weight], textColors[color], className);
return <p class={classes}>{children}</p>;
}
export interface LabelProps {
htmlFor?: string;
required?: boolean;
class?: string;
}
export function Label(props: PropsWithChildren<LabelProps>) {
const {htmlFor, required = false, children, class: className} = props;
const classes = cn('block text-xs font-semibold uppercase tracking-wide text-neutral-500', className);
return (
<label for={htmlFor} class={classes}>
{children}
{required && <span class="ml-1 text-red-600">*</span>}
</label>
);
}
export interface CaptionProps {
variant?: 'default' | 'error' | 'success';
class?: string;
}
const captionVariants: Record<NonNullable<CaptionProps['variant']>, string> = {
default: 'text-gray-500',
error: 'text-red-600',
success: 'text-green-600',
};
export function Caption(props: PropsWithChildren<CaptionProps>) {
const {variant = 'default', children, class: className} = props;
const classes = cn('text-xs', captionVariants[variant], className);
return <p class={classes}>{children}</p>;
}
export interface SectionHeadingProps {
actions?: Child;
class?: string;
}
export function SectionHeading(props: PropsWithChildren<SectionHeadingProps>) {
const {actions, children, class: className} = props;
if (actions) {
return (
<div class={cn('mb-4 flex items-center justify-between', className)}>
<h2 class="font-semibold text-gray-900 text-xl">{children}</h2>
<div class="flex items-center gap-2">{actions}</div>
</div>
);
}
return <h2 class={cn('mb-4 font-semibold text-gray-900 text-xl', className)}>{children}</h2>;
}