Phases 28-31 archived to milestones/v2.2-phases/ Requirements and roadmap snapshots archived to milestones/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 29 | 03 | fullstack | 2 |
|
|
true |
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 dependenciesTask 2: Create ImageCropEditor component
- src/client/components/ImageUpload.tsx - src/client/components/GearImage.tsx Create `src/client/components/ImageCropEditor.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
CropperwithobjectFit="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" <acceptance_criteria>
src/client/components/ImageCropEditor.tsxexists- Imports
Cropperfromreact-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
onSavewith{ zoom, x, y } - Dominant color used as background </acceptance_criteria>
Task 3: Add crop editor to ImageUpload
- src/client/components/ImageUpload.tsx - src/client/components/ImageCropEditor.tsx Update `src/client/components/ImageUpload.tsx`:- Add
onCropChange?: (crop: { zoom: number; x: number; y: number }) => voidtoImageUploadProps - Add
cropZoom?: number | null,cropX?: number | null,cropY?: number | nullto props - Add state:
const [showCropEditor, setShowCropEditor] = useState(false); - After successful upload (
onChange(result.filename)), setsetShowCropEditor(true) - When crop editor is visible, replace the image preview area with the
ImageCropEditorcomponent - On save: call
onCropChange?.({ zoom, x, y })andsetShowCropEditor(false) - On cancel:
setShowCropEditor(false) - 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" <acceptance_criteria>
- ImageUpload imports and conditionally renders ImageCropEditor
- Editor appears after successful upload
onCropChangecallback fires with zoom/x/y values- Editor can be dismissed via Cancel
- Save triggers crop change callback </acceptance_criteria>
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`:- Import
ImageCropEditor - Add state:
const [editingCrop, setEditingCrop] = useState(false) - Below the image area (after the
aspect-[4/3]div), add an "Adjust framing" button:
{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>
)}
- When
editingCropis true, replace the GearImage area withImageCropEditor:
{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 */
)}
- Use the existing
useUpdateItemmutation 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" <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 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:- Import ImageCropEditor and useState
- Add "Adjust framing" button below image
- Toggle between GearImage and ImageCropEditor
- Use
aspect={16/9}to match the global item detail page aspect ratio - 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" <acceptance_criteria>
- Global item detail shows "Adjust framing" button
- ImageCropEditor uses aspect 16/9
- Crop values persist via mutation </acceptance_criteria>
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:- Import ImageCropEditor and useState
- Add "Adjust framing" button below image
- Toggle between GearImage and ImageCropEditor
- Use
aspect={16/9}to match the candidate detail page aspect ratio - 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" <acceptance_criteria>
- Candidate detail shows "Adjust framing" button
- ImageCropEditor uses aspect 16/9
- Crop values persist via candidate update mutation </acceptance_criteria>
<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>