--- phase: 29 plan: 03 type: fullstack wave: 2 depends_on: [01, 02] files_modified: - src/client/components/ImageCropEditor.tsx - src/client/components/ImageUpload.tsx - src/client/routes/items/$itemId.tsx - src/client/routes/global-items/$globalItemId.tsx - src/client/routes/threads/$threadId/candidates/$candidateId.tsx - src/client/hooks/useItems.ts - package.json autonomous: true requirements: [] --- Implement the zoom+pan image framing editor using react-easy-crop. Users can adjust image framing during upload (ImageUpload) and from detail pages (item, global item, candidate). Crop settings (zoom, x, y) persist to the database via existing CRUD endpoints. ### Task 1: Install react-easy-crop Run `bun add react-easy-crop` to install the crop editor library. grep '"react-easy-crop"' package.json && echo "PASS" || echo "FAIL" - package.json contains `"react-easy-crop"` in dependencies ### Task 2: Create ImageCropEditor component - src/client/components/ImageUpload.tsx - src/client/components/GearImage.tsx Create `src/client/components/ImageCropEditor.tsx`: ```tsx import { useCallback, useState } from "react"; import Cropper from "react-easy-crop"; import type { Area, Point } from "react-easy-crop"; interface CropResult { zoom: number; x: number; y: number; } interface ImageCropEditorProps { imageUrl: string; dominantColor?: string | null; initialZoom?: number; initialX?: number; initialY?: number; aspect?: number; onSave: (result: CropResult) => void; onCancel: () => void; } export function ImageCropEditor({ imageUrl, dominantColor, initialZoom = 1, initialX = 0, initialY = 0, aspect = 4 / 3, onSave, onCancel, }: ImageCropEditorProps) { const [crop, setCrop] = useState({ x: initialX, y: initialY }); const [zoom, setZoom] = useState(initialZoom); const onCropComplete = useCallback((_croppedArea: Area, _croppedAreaPixels: Area) => { // We use the crop/zoom state directly, not the callback values }, []); function handleSave() { onSave({ zoom, x: crop.x, y: crop.y, }); } return (
{/* Crop area */}
{/* Zoom slider */}
setZoom(Number(e.target.value))} className="flex-1 h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer accent-gray-900" />
{/* Action buttons */}
); } ``` The component: - Uses react-easy-crop `Cropper` with `objectFit="contain"` so images fit within the frame - Min zoom 1.0 (fit-within), max zoom 3.0 - Zoom slider between zoom-out and zoom-in icons - "Cancel" (ghost) and "Save framing" (primary) buttons - Returns `{ zoom, x, y }` on save - Background color uses dominant color from the image
test -f src/client/components/ImageCropEditor.tsx && grep "react-easy-crop" src/client/components/ImageCropEditor.tsx && grep "Save framing" src/client/components/ImageCropEditor.tsx && echo "PASS" || echo "FAIL" - `src/client/components/ImageCropEditor.tsx` exists - Imports `Cropper` from `react-easy-crop` - `objectFit="contain"` set on Cropper - Min zoom 1, max zoom 3 - Zoom slider with range input - "Cancel" button calls `onCancel` - "Save framing" button calls `onSave` with `{ zoom, x, y }` - Dominant color used as background
### Task 3: Add crop editor to ImageUpload - src/client/components/ImageUpload.tsx - src/client/components/ImageCropEditor.tsx Update `src/client/components/ImageUpload.tsx`: 1. Add `onCropChange?: (crop: { zoom: number; x: number; y: number }) => void` to `ImageUploadProps` 2. Add `cropZoom?: number | null`, `cropX?: number | null`, `cropY?: number | null` to props 3. Add state: `const [showCropEditor, setShowCropEditor] = useState(false);` 4. After successful upload (`onChange(result.filename)`), set `setShowCropEditor(true)` 5. When crop editor is visible, replace the image preview area with the `ImageCropEditor` component 6. On save: call `onCropChange?.({ zoom, x, y })` and `setShowCropEditor(false)` 7. On cancel: `setShowCropEditor(false)` 8. Import `ImageCropEditor` The crop editor appears inline in the same container where the preview image normally shows, replacing the static preview temporarily. grep "ImageCropEditor" src/client/components/ImageUpload.tsx && grep "showCropEditor" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL" - ImageUpload imports and conditionally renders ImageCropEditor - Editor appears after successful upload - `onCropChange` callback fires with zoom/x/y values - Editor can be dismissed via Cancel - Save triggers crop change callback ### Task 4: Add "Adjust framing" to item detail page - src/client/routes/items/$itemId.tsx - src/client/components/ImageCropEditor.tsx - src/client/hooks/useItems.ts In `src/client/routes/items/$itemId.tsx`: 1. Import `ImageCropEditor` 2. Add state: `const [editingCrop, setEditingCrop] = useState(false)` 3. Below the image area (after the `aspect-[4/3]` div), add an "Adjust framing" button: ```tsx {item.imageUrl && ( )} ``` 4. When `editingCrop` is true, replace the GearImage area with `ImageCropEditor`: ```tsx {editingCrop ? ( { await updateItem({ id: item.id, cropZoom: crop.zoom, cropX: crop.x, cropY: crop.y }); setEditingCrop(false); }} onCancel={() => setEditingCrop(false)} /> ) : ( /* existing GearImage rendering */ )} ``` 5. Use the existing `useUpdateItem` mutation to persist crop values grep "Adjust framing" src/client/routes/items/\$itemId.tsx && grep "ImageCropEditor" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL" - Item detail page shows "Adjust framing" button when image exists - Clicking button shows ImageCropEditor inline - Save persists crop values via updateItem mutation - Cancel returns to normal image view ### Task 5: Add "Adjust framing" to global item detail page - src/client/routes/global-items/$globalItemId.tsx - src/client/components/ImageCropEditor.tsx Same pattern as Task 4 but for global item detail: 1. Import ImageCropEditor and useState 2. Add "Adjust framing" button below image 3. Toggle between GearImage and ImageCropEditor 4. Use `aspect={16/9}` to match the global item detail page aspect ratio 5. Use the appropriate mutation to persist crop values for global items grep "Adjust framing" src/client/routes/global-items/\$globalItemId.tsx && grep "ImageCropEditor" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL" - Global item detail shows "Adjust framing" button - ImageCropEditor uses aspect 16/9 - Crop values persist via mutation ### Task 6: Add "Adjust framing" to candidate detail page - src/client/routes/threads/$threadId/candidates/$candidateId.tsx - src/client/components/ImageCropEditor.tsx Same pattern as Task 4/5 but for candidate detail: 1. Import ImageCropEditor and useState 2. Add "Adjust framing" button below image 3. Toggle between GearImage and ImageCropEditor 4. Use `aspect={16/9}` to match the candidate detail page aspect ratio 5. Use candidate update mutation to persist crop values grep "Adjust framing" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && grep "ImageCropEditor" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && echo "PASS" || echo "FAIL" - Candidate detail shows "Adjust framing" button - ImageCropEditor uses aspect 16/9 - Crop values persist via candidate update mutation
1. `bun run lint` passes 2. `bun run build` passes 3. ImageCropEditor component renders react-easy-crop Cropper 4. "Adjust framing" button appears on all 3 detail pages when image exists 5. Crop values round-trip: set in editor → save → reload page → image renders with saved crop - react-easy-crop installed - ImageCropEditor component created with zoom slider and save/cancel actions - ImageUpload shows crop editor after upload - Item, global item, and candidate detail pages have "Adjust framing" button - Crop values persist through CRUD endpoints - Crop values render correctly via GearImage component | Threat | Severity | Mitigation | |--------|----------|------------| | Crop values outside expected range | Low | Server-side validation via Zod schema (nullable number) | | react-easy-crop supply chain | Low | MIT license, 1M+ weekly downloads, actively maintained | - [ ] react-easy-crop installed - [ ] ImageCropEditor component with zoom slider - [ ] Crop editor in ImageUpload (post-upload) - [ ] "Adjust framing" on item detail page - [ ] "Adjust framing" on global item detail page - [ ] "Adjust framing" on candidate detail page - [ ] Crop values persist to database