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:
265
src/lib/components/CustomDeviceModal.svelte
Normal file
265
src/lib/components/CustomDeviceModal.svelte
Normal 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}
|
||||
76
src/lib/components/DeviceSelector.svelte
Normal file
76
src/lib/components/DeviceSelector.svelte
Normal 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>
|
||||
101
src/lib/components/DropZone.svelte
Normal file
101
src/lib/components/DropZone.svelte
Normal 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 · Max {CONSTRAINTS.MAX_FILE_SIZE_MB}MB
|
||||
</p>
|
||||
</div>
|
||||
Reference in New Issue
Block a user