nexus/.planning/phases/42-wallpapers-social-format-conversion-voice/42-06-PLAN.md

262 lines
12 KiB
Markdown

---
phase: 42-wallpapers-social-format-conversion-voice
plan: 06
type: execute
wave: 3
depends_on: [42-03]
files_modified:
- ui/src/pages/ConvertPage.tsx
- ui/src/components/ConvertPanel.tsx
- ui/src/api/convert.ts
- ui/src/App.tsx
autonomous: true
requirements: [CONV-01, CONV-02, CONV-03, CONV-04, CONV-05, CONV-06, CONV-07, CONV-08, CONV-09]
must_haves:
truths:
- "User can drag-drop a file, select a target format, and download the converted file"
- "Format chips are grouped by Images, Audio/Video, Documents, Data"
- "All format pairs are selectable — unavailable direct converters show AI fallback notice"
- "Deep-link /convert/png/svg pre-selects PNG source and SVG target on mount"
- "MIME validation error shows inline when magic bytes mismatch extension"
- "Drag-drop zone shows correct states (idle, dragover, error)"
- "Convert button is disabled until both source file and target format are selected"
artifacts:
- path: "ui/src/pages/ConvertPage.tsx"
provides: "Standalone /convert page with deep-link routing"
contains: "sourceFormat"
- path: "ui/src/components/ConvertPanel.tsx"
provides: "Drag-drop zone + format chips + convert action bar"
contains: "ConvertSourceZone"
- path: "ui/src/api/convert.ts"
provides: "submitConvertJob (multipart), getConverterCapabilities"
exports: ["submitConvertJob", "getConverterCapabilities"]
- path: "ui/src/App.tsx"
provides: "Routes for /convert, /convert/:sourceFormat, /convert/:sourceFormat/:targetFormat"
contains: "ConvertPage"
key_links:
- from: "ui/src/pages/ConvertPage.tsx"
to: "ui/src/components/ConvertPanel.tsx"
via: "renders ConvertPanel with route params"
pattern: "ConvertPanel"
- from: "ui/src/components/ConvertPanel.tsx"
to: "ui/src/api/convert.ts"
via: "submitConvertJob for multipart upload"
pattern: "submitConvertJob"
- from: "ui/src/api/convert.ts"
to: "/api/companies/:companyId/convert"
via: "FormData multipart POST"
pattern: "FormData"
- from: "ui/src/App.tsx"
to: "ui/src/pages/ConvertPage.tsx"
via: "Route path convert"
pattern: "convert"
---
<objective>
Build the format conversion UI page with drag-drop upload, grouped format chips, AI fallback indicator, deep-link routing, and inline MIME validation errors.
Purpose: This surfaces the conversion renderer (Plan 03) to users. It fulfills the UI side of all CONV requirements.
Output: ConvertPage, ConvertPanel, convert API client, routes in App.tsx.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-UI-SPEC.md
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-03-SUMMARY.md
@ui/src/App.tsx
@ui/src/api/contentJobs.ts
@ui/src/api/client.ts
@ui/src/hooks/useContentJob.ts
@ui/src/components/ChatFileDropZone.tsx
</context>
<interfaces>
From server/src/routes/convert.ts (Plan 03):
```
POST /api/companies/:companyId/convert — multipart/form-data with fields: file, targetFormat
Returns: 202 { jobId, status: "queued" }
Error: 422 { error, actualMime, claimedMime }
GET /api/system/converters
Returns: { imageConverter: boolean, audioVideoConverter: boolean, docConverter: boolean, dataConverter: boolean }
```
From server/src/services/renderers/types.ts:
```typescript
interface ConvertBundle {
type: "convert-bundle";
outputFilename: string;
outputMime: string;
outputBase64: string;
method: "direct" | "ai-bridge";
}
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create convert API client and ConvertPanel component</name>
<files>ui/src/api/convert.ts, ui/src/components/ConvertPanel.tsx</files>
<read_first>ui/src/api/contentJobs.ts, ui/src/api/client.ts, ui/src/components/ChatFileDropZone.tsx, ui/src/hooks/useContentJob.ts</read_first>
<action>
1. Create ui/src/api/convert.ts:
- Export async function submitConvertJob(companyId: string, file: File, targetFormat: string): Promise<{ jobId: string; status: string } | { error: string; actualMime: string; claimedMime: string }>
- Build FormData with file and targetFormat
- POST to /api/companies/{companyId}/convert with FormData (do NOT set Content-Type header — browser sets multipart boundary)
- If response status === 422: return the MIME validation error body
- If response status === 202: return { jobId, status }
- Throw on other errors
- Export async function getConverterCapabilities(): Promise<{ imageConverter: boolean; audioVideoConverter: boolean; docConverter: boolean; dataConverter: boolean }>
- GET /api/system/converters
- Return JSON body
2. Create ui/src/components/ConvertPanel.tsx — this is a large component containing three visual zones:
**FORMAT_GROUPS constant** (define at top of file):
```typescript
const FORMAT_GROUPS = {
Images: ["png", "jpg", "svg", "webp", "gif"],
"Audio/Video": ["mp3", "mp4", "wav", "ogg", "webm"],
Documents: ["md", "html", "pdf", "docx"],
Data: ["csv", "json", "xlsx"],
};
```
**Props:** { initialSourceFormat?: string; initialTargetFormat?: string; companyId: string }
**State:**
- file: File | null
- targetFormat: string | null (selected chip)
- mimeError: { actualMime: string; claimedMime: string } | null
- dragOver: boolean
- capabilities: from getConverterCapabilities() (fetch on mount)
- Use useContentJob(companyId) for job tracking after submit
**ConvertSourceZone (left column):**
- Drag-drop zone: min-h-[120px], p-6, dashed border (2px dashed var(--border))
- role="button", tabIndex={0}, aria-label="Upload file - drop here or press Enter to browse"
- onKeyDown: Enter/Space triggers hidden file input click
- onDragOver: set dragOver=true, prevent default
- onDragLeave: set dragOver=false
- onDrop: extract file, set dragOver=false
- Idle state: bg-secondary border, copy "Drop a file here or click to browse" (or "Drop a {FORMAT} file here or click to browse" if initialSourceFormat provided)
- Dragover state: bg-accent/30, border-primary, copy "Release to select"
- After file selected: show filename (text-sm font-medium), file size (text-xs text-muted-foreground), detected MIME (text-xs font-mono text-muted-foreground)
- MIME error state: bg-destructive/10, border-destructive, error copy: "File extension does not match content. Got {actualMime}, expected {claimedMime}."
- Click anywhere in zone to re-select file
**ConvertTargetSelector (right column):**
- Render FORMAT_GROUPS as sections. Each group has a label (text-sm font-medium) and a row of chips
- Each chip: rounded-full px-3 py-1 text-xs font-medium
- Idle: bg-muted text-muted-foreground. Selected: bg-primary text-primary-foreground
- All chips are always selectable — never disabled (CONV-08: unavailable direct paths fall to AI bridge)
- role="radiogroup" on container, role="radio" + aria-checked on each chip
- When selected pair has no direct converter (check capabilities: e.g. Documents group when docConverter===false), show AI fallback notice below chips:
"No direct converter for this pair - AI bridge will be used." (text-xs text-muted-foreground, Info icon 14px, bg-secondary rounded-md p-3)
**ConvertActionBar (full width below):**
- "Convert File" Button (primary, disabled until file !== null AND targetFormat !== null)
- On click: call submitConvertJob. If result has "error" key: set mimeError. If result has "jobId": track via useContentJob SSE.
- Progress bar below button (same SSE pattern)
- After job done with ConvertBundle: "Download {outputFilename}" Button (primary) — create blob from outputBase64, trigger download
- Error state: "Conversion failed - {detail}. Try again."
- Empty state: heading "No conversion yet", body "Upload a file and choose a target format to convert."
**Layout:** Two-column on desktop (grid grid-cols-1 md:grid-cols-2 gap-6), single column on mobile.
**prefers-reduced-motion:** Disable drag-drop zone color transition.
**Initial format pre-selection:** If initialSourceFormat or initialTargetFormat props provided, pre-select the corresponding chip (case-insensitive normalize per Pitfall 6).
</action>
<verify>
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep "submitConvertJob" ui/src/api/convert.ts
- grep "getConverterCapabilities" ui/src/api/convert.ts
- grep "FormData" ui/src/api/convert.ts
- grep "FORMAT_GROUPS" ui/src/components/ConvertPanel.tsx
- grep "ConvertSourceZone\|drag" ui/src/components/ConvertPanel.tsx
- grep "role=\"radiogroup\"" ui/src/components/ConvertPanel.tsx
- grep "AI bridge" ui/src/components/ConvertPanel.tsx
- grep "Convert File" ui/src/components/ConvertPanel.tsx
- grep "actualMime" ui/src/components/ConvertPanel.tsx
- grep "initialSourceFormat" ui/src/components/ConvertPanel.tsx
</acceptance_criteria>
<done>Convert API client handles multipart upload and MIME errors. ConvertPanel has drag-drop zone, grouped format chips, AI fallback notice, and download.</done>
</task>
<task type="auto">
<name>Task 2: Create ConvertPage and wire routes in App.tsx</name>
<files>ui/src/pages/ConvertPage.tsx, ui/src/App.tsx</files>
<read_first>ui/src/App.tsx, ui/src/pages/ContentStudio.tsx</read_first>
<action>
1. Create ui/src/pages/ConvertPage.tsx:
- Read sourceFormat and targetFormat from URL params using useParams()
- Normalize params to lowercase (Pitfall 6: case-insensitive)
- If params are invalid format strings not in FORMAT_GROUPS values, silently ignore (render default state)
- Get companyId from context (same pattern as ContentStudio.tsx)
- Render page heading "Convert File" (h1, heading style from UI spec)
- Render ConvertPanel with { initialSourceFormat, initialTargetFormat, companyId }
2. Update ui/src/App.tsx:
- Add lazy import for ConvertPage:
```typescript
const ConvertPage = lazy(() => import("./pages/ConvertPage").then(m => ({ default: m.ConvertPage })));
```
- Add three routes inside boardRoutes() function, after the content-studio route:
```tsx
<Route path="convert" element={<ConvertPage />} />
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
```
This gives deep-link support: /convert/png/svg pre-selects PNG as source filter and SVG as target chip.
</action>
<verify>
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep "ConvertPage" ui/src/pages/ConvertPage.tsx
- grep "sourceFormat" ui/src/pages/ConvertPage.tsx
- grep "targetFormat" ui/src/pages/ConvertPage.tsx
- grep "toLowerCase" ui/src/pages/ConvertPage.tsx
- grep "ConvertPage" ui/src/App.tsx
- grep "convert/:sourceFormat/:targetFormat" ui/src/App.tsx
- grep "convert/:sourceFormat" ui/src/App.tsx
</acceptance_criteria>
<done>ConvertPage reads URL params for deep-link pre-selection. Three route variants wired in App.tsx. Format params normalized case-insensitively.</done>
</task>
</tasks>
<verification>
- `cd /opt/nexus/ui && npx tsc --noEmit` passes
- /convert route renders ConvertPage with ConvertPanel
- /convert/png/svg pre-selects PNG and SVG
- All format chips are always selectable (never disabled)
- AI fallback notice shows for pairs without direct converter
</verification>
<success_criteria>
- Drag-drop upload with correct idle/dragover/error states
- Format chips grouped by category, all selectable
- AI fallback notice for unavailable direct paths
- Deep-link routing with case-insensitive format params
- MIME validation error shown inline on magic-byte mismatch
- Download button after successful conversion
- tsc compiles cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-06-SUMMARY.md`
</output>