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:
56
.planning/phases/29-image-presentation/29-02-SUMMARY.md
Normal file
56
.planning/phases/29-image-presentation/29-02-SUMMARY.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -93,90 +93,90 @@ export function ImageUpload({
|
||||
|
||||
{/* Hero image area */}
|
||||
{!showCropEditor && (
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
|
||||
style={{
|
||||
backgroundColor: displayUrl
|
||||
? imageContainerBg(dominantColor)
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{displayUrl ? (
|
||||
<>
|
||||
<GearImage
|
||||
src={displayUrl}
|
||||
alt="Item"
|
||||
dominantColor={dominantColor}
|
||||
/>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
|
||||
style={{
|
||||
backgroundColor: displayUrl
|
||||
? imageContainerBg(dominantColor)
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{displayUrl ? (
|
||||
<>
|
||||
<GearImage
|
||||
src={displayUrl}
|
||||
alt="Item"
|
||||
dominantColor={dominantColor}
|
||||
/>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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
|
||||
className="w-4 h-4"
|
||||
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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
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>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as ProfileRouteImport } from './routes/profile'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SetupsIndexRouteImport } from './routes/setups/index'
|
||||
@@ -27,6 +28,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => 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,
|
||||
|
||||
@@ -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<EditFormState>({
|
||||
name: "",
|
||||
brand: "",
|
||||
@@ -242,34 +244,66 @@ function ItemDetail() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="aspect-[4/3] rounded-xl overflow-hidden mb-6"
|
||||
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>
|
||||
)}
|
||||
) : editingCrop && imageUrl ? (
|
||||
<div className="mb-6">
|
||||
<ImageCropEditor
|
||||
imageUrl={imageUrl}
|
||||
dominantColor={item.dominantColor}
|
||||
initialZoom={item.cropZoom ?? 1}
|
||||
initialX={item.cropX ?? 0}
|
||||
initialY={item.cropY ?? 0}
|
||||
aspect={4 / 3}
|
||||
onSave={(crop) => {
|
||||
updateItem.mutate({
|
||||
id: item.id,
|
||||
cropZoom: crop.zoom,
|
||||
cropX: crop.x,
|
||||
cropY: crop.y,
|
||||
});
|
||||
setEditingCrop(false);
|
||||
}}
|
||||
onCancel={() => setEditingCrop(false)}
|
||||
/>
|
||||
</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 */}
|
||||
|
||||
@@ -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<FormData>({
|
||||
name: "",
|
||||
weightGrams: "",
|
||||
@@ -204,22 +206,57 @@ function CandidateDetailPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<div
|
||||
className="aspect-[16/9] rounded-xl overflow-hidden mb-6"
|
||||
style={{
|
||||
backgroundColor: imageContainerBg(candidate.dominantColor),
|
||||
}}
|
||||
>
|
||||
<GearImage
|
||||
src={imageUrl}
|
||||
alt={candidate.name}
|
||||
) : editingCrop && imageUrl ? (
|
||||
<div className="mb-6">
|
||||
<ImageCropEditor
|
||||
imageUrl={imageUrl}
|
||||
dominantColor={candidate.dominantColor}
|
||||
cropZoom={candidate.cropZoom}
|
||||
cropX={candidate.cropX}
|
||||
cropY={candidate.cropY}
|
||||
initialZoom={candidate.cropZoom ?? 1}
|
||||
initialX={candidate.cropX ?? 0}
|
||||
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>
|
||||
) : 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}
|
||||
|
||||
{/* Header */}
|
||||
|
||||
Reference in New Issue
Block a user