feat: add core UI components

- DropZone: drag & drop file upload with visual feedback
- DeviceSelector: device dropdown grouped by brand
- CustomDeviceModal: form for custom device creation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:52:30 -04:00
parent 0b1cfc5666
commit a777ab364d
3 changed files with 442 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { CONSTRAINTS, type CustomDeviceFormData, type GreyLevel, type OutputFormat } from '$lib/types';
import { deviceStore } from '$lib/stores/device.svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
// Form state
let name = $state('My E-Reader');
let width = $state(1200);
let height = $state(1600);
let greyLevels = $state<GreyLevel>(16);
let outputFormat = $state<OutputFormat>('png');
// Validation
const isNameValid = $derived(name.trim().length > 0 && name.length <= CONSTRAINTS.MAX_DEVICE_NAME_LENGTH);
const isWidthValid = $derived(width >= CONSTRAINTS.MIN_DIMENSION && width <= CONSTRAINTS.MAX_DIMENSION);
const isHeightValid = $derived(height >= CONSTRAINTS.MIN_DIMENSION && height <= CONSTRAINTS.MAX_DIMENSION);
const isFormValid = $derived(isNameValid && isWidthValid && isHeightValid);
// Preview info
const aspectRatio = $derived(() => {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
});
function handleSubmit(e: Event): void {
e.preventDefault();
if (!isFormValid) return;
const formData: CustomDeviceFormData = {
name: name.trim(),
width,
height,
greyLevels,
outputFormat
};
deviceStore.addCustomDevice(formData);
resetForm();
onClose();
}
function resetForm(): void {
name = 'My E-Reader';
width = 1200;
height = 1600;
greyLevels = 16;
outputFormat = 'png';
}
function handleClose(): void {
resetForm();
onClose();
}
function handleBackdropClick(e: MouseEvent): void {
if (e.target === e.currentTarget) {
handleClose();
}
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
handleClose();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink/50 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={handleKeyDown}
>
<div class="bg-white rounded-lg shadow-[--shadow-modal] max-w-md w-full p-6">
<div class="flex items-center justify-between mb-6">
<h2 id="modal-title" class="text-lg font-semibold text-ink">
Custom Device
</h2>
<button
type="button"
class="p-1.5 text-grey-400 hover:text-grey-600 hover:bg-grey-100 rounded-md transition-colors duration-150"
onclick={handleClose}
aria-label="Close"
>
<X class="w-5 h-5" />
</button>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Name -->
<div>
<label for="device-name" class="block text-sm font-medium text-grey-500 mb-1">
Name
</label>
<input
id="device-name"
type="text"
bind:value={name}
maxlength={CONSTRAINTS.MAX_DEVICE_NAME_LENGTH}
class="
w-full px-3 py-2
bg-white text-ink
border rounded-md
text-sm
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
transition-colors duration-150
{isNameValid ? 'border-grey-200' : 'border-error'}
"
/>
</div>
<!-- Width and Height -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="device-width" class="block text-sm font-medium text-grey-500 mb-1">
Width (px)
</label>
<input
id="device-width"
type="number"
bind:value={width}
min={CONSTRAINTS.MIN_DIMENSION}
max={CONSTRAINTS.MAX_DIMENSION}
class="
w-full px-3 py-2
bg-white text-ink
border rounded-md
text-sm
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
transition-colors duration-150
{isWidthValid ? 'border-grey-200' : 'border-error'}
"
/>
</div>
<div>
<label for="device-height" class="block text-sm font-medium text-grey-500 mb-1">
Height (px)
</label>
<input
id="device-height"
type="number"
bind:value={height}
min={CONSTRAINTS.MIN_DIMENSION}
max={CONSTRAINTS.MAX_DIMENSION}
class="
w-full px-3 py-2
bg-white text-ink
border rounded-md
text-sm
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
transition-colors duration-150
{isHeightValid ? 'border-grey-200' : 'border-error'}
"
/>
</div>
</div>
<!-- Grey Levels and Format -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="device-levels" class="block text-sm font-medium text-grey-500 mb-1">
Grey Levels
</label>
<select
id="device-levels"
bind:value={greyLevels}
class="
w-full px-3 py-2
bg-white text-ink
border border-grey-200
rounded-md
text-sm
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
transition-colors duration-150
"
>
{#each CONSTRAINTS.GREY_LEVEL_OPTIONS as level}
<option value={level}>{level}</option>
{/each}
</select>
</div>
<div>
<label for="device-format" class="block text-sm font-medium text-grey-500 mb-1">
Output Format
</label>
<select
id="device-format"
bind:value={outputFormat}
class="
w-full px-3 py-2
bg-white text-ink
border border-grey-200
rounded-md
text-sm
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
transition-colors duration-150
"
>
{#each CONSTRAINTS.OUTPUT_FORMAT_OPTIONS as format}
<option value={format}>{format.toUpperCase()}</option>
{/each}
</select>
</div>
</div>
<!-- Preview -->
<p class="text-xs text-grey-400 font-mono">
Preview: {width} × {height} · {aspectRatio()} aspect
</p>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="
px-4 py-2
bg-white text-ink
border border-grey-200
rounded-md
text-sm font-medium
hover:bg-grey-50 hover:border-grey-300
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
transition-colors duration-150
"
onclick={handleClose}
>
Cancel
</button>
<button
type="submit"
disabled={!isFormValid}
class="
px-4 py-2
bg-accent text-white
rounded-md
text-sm font-medium
hover:bg-accent-hover
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
transition-colors duration-150
disabled:opacity-50 disabled:cursor-not-allowed
"
>
Add Device
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { deviceStore } from '$lib/stores/device.svelte';
import { groupDevicesByBrand } from '$lib/data/devices';
interface Props {
onCustomClick: () => void;
}
let { onCustomClick }: Props = $props();
const groupedDevices = $derived(groupDevicesByBrand(deviceStore.allDevices));
function handleChange(e: Event): void {
const select = e.target as HTMLSelectElement;
deviceStore.selectDevice(select.value);
}
// Format device info string
const deviceInfo = $derived(
`${deviceStore.selected.width} × ${deviceStore.selected.height} · ${deviceStore.selected.greyLevels} levels · ${deviceStore.selected.outputFormat.toUpperCase()}`
);
</script>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<label for="device-select" class="sr-only">Select device</label>
<select
id="device-select"
class="
flex-1 px-3 py-2
bg-white text-ink
border border-grey-200
rounded-md
text-sm
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
transition-colors duration-150
"
value={deviceStore.selected.id}
onchange={handleChange}
>
{#each groupedDevices as [brand, devices]}
<optgroup label={brand}>
{#each devices as device}
<option value={device.id}>
{device.name}
</option>
{/each}
</optgroup>
{/each}
</select>
<button
type="button"
class="
flex items-center gap-1.5
px-3 py-2
bg-white text-ink
border border-grey-200
rounded-md
text-sm font-medium
hover:bg-grey-50 hover:border-grey-300
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
transition-colors duration-150
"
onclick={onCustomClick}
>
<Plus class="w-4 h-4" />
<span class="hidden sm:inline">Custom</span>
</button>
</div>
<p class="text-xs text-grey-400 font-mono">
{deviceInfo}
</p>
</div>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { Upload } from 'lucide-svelte';
import { CONSTRAINTS } from '$lib/types';
interface Props {
onFiles: (files: FileList) => void;
}
let { onFiles }: Props = $props();
let isDragOver = $state(false);
let fileInput: HTMLInputElement;
function handleDragOver(e: DragEvent): void {
e.preventDefault();
isDragOver = true;
}
function handleDragLeave(e: DragEvent): void {
e.preventDefault();
isDragOver = false;
}
function handleDrop(e: DragEvent): void {
e.preventDefault();
isDragOver = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
onFiles(files);
}
}
function handleClick(): void {
fileInput.click();
}
function handleFileChange(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
onFiles(input.files);
// Reset input so same file can be selected again
input.value = '';
}
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}
</script>
<div
role="button"
tabindex="0"
class="
relative
flex flex-col items-center justify-center
min-h-[200px] p-8
border-2 border-dashed rounded-lg
cursor-pointer
transition-all duration-200
{isDragOver
? 'border-accent bg-accent/5 ring-4 ring-accent/20'
: 'border-grey-300 bg-grey-50 hover:border-accent hover:bg-accent/5'}
"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={handleClick}
onkeydown={handleKeyDown}
>
<input
bind:this={fileInput}
type="file"
accept={CONSTRAINTS.SUPPORTED_FORMATS.join(',')}
multiple
class="hidden"
onchange={handleFileChange}
/>
<Upload
class="w-10 h-10 mb-4 transition-colors duration-200 {isDragOver
? 'text-accent'
: 'text-grey-400'}"
/>
<p class="text-sm font-medium text-grey-500 mb-1">
{#if isDragOver}
Drop images here
{:else}
Drop images here or click to browse
{/if}
</p>
<p class="text-xs text-grey-400">
JPG, PNG, WebP &middot; Max {CONSTRAINTS.MAX_FILE_SIZE_MB}MB
</p>
</div>