Add boilerplate files and licenses

This commit is contained in:
2026-07-13 04:39:10 +02:00
parent 69cabda489
commit fac7e80bb7
202 changed files with 23125 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import clsx from 'clsx';
import { JSX } from 'preact';
import { twMerge } from 'tailwind-merge';
import { Link } from 'wouter';
interface ButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
children: any;
size?: 'sm' | 'md' | 'xs';
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'outlined' | 'tertiary' | 'ghost' | 'icon';
type?: 'button' | 'submit' | 'reset';
to?: string;
target?: string;
rel?: string;
}
function Button({
children,
className = '',
variant = 'primary',
size = 'md',
type = 'button',
to,
target,
rel,
disabled,
...props
}: ButtonProps) {
const variantClasses = {
primary: 'bg-face hover:bg-[#d0d2d0] active:bg-[#c5c7c5]',
secondary: 'bg-gray-100 hover:bg-gray-300 active:bg-gray-400',
tertiary: 'bg-[#c6d1d7] hover:bg-gray-100 active:bg-gray-200',
outlined: 'bg-transparent hover:bg-black/10 active:bg-black/20 border-1 border-black',
ghost: 'border-0 bg-transparent hover:bg-black/10 active:bg-black/20',
icon: 'flex-shrink-0 !py-1 !px-1 border-0 !h-auto',
} as const;
const sizeClasses = {
xs: 'text-xs py-1 px-2 h-[24px]',
sm: 'text-sm py-1 px-2 h-[28px]',
md: 'text-sm py-2 px-4 h-[42px]',
} as const;
const baseClasses =
'text-black cursor-pointer border-1 border-black disabled:opacity-80 disabled:cursor-not-allowed disabled:text-gray-400 outline-none transition-colors duration-200';
const linkDisabledClasses = disabled ? 'pointer-events-none opacity-80 text-gray-400' : '';
const composed = twMerge(
clsx(baseClasses, variantClasses[variant], sizeClasses[size], linkDisabledClasses, className),
);
if (to) {
return (
<Link href={to} className={composed} target={target} rel={rel} aria-disabled={disabled}>
{children}
</Link>
);
}
return (
<button type={type} className={composed} disabled={disabled} {...props}>
{children}
</button>
);
}
export default Button;

View File

@@ -0,0 +1,56 @@
import clsx from 'clsx';
import { JSX } from 'preact';
import { useId } from 'preact/hooks';
import IconInfo from '../icons/info.svg?react';
function CheckBox({
checked,
onChange,
title,
helperText,
disabled,
className,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
title: string;
helperText?: string;
disabled: boolean;
className?: string;
}) {
const _id = useId();
return (
<label className={clsx('flex items-center gap-2 text-sm', className)} htmlFor={_id}>
<input
type="checkbox"
id={_id}
checked={checked}
onChange={(e: JSX.TargetedEvent<HTMLInputElement>) => onChange(e.currentTarget.checked)}
disabled={disabled}
className={clsx('w-4 h-4', disabled && 'opacity-50')}
/>
<span className={clsx('overflow-hidden truncate', disabled && 'opacity-50')} title={title}>
{title}
</span>
{helperText && (
<div className="relative">
<div className="text-gray-400 cursor-help peer">
<IconInfo className="w-4 h-4" />
</div>
<div
className={clsx(
'absolute shadow-lg bottom-full left-1/2 -translate-x-1/2 mb-2 p-3 bg-[#b0babe] text-black border border-black text-sm z-2',
'opacity-0 peer-hover:opacity-100 transition-opacity duration-300 pointer-events-none min-w-125 max-w-150',
)}
>
{helperText}
</div>
</div>
)}
</label>
);
}
export default CheckBox;

View File

@@ -0,0 +1,51 @@
import clsx from 'clsx';
import { useEffect, useRef } from 'preact/hooks';
interface DialogProps {
isOpen: boolean;
onClose: () => void;
children: any;
className?: string;
containerClassName?: string;
}
function Dialog({
isOpen,
onClose,
children,
className = '',
containerClassName = '',
}: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) {
return;
}
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
return (
<dialog
ref={dialogRef}
className={clsx(
'shadow-lg backdrop:bg-black/40 fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 border border-black outline-none overflow-visible',
className,
)}
onClose={onClose}
>
<div className={clsx('bg-white p-4 rounded-md min-w-75 min-h-25 max-h-[95vh] overflow-y-auto', containerClassName)}>
{children}
</div>
</dialog>
);
}
export default Dialog;

View File

@@ -0,0 +1,84 @@
import { useRef } from 'preact/hooks';
import IconFile from '../icons/file.svg?react';
import IconTrash from '../icons/trash.svg?react';
import Button from './Button';
interface FileInputProps {
files: File[];
onFilesChange: (files: File[]) => void;
maxFiles?: number;
disabled?: boolean;
label?: string;
}
function FileInput({
files,
onFilesChange,
maxFiles = 3,
disabled = false,
label = 'Attach files',
}: FileInputProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files) {
const newFiles = Array.from(target.files);
onFilesChange([...files, ...newFiles].slice(0, maxFiles));
}
};
const removeFile = (index: number) => {
onFilesChange(files.filter((_, i) => i !== index));
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
return (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">
{label} (optional, max {maxFiles})
</div>
<div className="flex flex-col gap-2">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex gap-2 items-center p-2 bg-gray-50 rounded"
>
<IconFile className="w-6 h-6 flex-shrink-0" />
<span className="text-sm truncate flex-1 max-w-[500px]">{file.name}</span>
<span className="text-xs opacity-60 flex-shrink-0 ml-auto">
{(file.size / 1024).toFixed(2)}KB
</span>
<Button variant="icon" onClick={() => removeFile(index)} disabled={disabled}>
<IconTrash className="w-4 h-4" />
</Button>
</div>
))}
{files.length < maxFiles && (
<Button
variant="secondary"
size="sm"
onClick={openFileDialog}
className="flex items-center gap-2 w-fit"
disabled={disabled}
>
<span className="text-2xl">+</span>
<span className="text-sm">Add file</span>
</Button>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}
export default FileInput;

View File

@@ -0,0 +1,66 @@
import clsx from 'clsx';
import { JSX } from 'preact';
import { forwardRef } from 'preact/compat';
import { twMerge } from 'tailwind-merge';
interface BaseInputProps {
label?: string;
type?: 'text' | 'email' | 'textarea' | 'file';
error?: string;
className?: string;
value?: string;
disabled?: boolean;
onChange?: (e: Event) => void;
required?: boolean;
multiple?: boolean;
accept?: string;
placeholder?: string;
rows?: number;
}
type InputProps = BaseInputProps &
(JSX.HTMLAttributes<HTMLInputElement> | JSX.HTMLAttributes<HTMLTextAreaElement>);
const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
({ label, type = 'text', className = '', disabled = false, ...props }, ref) => {
const id = props.id || label?.toLowerCase().replace(/\s+/g, '-');
const inputClasses = twMerge(
clsx(
'w-full px-3 py-2 border-1 border-black focus:outline-none focus:ring-0 bg-gray-50 placeholder:text-gray-400',
disabled && 'opacity-50',
className,
),
);
return (
<div className="flex flex-col gap-1">
{label && (
<label
className={clsx('text-sm font-medium text-black', disabled && 'opacity-50')}
htmlFor={id}
>
{label}
</label>
)}
{type === 'textarea' ? (
<textarea
ref={ref as preact.Ref<HTMLTextAreaElement>}
className={inputClasses}
id={id}
{...(props as JSX.HTMLAttributes<HTMLTextAreaElement>)}
/>
) : (
<input
ref={ref as preact.Ref<HTMLInputElement>}
type={type}
className={inputClasses}
id={id}
{...(props as JSX.HTMLAttributes<HTMLInputElement>)}
/>
)}
</div>
);
},
);
export default Input;

View File

@@ -0,0 +1,60 @@
import clsx from 'clsx';
import { JSX } from 'preact';
interface SelectProps extends Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
children: any;
className?: string;
size?: 'sm' | 'md';
variant?: 'primary' | 'secondary' | 'outlined' | 'tertiary' | 'ghost' | 'icon';
}
function Select({
children,
className = '',
size = 'md',
variant = 'primary',
...props
}: SelectProps) {
const variantClasses = {
primary: 'bg-gray-100 hover:bg-[#d0d2d0] active:bg-[#c5c7c5] border-1 border-black',
secondary: 'bg-gray-100 hover:bg-gray-300 active:bg-gray-400 border-1 border-black',
tertiary: 'bg-[#c6d1d7] hover:bg-gray-100 active:bg-gray-200 border-1 border-black',
outlined: 'bg-transparent hover:bg-black/10 active:bg-black/20 border-1 border-black',
ghost: 'border-0 bg-transparent hover:bg-black/10 active:bg-black/20',
icon: 'border-0 bg-transparent',
} as const;
const sizeClasses = {
sm: {
wrapper: 'h-[28px]',
select: 'relative h-[28px] px-1 py-0 text-sm -top-[1px]',
},
md: {
wrapper: 'h-[42px]',
select: 'h-[42px] p-2 text-sm',
},
} as const;
return (
<div
className={clsx(
'px-2 transition-colors duration-200',
variantClasses[variant],
sizeClasses[size].wrapper,
className,
)}
>
<select
className={clsx(
'outline-none disabled:opacity-80 disabled:cursor-not-allowed disabled:text-gray-400 w-full cursor-pointer bg-transparent',
sizeClasses[size].select,
)}
{...props}
>
{children}
</select>
</div>
);
}
export default Select;

View File

@@ -0,0 +1,64 @@
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { useEffect, useRef } from 'preact/hooks';
import { removeToastAtom, type ToastMessage, toastsAtom } from '../../atoms/toast';
import Button from './Button';
const TOAST_DURATION = 5000;
function ToastItem({ toast, onRemove }: { toast: ToastMessage; onRemove: (id: string) => void }) {
const toastRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (toastRef.current) {
toastRef.current.showPopover();
}
}, [toast.id]);
useEffect(() => {
const timer = setTimeout(() => {
onRemove(toast.id);
}, TOAST_DURATION);
return () => clearTimeout(timer);
}, [toast.id, onRemove]);
return (
<div ref={toastRef} popover>
<div
className={clsx(
'flex gap-8 items-center px-4 py-3 shadow-md max-w-125 w-full text-black/90 border border-gray-800',
'transform transition-all duration-300 ease-out',
toast.severity === 'error' ? 'bg-red-500' : 'bg-[#7af07e]',
)}
>
<p className="flex-1">{toast.message}</p>
<Button variant="primary" size="sm" onClick={() => onRemove(toast.id)} className="ml-auto">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
</div>
);
}
function Toast() {
const [toasts] = useAtom(toastsAtom);
const [, removeToast] = useAtom(removeToastAtom);
return (
<>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
))}
</>
);
}
export default Toast;