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,12 @@
import { createContext } from 'preact';
import { TEDevice } from '../lib/midi/types';
const DeviceContext = createContext<{
device: TEDevice | null;
error: Error | null;
}>({
device: null,
error: null,
});
export default DeviceContext;

View File

@@ -0,0 +1,40 @@
import { useAtom } from 'jotai';
import { useEffect, useState } from 'preact/hooks';
import { deviceSkuAtom } from '~/atoms/deviceSku';
import { SKU_EP133 } from '~/lib/constants';
import { initDevice } from '../lib/midi';
import { TEDevice } from '../lib/midi/types';
import DeviceContext from './DeviceContext';
function DeviceProvider({ children }: { children: preact.ComponentChildren }) {
const [device, setDevice] = useState<TEDevice | null>(null);
const [deviceError, setDeviceError] = useState<Error | null>(null);
const [, setDeviceSku] = useAtom(deviceSkuAtom);
useEffect(() => {
initDevice({
onDeviceFound: (deviceInfo) => {
console.log('Device found:', deviceInfo);
setDevice(deviceInfo);
setDeviceSku(deviceInfo.sku);
},
onDeviceLost: () => {
console.log('Device lost');
setDevice(null);
setDeviceSku(SKU_EP133);
},
onNoMidiAccess: (error) => {
console.error('No MIDI access:', error);
setDeviceError(error);
},
});
}, []);
return (
<DeviceContext.Provider value={{ device, error: deviceError }}>
{children}
</DeviceContext.Provider>
);
}
export default DeviceProvider;

View File

@@ -0,0 +1,41 @@
import * as Sentry from '@sentry/react';
import { Component } from 'preact';
interface ErrorBoundaryProps {
children: any;
FallbackComponent: any;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState;
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
Sentry.captureReactException(error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
const FallbackComponent = this.props.FallbackComponent;
return <FallbackComponent error={this.state.error} />;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,22 @@
import Button from './ui/Button';
function ErrorFallback() {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="max-w-md w-full bg-white border-1 border-black p-6 flex flex-col gap-4">
<h1 className="text-xl font-medium">Something went wrong</h1>
<div className="mb-4 text-sm text-red-700 mb-2">
An unexpected error occurred. Please try refreshing the page. If the problem persists,
please submit a bug report. There is a feedback button in the top right corner of the app.
</div>
<div className="flex gap-2">
<Button onClick={() => window.location.reload()}>Refresh Page</Button>
</div>
</div>
</div>
);
}
export default ErrorFallback;

12
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,12 @@
import useTheme from '~/hooks/useTheme';
function Layout({ children }: { children: preact.ComponentChildren }) {
const theme = useTheme();
return (
<div className={`${theme.id} h-full w-full bg-(image:--bg-url) fixed overflow-y-auto`}>
{children}
</div>
);
}
export default Layout;

View File

@@ -0,0 +1,34 @@
import clsx from 'clsx';
import { useEffect, useState } from 'preact/hooks';
function OfflineInformer({ className }: { className?: string }) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (isOnline) return null;
return (
<div
className={clsx(
'bg-yellow-100 text-yellow-800 px-4 py-1 text-center text-sm border-b border-yellow-300',
className,
)}
>
Youre currently offline, but the app is still working just fine!
</div>
);
}
export default OfflineInformer;

View File

@@ -0,0 +1,12 @@
function Page404() {
return (
<div className="flex flex-col items-center justify-center h-dvh p-4">
<div className="p-4 border border-black bg-white text-center max-w-md">
<h1 className="text-2xl mb-4">404: Not Found</h1>
<p>The page you are looking for does not exist.</p>
</div>
</div>
);
}
export default Page404;

View File

@@ -0,0 +1,47 @@
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { useEffect, useState } from 'preact/hooks';
import { swUpdateAvailableAtom } from '~/atoms/swUpdate';
const RELOAD_DELAY_SECONDS = 3;
function UpdateNotifier({ className }: { className?: string }) {
const updateAvailable = useAtomValue(swUpdateAvailableAtom);
const [countdown, setCountdown] = useState(RELOAD_DELAY_SECONDS);
useEffect(() => {
if (!updateAvailable) {
return;
}
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(interval);
window.location.reload();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [updateAvailable]);
if (!updateAvailable) {
return null;
}
return (
<div
className={clsx(
'bg-blue-200 text-blue-800 px-4 py-1 text-center text-sm border-b border-blue-300',
className,
)}
>
A new version is available. Reloading in {countdown} second{countdown !== 1 ? 's' : ''}...
</div>
);
}
export default UpdateNotifier;

View File

@@ -0,0 +1,37 @@
import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
import CheckBox from '../ui/CheckBox';
function CheckboxField<T extends FieldValues = FieldValues>({
name,
title,
disabled,
helperText,
className,
}: {
name: FieldPath<T>;
title: string;
disabled?: boolean;
helperText?: string;
className?: string;
}) {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<CheckBox
checked={field.value as boolean}
onChange={(checked) => field.onChange(checked)}
title={title}
disabled={disabled ?? false}
helperText={helperText}
className={className}
/>
)}
/>
);
}
export default CheckboxField;

View File

@@ -0,0 +1,42 @@
import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
import Input from '../ui/Input';
function InputField<T extends FieldValues = FieldValues>({
name,
label,
disabled,
className,
placeholder,
transform,
}: {
name: FieldPath<T>;
label?: string;
disabled?: boolean;
className?: string;
placeholder?: string;
transform?: (value: string) => string;
}) {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Input
label={label}
value={field.value as string}
onChange={(e: Event) => {
const raw = (e.currentTarget as HTMLInputElement).value;
field.onChange(transform ? transform(raw) : raw);
}}
disabled={disabled}
className={className}
placeholder={placeholder}
/>
)}
/>
);
}
export default InputField;

View File

@@ -0,0 +1,39 @@
import { JSX } from 'preact';
import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
import Select from '../ui/Select';
function SelectField<T extends FieldValues = FieldValues>({
name,
children,
disabled,
className,
}: {
name: FieldPath<T>;
children: any;
disabled?: boolean;
className?: string;
}) {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Select
onChange={(e: JSX.TargetedEvent<HTMLSelectElement, Event>) =>
field.onChange(e.currentTarget.value)
}
value={field.value as string}
name={name}
disabled={disabled}
className={className}
>
{children}
</Select>
)}
/>
);
}
export default SelectField;

View File

@@ -0,0 +1,6 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="14 3 21 3 21 10"/>
<line x1="10" y1="14" x2="21" y2="3"/>
<path d="M21 21H5a2 2 0 0 1-2-2V5"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z"/></svg>

After

Width:  |  Height:  |  Size: 157 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>

After

Width:  |  Height:  |  Size: 201 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z"/></svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M 6 3 L 6 4 L 6 28 L 6 29 L 7 29 L 25 29 L 26 29 L 26 28 L 26 10 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 L 19 3 L 7 3 L 6 3 z M 8 5 L 18 5 L 18 10 L 18 11 L 19 11 L 24 11 L 24 27 L 8 27 L 8 5 z M 20 6.4375 L 22.5625 9 L 20 9 L 20 6.4375 z" />
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -0,0 +1,8 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -0,0 +1,8 @@
<svg height="24px" width="24px" version="1.1" viewBox="0 0 512 512" >
<g>
<path d="M0,64v384h512V64H0z M264.132,266.765c-1.996,1.688-4.95,2.657-8.1,2.657c-3.153,0-6.098-0.964-8.083-2.642
L48,112h416L264.132,266.765z M95.11,194.306l69.518,58.954L48,368V160L95.11,194.306z M199.288,282.652l16.35,13.866
c10.836,9.215,25.169,14.288,40.361,14.288c15.236,0,29.588-5.071,40.418-14.282l16.321-13.846L432,400H80L199.288,282.652z
M347.394,253.282L464,160v208L347.394,253.282z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -0,0 +1,10 @@
<svg width="17.202343" height="14.702343" viewBox="0 0 17.202343 14.702343" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="m2.7515625.15000007c-1.4294397 0-2.60156255 1.16821683-2.60156255 2.59765633v9.2031246c0 1.42944 1.17212285 2.601563 2.60156255 2.601563h5.4511719V13.251563H2.7515625c-.7317272 0-1.3007812-.569054-1.3007812-1.300782V2.7476564c0-.7317273.569054-1.2988281 1.3007812-1.2988282H14.452734c.731728 0 1.298829.5671009 1.298829 1.2988282v2.9550781h1.300781V2.7476564c0-1.4294395-1.17017-2.59765633-2.59961-2.59765633z"
fill="currentColor"
/>
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" transform="translate(-8.6914339 -2.1223497)">
<line x1="19.643778" y1="10.574694" x2="25.143778" y2="16.074694" />
<line x1="25.143778" y1="10.574694" x2="19.643778" y2="16.074694" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 878 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

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;