docs(29): fix plan file naming convention
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
361
.planning/phases/29-image-presentation/29-03-PLAN.md
Normal file
361
.planning/phases/29-image-presentation/29-03-PLAN.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
|
||||
### Task 1: Install react-easy-crop
|
||||
<task type="command">
|
||||
<action>
|
||||
Run `bun add react-easy-crop` to install the crop editor library.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep '"react-easy-crop"' package.json && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- package.json contains `"react-easy-crop"` in dependencies
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 2: Create ImageCropEditor component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/ImageUpload.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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<Point>({ 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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Crop area */}
|
||||
<div className="relative w-full" style={{ aspectRatio: `${aspect}` }}>
|
||||
<Cropper
|
||||
image={imageUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
minZoom={1}
|
||||
maxZoom={3}
|
||||
style={{
|
||||
containerStyle: {
|
||||
backgroundColor: dominantColor || "#f3f4f6",
|
||||
borderRadius: "0.75rem",
|
||||
},
|
||||
}}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<label htmlFor="crop-zoom" className="sr-only">Zoom</label>
|
||||
<svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
<path d="M8 11h6" />
|
||||
</svg>
|
||||
<input
|
||||
id="crop-zoom"
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer accent-gray-900"
|
||||
/>
|
||||
<svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
<path d="M8 11h6M11 8v6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 text-sm font-semibold text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Save framing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 3: Add crop editor to ImageUpload
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/ImageUpload.tsx
|
||||
- src/client/components/ImageCropEditor.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "ImageCropEditor" src/client/components/ImageUpload.tsx && grep "showCropEditor" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 4: Add "Adjust framing" to item detail page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/items/$itemId.tsx
|
||||
- src/client/components/ImageCropEditor.tsx
|
||||
- src/client/hooks/useItems.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingCrop(true)}
|
||||
className="mt-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Adjust framing
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
4. When `editingCrop` is true, replace the GearImage area with `ImageCropEditor`:
|
||||
```tsx
|
||||
{editingCrop ? (
|
||||
<ImageCropEditor
|
||||
imageUrl={item.imageUrl}
|
||||
dominantColor={item.dominantColor}
|
||||
initialZoom={item.cropZoom ?? 1}
|
||||
initialX={item.cropX ?? 0}
|
||||
initialY={item.cropY ?? 0}
|
||||
aspect={4 / 3}
|
||||
onSave={async (crop) => {
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "Adjust framing" src/client/routes/items/\$itemId.tsx && grep "ImageCropEditor" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 5: Add "Adjust framing" to global item detail page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/global-items/$globalItemId.tsx
|
||||
- src/client/components/ImageCropEditor.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "Adjust framing" src/client/routes/global-items/\$globalItemId.tsx && grep "ImageCropEditor" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Global item detail shows "Adjust framing" button
|
||||
- ImageCropEditor uses aspect 16/9
|
||||
- Crop values persist via mutation
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 6: Add "Adjust framing" to candidate detail page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||
- src/client/components/ImageCropEditor.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Candidate detail shows "Adjust framing" button
|
||||
- ImageCropEditor uses aspect 16/9
|
||||
- Crop values persist via candidate update mutation
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<threat_model>
|
||||
| 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 |
|
||||
</threat_model>
|
||||
|
||||
<must_haves>
|
||||
- [ ] 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
|
||||
</must_haves>
|
||||
Reference in New Issue
Block a user