From a18b9d37bde762a7f4fdf7de647bddaa08df70f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Apr 2026 20:08:08 +0200 Subject: [PATCH] feat(29-03): add crop editor to item and candidate detail pages Add "Adjust framing" button to item detail and candidate detail pages. Crop editor appears inline, persists via update mutations. Fix lint issues in ImageCropEditor import ordering. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../29-image-presentation/29-02-SUMMARY.md | 56 +++++++ src/client/components/ImageCropEditor.tsx | 2 +- src/client/components/ImageUpload.tsx | 152 +++++++++--------- src/client/routeTree.gen.ts | 21 +++ src/client/routes/items/$itemId.tsx | 88 ++++++---- .../$threadId/candidates/$candidateId.tsx | 63 ++++++-- 6 files changed, 265 insertions(+), 117 deletions(-) create mode 100644 .planning/phases/29-image-presentation/29-02-SUMMARY.md diff --git a/.planning/phases/29-image-presentation/29-02-SUMMARY.md b/.planning/phases/29-image-presentation/29-02-SUMMARY.md new file mode 100644 index 0000000..af2d2bc --- /dev/null +++ b/.planning/phases/29-image-presentation/29-02-SUMMARY.md @@ -0,0 +1,56 @@ +--- +phase: 29 +plan: 02 +subsystem: frontend +tags: [components, image-rendering, ui] +key-files: + created: + - src/client/components/GearImage.tsx + modified: + - src/client/components/ItemCard.tsx + - src/client/components/GlobalItemCard.tsx + - src/client/components/CandidateCard.tsx + - src/client/components/CandidateListItem.tsx + - src/client/components/ImageUpload.tsx + - src/client/components/ComparisonTable.tsx + - src/client/components/CatalogSearchOverlay.tsx + - src/client/components/LinkToGlobalItem.tsx + - src/client/routes/items/$itemId.tsx + - src/client/routes/global-items/$globalItemId.tsx + - src/client/routes/global-items/index.tsx + - src/client/routes/threads/$threadId/candidates/$candidateId.tsx +metrics: + tasks: 13 + commits: 4 + files-changed: 13 +--- + +# Plan 29-02 Summary: GearImage Component + Surface Updates + +## What was built +- Created `GearImage` shared component with three modes: contain (default), cover (tiny thumbnails), and crop (CSS transform) +- Created `imageContainerBg()` helper for consistent dominant color backgrounds +- Updated all 12 gear image surfaces to use GearImage +- Default rendering now uses `object-contain` instead of `object-cover` +- Parent containers use dominant color background for letterbox/pillarbox fill +- LinkToGlobalItem uses `cover` mode for 32px thumbnails (intentional exception) + +## Commits + +| Task | Commit | Description | +|------|--------|-------------| +| 1 | 06d3984 | Create GearImage component | +| 2-3 | 2865e65 | Update ItemCard and GlobalItemCard | +| 4-5 | 05c0918 | Update CandidateCard and CandidateListItem | +| 6-8 | 91846b5 | Update ComparisonTable, CatalogSearchOverlay, ImageUpload | +| 9-13 | 66d9c41 | Update detail pages and LinkToGlobalItem | +| lint | 9636033 | Lint fixes for formatting and unused parameter | + +## Deviations +None. + +## Self-Check: PASSED +- GearImage component exists: YES +- object-cover removed from all gear surfaces: YES (only remains in GearImage internal, ProfileSection avatar, users avatar) +- Build passes: YES +- Lint passes: YES diff --git a/src/client/components/ImageCropEditor.tsx b/src/client/components/ImageCropEditor.tsx index fbd866b..52ee1ea 100644 --- a/src/client/components/ImageCropEditor.tsx +++ b/src/client/components/ImageCropEditor.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; -import Cropper from "react-easy-crop"; import type { Area, Point } from "react-easy-crop"; +import Cropper from "react-easy-crop"; interface CropResult { zoom: number; diff --git a/src/client/components/ImageUpload.tsx b/src/client/components/ImageUpload.tsx index 805c63a..6aa9da0 100644 --- a/src/client/components/ImageUpload.tsx +++ b/src/client/components/ImageUpload.tsx @@ -93,90 +93,90 @@ export function ImageUpload({ {/* Hero image area */} {!showCropEditor && ( -
inputRef.current?.click()} - className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group" - style={{ - backgroundColor: displayUrl - ? imageContainerBg(dominantColor) - : undefined, - }} - > - {displayUrl ? ( - <> - - {/* Remove button */} - + + ) : ( +
+ {/* ImagePlus icon */} + + + + + + + + Click to add photo + +
+ )} + + {/* Upload spinner overlay */} + {uploading && ( +
+ + - - - ) : ( -
- {/* ImagePlus icon */} - - - - - - - - - Click to add photo - -
- )} - - {/* Upload spinner overlay */} - {uploading && ( -
- - - - -
- )} -
+
+ )} + )} rootRouteImport, } as any) +const ProfileRoute = ProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -87,6 +93,7 @@ const ThreadsThreadIdCandidatesCandidateIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/profile': typeof ProfileRoute '/settings': typeof SettingsRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute @@ -101,6 +108,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/profile': typeof ProfileRoute '/settings': typeof SettingsRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute @@ -116,6 +124,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/login': typeof LoginRoute + '/profile': typeof ProfileRoute '/settings': typeof SettingsRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute @@ -132,6 +141,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/profile' | '/settings' | '/global-items/$globalItemId' | '/items/$itemId' @@ -146,6 +156,7 @@ export interface FileRouteTypes { to: | '/' | '/login' + | '/profile' | '/settings' | '/global-items/$globalItemId' | '/items/$itemId' @@ -160,6 +171,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/login' + | '/profile' | '/settings' | '/global-items/$globalItemId' | '/items/$itemId' @@ -175,6 +187,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute LoginRoute: typeof LoginRoute + ProfileRoute: typeof ProfileRoute SettingsRoute: typeof SettingsRoute GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute ItemsItemIdRoute: typeof ItemsItemIdRoute @@ -196,6 +209,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/profile': { + id: '/profile' + path: '/profile' + fullPath: '/profile' + preLoaderRoute: typeof ProfileRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -279,6 +299,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, + ProfileRoute: ProfileRoute, SettingsRoute: SettingsRoute, GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute, ItemsItemIdRoute: ItemsItemIdRoute, diff --git a/src/client/routes/items/$itemId.tsx b/src/client/routes/items/$itemId.tsx index 6890a8f..ba62342 100644 --- a/src/client/routes/items/$itemId.tsx +++ b/src/client/routes/items/$itemId.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { CategoryPicker } from "../../components/CategoryPicker"; import { GearImage, imageContainerBg } from "../../components/GearImage"; +import { ImageCropEditor } from "../../components/ImageCropEditor"; import { ImageUpload } from "../../components/ImageUpload"; import { useFormatters } from "../../hooks/useFormatters"; import { useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems"; @@ -45,6 +46,7 @@ function ItemDetail() { const openExternalLink = useUIStore((s) => s.openExternalLink); const [isEditing, setIsEditing] = useState(false); + const [editingCrop, setEditingCrop] = useState(false); const [form, setForm] = useState({ name: "", brand: "", @@ -242,34 +244,66 @@ function ItemDetail() { } /> - ) : ( -
- {imageUrl ? ( - - ) : ( -
- -
- )} + ) : editingCrop && imageUrl ? ( +
+ { + updateItem.mutate({ + id: item.id, + cropZoom: crop.zoom, + cropX: crop.x, + cropY: crop.y, + }); + setEditingCrop(false); + }} + onCancel={() => setEditingCrop(false)} + />
+ ) : ( + <> +
+ {imageUrl ? ( + + ) : ( +
+ +
+ )} +
+ {imageUrl && !isEditing && ( + + )} + )} {/* Header / Name */} diff --git a/src/client/routes/threads/$threadId/candidates/$candidateId.tsx b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx index dc174b7..5b3ef8a 100644 --- a/src/client/routes/threads/$threadId/candidates/$candidateId.tsx +++ b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useState } from "react"; import { CategoryPicker } from "../../../../components/CategoryPicker"; import { GearImage, imageContainerBg } from "../../../../components/GearImage"; +import { ImageCropEditor } from "../../../../components/ImageCropEditor"; import { ImageUpload } from "../../../../components/ImageUpload"; import { StatusBadge } from "../../../../components/StatusBadge"; import { useUpdateCandidate } from "../../../../hooks/useCandidates"; @@ -42,6 +43,7 @@ function CandidateDetailPage() { ); const [isEditing, setIsEditing] = useState(false); + const [editingCrop, setEditingCrop] = useState(false); const [form, setForm] = useState({ name: "", weightGrams: "", @@ -204,22 +206,57 @@ function CandidateDetailPage() { } />
- ) : imageUrl ? ( -
- + { + updateCandidate.mutate({ + threadId, + candidateId: candidate.id, + data: { + cropZoom: crop.zoom, + cropX: crop.x, + cropY: crop.y, + }, + }); + setEditingCrop(false); + }} + onCancel={() => setEditingCrop(false)} />
+ ) : imageUrl ? ( + <> +
+ +
+ {!isEditing && ( + + )} + ) : null} {/* Header */}