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

20
packages/ui/src/HonoJsx.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
import 'hono/jsx';

View File

@@ -0,0 +1,42 @@
/*
* 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 {getAlertClasses} from '@fluxer/ui/src/utils/ColorVariants';
import type {FC, PropsWithChildren} from 'hono/jsx';
export type AlertVariant = 'error' | 'warning' | 'success' | 'info';
export interface AlertProps {
variant: AlertVariant;
title?: string;
}
export const Alert: FC<PropsWithChildren<AlertProps>> = ({variant, title, children}) => {
const classes = ['rounded-lg border px-4 py-3 text-sm', getAlertClasses(variant)].filter(Boolean).join(' ');
return (
<div class={classes}>
{title && <p class="mb-1 font-semibold">{title}</p>}
{children && <div>{children}</div>}
</div>
);
};

View File

@@ -0,0 +1,71 @@
/*
* 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 {LabelProps, TextProps} from '@fluxer/ui/src/types/Common';
import {type ColorIntensity, type ColorTone, getColorClasses} from '@fluxer/ui/src/utils/ColorVariants';
export type BadgeVariant = 'default' | 'info' | 'success' | 'warning' | 'danger';
export interface BadgeProps extends TextProps {
variant?: BadgeVariant;
intensity?: ColorIntensity;
}
export function Badge({text, variant = 'default', intensity = 'normal'}: BadgeProps) {
const toneMapping: Record<BadgeVariant, ColorTone> = {
default: 'neutral',
info: 'info',
success: 'success',
warning: 'warning',
danger: 'danger',
};
const tone = toneMapping[variant];
const colorClasses = getColorClasses(tone, intensity);
return (
<span class={`inline-flex items-center rounded-full px-2.5 py-0.5 font-medium text-xs ${colorClasses}`}>
{text}
</span>
);
}
export interface UnifiedBadgeProps extends LabelProps {
tone: ColorTone;
intensity?: ColorIntensity;
rounded?: 'full' | 'default';
}
export function UnifiedBadge({label, tone, intensity = 'normal', rounded = 'full'}: UnifiedBadgeProps) {
const colorClasses = getColorClasses(tone, intensity);
const roundedClass = rounded === 'full' ? 'rounded-full' : 'rounded';
return (
<span class={`inline-flex items-center ${roundedClass} px-2 py-1 font-medium text-xs ${colorClasses}`}>
{label}
</span>
);
}
export interface PillProps extends Omit<UnifiedBadgeProps, 'rounded'> {}
export function Pill({label, tone, intensity = 'normal'}: PillProps) {
return <UnifiedBadge label={label} tone={tone} intensity={intensity} rounded="full" />;
}

View File

@@ -0,0 +1,140 @@
/*
* 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 {createCompoundVariantClasses} from '@fluxer/ui/src/utils/VariantClasses';
import type {Child, FC, PropsWithChildren} from 'hono/jsx';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'info' | 'ghost' | 'brand';
export type ButtonSize = 'small' | 'medium' | 'large' | 'xl';
export type ButtonIconPosition = 'left' | 'right';
const {getVariant: getVariantClasses, getSize: getSizeClasses} = createCompoundVariantClasses(
{
primary: 'bg-neutral-900 text-white hover:bg-neutral-800',
secondary:
'bg-neutral-50 text-neutral-700 hover:text-neutral-900 border border-neutral-300 hover:border-neutral-400',
danger: 'bg-red-600 text-white hover:bg-red-700',
success: 'bg-blue-600 text-white hover:bg-blue-700',
info: 'bg-blue-50 text-blue-700 hover:bg-blue-100',
ghost: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100',
brand: 'bg-brand-primary text-white shadow-sm hover:bg-[color-mix(in_srgb,var(--brand-primary)_80%,black)]',
},
{
small: 'px-3 py-1.5 text-sm',
medium: 'px-4 py-2 text-base',
large: 'px-6 py-3 text-lg',
xl: 'px-8 py-4 text-xl',
},
'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2',
);
export interface ButtonProps {
type?: 'button' | 'submit' | 'reset';
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
name?: string;
value?: string;
disabled?: boolean;
loading?: boolean;
href?: string;
icon?: Child;
iconPosition?: ButtonIconPosition;
onclick?: string;
class?: string;
target?: '_blank' | '_self' | '_parent' | '_top';
rel?: string;
id?: string;
ariaLabel?: string;
}
export const Button: FC<PropsWithChildren<ButtonProps>> = ({
type = 'button',
variant = 'primary',
size = 'medium',
fullWidth = false,
name,
value,
disabled = false,
loading = false,
href,
icon,
iconPosition = 'left',
onclick,
class: extraClass,
target,
rel,
id,
ariaLabel,
children,
}) => {
const baseClasses = getVariantClasses(variant);
const sizeClasses = getSizeClasses(size);
const stateClasses = [
fullWidth ? 'w-full sm:w-fit' : 'w-fit',
disabled || loading ? 'opacity-50 cursor-not-allowed' : '',
loading ? 'pointer-events-none' : '',
variant === 'primary' || variant === 'danger' || variant === 'success' || variant === 'brand'
? 'focus:ring-offset-white'
: '',
].filter(Boolean);
const classes = [baseClasses, sizeClasses, ...stateClasses, extraClass || ''].filter(Boolean).join(' ');
const iconElement = icon ? <span class="flex-shrink-0">{icon}</span> : null;
const content = (
<>
{icon && iconPosition === 'left' && iconElement}
{loading && <span class="mr-2 inline-block animate-spin"></span>}
<span>{children}</span>
{icon && iconPosition === 'right' && iconElement}
</>
);
const commonProps = {
class: classes,
id,
'aria-label': ariaLabel,
};
if (href) {
return (
<a
{...commonProps}
href={href}
target={target}
rel={rel || (target === '_blank' ? 'noopener noreferrer' : undefined)}
role="button"
>
{content}
</a>
);
}
return (
<button {...commonProps} type={type} name={name} value={value} disabled={disabled || loading} onclick={onclick}>
{content}
</button>
);
};

View File

@@ -0,0 +1,151 @@
/*
* 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 {ChildrenProps, TextProps} from '@fluxer/ui/src/types/Common';
import type {PropsWithChildren} from 'hono/jsx';
export type CardPadding = 'none' | 'sm' | 'md' | 'lg' | 'xl';
export type CardVariant = 'default' | 'elevated' | 'empty' | 'marketing';
export type CardShadow = 'none' | 'sm' | 'md' | 'lg' | 'xl';
export interface CardProps {
variant?: CardVariant;
padding?: CardPadding;
hoverable?: boolean;
centerContent?: boolean;
heading?: string;
description?: string;
shadow?: CardShadow;
border?: boolean;
class?: string;
id?: string;
}
const cardPaddingClasses: Record<CardPadding, string> = {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
xl: 'p-12',
};
const cardShadowClasses: Record<CardShadow, string> = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
};
export function Card({
variant = 'default',
padding = 'md',
hoverable = false,
centerContent = false,
heading,
description,
shadow,
border = true,
class: extraClass,
id,
children,
}: PropsWithChildren<CardProps>) {
const baseClasses = ['rounded-lg', 'bg-white', 'transition-all'];
const variantClasses: Record<CardVariant, string> = {
default: border ? 'border border-neutral-200' : '',
elevated: border ? 'border border-neutral-200' : '',
empty: border ? 'border border-neutral-200' : '',
marketing: 'border-2 border-white/20 bg-white/5 backdrop-blur-sm',
};
const shadowValue = shadow !== undefined ? shadow : variant === 'elevated' ? 'sm' : 'none';
const shadowClass = cardShadowClasses[shadowValue];
const stateClasses = [
hoverable ? 'hover:shadow-lg hover:-translate-y-1 cursor-pointer' : '',
centerContent ? 'text-center' : '',
].filter(Boolean);
const classes = [
...baseClasses,
variantClasses[variant],
shadowClass,
cardPaddingClasses[padding],
...stateClasses,
extraClass || '',
]
.filter(Boolean)
.join(' ');
const content = (
<>
{heading && (
<div class="mb-4 space-y-1">
<h3 class="font-medium text-lg text-neutral-900">{heading}</h3>
{description && <p class="text-neutral-500 text-sm">{description}</p>}
</div>
)}
{children}
</>
);
return (
<div class={classes} id={id}>
{content}
</div>
);
}
export function CardElevated({padding = 'md', children, ...props}: PropsWithChildren<CardProps>) {
return (
<Card variant="elevated" padding={padding} {...props}>
{children}
</Card>
);
}
export function CardEmpty({children}: ChildrenProps) {
return (
<Card variant="empty" centerContent padding="xl">
{children}
</Card>
);
}
export interface HeadingCardProps extends Omit<CardProps, 'heading'>, TextProps {
description?: string;
}
export function HeadingCard({
text,
description,
padding = 'md',
children,
...props
}: PropsWithChildren<HeadingCardProps>) {
return (
<Card heading={text} description={description} padding={padding} {...props}>
{children}
</Card>
);
}

View File

@@ -0,0 +1,98 @@
/*
* 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 {Button} from '@fluxer/ui/src/components/Button';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import type {Child, FC} from 'hono/jsx';
export interface CheckboxFormProps {
id: string;
action?: string;
method?: 'post' | 'get';
saveButtonId?: string;
autoReveal?: boolean;
saveButtonLabel?: string;
children?: Child;
}
function getRevealScript(saveButtonId: string | undefined): string | undefined {
if (!saveButtonId) {
return undefined;
}
return `document.getElementById('${saveButtonId}')?.classList.remove('hidden');`;
}
export const CheckboxForm: FC<CheckboxFormProps> = ({
id,
action,
method = 'post',
saveButtonId,
autoReveal = false,
saveButtonLabel = 'Save Changes',
children,
}) => {
const actualSaveButtonId = saveButtonId ?? `${id}-save-button`;
return (
<form method={method} action={action} id={id}>
{children}
<div class={`mt-6 border-neutral-200 border-t pt-6 ${autoReveal ? '' : 'hidden'}`} id={actualSaveButtonId}>
<Button type="submit" variant="primary" size="medium">
{saveButtonLabel}
</Button>
</div>
</form>
);
};
export interface CheckboxItemProps {
name: string;
value: string;
label: string;
checked: boolean;
saveButtonId?: string;
}
export const CheckboxItem: FC<CheckboxItemProps> = ({name, value, label, checked, saveButtonId}) => {
const revealScript = getRevealScript(saveButtonId);
return <Checkbox name={name} value={value} label={label} checked={checked} onChange={revealScript} />;
};
export interface NativeCheckboxItemProps {
name: string;
value: string | number;
label: string;
checked: boolean;
saveButtonId?: string;
}
export const NativeCheckboxItem: FC<NativeCheckboxItemProps> = ({name, value, label, checked, saveButtonId}) => {
return (
<Checkbox
name={name}
value={String(value)}
label={label}
checked={checked}
onChange={getRevealScript(saveButtonId)}
/>
);
};

View File

@@ -0,0 +1,30 @@
/*
* 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 {CSRF_FORM_FIELD} from '@fluxer/constants/src/Cookies';
import type {FC} from 'hono/jsx';
export interface CsrfInputProps {
token: string;
}
export const CsrfInput: FC<CsrfInputProps> = ({token}) => <input type="hidden" name={CSRF_FORM_FIELD} value={token} />;

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 {CardEmpty} from '@fluxer/ui/src/components/Card';
import {TextMuted, TextSmallMuted} from '@fluxer/ui/src/components/Typography';
import type {Child, FC} from 'hono/jsx';
export interface EmptyStateProps {
title?: string;
message?: string;
icon?: Child;
action?: Child;
}
export const EmptyState: FC<EmptyStateProps> = ({title, message, icon, action}) => {
return (
<CardEmpty>
<div class="flex flex-col items-center gap-4">
{icon && <div class="text-neutral-400">{icon}</div>}
{title && <TextMuted text={title} />}
{message && <TextSmallMuted text={message} />}
{action && <div>{action}</div>}
</div>
</CardEmpty>
);
};

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 Flash, parseFlash} from '@fluxer/hono/src/Flash';
import {getAlertClasses} from '@fluxer/ui/src/utils/ColorVariants';
export interface FlashMessageProps {
flash: Flash;
}
export function FlashMessage({flash}: FlashMessageProps) {
return (
<div class={`rounded-lg border px-4 py-3 text-sm ${getAlertClasses(flash.type)}`}>
<div>{flash.message}</div>
{flash.detail && (
<div class="mt-2 break-all rounded border border-current/20 bg-white/60 px-3 py-2 font-mono text-xs">
{flash.detail}
</div>
)}
</div>
);
}
export function parseFlashFromCookie(cookieHeader: string | null): Flash | undefined {
if (cookieHeader === null) {
return undefined;
}
return parseFlash(cookieHeader) ?? undefined;
}

View File

@@ -0,0 +1,236 @@
/*
* 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 {
FORM_CONTROL_INPUT_CLASS,
FORM_CONTROL_SELECT_CLASS,
FORM_CONTROL_TEXTAREA_CLASS,
FORM_FIELD_CLASS,
FORM_HELPER_CLASS,
FORM_LABEL_CLASS,
FORM_SELECT_ICON_CLASS,
} from '@fluxer/ui/src/styles/FormControls';
import type {BaseFormProps, SelectOption} from '@fluxer/ui/src/types/Common';
import {cn} from '@fluxer/ui/src/utils/ClassNames';
export type InputType = 'text' | 'email' | 'password' | 'tel' | 'number' | 'date' | 'url';
function toInputId(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function toHelperId(id: string, helper: string | undefined): string | undefined {
if (!helper) {
return undefined;
}
return `${id}-helper`;
}
export interface InputProps extends BaseFormProps {
type?: InputType;
value?: string | undefined;
autocomplete?: string;
step?: string;
min?: string | number;
max?: string | number;
readonly?: boolean;
}
export function Input({
label,
helper,
name,
id,
type = 'text',
value,
required,
placeholder,
disabled,
autocomplete,
step,
min,
max,
readonly,
}: InputProps) {
const inputId = id ?? toInputId(name);
const helperId = toHelperId(inputId, helper);
return (
<div class={FORM_FIELD_CLASS}>
{label && (
<label for={inputId} class={FORM_LABEL_CLASS}>
{label}
</label>
)}
<input
id={inputId}
type={type}
name={name}
value={value}
required={required}
placeholder={placeholder}
disabled={disabled}
readonly={readonly}
autocomplete={autocomplete}
step={step}
min={min}
max={max}
aria-describedby={helperId}
class={FORM_CONTROL_INPUT_CLASS}
/>
{helper && (
<p id={helperId} class={FORM_HELPER_CLASS}>
{helper}
</p>
)}
</div>
);
}
export interface TextareaProps extends BaseFormProps {
value?: string;
rows?: number;
}
export function Textarea({label, helper, name, id, value, required, placeholder, disabled, rows = 4}: TextareaProps) {
const textareaId = id ?? toInputId(name);
const helperId = toHelperId(textareaId, helper);
return (
<div class={FORM_FIELD_CLASS}>
{label && (
<label for={textareaId} class={FORM_LABEL_CLASS}>
{label}
</label>
)}
<textarea
id={textareaId}
name={name}
rows={rows}
required={required}
placeholder={placeholder}
disabled={disabled}
aria-describedby={helperId}
class={FORM_CONTROL_TEXTAREA_CLASS}
>
{value}
</textarea>
{helper && (
<p id={helperId} class={FORM_HELPER_CLASS}>
{helper}
</p>
)}
</div>
);
}
export interface SelectProps extends BaseFormProps {
value?: string;
options: Array<SelectOption>;
}
export function Select({label, helper, name, id, value, required, disabled, options}: SelectProps) {
const selectId = id ?? toInputId(name);
const helperId = toHelperId(selectId, helper);
return (
<div class={FORM_FIELD_CLASS}>
{label && (
<label for={selectId} class={FORM_LABEL_CLASS}>
{label}
</label>
)}
<div class="relative">
<select
id={selectId}
name={name}
required={required}
disabled={disabled}
aria-describedby={helperId}
class={FORM_CONTROL_SELECT_CLASS}
>
{options.map((option) => (
<option key={option.value} value={option.value} selected={option.value === value}>
{option.label}
</option>
))}
</select>
<svg class={FORM_SELECT_ICON_CLASS} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="m6 8 4 4 4-4"
/>
</svg>
</div>
{helper && (
<p id={helperId} class={FORM_HELPER_CLASS}>
{helper}
</p>
)}
</div>
);
}
export interface CheckboxProps {
name: string;
value: string;
label: string;
checked?: boolean;
onChange?: string;
}
export function Checkbox({name, value, label, checked, onChange}: CheckboxProps) {
return (
<label class="group flex w-full cursor-pointer items-center gap-2">
<input
type="checkbox"
name={name}
value={value}
checked={checked}
class="hidden"
{...(onChange ? {onchange: onChange} : {})}
/>
<div class="checkbox-custom flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
class="h-[18px] w-[18px]"
style="stroke-width: 32;"
>
<polyline
points="40 144 96 200 224 72"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<span class={cn('block text-neutral-900 text-sm', 'leading-5')}>{label}</span>
</div>
</label>
);
}

View File

@@ -0,0 +1,72 @@
/*
* 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, ReactNode} from 'hono/jsx';
export interface FormFieldGroupProps {
label: string;
helperText?: string;
error?: string;
required?: boolean;
children: ReactNode;
id?: string;
}
export function FormFieldGroup({
label,
helperText,
error,
required = false,
children,
id,
}: PropsWithChildren<FormFieldGroupProps>) {
const fieldId = id || label.toLowerCase().replace(/\s+/g, '-');
const helperId = `${fieldId}-helper`;
const errorId = `${fieldId}-error`;
const labelClass = error ? 'text-red-700 font-medium' : 'text-neutral-700 font-medium';
const helperClass = error ? 'text-red-600' : 'text-neutral-500';
return (
<div class="space-y-1.5">
<label for={fieldId} class={`block text-sm ${labelClass}`}>
{label}
{required && <span class="ml-1 text-red-600">*</span>}
</label>
{children}
{helperText && !error && (
<p id={helperId} class={`text-xs ${helperClass}`}>
{helperText}
</p>
)}
{error && (
<p id={errorId} class="font-medium text-red-600 text-xs">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
/*
* 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, ReactNode} from 'hono/jsx';
export interface FormModalProps {
id: string;
title: string;
action: string;
method?: 'post' | 'get';
children: ReactNode;
submitText?: string;
cancelText?: string;
size?: 'small' | 'medium' | 'large';
footer?: ReactNode;
}
const sizeClasses = {
small: 'max-w-md',
medium: 'max-w-lg',
large: 'max-w-2xl',
};
export function FormModal({
id,
title,
action,
method = 'post',
children,
submitText = 'Submit',
cancelText = 'Cancel',
size = 'medium',
footer,
}: PropsWithChildren<FormModalProps>) {
const modalClass = sizeClasses[size];
const closeScript = `document.getElementById('${id}').classList.add('hidden')`;
return (
<div id={id} class="fixed inset-0 z-50 hidden overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick={closeScript} aria-hidden="true" />
<div class={`relative rounded-lg bg-white shadow-xl ${modalClass} w-full`}>
<div class="flex items-center justify-between border-neutral-200 border-b p-4">
<h2 class="font-semibold text-lg text-neutral-900">{title}</h2>
<button
type="button"
class="p-1 text-neutral-400 transition-colors hover:text-neutral-600"
onclick={closeScript}
aria-label="Close"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form action={action} method={method}>
<div class="p-4">{children}</div>
<div class="flex items-center justify-end gap-3 rounded-b-lg border-neutral-200 border-t bg-neutral-50 p-4">
{footer || (
<>
<button
type="button"
class="rounded border border-neutral-300 bg-white px-4 py-2 font-medium text-neutral-700 text-sm transition-colors hover:bg-neutral-50"
onclick={closeScript}
>
{cancelText}
</button>
<button
type="submit"
class="rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800"
>
{submitText}
</button>
</>
)}
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
/*
* 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, ReactNode} from 'hono/jsx';
export type FormSectionPadding = 'none' | 'small' | 'medium' | 'large';
export interface FormSectionProps {
title?: string;
bordered?: boolean;
padding?: FormSectionPadding;
children: ReactNode;
class?: string;
}
const paddingClasses: Record<FormSectionPadding, string> = {
none: '',
small: 'p-3',
medium: 'p-4',
large: 'p-6',
};
export function FormSection({
title,
bordered = false,
padding = 'medium',
children,
class: className,
}: PropsWithChildren<FormSectionProps>) {
const containerClass = bordered
? `border border-neutral-200 rounded-lg bg-white ${paddingClasses[padding]}`
: paddingClasses[padding];
const content = <div class={`space-y-4 ${containerClass} ${className ?? ''}`}>{children}</div>;
if (title) {
return (
<div class={className ?? ''}>
<h3 class="mb-3 font-semibold text-lg text-neutral-900">{title}</h3>
{bordered ? content : <div class={containerClass}>{children}</div>}
</div>
);
}
return content;
}

View File

@@ -0,0 +1,65 @@
/*
* 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, ReactNode} from 'hono/jsx';
export type InputGroupGap = 'small' | 'medium' | 'large';
export interface InputGroupProps {
children: ReactNode;
gap?: InputGroupGap;
direction?: 'vertical' | 'horizontal';
align?: 'start' | 'center' | 'end' | 'stretch';
class?: string;
}
const gapClasses: Record<InputGroupGap, string> = {
small: 'gap-3',
medium: 'gap-4',
large: 'gap-6',
};
const directionClasses: Record<'vertical' | 'horizontal', string> = {
vertical: 'flex-col',
horizontal: 'flex-row',
};
const alignClasses: Record<'start' | 'center' | 'end' | 'stretch', string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
};
export function InputGroup({
children,
gap = 'medium',
direction = 'vertical',
align = 'stretch',
class: className,
}: PropsWithChildren<InputGroupProps>) {
return (
<div class={`flex ${directionClasses[direction]} ${gapClasses[gap]} ${alignClasses[align]} ${className ?? ''}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,57 @@
/*
* 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 {ChildrenProps, GapProps, GridProps, InfoItemProps} from '@fluxer/ui/src/types/Common';
import type {PropsWithChildren} from 'hono/jsx';
export interface FlexRowProps extends GapProps {}
export interface StackProps extends GapProps {}
export function FlexRow({gap = '3', children}: PropsWithChildren<FlexRowProps>) {
return <div class={`flex items-center gap-${gap}`}>{children}</div>;
}
export function FlexRowBetween({children}: ChildrenProps) {
return <div class="flex flex-wrap items-center justify-between gap-3">{children}</div>;
}
export function Stack({gap = '4', children}: PropsWithChildren<StackProps>) {
return <div class={`space-y-${gap}`}>{children}</div>;
}
export function Grid({cols = '2', gap = '4', children}: PropsWithChildren<GridProps>) {
return <div class={`grid grid-cols-${cols} gap-${gap}`}>{children}</div>;
}
export function InfoItem({label, value}: InfoItemProps) {
return (
<div>
<div class="mb-1 font-medium text-neutral-600 text-sm">{label}</div>
<div class="text-neutral-900 text-sm">{value ?? '-'}</div>
</div>
);
}
export function InfoGrid({children}: ChildrenProps) {
return <div class="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3">{children}</div>;
}

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 {Card} from '@fluxer/ui/src/components/Card';
import type {FC} from 'hono/jsx';
interface BackButtonProps {
href: string;
label: string;
}
export function BackButton({href, label}: BackButtonProps) {
return (
<a
href={href}
class="inline-flex items-center gap-2 text-neutral-900 text-sm underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500"
>
&larr; {label}
</a>
);
}
export const NotFoundView: FC<{resourceName: string; backUrl: string; backLabel: string}> = ({
resourceName,
backUrl,
backLabel,
}) => (
<div class="mx-auto max-w-2xl">
<Card padding="lg">
<div class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-neutral-100">
<span class="font-semibold text-2xl text-neutral-400">?</span>
</div>
<h2 class="font-semibold text-lg text-neutral-900">{resourceName} Not Found</h2>
<p class="text-neutral-600">
The {resourceName} you're looking for doesn't exist or you don't have permission to view it.
</p>
<div class="pt-4">
<BackButton href={backUrl} label={backLabel} />
</div>
</div>
</Card>
</div>
);

View File

@@ -0,0 +1,89 @@
/*
* 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 interface PaginationProps {
currentPage: number;
totalPages: number;
basePath?: string;
showPageNumbers?: boolean;
previousLabel?: string;
nextLabel?: string;
pageInfo?: string;
buildUrlFn?: (page: number) => string;
}
export function Pagination({
currentPage,
totalPages,
basePath = '',
showPageNumbers = true,
previousLabel = '← Previous',
nextLabel = 'Next →',
pageInfo,
buildUrlFn,
}: PaginationProps) {
const hasPrevious = currentPage > 0;
const hasNext = currentPage < totalPages - 1;
const getPageUrl = (page: number) => {
if (buildUrlFn) {
return `${basePath}${buildUrlFn(page)}`;
}
return `${basePath}?page=${page}`;
};
const previousButton = hasPrevious ? (
<a
href={getPageUrl(currentPage - 1)}
class="rounded-lg border border-neutral-300 bg-white px-6 py-2 font-medium text-neutral-900 text-sm no-underline transition-colors hover:bg-neutral-50"
>
{previousLabel}
</a>
) : (
<div class="cursor-not-allowed rounded-lg border border-neutral-200 bg-neutral-100 px-6 py-2 font-medium text-neutral-400 text-sm">
{previousLabel}
</div>
);
const nextButton = hasNext ? (
<a
href={getPageUrl(currentPage + 1)}
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm text-white no-underline transition-colors hover:bg-neutral-800"
>
{nextLabel}
</a>
) : (
<div class="cursor-not-allowed rounded-lg bg-neutral-100 px-6 py-2 font-medium text-neutral-400 text-sm">
{nextLabel}
</div>
);
const pageIndicator = pageInfo ?? `Page ${currentPage + 1} of ${totalPages}`;
return (
<div class="mt-6 flex items-center justify-center gap-3">
{previousButton}
{showPageNumbers && <span class="text-neutral-600 text-sm">{pageIndicator}</span>}
{nextButton}
</div>
);
}

View File

@@ -0,0 +1,108 @@
/*
* 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 type RadioGroupOrientation = 'vertical' | 'horizontal';
export interface RadioOption {
value: string;
label: string;
disabled?: boolean;
}
export interface RadioGroupProps {
name: string;
label?: string;
value: string;
options: Array<RadioOption>;
orientation?: RadioGroupOrientation;
disabled?: boolean;
helperText?: string;
error?: string;
onChangeScript?: string;
}
const orientationClasses: Record<RadioGroupOrientation, string> = {
vertical: 'flex-col gap-3',
horizontal: 'flex-row gap-6',
};
export function RadioGroup({
name,
label,
value,
options,
orientation = 'vertical',
disabled = false,
helperText,
error,
onChangeScript,
}: RadioGroupProps) {
const groupId = `${name}-group`;
return (
<div class="space-y-1.5">
{label && <span class="block font-medium text-neutral-700 text-sm">{label}</span>}
<div id={groupId} class={`flex ${orientationClasses[orientation]}`}>
{options.map((option) => {
const optionId = `${name}-${option.value}`;
const isOptionDisabled = disabled || option.disabled;
return (
<label
key={option.value}
for={optionId}
class={`flex cursor-pointer items-center gap-2 ${
isOptionDisabled ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<div class="relative">
<input
type="radio"
id={optionId}
name={name}
value={option.value}
checked={value === option.value}
disabled={isOptionDisabled}
class="sr-only"
onchange={onChangeScript}
/>
<div
class={`h-4 w-4 rounded-full border-2 transition-colors ${
value === option.value ? 'border-neutral-900' : 'border-neutral-300'
}`}
>
{value === option.value && <div class="mt-0.5 ml-0.5 h-2 w-2 rounded-full bg-neutral-900" />}
</div>
</div>
<span class="text-neutral-700 text-sm">{option.label}</span>
</label>
);
})}
</div>
{helperText && !error && <p class="text-neutral-500 text-xs">{helperText}</p>}
{error && <p class="font-medium text-red-600 text-xs">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,179 @@
/*
* 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 {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {
FORM_CONTROL_INPUT_CLASS,
FORM_CONTROL_SELECT_CLASS,
FORM_SELECT_ICON_CLASS,
} from '@fluxer/ui/src/styles/FormControls';
import {cn} from '@fluxer/ui/src/utils/ClassNames';
import type {FC} from 'hono/jsx';
export type SearchFieldType = 'text' | 'select' | 'number';
export interface SearchFieldOption {
value: string;
label: string;
}
export interface SearchField {
name: string;
type: SearchFieldType;
label?: string;
placeholder?: string;
value?: string | number | undefined;
options?: Array<SearchFieldOption>;
autocomplete?: string;
}
export interface SearchFormProps {
action: string;
method?: 'get' | 'post';
fields: Array<SearchField>;
submitLabel?: string;
showClear?: boolean;
clearHref?: string;
clearLabel?: string;
helperText?: string;
layout?: 'vertical' | 'horizontal';
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
basePath?: string;
}
function withBasePath(basePath: string, path: string): string {
return `${basePath}${path}`;
}
function getSearchInputClass(): string {
return cn(FORM_CONTROL_INPUT_CLASS, 'h-10');
}
function getSearchSelectClass(): string {
return cn(FORM_CONTROL_SELECT_CLASS, 'h-10');
}
export const SearchForm: FC<SearchFormProps> = ({
action,
method = 'get',
fields,
submitLabel = 'Search',
showClear = true,
clearHref,
clearLabel = 'Clear',
helperText,
layout = 'vertical',
padding = 'sm',
basePath = '',
}) => {
const isHorizontal = layout === 'horizontal';
const actionUrl = withBasePath(basePath, action);
const formClass = isHorizontal ? 'flex flex-col gap-3 sm:flex-row sm:items-center' : 'space-y-4';
const fieldGroupClass = isHorizontal ? 'flex flex-1 flex-col gap-2 sm:flex-row' : 'space-y-4';
const actionGroupClass = isHorizontal ? 'flex flex-col gap-2 sm:shrink-0 sm:flex-row' : 'flex flex-wrap gap-2';
const clearUrl = clearHref ? withBasePath(basePath, clearHref) : undefined;
return (
<Card padding={padding}>
<form method={method} action={actionUrl} class={formClass}>
<div class={fieldGroupClass}>
{fields.map((field) => (
<SearchFieldInput key={field.name} field={field} layout={layout} />
))}
</div>
<div class={actionGroupClass}>
<Button type="submit" variant="primary" fullWidth={isHorizontal}>
{submitLabel}
</Button>
{showClear && clearUrl && (
<Button type="button" href={clearUrl} variant="secondary" fullWidth={isHorizontal} ariaLabel={clearLabel}>
{clearLabel}
</Button>
)}
</div>
{helperText && <p class={cn('text-neutral-500 text-xs', isHorizontal && 'sm:pt-1')}>{helperText}</p>}
</form>
</Card>
);
};
interface SearchFieldInputProps {
field: SearchField;
layout: 'vertical' | 'horizontal';
}
function SearchFieldInput({field, layout}: SearchFieldInputProps) {
const controlId = `search-${field.name}`;
const isVertical = layout === 'vertical';
const containerClass = isVertical ? 'w-full' : 'flex-1';
const labelClass = 'mb-2 block font-medium text-neutral-700 text-sm';
if (field.type === 'select') {
return (
<div class={containerClass}>
{isVertical && field.label && (
<label for={controlId} class={labelClass}>
{field.label}
</label>
)}
<div class="relative">
<select id={controlId} name={field.name} class={getSearchSelectClass()} autocomplete={field.autocomplete}>
{field.options?.map((option) => (
<option key={option.value} value={option.value} selected={String(field.value) === option.value}>
{option.label}
</option>
))}
</select>
<svg class={FORM_SELECT_ICON_CLASS} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="m6 8 4 4 4-4"
/>
</svg>
</div>
</div>
);
}
return (
<div class={containerClass}>
{isVertical && field.label && (
<label for={controlId} class={labelClass}>
{field.label}
</label>
)}
<input
id={controlId}
type={field.type}
name={field.name}
value={field.value ?? ''}
placeholder={field.placeholder}
class={getSearchInputClass()}
autocomplete={field.autocomplete}
/>
</div>
);
}

View File

@@ -0,0 +1,102 @@
/*
* 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 interface SliderInputProps {
id: string;
name: string;
label: string;
min: number;
max: number;
value: number;
step?: number;
rangeText?: string;
displayId?: string;
disabled?: boolean;
}
function getSliderPercent(value: number, min: number, max: number): number {
const range = max - min;
if (range <= 0) {
return 0;
}
return ((value - min) / range) * 100;
}
export const SliderInput: FC<SliderInputProps> = ({
id,
name,
label,
min,
max,
value: initialValue,
step = 1,
rangeText,
displayId,
disabled = false,
}) => {
const actualDisplayId = displayId ?? `${id}-value`;
const rangeTextId = rangeText ? `${id}-range-text` : undefined;
const sliderPercent = getSliderPercent(initialValue, min, max);
const sliderStyle = `--slider-percent: ${sliderPercent}%;`;
const onInputScript = `const value=Number(this.value);const min=${min};const max=${max};const percent=max>min?((value-min)/(max-min))*100:0;this.style.setProperty('--slider-percent', percent + '%');const output=document.getElementById('${actualDisplayId}');if(output){output.textContent=String(value);}`;
return (
<>
<div class="flex items-center justify-between">
<label for={id} class="font-medium text-neutral-800 text-sm">
{label}
</label>
{rangeText && (
<span id={rangeTextId} class="text-neutral-500 text-xs">
{rangeText}
</span>
)}
</div>
<div class="flex items-center gap-4">
<input
type="range"
id={id}
name={name}
min={min}
max={max}
step={step}
value={initialValue}
disabled={disabled}
aria-describedby={rangeTextId}
oninput={onInputScript}
style={sliderStyle}
class="slider-input w-full flex-1 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
/>
<output
id={actualDisplayId}
for={id}
aria-live="polite"
class={`w-12 text-right font-medium text-sm tabular-nums ${disabled ? 'text-neutral-400' : 'text-neutral-900'}`}
>
{initialValue}
</output>
</div>
</>
);
};

View File

@@ -0,0 +1,62 @@
/*
* 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 {ChildrenProps, LabelProps, MutedProps} from '@fluxer/ui/src/types/Common';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableHeaderCellProps extends LabelProps {}
export interface TableCellProps extends MutedProps {
colSpan?: number;
}
export function TableContainer({children}: ChildrenProps) {
return <div class="overflow-hidden overflow-x-auto rounded-lg border border-neutral-200 bg-white">{children}</div>;
}
export function Table({children}: ChildrenProps) {
return <table class="min-w-full divide-y divide-neutral-200">{children}</table>;
}
export function TableHead({children}: ChildrenProps) {
return <thead class="bg-neutral-50">{children}</thead>;
}
export function TableBody({children}: ChildrenProps) {
return <tbody class="divide-y divide-neutral-200 bg-white">{children}</tbody>;
}
export function TableRow({children}: ChildrenProps) {
return <tr class="transition-colors hover:bg-neutral-50">{children}</tr>;
}
export function TableHeaderCell({label}: TableHeaderCellProps) {
return <th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider">{label}</th>;
}
export function TableCell({muted, colSpan, children}: PropsWithChildren<TableCellProps>) {
return (
<td class={`px-6 py-4 text-sm ${muted ? 'text-neutral-600' : 'text-neutral-900'}`} colspan={colSpan}>
{children}
</td>
);
}

View File

@@ -0,0 +1,98 @@
/*
* 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 type ToggleSwitchSize = 'small' | 'medium' | 'large';
export interface ToggleSwitchProps {
name: string;
label?: string;
checked: boolean;
disabled?: boolean;
size?: ToggleSwitchSize;
helperText?: string;
id?: string;
onChangeScript?: string;
}
const sizeClasses: Record<ToggleSwitchSize, {track: string; thumb: string}> = {
small: {
track: 'w-8 h-5',
thumb: 'w-3 h-3 translate-x-3',
},
medium: {
track: 'w-11 h-6',
thumb: 'w-4 h-4 translate-x-5',
},
large: {
track: 'w-14 h-7',
thumb: 'w-5 h-5 translate-x-7',
},
};
export function ToggleSwitch({
name,
label,
checked,
disabled = false,
size = 'medium',
helperText,
id,
onChangeScript,
}: ToggleSwitchProps) {
const switchId = id || name;
const {track, thumb} = sizeClasses[size];
const trackClass = checked ? `bg-neutral-900` : `bg-neutral-300`;
const thumbPosition = checked ? thumb : 'translate-x-0.5';
return (
<div class="space-y-1">
<label
for={switchId}
class={`flex items-center gap-3 ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<div class="relative">
<input
type="checkbox"
id={switchId}
name={name}
checked={checked}
disabled={disabled}
class="sr-only"
onchange={onChangeScript}
/>
<div class={`${track} ${trackClass} rounded-full transition-colors duration-200 ease-in-out`}>
<div
class={`${thumb} ${thumbPosition} absolute top-0.5 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out`}
/>
</div>
</div>
{label && <span class="font-medium text-neutral-700 text-sm">{label}</span>}
</label>
{helperText && <p class="pl-14 text-neutral-500 text-xs">{helperText}</p>}
</div>
);
}

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 */
interface TextProps {
text: string;
}
export function HeadingPage({text}: TextProps) {
return <h1 class="font-bold text-lg text-neutral-900">{text}</h1>;
}
export function HeadingSection({text}: TextProps) {
return <h2 class="font-bold text-base text-neutral-900">{text}</h2>;
}
export function HeadingCard({text}: TextProps) {
return <h3 class="font-semibold text-base text-neutral-900">{text}</h3>;
}
export function HeadingCardWithMargin({text}: TextProps) {
return (
<div class="mb-4">
<HeadingCard text={text} />
</div>
);
}
export function TextMuted({text}: TextProps) {
return <p class="text-neutral-600 text-sm">{text}</p>;
}
export function TextSmallMuted({text}: TextProps) {
return <p class="text-neutral-500 text-xs">{text}</p>;
}

View File

@@ -0,0 +1,202 @@
/*
* 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 {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
import type {FC} from 'hono/jsx';
export interface ErrorPageProps {
statusCode: number;
title: string;
description: string;
locale?: string;
staticCdnEndpoint?: string;
homeUrl?: string;
homeLabel?: string;
helpUrl?: string;
helpLabel?: string;
showLogo?: boolean;
}
const inlineStyles = `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(180deg, #4641D9 0%, #3832B8 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1.5rem;
}
.error-container {
text-align: center;
max-width: 32rem;
}
.logo {
height: 4rem;
opacity: 0.8;
margin-bottom: 2rem;
}
.status-code {
font-family: 'Bricolage Grotesque', 'IBM Plex Sans', sans-serif;
font-size: 6rem;
font-weight: 700;
line-height: 1;
margin-bottom: 1.5rem;
opacity: 0.9;
}
.title {
font-family: 'Bricolage Grotesque', 'IBM Plex Sans', sans-serif;
font-size: 1.875rem;
font-weight: 600;
margin-bottom: 1rem;
}
.description {
font-size: 1.125rem;
opacity: 0.8;
margin-bottom: 2rem;
line-height: 1.6;
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
transition: opacity 0.15s ease;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #fff;
color: #4641D9;
border: 1px solid #fff;
}
.btn-secondary {
background: transparent;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
@media (max-width: 640px) {
.status-code {
font-size: 4rem;
}
.title {
font-size: 1.5rem;
}
.description {
font-size: 1rem;
}
}
`;
const FluxerLogoWordmark: FC = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 1959 512" class="logo">
<path
fill="currentColor"
d="M585 431.4V93.48h82.944V431.4H585Zm41.472-120.32v-67.584h172.032v67.584H626.472Zm0-148.48V93.48h189.44v69.12h-189.44ZM843.951 431.4V73h82.944v358.4h-82.944ZM1047.92 438.568c-29.35 0-51.2-10.24-65.536-30.72-13.995-20.48-20.992-51.883-20.992-94.208V161.064h82.948v151.552c0 19.797 2.73 33.621 8.19 41.472 5.8 7.851 13.99 11.776 24.57 11.776 7.51 0 14.34-1.877 20.48-5.632 6.49-4.096 12.12-10.069 16.9-17.92 4.78-8.192 8.36-18.603 10.75-31.232 2.73-12.629 4.1-27.819 4.1-45.568V161.064h82.94V431.4h-70.65V325.928h-3.59c-2.05 26.283-6.65 47.787-13.82 64.512-7.17 16.384-17.07 28.501-29.7 36.352-12.63 7.851-28.16 11.776-46.59 11.776ZM1232.57 431.4l84.99-135.68-83.97-134.656h96.26l39.42 87.552h2.56l38.4-87.552h95.23l-81.4 134.656 82.43 135.68h-97.79l-37.38-86.016h-2.05L1327.8 431.4h-95.23Z"
/>
<path
fill="currentColor"
d="M1630.96 438.568c-25.6 0-47.1-3.584-64.51-10.752-17.06-7.509-30.89-17.579-41.47-30.208-10.58-12.971-18.26-27.648-23.04-44.032-4.44-16.384-6.66-33.621-6.66-51.712 0-19.456 2.39-38.059 7.17-55.808 5.12-17.749 12.8-33.451 23.04-47.104 10.58-13.995 24.07-24.917 40.45-32.768 16.73-8.192 36.69-12.288 59.9-12.288s43.01 4.096 59.4 12.288c16.72 7.851 30.03 18.944 39.93 33.28 9.9 14.336 16.22 30.891 18.95 49.664 3.07 18.773 2.73 39.083-1.03 60.928L1547 313.128v-45.056l132.6-2.56-10.75 26.112c2.05-15.701 1.71-28.843-1.02-39.424-2.39-10.923-7-19.115-13.83-24.576-6.82-5.803-16.21-8.704-28.16-8.704-12.63 0-22.69 3.243-30.2 9.728-7.51 6.485-12.8 15.701-15.88 27.648-3.07 11.605-4.6 25.429-4.6 41.472 0 27.648 4.6 47.787 13.82 60.416 9.22 12.629 23.38 18.944 42.5 18.944 8.19 0 15.01-1.024 20.48-3.072 5.46-2.048 9.89-4.949 13.31-8.704 3.41-4.096 5.8-8.875 7.17-14.336 1.36-5.803 1.87-12.288 1.53-19.456l75.78 4.096c1.02 11.264-.17 22.869-3.59 34.816-3.07 11.947-9.04 23.04-17.92 33.28-8.87 10.24-21.33 18.603-37.37 25.088-15.7 6.485-35.67 9.728-59.91 9.728ZM1778.45 431.4V161.064h71.68v107.52h4.1c2.05-28.672 5.97-51.2 11.77-67.584 6.15-16.725 13.66-28.501 22.53-35.328 9.22-7.168 19.46-10.752 30.72-10.752 6.15 0 12.46.853 18.95 2.56 6.82 1.707 13.48 4.437 19.96 8.192l-4.09 92.16c-7.51-4.437-14.85-7.68-22.02-9.728-7.17-2.389-13.99-3.584-20.48-3.584-10.92 0-20.14 3.072-27.65 9.216-7.51 6.144-13.31 15.189-17.4 27.136-3.76 11.947-5.64 26.453-5.64 43.52V431.4h-82.43ZM256 0c141.385 0 256 114.615 256 256S397.385 512 256 512 0 397.385 0 256 114.615 0 256 0Zm-68.47 266.057c-15.543 0-30.324 3.505-44.343 10.514-13.866 7.01-25.143 18.21-33.828 33.6-5.616 10.129-9.318 22.403-11.1061 36.822-1.6543 13.341 9.5761 24.207 23.0181 24.207 13.778 0 24.065-11.574 27.402-24.941 1.891-7.579 4.939-13.589 9.142-18.03 8.076-8.534 18.286-12.8 30.629-12.8 8.229 0 15.772 2.057 22.629 6.171 6.857 3.962 15.771 10.743 26.742 20.343 16.762 14.781 31.544 25.524 44.344 32.228 12.8 6.553 26.971 9.829 42.514 9.829 15.543 0 30.324-3.505 44.343-10.514 14.019-7.01 25.371-18.21 34.057-33.6 5.738-10.168 9.448-22.497 11.129-36.987 1.543-13.302-9.704-24.042-23.096-24.042-13.863.001-24.202 11.704-27.888 25.07-1.797 6.515-4.512 12.025-8.145 16.53-7.619 9.448-18.057 14.172-31.314 14.172-8.229 0-15.696-1.982-22.4-5.943-6.553-4.115-15.543-10.972-26.972-20.572-16.914-14.171-31.772-24.685-44.572-31.543-12.647-7.009-26.742-10.514-42.285-10.514Zm0-138.057c-15.543 0-30.324 3.505-44.343 10.514-13.866 7.01-25.143 18.21-33.828 33.6-5.616 10.129-9.318 22.403-11.1061 36.821-1.6544 13.341 9.5761 24.207 23.0181 24.208 13.778 0 24.065-11.574 27.402-24.941 1.891-7.579 4.939-13.589 9.142-18.031 8.076-8.533 18.286-12.8 30.629-12.8 8.229 0 15.772 2.058 22.629 6.172 6.857 3.962 15.771 10.743 26.742 20.343 16.762 14.781 31.544 25.524 44.344 32.228 12.8 6.553 26.971 9.829 42.514 9.829 15.543 0 30.324-3.505 44.343-10.514 14.019-7.01 25.371-18.21 34.057-33.6 5.738-10.168 9.448-22.497 11.129-36.987 1.543-13.303-9.704-24.042-23.096-24.042-13.863 0-24.202 11.704-27.888 25.07-1.797 6.515-4.512 12.025-8.145 16.53-7.619 9.448-18.057 14.171-31.314 14.171-8.229 0-15.696-1.981-22.4-5.942-6.553-4.115-15.543-10.972-26.972-20.572-16.914-14.171-31.772-24.686-44.572-31.543C217.168 131.505 203.073 128 187.53 128Z"
/>
</svg>
);
};
export function ErrorPage({
statusCode,
title,
description,
locale = 'en',
staticCdnEndpoint = CdnEndpoints.STATIC,
homeUrl,
homeLabel = 'Go home',
helpUrl = 'https://fluxer.app/help',
helpLabel = 'Get help',
showLogo = true,
}: ErrorPageProps) {
const hasButtons = homeUrl || helpUrl;
return (
<html lang={locale}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>
{statusCode} - {title}
</title>
<link rel="preconnect" href={staticCdnEndpoint} />
<link rel="stylesheet" href={`${staticCdnEndpoint}/fonts/ibm-plex.css`} />
<link rel="stylesheet" href={`${staticCdnEndpoint}/fonts/bricolage.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`} />
<style dangerouslySetInnerHTML={{__html: inlineStyles}} />
</head>
<body>
<div class="error-container">
{showLogo && <FluxerLogoWordmark />}
<div class="status-code">{statusCode}</div>
<h1 class="title">{title}</h1>
<p class="description">{description}</p>
{hasButtons && (
<div class="buttons">
{homeUrl && (
<a href={homeUrl} class="btn btn-primary">
{homeLabel}
</a>
)}
{helpUrl && (
<a href={helpUrl} class="btn btn-secondary">
{helpLabel}
</a>
)}
</div>
)}
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,35 @@
/*
* 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/ui/src/utils/ClassNames';
const FORM_CONTROL_BASE_CLASS =
'w-full rounded-lg border border-neutral-300 bg-white text-neutral-900 text-sm transition-all placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 disabled:cursor-not-allowed disabled:bg-neutral-50 disabled:opacity-50';
export const FORM_FIELD_CLASS = 'flex flex-col gap-2';
export const FORM_LABEL_CLASS = 'font-semibold text-neutral-500 text-xs uppercase tracking-wide';
export const FORM_HELPER_CLASS = 'text-neutral-500 text-xs';
export const FORM_CONTROL_INPUT_CLASS = cn(FORM_CONTROL_BASE_CLASS, 'h-8 px-3 py-1.5');
export const FORM_CONTROL_TEXTAREA_CLASS = cn(FORM_CONTROL_BASE_CLASS, 'px-3 py-2');
export const FORM_CONTROL_SELECT_CLASS = cn(FORM_CONTROL_BASE_CLASS, 'h-8 appearance-none px-3 py-1.5 pr-10');
export const FORM_SELECT_ICON_CLASS =
'pointer-events-none absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-neutral-500';

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 */
export const GRADIENTS = {
purple: 'gradient-purple',
light: 'bg-gradient-to-b from-white to-gray-50',
cta: 'gradient-cta',
} as const;

View File

@@ -0,0 +1,29 @@
/*
* 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 const SPACING = {
standard: 'px-6 py-16 sm:px-8 md:px-12 md:py-24 lg:px-16 xl:px-20',
large: 'px-6 py-24 sm:px-8 md:px-12 md:py-40 lg:px-16 xl:px-20',
medium: 'px-6 py-24 sm:px-8 md:px-12 md:py-32 lg:px-16 xl:px-20',
cta: 'px-6 py-20 sm:px-8 md:px-12 md:py-28 lg:px-16 xl:px-20',
hero: 'px-6 pt-44 pb-16 sm:px-8 md:px-12 md:pt-52 md:pb-20 lg:px-16 lg:pb-24 xl:px-20',
} as const;

View File

@@ -0,0 +1,79 @@
/*
* 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} from 'hono/jsx';
export type LayoutGap = '0' | '1' | '1.5' | '2' | '3' | '4' | '5' | '6' | 'sm' | 'md' | 'lg';
export type LayoutColumns = '1' | '2' | '3' | '4' | 1 | 2 | 3 | 4;
export interface ChildrenProps {
children?: Child;
}
export interface GapProps {
gap?: LayoutGap;
}
export interface GridProps extends GapProps {
cols?: LayoutColumns;
}
export interface InfoItemProps {
label: string;
value?: string | null;
}
export interface LabelProps {
label: string;
}
export interface MutedProps {
muted?: boolean;
}
export interface TextProps {
text: string;
}
export interface BaseFormProps {
name: string;
id?: string;
label?: string;
helper?: string;
required?: boolean;
placeholder?: string;
disabled?: boolean;
}
export interface SelectOption {
value: string;
label: string;
}
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'info' | 'ghost' | 'brand';
export type ButtonSize = 'small' | 'medium' | 'large' | 'xl';
export type ButtonIconPosition = 'left' | 'right';
export type CardPadding = 'none' | 'small' | 'medium' | 'large' | 'xl';
export type CardVariant = 'default' | 'elevated' | 'empty' | 'marketing';
export type CardShadow = 'none' | 'sm' | 'md' | 'lg' | 'xl';

View File

@@ -0,0 +1,64 @@
/*
* 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 {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
const DEFAULT_AVATAR_PRIMARY_COLORS = [0x4641d9, 0xf0b100, 0x00bba7, 0x2b7fff, 0xad46ff, 0x6a7282];
export const DEFAULT_AVATAR_COUNT = BigInt(DEFAULT_AVATAR_PRIMARY_COLORS.length);
export const normalizeEndpoint = (endpoint: string): string => endpoint.replace(/\/$/, '');
export const parseAvatarHash = (value: string) => {
const animated = value.startsWith('a_');
const hash = animated ? value.slice(2) : value;
return {animated, hash};
};
export const buildMediaUrl = ({
endpoint,
path,
id,
hash,
size,
animated,
}: {
endpoint: string;
path: string;
id: string;
hash: string;
size: MediaProxyImageSize;
animated?: boolean;
}) => {
const normalizedEndpoint = normalizeEndpoint(endpoint);
const params = new URLSearchParams();
params.set('size', size.toString());
if (animated) {
params.set('animated', 'true');
}
const query = params.toString();
return `${normalizedEndpoint}/${path}/${id}/${hash}.webp${query ? `?${query}` : ''}`;
};
export const getDefaultAvatarIndex = (id: string): number => Number(BigInt(id) % DEFAULT_AVATAR_COUNT);
export const getDefaultAvatarPrimaryColor = (id: string): number =>
DEFAULT_AVATAR_PRIMARY_COLORS[getDefaultAvatarIndex(id)];

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 */
export type ClassNameInput = string | boolean | undefined | null;
export function cn(...inputs: Array<ClassNameInput>): string {
const classes: Array<string> = [];
for (const input of inputs) {
if (typeof input === 'string' && input.length > 0) {
classes.push(input);
}
}
return classes.join(' ');
}

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 */
export type ColorTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'primary' | 'purple' | 'orange';
export type ColorIntensity = 'subtle' | 'normal' | 'strong';
export interface ColorVariant {
bg: string;
text: string;
border?: string;
}
export const colorVariants: Record<ColorTone, Record<ColorIntensity, ColorVariant>> = {
neutral: {
subtle: {bg: 'bg-neutral-50', text: 'text-neutral-700', border: 'border-neutral-200'},
normal: {bg: 'bg-neutral-100', text: 'text-neutral-700', border: 'border-neutral-200'},
strong: {bg: 'bg-neutral-900', text: 'text-white'},
},
info: {
subtle: {bg: 'bg-blue-50', text: 'text-blue-800', border: 'border-blue-200'},
normal: {bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-200'},
strong: {bg: 'bg-blue-600', text: 'text-white'},
},
success: {
subtle: {bg: 'bg-green-50', text: 'text-green-800', border: 'border-green-200'},
normal: {bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-200'},
strong: {bg: 'bg-green-600', text: 'text-white'},
},
warning: {
subtle: {bg: 'bg-yellow-50', text: 'text-yellow-800', border: 'border-yellow-200'},
normal: {bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-200'},
strong: {bg: 'bg-yellow-600', text: 'text-white'},
},
danger: {
subtle: {bg: 'bg-red-50', text: 'text-red-800', border: 'border-red-200'},
normal: {bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-200'},
strong: {bg: 'bg-red-600', text: 'text-white'},
},
primary: {
subtle: {bg: 'bg-neutral-100', text: 'text-neutral-700'},
normal: {bg: 'bg-neutral-900', text: 'text-white'},
strong: {bg: 'bg-neutral-900', text: 'text-white'},
},
purple: {
subtle: {bg: 'bg-purple-50', text: 'text-purple-800', border: 'border-purple-200'},
normal: {bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-200'},
strong: {bg: 'bg-purple-600', text: 'text-white'},
},
orange: {
subtle: {bg: 'bg-orange-50', text: 'text-orange-800', border: 'border-orange-200'},
normal: {bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-200'},
strong: {bg: 'bg-orange-600', text: 'text-white'},
},
};
export function getColorClasses(tone: ColorTone, intensity: ColorIntensity = 'normal'): string {
const variant = colorVariants[tone][intensity];
const classes = [variant.bg, variant.text];
if (variant.border) {
classes.push(variant.border);
}
return classes.join(' ');
}
export type AlertTone = 'error' | 'warning' | 'success' | 'info';
export function getAlertClasses(tone: AlertTone): string {
const toneMapping: Record<AlertTone, ColorTone> = {
error: 'danger',
warning: 'warning',
success: 'success',
info: 'info',
};
return getColorClasses(toneMapping[tone], 'subtle');
}

View File

@@ -0,0 +1,42 @@
/*
* 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 formatNumber(value: number): string {
const digits = Math.max(0, Math.trunc(value)).toString();
return formatNumberDigits(digits);
}
function formatNumberDigits(digits: string): string {
const len = digits.length;
if (len <= 3) return digits;
const headLength = len % 3 === 0 ? 3 : len % 3;
const head = digits.slice(0, headLength);
const tail = digits.slice(headLength);
return `${head}${chunkDigits(tail)}`;
}
function chunkDigits(digits: string): string {
if (digits.length <= 3) return digits;
const head = digits.slice(0, 3);
const tail = digits.slice(3);
return `${head},${chunkDigits(tail)}`;
}

View File

@@ -0,0 +1,35 @@
/*
* 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 formatFileSize(bytes: number, decimals = 2): string {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}

View File

@@ -0,0 +1,214 @@
/*
* 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 {
MEDIA_PROXY_AVATAR_SIZE_DEFAULT,
MEDIA_PROXY_GUILD_BANNER_SIZE_DEFAULT,
MEDIA_PROXY_GUILD_EMBED_SPLASH_SIZE_DEFAULT,
MEDIA_PROXY_GUILD_SPLASH_SIZE_DEFAULT,
MEDIA_PROXY_ICON_SIZE_DEFAULT,
MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL,
} from '@fluxer/constants/src/MediaProxyAssetSizes';
import {extractTimestampFromSnowflakeAsDate} from '@fluxer/snowflake/src/SnowflakeUtils';
import {
buildMediaUrl,
getDefaultAvatarIndex,
normalizeEndpoint,
parseAvatarHash,
} from '@fluxer/ui/src/utils/AvatarMediaUtils';
export function formatDiscriminator(discriminator: number | string): string {
const discStr = String(discriminator).padStart(4, '0');
return discStr;
}
export function formatUserTag(username: string, discriminator: string | number): string {
const discStr = typeof discriminator === 'number' ? formatDiscriminator(discriminator) : discriminator;
return `${username}#${discStr}`;
}
export function getInitials(name: string): string {
if (!name || name.trim() === '') {
return '?';
}
const words = name.trim().split(/\s+/);
const firstWord = words[0];
const lastWord = words[words.length - 1];
if (!firstWord) {
return '?';
}
if (words.length === 1 || !lastWord) {
return firstWord.charAt(0).toUpperCase();
}
return (firstWord.charAt(0) + lastWord.charAt(0)).toUpperCase();
}
export function extractTimestampFromSnowflake(snowflake: string, epoch = '1420070400000'): string {
try {
const date = extractTimestampFromSnowflakeAsDate(snowflake, epoch);
if (Number.isNaN(date.getTime())) {
return 'Unknown';
}
const year = date.getUTCFullYear().toString();
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0');
const day = date.getUTCDate().toString().padStart(2, '0');
const hour = date.getUTCHours().toString().padStart(2, '0');
const minute = date.getUTCMinutes().toString().padStart(2, '0');
const monthNames: Record<string, string> = {
'01': 'Jan',
'02': 'Feb',
'03': 'Mar',
'04': 'Apr',
'05': 'May',
'06': 'Jun',
'07': 'Jul',
'08': 'Aug',
'09': 'Sep',
'10': 'Oct',
'11': 'Nov',
'12': 'Dec',
};
const monthName = monthNames[month] ?? month;
return `${monthName} ${day}, ${year} at ${hour}:${minute}`;
} catch {
return 'Unknown';
}
}
export function getUserAvatarUrl(
mediaEndpoint: string,
staticCdnEndpoint: string,
userId: string,
avatar: string | null,
forceStatic: boolean,
_assetVersion: string,
): string {
if (avatar) {
const {hash, animated} = parseAvatarHash(avatar);
const shouldAnimate = animated && !forceStatic;
return buildMediaUrl({
endpoint: mediaEndpoint,
path: 'avatars',
id: userId,
hash,
size: MEDIA_PROXY_AVATAR_SIZE_DEFAULT,
animated: shouldAnimate,
});
}
const defaultIndex = getDefaultAvatarIndex(userId);
return `${normalizeEndpoint(staticCdnEndpoint)}/avatars/${defaultIndex}.png`;
}
export function getGuildIconUrl(
mediaEndpoint: string,
guildId: string,
icon: string | null,
forceStatic: boolean,
): string | null {
if (!icon) {
return null;
}
const {hash, animated} = parseAvatarHash(icon);
const shouldAnimate = animated && !forceStatic;
return buildMediaUrl({
endpoint: mediaEndpoint,
path: 'icons',
id: guildId,
hash,
size: MEDIA_PROXY_ICON_SIZE_DEFAULT,
animated: shouldAnimate,
});
}
export function getUserBannerUrl(
mediaEndpoint: string,
userId: string,
banner: string | null,
forceStatic: boolean,
): string | null {
if (!banner) {
return null;
}
const {hash, animated} = parseAvatarHash(banner);
const shouldAnimate = animated && !forceStatic;
return buildMediaUrl({
endpoint: mediaEndpoint,
path: 'banners',
id: userId,
hash,
size: MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL,
animated: shouldAnimate,
});
}
export function getGuildBannerUrl(
mediaEndpoint: string,
guildId: string,
banner: string | null,
forceStatic: boolean,
): string | null {
if (!banner) {
return null;
}
const {hash, animated} = parseAvatarHash(banner);
const shouldAnimate = animated && !forceStatic;
return buildMediaUrl({
endpoint: mediaEndpoint,
path: 'banners',
id: guildId,
hash,
size: MEDIA_PROXY_GUILD_BANNER_SIZE_DEFAULT,
animated: shouldAnimate,
});
}
export function getGuildSplashUrl(mediaEndpoint: string, guildId: string, splash: string | null): string | null {
if (!splash) {
return null;
}
return buildMediaUrl({
endpoint: mediaEndpoint,
path: 'splashes',
id: guildId,
hash: splash,
size: MEDIA_PROXY_GUILD_SPLASH_SIZE_DEFAULT,
});
}
export function getGuildEmbedSplashUrl(mediaEndpoint: string, guildId: string, splash: string | null): string | null {
if (!splash) {
return null;
}
return buildMediaUrl({
endpoint: mediaEndpoint,
path: 'embed-splashes',
id: guildId,
hash: splash,
size: MEDIA_PROXY_GUILD_EMBED_SPLASH_SIZE_DEFAULT,
});
}

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 */
export function createVariantClasses<T extends string>(
variants: Record<T, string>,
base: string = '',
): (variant: T) => string {
return (variant: T) => {
const classes = variants[variant];
return base ? `${base} ${classes}` : classes;
};
}
export function createCompoundVariantClasses<T extends string, S extends string>(
variants: Record<T, string>,
sizes?: Record<S, string>,
base: string = '',
): {
getVariant: (variant: T) => string;
getSize: (size: S) => string;
getBase: () => string;
} {
return {
getVariant: (variant: T) => {
const classes = variants[variant];
return base ? `${base} ${classes}` : classes;
},
getSize: (size: S) => {
if (!sizes) return '';
return sizes[size];
},
getBase: () => base,
};
}