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 */}