diff --git a/app/src/App.css b/app/src/App.css new file mode 100644 index 0000000..b6c22ed --- /dev/null +++ b/app/src/App.css @@ -0,0 +1,172 @@ +/* ── Global component styles for Kerf Engine app ── */ + +/* File Upload Zone */ +.file-upload-zone { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 24px 16px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s; + user-select: none; +} + +.file-upload-zone:hover, +.file-upload-zone--drag-over { + border-color: var(--accent); + background: var(--accent-bg); +} + +.file-upload-zone:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.file-upload-prompt { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.file-upload-icon { + font-size: 28px; +} + +.file-upload-prompt p { + margin: 0; + color: var(--text-h); + font-size: 15px; +} + +.file-upload-prompt small { + color: var(--text); + font-size: 13px; +} + +.file-upload-preview { + display: flex; + align-items: center; + gap: 12px; +} + +.file-upload-thumb { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border); +} + +.file-upload-info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + text-align: left; +} + +.file-upload-name { + font-size: 14px; + font-weight: 500; + color: var(--text-h); + word-break: break-all; +} + +.file-upload-size { + font-size: 13px; + color: var(--text); +} + +.file-upload-badge { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--accent); + background: var(--accent-bg); + padding: 2px 6px; + border-radius: 3px; +} + +/* Preset Selector */ +.preset-selector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.preset-heading { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.preset-loading, +.preset-error { + font-size: 14px; + color: var(--text); + margin: 0; +} + +.preset-error { + color: #e74c3c; +} + +.preset-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.preset-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + cursor: pointer; + text-align: left; + transition: border-color 0.12s, background-color 0.12s; + font-family: inherit; +} + +.preset-card:hover { + border-color: var(--accent-border); + background: var(--accent-bg); +} + +.preset-card--selected { + border-color: var(--accent); + background: var(--accent-bg); + box-shadow: 0 0 0 1px var(--accent); +} + +.preset-card-name { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + text-transform: capitalize; +} + +.preset-card-desc { + font-size: 12px; + color: var(--text); + line-height: 1.3; +} + +/* Placeholder views */ +.placeholder-view { + display: flex; + align-items: center; + justify-content: center; + min-height: 60vh; + font-size: 20px; + color: var(--text); +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 8e73d12..f1dd640 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,10 +1,28 @@ +import { useState } from 'react'; +import type { TraceMetadata } from './types/engine'; +import ImportConvert from './views/ImportConvert'; +import './App.css'; + +type ViewState = 'import' | 'canvas' | 'export'; + function App() { + const [view, setView] = useState('import'); + const [_svgResult, setSvgResult] = useState(null); + const [_traceMetadata, setTraceMetadata] = useState(null); + + const handleUseThis = (svgOutput: string, metadata: unknown) => { + setSvgResult(svgOutput); + setTraceMetadata(metadata as TraceMetadata); + setView('canvas'); + }; + return (
-

Kerf Engine

-

Import & Convert view coming in T02.

+ {view === 'import' && } + {view === 'canvas' &&
View 2: Design Canvas
} + {view === 'export' &&
View 3: Export
}
- ) + ); } -export default App +export default App; diff --git a/app/src/components/FileUpload.tsx b/app/src/components/FileUpload.tsx new file mode 100644 index 0000000..d22fc9e --- /dev/null +++ b/app/src/components/FileUpload.tsx @@ -0,0 +1,124 @@ +import { useCallback, useRef, useState } from 'react'; + +const ACCEPTED_TYPES = '.png,.jpg,.jpeg,.bmp,.tiff,.webp,.svg'; + +interface FileUploadProps { + onFileSelect: (file: File, isSvg: boolean) => void; + selectedFile: File | null; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function isSvgFile(file: File): boolean { + return ( + file.type === 'image/svg+xml' || + file.name.toLowerCase().endsWith('.svg') + ); +} + +export default function FileUpload({ onFileSelect, selectedFile }: FileUploadProps) { + const inputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + + const handleFile = useCallback( + (file: File) => { + // Revoke previous thumbnail URL to avoid memory leaks + if (thumbnailUrl) { + URL.revokeObjectURL(thumbnailUrl); + } + const url = URL.createObjectURL(file); + setThumbnailUrl(url); + onFileSelect(file, isSvgFile(file)); + }, + [onFileSelect, thumbnailUrl], + ); + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const onDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const onInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + return ( +
inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click(); + }} + aria-label="Upload image file" + > + + {selectedFile ? ( +
+ {thumbnailUrl && ( + Uploaded preview + )} +
+ {selectedFile.name} + + {formatFileSize(selectedFile.size)} + + {isSvgFile(selectedFile) && ( + SVG — simplify mode + )} +
+
+ ) : ( +
+ 📁 +

+ Drop an image here or click to browse +

+ PNG, JPG, BMP, TIFF, WebP, or SVG +
+ )} +
+ ); +} diff --git a/app/src/components/PresetSelector.tsx b/app/src/components/PresetSelector.tsx new file mode 100644 index 0000000..032fbea --- /dev/null +++ b/app/src/components/PresetSelector.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { getPresets } from '../api/engine'; +import type { PresetConfig } from '../types/engine'; + +interface PresetSelectorProps { + selectedPreset: string; + onPresetSelect: (presetName: string, config: PresetConfig) => void; +} + +export default function PresetSelector({ + selectedPreset, + onPresetSelect, +}: PresetSelectorProps) { + const [presets, setPresets] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + + getPresets(controller.signal) + .then((res) => { + setPresets(res.presets); + setLoading(false); + + // Auto-select default preset if none selected yet, or if current selection doesn't exist + if (!res.presets[selectedPreset]) { + const defaultKey = res.presets['sign'] ? 'sign' : Object.keys(res.presets)[0]; + if (defaultKey && res.presets[defaultKey]) { + onPresetSelect(defaultKey, res.presets[defaultKey]); + } + } + }) + .catch((err) => { + if (err instanceof Error && err.name === 'AbortError') return; + setError(err instanceof Error ? err.message : 'Failed to load presets'); + setLoading(false); + }); + + return () => controller.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (loading) { + return

Loading presets…

; + } + + if (error) { + return ( +
+

⚠ {error}

+
+ ); + } + + const entries = Object.entries(presets); + + if (entries.length === 0) { + return

No presets available.

; + } + + return ( +
+

Preset

+
+ {entries.map(([key, config]) => ( + + ))} +
+
+ ); +} diff --git a/app/src/views/ImportConvert.module.css b/app/src/views/ImportConvert.module.css new file mode 100644 index 0000000..7b828ac --- /dev/null +++ b/app/src/views/ImportConvert.module.css @@ -0,0 +1,48 @@ +/* Import & Convert (View 1) layout */ + +.container { + display: flex; + gap: 24px; + padding: 24px; + min-height: calc(100svh - 80px); + box-sizing: border-box; +} + +.leftPanel { + flex: 0 0 340px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.rightPanel { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 8px; + min-height: 400px; + background: var(--code-bg); + padding: 16px; +} + +.previewPlaceholder { + color: var(--text); + font-size: 15px; + text-align: center; + opacity: 0.7; +} + +@media (max-width: 768px) { + .container { + flex-direction: column; + padding: 16px; + } + + .leftPanel { + flex: none; + width: 100%; + } +} diff --git a/app/src/views/ImportConvert.tsx b/app/src/views/ImportConvert.tsx new file mode 100644 index 0000000..2025f0a --- /dev/null +++ b/app/src/views/ImportConvert.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import type { PresetConfig } from '../types/engine'; +import FileUpload from '../components/FileUpload'; +import PresetSelector from '../components/PresetSelector'; +import styles from './ImportConvert.module.css'; + +interface ImportConvertProps { + onUseThis: (svgOutput: string, metadata: unknown) => void; +} + +export default function ImportConvert({ onUseThis: _onUseThis }: ImportConvertProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [_isSvgMode, setIsSvgMode] = useState(false); + const [selectedPreset, setSelectedPreset] = useState('sign'); + const [_presetConfig, setPresetConfig] = useState(null); + + const handleFileSelect = (file: File, isSvg: boolean) => { + setSelectedFile(file); + setIsSvgMode(isSvg); + }; + + const handlePresetSelect = (name: string, config: PresetConfig) => { + setSelectedPreset(name); + setPresetConfig(config); + }; + + return ( +
+
+ + +
+
+
+ {selectedFile + ? 'Preview will appear here after tracing (T03)' + : 'Upload an image to begin vectorization'} +
+
+
+ ); +}