refactor progress
This commit is contained in:
20
packages/ui/src/HonoJsx.d.ts
vendored
Normal file
20
packages/ui/src/HonoJsx.d.ts
vendored
Normal 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';
|
||||
42
packages/ui/src/components/Alert.tsx
Normal file
42
packages/ui/src/components/Alert.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
packages/ui/src/components/Badge.tsx
Normal file
71
packages/ui/src/components/Badge.tsx
Normal 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" />;
|
||||
}
|
||||
140
packages/ui/src/components/Button.tsx
Normal file
140
packages/ui/src/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
151
packages/ui/src/components/Card.tsx
Normal file
151
packages/ui/src/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
packages/ui/src/components/CheckboxForm.tsx
Normal file
98
packages/ui/src/components/CheckboxForm.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
30
packages/ui/src/components/CsrfInput.tsx
Normal file
30
packages/ui/src/components/CsrfInput.tsx
Normal 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} />;
|
||||
45
packages/ui/src/components/EmptyState.tsx
Normal file
45
packages/ui/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
packages/ui/src/components/Flash.tsx
Normal file
48
packages/ui/src/components/Flash.tsx
Normal 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;
|
||||
}
|
||||
236
packages/ui/src/components/Form.tsx
Normal file
236
packages/ui/src/components/Form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
packages/ui/src/components/FormFieldGroup.tsx
Normal file
72
packages/ui/src/components/FormFieldGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
packages/ui/src/components/FormModal.tsx
Normal file
104
packages/ui/src/components/FormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
packages/ui/src/components/FormSection.tsx
Normal file
65
packages/ui/src/components/FormSection.tsx
Normal 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;
|
||||
}
|
||||
65
packages/ui/src/components/InputGroup.tsx
Normal file
65
packages/ui/src/components/InputGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/ui/src/components/Layout.tsx
Normal file
57
packages/ui/src/components/Layout.tsx
Normal 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>;
|
||||
}
|
||||
63
packages/ui/src/components/Navigation.tsx
Normal file
63
packages/ui/src/components/Navigation.tsx
Normal 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"
|
||||
>
|
||||
← {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>
|
||||
);
|
||||
89
packages/ui/src/components/Pagination.tsx
Normal file
89
packages/ui/src/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
packages/ui/src/components/RadioGroup.tsx
Normal file
108
packages/ui/src/components/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
packages/ui/src/components/SearchForm.tsx
Normal file
179
packages/ui/src/components/SearchForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
packages/ui/src/components/SliderInput.tsx
Normal file
102
packages/ui/src/components/SliderInput.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
62
packages/ui/src/components/Table.tsx
Normal file
62
packages/ui/src/components/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
packages/ui/src/components/ToggleSwitch.tsx
Normal file
98
packages/ui/src/components/ToggleSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
packages/ui/src/components/Typography.tsx
Normal file
53
packages/ui/src/components/Typography.tsx
Normal 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>;
|
||||
}
|
||||
202
packages/ui/src/pages/ErrorPage.tsx
Normal file
202
packages/ui/src/pages/ErrorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
packages/ui/src/styles/FormControls.tsx
Normal file
35
packages/ui/src/styles/FormControls.tsx
Normal 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';
|
||||
27
packages/ui/src/styles/Gradients.tsx
Normal file
27
packages/ui/src/styles/Gradients.tsx
Normal 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;
|
||||
29
packages/ui/src/styles/Spacing.tsx
Normal file
29
packages/ui/src/styles/Spacing.tsx
Normal 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;
|
||||
79
packages/ui/src/types/Common.tsx
Normal file
79
packages/ui/src/types/Common.tsx
Normal 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';
|
||||
64
packages/ui/src/utils/AvatarMediaUtils.tsx
Normal file
64
packages/ui/src/utils/AvatarMediaUtils.tsx
Normal 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)];
|
||||
33
packages/ui/src/utils/ClassNames.tsx
Normal file
33
packages/ui/src/utils/ClassNames.tsx
Normal 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(' ');
|
||||
}
|
||||
94
packages/ui/src/utils/ColorVariants.tsx
Normal file
94
packages/ui/src/utils/ColorVariants.tsx
Normal 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');
|
||||
}
|
||||
42
packages/ui/src/utils/FormatNumber.tsx
Normal file
42
packages/ui/src/utils/FormatNumber.tsx
Normal 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)}`;
|
||||
}
|
||||
35
packages/ui/src/utils/FormatSize.tsx
Normal file
35
packages/ui/src/utils/FormatSize.tsx
Normal 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]}`;
|
||||
}
|
||||
214
packages/ui/src/utils/FormatUser.tsx
Normal file
214
packages/ui/src/utils/FormatUser.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
53
packages/ui/src/utils/VariantClasses.tsx
Normal file
53
packages/ui/src/utils/VariantClasses.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user