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
9be90a4494
commit
16d336913f
11 changed files with 639 additions and 17 deletions
|
|
@ -14,3 +14,4 @@
|
||||||
{"cmd":"complete-milestone","params":{"milestoneId":"M001"},"ts":"2026-03-26T04:56:43.004Z","actor":"agent","hash":"c877176040436ab9","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
{"cmd":"complete-milestone","params":{"milestoneId":"M001"},"ts":"2026-03-26T04:56:43.004Z","actor":"agent","hash":"c877176040436ab9","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S01"},"ts":"2026-03-26T05:01:43.661Z","actor":"agent","hash":"d83541fb49b2737b","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S01"},"ts":"2026-03-26T05:01:43.661Z","actor":"agent","hash":"d83541fb49b2737b","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T05:05:22.658Z","actor":"agent","hash":"59aebe24d8f53b7a","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T05:05:22.658Z","actor":"agent","hash":"59aebe24d8f53b7a","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
|
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T05:07:29.861Z","actor":"agent","hash":"a3980272c7b74afa","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
- Estimate: 45m
|
- Estimate: 45m
|
||||||
- Files: engine/main.py, app/package.json, app/vite.config.ts, app/tsconfig.json, app/src/types/engine.ts, app/src/api/engine.ts, app/src/api/__tests__/engine.test.ts, app/src/App.tsx
|
- Files: engine/main.py, app/package.json, app/vite.config.ts, app/tsconfig.json, app/src/types/engine.ts, app/src/api/engine.ts, app/src/api/__tests__/engine.test.ts, app/src/App.tsx
|
||||||
- Verify: cd app && npx vitest run --reporter=verbose 2>&1 | tail -20
|
- Verify: cd app && npx vitest run --reporter=verbose 2>&1 | tail -20
|
||||||
- [ ] **T02: Build app shell with view routing, file upload zone, and preset selector** — Build the first user-facing components: the app shell with view-state routing (Import → Canvas → Export), a drag-and-drop file upload zone, and a preset selector that fetches presets from the engine and populates parameter defaults.
|
- [x] **T02: Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout** — Build the first user-facing components: the app shell with view-state routing (Import → Canvas → Export), a drag-and-drop file upload zone, and a preset selector that fetches presets from the engine and populates parameter defaults.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
16
.gsd/milestones/M002/slices/S01/tasks/T01-VERIFY.json
Normal file
16
.gsd/milestones/M002/slices/S01/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T01",
|
||||||
|
"unitId": "M002/S01/T01",
|
||||||
|
"timestamp": 1774501531197,
|
||||||
|
"passed": true,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd app",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 5,
|
||||||
|
"verdict": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
86
.gsd/milestones/M002/slices/S01/tasks/T02-SUMMARY.md
Normal file
86
.gsd/milestones/M002/slices/S01/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S01
|
||||||
|
milestone: M002
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["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"]
|
||||||
|
key_decisions: ["Use CSS modules for view-specific layout, global App.css for shared component styles", "Underscore-prefix unused state variables to keep them wired for T03 without lint warnings"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 9 existing tests pass."
|
||||||
|
completed_at: 2026-03-26T05:07:29.816Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout
|
||||||
|
|
||||||
|
> Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S01
|
||||||
|
milestone: M002
|
||||||
|
key_files:
|
||||||
|
- 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
|
||||||
|
key_decisions:
|
||||||
|
- Use CSS modules for view-specific layout, global App.css for shared component styles
|
||||||
|
- Underscore-prefix unused state variables to keep them wired for T03 without lint warnings
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-03-26T05:07:29.827Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout
|
||||||
|
|
||||||
|
**Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Created the ImportConvert view (View 1) with a two-column flexbox layout: left panel for controls, right panel for the SVG preview placeholder. Built FileUpload component with drag-and-drop zone using onDragOver/onDrop handlers plus a hidden file input fallback, thumbnail rendering via URL.createObjectURL, file size display, and SVG file detection for mode switching. Built PresetSelector that fetches presets from the engine API on mount with AbortController cleanup, renders preset cards in a 2-column grid with selected state highlighting, and auto-selects 'sign' as the default. Updated App.tsx with a ViewState type union ('import' | 'canvas' | 'export') and conditional rendering. Created App.css with global styles for the upload zone, preset cards, and placeholder views. Wired state in ImportConvert for T03 consumption.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 9 existing tests pass.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 832ms |
|
||||||
|
| 2 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2000ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None. All 6 expected output files created as planned.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None. All 6 expected output files created as planned.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"exported_at": "2026-03-26T05:05:22.657Z",
|
"exported_at": "2026-03-26T05:07:29.861Z",
|
||||||
"milestones": [
|
"milestones": [
|
||||||
{
|
{
|
||||||
"id": "M001",
|
"id": "M001",
|
||||||
|
|
@ -879,19 +879,29 @@
|
||||||
"milestone_id": "M002",
|
"milestone_id": "M002",
|
||||||
"slice_id": "S01",
|
"slice_id": "S01",
|
||||||
"id": "T02",
|
"id": "T02",
|
||||||
"title": "Build app shell with view routing, file upload zone, and preset selector",
|
"title": "Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout",
|
||||||
"status": "pending",
|
"status": "complete",
|
||||||
"one_liner": "",
|
"one_liner": "Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout",
|
||||||
"narrative": "",
|
"narrative": "Created the ImportConvert view (View 1) with a two-column flexbox layout: left panel for controls, right panel for the SVG preview placeholder. Built FileUpload component with drag-and-drop zone using onDragOver/onDrop handlers plus a hidden file input fallback, thumbnail rendering via URL.createObjectURL, file size display, and SVG file detection for mode switching. Built PresetSelector that fetches presets from the engine API on mount with AbortController cleanup, renders preset cards in a 2-column grid with selected state highlighting, and auto-selects 'sign' as the default. Updated App.tsx with a ViewState type union ('import' | 'canvas' | 'export') and conditional rendering. Created App.css with global styles for the upload zone, preset cards, and placeholder views. Wired state in ImportConvert for T03 consumption.",
|
||||||
"verification_result": "",
|
"verification_result": "Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 9 existing tests pass.",
|
||||||
"duration": "",
|
"duration": "",
|
||||||
"completed_at": null,
|
"completed_at": "2026-03-26T05:07:29.816Z",
|
||||||
"blocker_discovered": false,
|
"blocker_discovered": false,
|
||||||
"deviations": "",
|
"deviations": "None. All 6 expected output files created as planned.",
|
||||||
"known_issues": "",
|
"known_issues": "None.",
|
||||||
"key_files": [],
|
"key_files": [
|
||||||
"key_decisions": [],
|
"app/src/App.tsx",
|
||||||
"full_summary_md": "",
|
"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"
|
||||||
|
],
|
||||||
|
"key_decisions": [
|
||||||
|
"Use CSS modules for view-specific layout, global App.css for shared component styles",
|
||||||
|
"Underscore-prefix unused state variables to keep them wired for T03 without lint warnings"
|
||||||
|
],
|
||||||
|
"full_summary_md": "---\nid: T02\nparent: S01\nmilestone: M002\nkey_files:\n - app/src/App.tsx\n - app/src/App.css\n - app/src/views/ImportConvert.tsx\n - app/src/views/ImportConvert.module.css\n - app/src/components/FileUpload.tsx\n - app/src/components/PresetSelector.tsx\nkey_decisions:\n - Use CSS modules for view-specific layout, global App.css for shared component styles\n - Underscore-prefix unused state variables to keep them wired for T03 without lint warnings\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:07:29.827Z\nblocker_discovered: false\n---\n\n# T02: Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout\n\n**Built app shell with ViewState routing, drag-and-drop FileUpload with thumbnail preview, PresetSelector fetching from engine API, and two-column ImportConvert layout**\n\n## What Happened\n\nCreated the ImportConvert view (View 1) with a two-column flexbox layout: left panel for controls, right panel for the SVG preview placeholder. Built FileUpload component with drag-and-drop zone using onDragOver/onDrop handlers plus a hidden file input fallback, thumbnail rendering via URL.createObjectURL, file size display, and SVG file detection for mode switching. Built PresetSelector that fetches presets from the engine API on mount with AbortController cleanup, renders preset cards in a 2-column grid with selected state highlighting, and auto-selects 'sign' as the default. Updated App.tsx with a ViewState type union ('import' | 'canvas' | 'export') and conditional rendering. Created App.css with global styles for the upload zone, preset cards, and placeholder views. Wired state in ImportConvert for T03 consumption.\n\n## Verification\n\nRan `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 9 existing tests pass.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 832ms |\n| 2 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2000ms |\n\n\n## Deviations\n\nNone. All 6 expected output files created as planned.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/App.tsx`\n- `app/src/App.css`\n- `app/src/views/ImportConvert.tsx`\n- `app/src/views/ImportConvert.module.css`\n- `app/src/components/FileUpload.tsx`\n- `app/src/components/PresetSelector.tsx`\n",
|
||||||
"description": "Build the first user-facing components: the app shell with view-state routing (Import → Canvas → Export), a drag-and-drop file upload zone, and a preset selector that fetches presets from the engine and populates parameter defaults.\n\n## Steps\n\n1. Create `app/src/views/ImportConvert.tsx` — the main View 1 container. Layout: left panel (upload + controls) and right panel (preview area, placeholder for now). Use CSS modules or a single `app/src/views/ImportConvert.module.css`.\n2. Update `app/src/App.tsx` with view-state routing using React useState:\n - `type ViewState = 'import' | 'canvas' | 'export'`\n - Render `ImportConvert` when state is 'import', placeholder `<div>View 2: Design Canvas</div>` for 'canvas', placeholder for 'export'\n - Pass a callback `onUseThis(svgOutput: string, metadata: TraceMetadata)` that transitions to 'canvas'\n3. Create `app/src/components/FileUpload.tsx`:\n - Drag-and-drop zone with `onDragOver`/`onDrop` handlers + hidden file `<input>` fallback\n - Accept: `.png,.jpg,.jpeg,.bmp,.tiff,.webp,.svg` via accept attribute\n - On file selection: store `File` in parent state, show filename + file size, render thumbnail via `URL.createObjectURL()`\n - Detect SVG uploads (check file type or extension) and surface this to parent for mode switching\n4. Create `app/src/components/PresetSelector.tsx`:\n - On mount: call `getPresets()` from `app/src/api/engine.ts`\n - Render preset cards — each shows name and description from the preset config\n - Clicking a card selects it (visual highlight) and calls `onPresetSelect(presetName, presetConfig)` callback\n - Default selection: 'sign' preset\n5. Create `app/src/App.css` (or global styles) with minimal layout: flexbox two-column layout for View 1, card styles for presets, drop zone styling with dashed border + hover state.\n6. Wire components together in `ImportConvert.tsx`: FileUpload at top, PresetSelector below it, both in the left panel. Store selected file and preset in local state.",
|
"description": "Build the first user-facing components: the app shell with view-state routing (Import → Canvas → Export), a drag-and-drop file upload zone, and a preset selector that fetches presets from the engine and populates parameter defaults.\n\n## Steps\n\n1. Create `app/src/views/ImportConvert.tsx` — the main View 1 container. Layout: left panel (upload + controls) and right panel (preview area, placeholder for now). Use CSS modules or a single `app/src/views/ImportConvert.module.css`.\n2. Update `app/src/App.tsx` with view-state routing using React useState:\n - `type ViewState = 'import' | 'canvas' | 'export'`\n - Render `ImportConvert` when state is 'import', placeholder `<div>View 2: Design Canvas</div>` for 'canvas', placeholder for 'export'\n - Pass a callback `onUseThis(svgOutput: string, metadata: TraceMetadata)` that transitions to 'canvas'\n3. Create `app/src/components/FileUpload.tsx`:\n - Drag-and-drop zone with `onDragOver`/`onDrop` handlers + hidden file `<input>` fallback\n - Accept: `.png,.jpg,.jpeg,.bmp,.tiff,.webp,.svg` via accept attribute\n - On file selection: store `File` in parent state, show filename + file size, render thumbnail via `URL.createObjectURL()`\n - Detect SVG uploads (check file type or extension) and surface this to parent for mode switching\n4. Create `app/src/components/PresetSelector.tsx`:\n - On mount: call `getPresets()` from `app/src/api/engine.ts`\n - Render preset cards — each shows name and description from the preset config\n - Clicking a card selects it (visual highlight) and calls `onPresetSelect(presetName, presetConfig)` callback\n - Default selection: 'sign' preset\n5. Create `app/src/App.css` (or global styles) with minimal layout: flexbox two-column layout for View 1, card styles for presets, drop zone styling with dashed border + hover state.\n6. Wire components together in `ImportConvert.tsx`: FileUpload at top, PresetSelector below it, both in the left panel. Store selected file and preset in local state.",
|
||||||
"estimate": "1h",
|
"estimate": "1h",
|
||||||
"files": [
|
"files": [
|
||||||
|
|
@ -1247,6 +1257,28 @@
|
||||||
"verdict": "✅ pass",
|
"verdict": "✅ pass",
|
||||||
"duration_ms": 2000,
|
"duration_ms": 2000,
|
||||||
"created_at": "2026-03-26T05:05:22.615Z"
|
"created_at": "2026-03-26T05:05:22.615Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"task_id": "T02",
|
||||||
|
"slice_id": "S01",
|
||||||
|
"milestone_id": "M002",
|
||||||
|
"command": "cd app && npx vitest run --reporter=verbose",
|
||||||
|
"exit_code": 0,
|
||||||
|
"verdict": "✅ pass",
|
||||||
|
"duration_ms": 832,
|
||||||
|
"created_at": "2026-03-26T05:07:29.816Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"task_id": "T02",
|
||||||
|
"slice_id": "S01",
|
||||||
|
"milestone_id": "M002",
|
||||||
|
"command": "cd app && npx tsc --noEmit",
|
||||||
|
"exit_code": 0,
|
||||||
|
"verdict": "✅ pass",
|
||||||
|
"duration_ms": 2000,
|
||||||
|
"created_at": "2026-03-26T05:07:29.816Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
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() {
|
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 (
|
return (
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>Kerf Engine</h1>
|
{view === 'import' && <ImportConvert onUseThis={handleUseThis} />}
|
||||||
<p>Import & Convert view coming in T02.</p>
|
{view === 'canvas' && <div className="placeholder-view">View 2: Design Canvas</div>}
|
||||||
|
{view === 'export' && <div className="placeholder-view">View 3: Export</div>}
|
||||||
</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