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:
parent
fda6bfbafc
commit
35bc542aad
6 changed files with 491 additions and 4 deletions
172
app/src/App.css
Normal file
172
app/src/App.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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 & 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;
|
||||
|
|
|
|||
124
app/src/components/FileUpload.tsx
Normal file
124
app/src/components/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
app/src/components/PresetSelector.tsx
Normal file
80
app/src/components/PresetSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/src/views/ImportConvert.module.css
Normal file
48
app/src/views/ImportConvert.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
45
app/src/views/ImportConvert.tsx
Normal file
45
app/src/views/ImportConvert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue