feat: add fluxer upstream source and self-hosting documentation
- Clone of github.com/fluxerapp/fluxer (official upstream) - SELF_HOSTING.md: full VM rebuild procedure, architecture overview, service reference, step-by-step setup, troubleshooting, seattle reference - dev/.env.example: all env vars with secrets redacted and generation instructions - dev/livekit.yaml: LiveKit config template with placeholder keys - fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
42
fluxer/packages/ui/src/components/Alert.tsx
Normal file
42
fluxer/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
fluxer/packages/ui/src/components/Badge.tsx
Normal file
71
fluxer/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
fluxer/packages/ui/src/components/Button.tsx
Normal file
140
fluxer/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
fluxer/packages/ui/src/components/Card.tsx
Normal file
151
fluxer/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
fluxer/packages/ui/src/components/CheckboxForm.tsx
Normal file
98
fluxer/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
fluxer/packages/ui/src/components/CsrfInput.tsx
Normal file
30
fluxer/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
fluxer/packages/ui/src/components/EmptyState.tsx
Normal file
45
fluxer/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
fluxer/packages/ui/src/components/Flash.tsx
Normal file
48
fluxer/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
fluxer/packages/ui/src/components/Form.tsx
Normal file
236
fluxer/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
fluxer/packages/ui/src/components/FormFieldGroup.tsx
Normal file
72
fluxer/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
fluxer/packages/ui/src/components/FormModal.tsx
Normal file
104
fluxer/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
fluxer/packages/ui/src/components/FormSection.tsx
Normal file
65
fluxer/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
fluxer/packages/ui/src/components/InputGroup.tsx
Normal file
65
fluxer/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
fluxer/packages/ui/src/components/Layout.tsx
Normal file
57
fluxer/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
fluxer/packages/ui/src/components/Navigation.tsx
Normal file
63
fluxer/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
fluxer/packages/ui/src/components/Pagination.tsx
Normal file
89
fluxer/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
fluxer/packages/ui/src/components/RadioGroup.tsx
Normal file
108
fluxer/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
fluxer/packages/ui/src/components/SearchForm.tsx
Normal file
179
fluxer/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
fluxer/packages/ui/src/components/SliderInput.tsx
Normal file
102
fluxer/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
fluxer/packages/ui/src/components/Table.tsx
Normal file
62
fluxer/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
fluxer/packages/ui/src/components/ToggleSwitch.tsx
Normal file
98
fluxer/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
fluxer/packages/ui/src/components/Typography.tsx
Normal file
53
fluxer/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>;
|
||||
}
|
||||
Reference in New Issue
Block a user