Add boilerplate files and licenses
12
src/components/DeviceContext.tsx
Normal 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;
|
||||
40
src/components/DeviceProvider.tsx
Normal 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;
|
||||
41
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
22
src/components/ErrorFallback.tsx
Normal 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
@@ -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;
|
||||
34
src/components/OfflineInformer.tsx
Normal 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,
|
||||
)}
|
||||
>
|
||||
You’re currently offline, but the app is still working just fine!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfflineInformer;
|
||||
12
src/components/Page404.tsx
Normal 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;
|
||||
47
src/components/UpdateNotifier.tsx
Normal 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;
|
||||
37
src/components/form/CheckboxField.tsx
Normal 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;
|
||||
42
src/components/form/InputField.tsx
Normal 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;
|
||||
39
src/components/form/SelectField.tsx
Normal 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;
|
||||
6
src/components/icons/arrow-dialog.svg
Normal 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 |
5
src/components/icons/arrow-left.svg
Normal 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 |
1
src/components/icons/check.svg
Normal 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 |
1
src/components/icons/close.svg
Normal 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 |
1
src/components/icons/edit.svg
Normal 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 |
4
src/components/icons/file.svg
Normal 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 |
8
src/components/icons/github.svg
Normal 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 |
5
src/components/icons/info.svg
Normal 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 |
8
src/components/icons/mail.svg
Normal 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 |
1
src/components/icons/music.svg
Normal 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 |
5
src/components/icons/reload.svg
Normal 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 |
10
src/components/icons/reset-settings.svg
Normal 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 |
8
src/components/icons/trash.svg
Normal 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 |
68
src/components/ui/Button.tsx
Normal 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;
|
||||
56
src/components/ui/CheckBox.tsx
Normal 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;
|
||||
51
src/components/ui/Dialog.tsx
Normal 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;
|
||||
84
src/components/ui/FileInput.tsx
Normal 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;
|
||||
66
src/components/ui/Input.tsx
Normal 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;
|
||||
60
src/components/ui/Select.tsx
Normal 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;
|
||||
64
src/components/ui/Toast.tsx
Normal 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;
|
||||