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) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 20:08:08 +02:00
parent 78a097cba2
commit a18b9d37bd
6 changed files with 265 additions and 117 deletions

View File

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

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Cropper from "react-easy-crop";
import type { Area, Point } from "react-easy-crop"; import type { Area, Point } from "react-easy-crop";
import Cropper from "react-easy-crop";
interface CropResult { interface CropResult {
zoom: number; zoom: number;

View File

@@ -93,90 +93,90 @@ export function ImageUpload({
{/* Hero image area */} {/* Hero image area */}
{!showCropEditor && ( {!showCropEditor && (
<div <div
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group" className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
style={{ style={{
backgroundColor: displayUrl backgroundColor: displayUrl
? imageContainerBg(dominantColor) ? imageContainerBg(dominantColor)
: undefined, : undefined,
}} }}
> >
{displayUrl ? ( {displayUrl ? (
<> <>
<GearImage <GearImage
src={displayUrl} src={displayUrl}
alt="Item" alt="Item"
dominantColor={dominantColor} dominantColor={dominantColor}
/> />
{/* Remove button */} {/* Remove button */}
<button <button
type="button" type="button"
onClick={handleRemove} onClick={handleRemove}
className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900 transition-colors shadow-sm" className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center bg-white/80 hover:bg-white rounded-full text-gray-600 hover:text-gray-900 transition-colors shadow-sm"
> >
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</>
) : (
<div className="w-full h-full bg-gray-100 flex flex-col items-center justify-center">
{/* ImagePlus icon */}
<svg <svg
className="w-4 h-4" className="w-10 h-10 text-gray-300 group-hover:text-gray-400 transition-colors"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5}
> >
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
<path d="M14 4v3" />
<path d="M12.5 5.5h3" />
</svg>
<span className="mt-2 text-sm text-gray-400 group-hover:text-gray-500 transition-colors">
Click to add photo
</span>
</div>
)}
{/* Upload spinner overlay */}
{uploading && (
<div className="absolute inset-0 bg-white/60 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path <path
strokeLinecap="round" className="opacity-75"
strokeLinejoin="round" fill="currentColor"
strokeWidth={2} d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
d="M6 18L18 6M6 6l12 12"
/> />
</svg> </svg>
</button> </div>
</> )}
) : ( </div>
<div className="w-full h-full bg-gray-100 flex flex-col items-center justify-center">
{/* ImagePlus icon */}
<svg
className="w-10 h-10 text-gray-300 group-hover:text-gray-400 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
<path d="M14 4v3" />
<path d="M12.5 5.5h3" />
</svg>
<span className="mt-2 text-sm text-gray-400 group-hover:text-gray-500 transition-colors">
Click to add photo
</span>
</div>
)}
{/* Upload spinner overlay */}
{uploading && (
<div className="absolute inset-0 bg-white/60 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
</div>
)} )}
<input <input

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings' import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ProfileRouteImport } from './routes/profile'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as SetupsIndexRouteImport } from './routes/setups/index' import { Route as SetupsIndexRouteImport } from './routes/setups/index'
@@ -27,6 +28,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings', path: '/settings',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ProfileRoute = ProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
@@ -87,6 +93,7 @@ const ThreadsThreadIdCandidatesCandidateIdRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute
@@ -101,6 +108,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute
@@ -116,6 +124,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute
@@ -132,6 +141,7 @@ export interface FileRouteTypes {
fullPaths: fullPaths:
| '/' | '/'
| '/login' | '/login'
| '/profile'
| '/settings' | '/settings'
| '/global-items/$globalItemId' | '/global-items/$globalItemId'
| '/items/$itemId' | '/items/$itemId'
@@ -146,6 +156,7 @@ export interface FileRouteTypes {
to: to:
| '/' | '/'
| '/login' | '/login'
| '/profile'
| '/settings' | '/settings'
| '/global-items/$globalItemId' | '/global-items/$globalItemId'
| '/items/$itemId' | '/items/$itemId'
@@ -160,6 +171,7 @@ export interface FileRouteTypes {
| '__root__' | '__root__'
| '/' | '/'
| '/login' | '/login'
| '/profile'
| '/settings' | '/settings'
| '/global-items/$globalItemId' | '/global-items/$globalItemId'
| '/items/$itemId' | '/items/$itemId'
@@ -175,6 +187,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
ProfileRoute: typeof ProfileRoute
SettingsRoute: typeof SettingsRoute SettingsRoute: typeof SettingsRoute
GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute
ItemsItemIdRoute: typeof ItemsItemIdRoute ItemsItemIdRoute: typeof ItemsItemIdRoute
@@ -196,6 +209,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/profile': {
id: '/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof ProfileRouteImport
parentRoute: typeof rootRouteImport
}
'/login': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@@ -279,6 +299,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
ProfileRoute: ProfileRoute,
SettingsRoute: SettingsRoute, SettingsRoute: SettingsRoute,
GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute, GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute,
ItemsItemIdRoute: ItemsItemIdRoute, ItemsItemIdRoute: ItemsItemIdRoute,

View File

@@ -2,6 +2,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { CategoryPicker } from "../../components/CategoryPicker"; import { CategoryPicker } from "../../components/CategoryPicker";
import { GearImage, imageContainerBg } from "../../components/GearImage"; import { GearImage, imageContainerBg } from "../../components/GearImage";
import { ImageCropEditor } from "../../components/ImageCropEditor";
import { ImageUpload } from "../../components/ImageUpload"; import { ImageUpload } from "../../components/ImageUpload";
import { useFormatters } from "../../hooks/useFormatters"; import { useFormatters } from "../../hooks/useFormatters";
import { useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems"; import { useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems";
@@ -45,6 +46,7 @@ function ItemDetail() {
const openExternalLink = useUIStore((s) => s.openExternalLink); const openExternalLink = useUIStore((s) => s.openExternalLink);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingCrop, setEditingCrop] = useState(false);
const [form, setForm] = useState<EditFormState>({ const [form, setForm] = useState<EditFormState>({
name: "", name: "",
brand: "", brand: "",
@@ -242,34 +244,66 @@ function ItemDetail() {
} }
/> />
</div> </div>
) : ( ) : editingCrop && imageUrl ? (
<div <div className="mb-6">
className="aspect-[4/3] rounded-xl overflow-hidden mb-6" <ImageCropEditor
style={{ imageUrl={imageUrl}
backgroundColor: imageUrl dominantColor={item.dominantColor}
? imageContainerBg(item.dominantColor) initialZoom={item.cropZoom ?? 1}
: undefined, initialX={item.cropX ?? 0}
}} initialY={item.cropY ?? 0}
> aspect={4 / 3}
{imageUrl ? ( onSave={(crop) => {
<GearImage updateItem.mutate({
src={imageUrl} id: item.id,
alt={item.name} cropZoom: crop.zoom,
dominantColor={item.dominantColor} cropX: crop.x,
cropZoom={item.cropZoom} cropY: crop.y,
cropX={item.cropX} });
cropY={item.cropY} setEditingCrop(false);
/> }}
) : ( onCancel={() => setEditingCrop(false)}
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center"> />
<LucideIcon
name={item.categoryIcon}
size={64}
className="text-gray-300"
/>
</div>
)}
</div> </div>
) : (
<>
<div
className="aspect-[4/3] rounded-xl overflow-hidden mb-2"
style={{
backgroundColor: imageUrl
? imageContainerBg(item.dominantColor)
: undefined,
}}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={item.name}
dominantColor={item.dominantColor}
cropZoom={item.cropZoom}
cropX={item.cropX}
cropY={item.cropY}
/>
) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<LucideIcon
name={item.categoryIcon}
size={64}
className="text-gray-300"
/>
</div>
)}
</div>
{imageUrl && !isEditing && (
<button
type="button"
onClick={() => setEditingCrop(true)}
className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Adjust framing
</button>
)}
</>
)} )}
{/* Header / Name */} {/* Header / Name */}

View File

@@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { CategoryPicker } from "../../../../components/CategoryPicker"; import { CategoryPicker } from "../../../../components/CategoryPicker";
import { GearImage, imageContainerBg } from "../../../../components/GearImage"; import { GearImage, imageContainerBg } from "../../../../components/GearImage";
import { ImageCropEditor } from "../../../../components/ImageCropEditor";
import { ImageUpload } from "../../../../components/ImageUpload"; import { ImageUpload } from "../../../../components/ImageUpload";
import { StatusBadge } from "../../../../components/StatusBadge"; import { StatusBadge } from "../../../../components/StatusBadge";
import { useUpdateCandidate } from "../../../../hooks/useCandidates"; import { useUpdateCandidate } from "../../../../hooks/useCandidates";
@@ -42,6 +43,7 @@ function CandidateDetailPage() {
); );
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editingCrop, setEditingCrop] = useState(false);
const [form, setForm] = useState<FormData>({ const [form, setForm] = useState<FormData>({
name: "", name: "",
weightGrams: "", weightGrams: "",
@@ -204,22 +206,57 @@ function CandidateDetailPage() {
} }
/> />
</div> </div>
) : imageUrl ? ( ) : editingCrop && imageUrl ? (
<div <div className="mb-6">
className="aspect-[16/9] rounded-xl overflow-hidden mb-6" <ImageCropEditor
style={{ imageUrl={imageUrl}
backgroundColor: imageContainerBg(candidate.dominantColor),
}}
>
<GearImage
src={imageUrl}
alt={candidate.name}
dominantColor={candidate.dominantColor} dominantColor={candidate.dominantColor}
cropZoom={candidate.cropZoom} initialZoom={candidate.cropZoom ?? 1}
cropX={candidate.cropX} initialX={candidate.cropX ?? 0}
cropY={candidate.cropY} initialY={candidate.cropY ?? 0}
aspect={16 / 9}
onSave={(crop) => {
updateCandidate.mutate({
threadId,
candidateId: candidate.id,
data: {
cropZoom: crop.zoom,
cropX: crop.x,
cropY: crop.y,
},
});
setEditingCrop(false);
}}
onCancel={() => setEditingCrop(false)}
/> />
</div> </div>
) : imageUrl ? (
<>
<div
className="aspect-[16/9] rounded-xl overflow-hidden mb-2"
style={{
backgroundColor: imageContainerBg(candidate.dominantColor),
}}
>
<GearImage
src={imageUrl}
alt={candidate.name}
dominantColor={candidate.dominantColor}
cropZoom={candidate.cropZoom}
cropX={candidate.cropX}
cropY={candidate.cropY}
/>
</div>
{!isEditing && (
<button
type="button"
onClick={() => setEditingCrop(true)}
className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Adjust framing
</button>
)}
</>
) : null} ) : null}
{/* Header */} {/* Header */}