feat: Built app shell with ViewState routing, drag-and-drop FileUpload…

- "app/src/App.tsx"
- "app/src/App.css"
- "app/src/views/ImportConvert.tsx"
- "app/src/views/ImportConvert.module.css"
- "app/src/components/FileUpload.tsx"
- "app/src/components/PresetSelector.tsx"

GSD-Task: S01/T02
This commit is contained in:
jlightner 2026-03-26 05:07:37 +00:00
parent fda6bfbafc
commit 35bc542aad
6 changed files with 491 additions and 4 deletions

172
app/src/App.css Normal file
View file

@ -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);
}

View file

@ -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<ViewState>('import');
const [_svgResult, setSvgResult] = useState<string | null>(null);
const [_traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
const handleUseThis = (svgOutput: string, metadata: unknown) => {
setSvgResult(svgOutput);
setTraceMetadata(metadata as TraceMetadata);
setView('canvas');
};
return (
<div id="app">
<h1>Kerf Engine</h1>
<p>Import &amp; Convert view coming in T02.</p>
{view === 'import' && <ImportConvert onUseThis={handleUseThis} />}
{view === 'canvas' && <div className="placeholder-view">View 2: Design Canvas</div>}
{view === 'export' && <div className="placeholder-view">View 3: Export</div>}
</div>
)
);
}
export default App
export default App;

View file

@ -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<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
},
[handleFile],
);
return (
<div
className={`file-upload-zone ${isDragOver ? 'file-upload-zone--drag-over' : ''}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
}}
aria-label="Upload image file"
>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={onInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
{selectedFile ? (
<div className="file-upload-preview">
{thumbnailUrl && (
<img
src={thumbnailUrl}
alt="Uploaded preview"
className="file-upload-thumb"
/>
)}
<div className="file-upload-info">
<span className="file-upload-name">{selectedFile.name}</span>
<span className="file-upload-size">
{formatFileSize(selectedFile.size)}
</span>
{isSvgFile(selectedFile) && (
<span className="file-upload-badge">SVG simplify mode</span>
)}
</div>
</div>
) : (
<div className="file-upload-prompt">
<span className="file-upload-icon">📁</span>
<p>
<strong>Drop an image here</strong> or click to browse
</p>
<small>PNG, JPG, BMP, TIFF, WebP, or SVG</small>
</div>
)}
</div>
);
}

View file

@ -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<Record<string, PresetConfig>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="preset-selector"><p className="preset-loading">Loading presets</p></div>;
}
if (error) {
return (
<div className="preset-selector">
<p className="preset-error"> {error}</p>
</div>
);
}
const entries = Object.entries(presets);
if (entries.length === 0) {
return <div className="preset-selector"><p>No presets available.</p></div>;
}
return (
<div className="preset-selector">
<h3 className="preset-heading">Preset</h3>
<div className="preset-grid">
{entries.map(([key, config]) => (
<button
key={key}
className={`preset-card ${selectedPreset === key ? 'preset-card--selected' : ''}`}
onClick={() => onPresetSelect(key, config)}
type="button"
>
<span className="preset-card-name">{config.name}</span>
<span className="preset-card-desc">{config.description}</span>
</button>
))}
</div>
</div>
);
}

View file

@ -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%;
}
}

View file

@ -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<File | null>(null);
const [_isSvgMode, setIsSvgMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState('sign');
const [_presetConfig, setPresetConfig] = useState<PresetConfig | null>(null);
const handleFileSelect = (file: File, isSvg: boolean) => {
setSelectedFile(file);
setIsSvgMode(isSvg);
};
const handlePresetSelect = (name: string, config: PresetConfig) => {
setSelectedPreset(name);
setPresetConfig(config);
};
return (
<div className={styles.container}>
<div className={styles.leftPanel}>
<FileUpload onFileSelect={handleFileSelect} selectedFile={selectedFile} />
<PresetSelector
selectedPreset={selectedPreset}
onPresetSelect={handlePresetSelect}
/>
</div>
<div className={styles.rightPanel}>
<div className={styles.previewPlaceholder}>
{selectedFile
? 'Preview will appear here after tracing (T03)'
: 'Upload an image to begin vectorization'}
</div>
</div>
</div>
);
}