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