# Images Feature — Implementation Plan (Admin Dashboard)

**Audience:** Senior software engineers  
**Scope:** Images route only — product search, image gallery per product, upload flow (local select → process → Cloudinary → Convex). No variants.  
**References:** Convex MCP, ShadCN UI MCP, Cloudinary docs, `.agent/skills/shadcn-ui`, `apps/admin/docs/other/Image_Processing_API.md`, admin CLAUDE.md and admin-dashboard-ui rule.

---

## 1. Overview

Implement the Product Images feature for the admin dashboard (checklist items 3.5, 3.6 — product image upload and gallery management), limited to:

- **Images page** (`/images`) — product search (max 3 results, inline); on product select, show that product’s images in a carousel with delete and “add more”; upload section with local + processed preview and submit to Cloudinary then Convex.
- **Flow:** Select product → view/edit gallery (carousel, delete, add) → add: choose file → local preview → call Image Processing API → processed preview (skeleton while loading) → submit: upload to Cloudinary → get URL → `products.addImage` with position.

**Storage:** Cloudinary (env already: `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `NEXT_PUBLIC_CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`).  
**Processing:** External Image Processing API (background removal) — see `apps/admin/docs/other/Image_Processing_API.md`.

---

## 2. Backend (Convex) — No Schema Change

Existing API is sufficient:

| API | Purpose |
|-----|---------|
| `products.search` | Product search for the images page (query, optional limit). Use limit 3 for inline results. |
| `products.getByIdForAdmin` | Load one product with `images` (and variants/category). Images are sorted by `position`. |
| `products.addImage` | `{ productId, url, alt?, position }` — insert after upload to Cloudinary. |
| `products.deleteImage` | `{ id }` — delete by `productImages._id`. |
| `products.reorderImages` | `{ updates: [{ id, position }] }` — set new positions for reorder/drag. |

**Position when adding:** New image gets `position = max(existing positions) + 1`, or `0` if no images. Compute on the client from current `product.images` before calling `addImage`. After a new add, no need to call `reorderImages` unless the UI allows reordering (e.g. drag in carousel); then send the full new order as `updates`.

---

## 3. Image Processing API Integration

**Spec:** `apps/admin/docs/other/Image_Processing_API.md`.

- **Endpoint:** `POST /api/remove-background` (multipart/form-data: `file`, optional `format`, `quality`).
- **Response:** 200 returns binary image (e.g. WebP); errors return JSON `{ detail: string }`.
- **Env:** Add a configurable base URL for the processing API (e.g. `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` or `IMAGE_PROCESSING_API_URL`). Local: `http://localhost:8000`; production: your deployed URL.

**Client flow:**

1. User selects a file (click or drag-and-drop).
2. Show local preview (object URL or FileReader).
3. Send `POST {baseUrl}/api/remove-background` with `FormData` (`file`, optionally `format: "webp"`, `quality: 95`).
4. While waiting, show skeleton in the “processed image” preview area.
5. On success: receive blob → create object URL (or blob) for side-by-side preview.
6. On error: show error message (e.g. from `detail`); keep local preview only.

Use a single file input (or drop zone); one image at a time for the “add one” flow keeps UX and error handling simple.

---

## 4. Cloudinary Upload — Server-Side

**Why server-side:** `CLOUDINARY_API_SECRET` must not be exposed. Upload from the client only after the server has signed the request or performed the upload.

**Options:**

- **A — Next.js API route (recommended):**  
  - `POST /api/upload-image` (or under a namespaced route, e.g. `/api/admin/upload-image`) in the admin app.  
  - Body: multipart with the **processed** image file (or base64/blob).  
  - Handler: use `cloudinary` (Node) with `CLOUDINARY_API_SECRET` to perform a signed upload (or use the upload API with secret server-side).  
  - Response: `{ url: string }` (Cloudinary `secure_url`).

- **B — Unsigned client upload:**  
  - Create an **unsigned upload preset** in Cloudinary and use it from the client. Simpler but less control and preset is visible in client. Only use if you accept that trade-off.

**Recommendation:** Implement **A**. Flow: client has the processed image blob → send to Next.js route → route uploads to Cloudinary → return `secure_url` → client calls `products.addImage(productId, url, alt?, position)`.

**Env (already set):** `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `NEXT_PUBLIC_CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`. Use the secret only in the API route.

---

## 5. Images Page Layout and Behaviour

**Route:** `apps/admin/src/app/(dashboard)/images/page.tsx`.

### 5.1 Structure (top to bottom)

1. **Title** — e.g. “Product images”.
2. **Product search section (no overlay):**
   - **Search input** — debounced (e.g. 300 ms); query `products.search({ query, limit: 3 })` (or equivalent with limit 3).
   - **Search results** — rendered **below** the input (not a floating dropdown). Max 3 results. Each result: product name only, clickable to select. Use a list or card list; no overlay/popover so the block is part of the page flow.
3. **Selected product gallery (only when a product is selected):**
   - **Carousel** — ShadCN Carousel of the product’s images (from `products.getByIdForAdmin(productId).images`). Each slide: image (and optional alt); top-right **delete** icon button (aria-label “Delete image”) that calls `products.deleteImage(id)` with confirmation (AlertDialog), then invalidate/refetch.
   - **Last carousel item** — “Add more” control (e.g. button or card). Click → show the upload section below.
4. **Upload section (shown when “Add more” is clicked):**
   - **Image selection** — one zone for “click to select or drag and drop” (single file). Accept images (e.g. PNG, JPEG, WebP).
   - **Two preview containers side by side:**
     - **Local file preview** — preview of the selected file (object URL) as soon as a file is chosen.
     - **Processed image preview** — initially empty; while the processing API request is in flight show a **Skeleton**; when the API returns success, show the processed image so the user can compare.
   - **Upload / Submit button** — disabled until processed image is ready. On click: send processed image to the Next.js Cloudinary upload route → get URL → call `products.addImage(productId, url, alt?, position)`. Position = current `images.length` or `max(positions)+1`. On success: refetch product, clear upload state, optionally hide upload section or leave it open for another image.

### 5.2 Position and reorder

- **Add:** `position = product.images.length > 0 ? Math.max(...product.images.map(i => i.position)) + 1 : 0`.
- **Reorder:** If you add drag-and-drop reorder in the carousel later, on drop compute the new order and call `products.reorderImages({ updates: images.map((img, i) => ({ id: img._id, position: i })) })`. Out of scope for MVP: optional “Reorder” mode + reorderImages call.

---

## 6. UI Components and ShadCN

- **Carousel:** ShadCN Carousel (Embla). Install: `npx shadcn@latest add carousel`. Use `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselPrevious`, `CarouselNext`. Last item is the “Add more” tile/button.
- **Search:** Input + debounced query; results as a static list below (no floating dropdown).
- **Preview containers:** Divs with aspect ratio; use `next/image` only if you have a URL (e.g. Cloudinary); for local/processed blob preview use `<img src={objectUrl} />` or a small preview component.
- **Skeleton:** ShadCN Skeleton in the processed preview area while the processing API is loading.
- **Delete:** Icon button (e.g. Trash) with `aria-label="Delete image"`; AlertDialog for confirmation before `products.deleteImage`.
- **Drag-and-drop zone:** A single zone (div with border/dashed) that accepts click (hidden file input) and drag/drop; one file at a time. Use native drag events or a small library; keep it simple.

Install any missing ShadCN components:

```bash
npx shadcn@latest add carousel button input skeleton alert-dialog
```

Use relative imports; no `@/` in the admin app.

---

## 7. Environment Variables

| Variable | Where used | Notes |
|----------|-------------|--------|
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | Client (e.g. display) / server upload | Already set. |
| `NEXT_PUBLIC_CLOUDINARY_API_KEY` | Server upload (if needed for upload API) | Already set. |
| `CLOUDINARY_API_SECRET` | **Server only** (Next.js API route) | Already set. |
| `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` or `IMAGE_PROCESSING_API_URL` | Client (or server proxy) | Add: base URL for background-removal API (e.g. `http://localhost:8000`). |

---

## 8. File and Component Structure

- `app/(dashboard)/images/page.tsx` — main page (client component: search state, selected product, upload state, Convex queries/mutations).
- **Optional split:**
  - `components/images/ProductSearchSection.tsx` — search input + inline results list (max 3).
  - `components/images/ProductImageCarousel.tsx` — carousel + delete + “Add more” tile.
  - `components/images/ImageUploadSection.tsx` — drop/select zone, local preview, processed preview (with skeleton), submit button.
- **API route:** `app/api/upload-image/route.ts` (or `app/api/admin/upload-image/route.ts`) — receives file, uploads to Cloudinary, returns `{ url }`.

---

## 9. Implementation Order

1. **Env** — Add Image Processing API base URL.
2. **ShadCN** — Install carousel, skeleton, button, input, alert-dialog if not present.
3. **Product search** — Search input, debounced `products.search` with limit 3, inline results (product name, click to select). No overlay.
4. **Gallery** — On select, fetch `products.getByIdForAdmin(id)`; render carousel of `product.images`; last item = “Add more” button that reveals upload section.
5. **Delete** — Delete icon per slide; AlertDialog confirm → `products.deleteImage` → refetch.
6. **Upload section** — Drop/select zone; local preview; call Image Processing API; processed preview with skeleton; side-by-side layout.
7. **Cloudinary** — Next.js API route: accept processed image, upload to Cloudinary (using secret server-side), return URL.
8. **Submit** — On submit: call upload route with processed blob → get URL → `products.addImage(productId, url, alt?, position)`; position = next index; refetch product and clear upload state.
9. **Polish** — Empty state (no product selected; no images for product); error toasts for processing/upload/Convex errors; a11y (labels, aria-labels on icon buttons).

---

## 10. Out of Scope (This Plan)

- Variants.
- Convex file storage (we use Cloudinary only).
- Alt text editing in the UI (can pass optional `alt` to `addImage`; extend form later if needed).
- Bulk upload (multiple files at once).

This plan is the single reference for implementing the images feature on the admin images route for senior engineers. Use Convex, ShadCN, and Cloudinary docs/MCP as needed for API and component details.

---

## 11. Completed Work

**Checklist items delivered: 3.5 (Product image upload) and 3.6 (Image gallery management).**

### Dependencies installed
- `embla-carousel-react` + ShadCN `carousel` component (via `npx shadcn@latest add carousel`)
- `cloudinary` npm package — server-side upload in Next.js API route
- `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` — drag-and-drop reorder

### Configuration
- `apps/admin/.env.local` — added `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` (background removal API base URL)
- `apps/admin/next.config.js` — added `images.remotePatterns` for `res.cloudinary.com`

### API route
- **`src/app/api/upload-image/route.ts`** — receives multipart form data (`file`, `productId`, `position`); uploads to Cloudinary server-side using `CLOUDINARY_API_SECRET`; sets structured `public_id` and `asset_folder` for portal organisation; returns `{ url }`.
  - `public_id` pattern: `the-pet-loft/products/{productId}/main` (position 0) or `the-pet-loft/products/{productId}/gallery-{n}` (position n)
  - `asset_folder` set to `the-pet-loft/products/{productId}` for Cloudinary portal folder visibility (dynamic folder mode)
  - `overwrite: true` — re-uploading to the same position replaces the asset in-place

### Components
- **`src/components/images/ProductSearchSection.tsx`** — debounced search input (300 ms); queries `products.search` with `limit: 3`; inline results list (no popover); highlights selected product; `onClear` callback resets parent state when input is cleared (X button or backspace to empty).
- **`src/components/images/ProductImageCarousel.tsx`** — horizontal drag-and-drop image gallery using `@dnd-kit/sortable` with `horizontalListSortingStrategy`. Each image card uses the 180° rotation technique: the container is `rotate-180` and children are defined in DOM order `[delete → image → drag handle]` — counter-rotated with `rotate-180` each — so the visual order is `[drag handle → image → delete]` (top to bottom). `DragHandle` is a separate component following the dnd-kit custom drag handle pattern. On drag end, `arrayMove` updates local state immediately and `reorderImages` mutation persists new positions. AlertDialog confirmation before delete. "Add image" tile sits outside `SortableContext`, always at the end.
- **`src/components/images/ImageUploadSection.tsx`** — drag-and-drop / click file zone; stores `originalFile` in state; auto-calls background removal API on file select; side-by-side local vs processed preview (Skeleton while loading); dual upload buttons:
  - **Upload processed** — uploads background-removed blob; enabled when processing is complete.
  - **Upload original** — uploads raw original file; enabled as soon as a file is selected.
  - Both share `uploadBlob` helper; both send `productId` + `position` to the API route; both call `products.addImage` on success.

### Page
- **`src/app/(dashboard)/images/page.tsx`** — client component; manages `selectedProduct` and `showUpload` state; queries `getByIdForAdmin` only when a product is selected; computes `nextPosition` from current images; clears gallery when search is cleared.
