Files
GearBox/.planning/milestones/v2.2-phases/29-image-presentation/29-03-PLAN.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
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>
2026-04-13 16:00:35 +02:00

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
01
02
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
true
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`:
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 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.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 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" <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 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:
{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>
)}
  1. When editingCrop is true, replace the GearImage area with ImageCropEditor:
{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 */
)}
  1. 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" <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:
  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" <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:
  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" <acceptance_criteria>
  • Candidate detail shows "Adjust framing" button
  • ImageCropEditor uses aspect 16/9
  • Crop values persist via candidate update mutation </acceptance_criteria>
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

<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>