From 1ea527ca1fb34cbfcbd1f2a35d48031cff136a7e Mon Sep 17 00:00:00 2001 From: ianshaloom Date: Fri, 6 Mar 2026 06:45:03 +0300 Subject: [PATCH] feat(admin): implement product image upload & gallery management (Plan 04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Next.js API route for server-side Cloudinary upload with structured public_id (the-pet-loft/products/{id}/main|gallery-n) and asset_folder for portal folder visibility in dynamic folder mode - Add background removal flow via Image Processing API with side-by-side original vs processed preview (Skeleton while loading) - Dual upload buttons: processed (background removed) or original file - Horizontal drag-and-drop image gallery using @dnd-kit/sortable with horizontalListSortingStrategy; reorder persisted via reorderImages mutation - Per-image delete with AlertDialog confirmation - 180° rotation technique for card layout: drag handle top, image center, delete bottom - Debounced product search (300 ms) with inline results (max 3); clears gallery state when search input is cleared - Install: cloudinary, @dnd-kit/core/sortable/utilities, embla-carousel-react, ShadCN carousel component - Configure next.config.js with Cloudinary remote image pattern - Mark checklist items 3.5 and 3.6 complete Co-Authored-By: Claude Sonnet 4.6 --- .../00-admin-dashboard-feature-checklist.md | 4 +- .../04-images-feature-implementation-plan.md | 200 ++++++++++++ apps/admin/next.config.js | 8 + apps/admin/package.json | 5 + .../admin/src/app/(dashboard)/images/page.tsx | 116 +++++++ apps/admin/src/app/api/upload-image/route.ts | 53 ++++ .../components/images/ImageUploadSection.tsx | 284 ++++++++++++++++++ .../images/ProductImageCarousel.tsx | 242 +++++++++++++++ .../images/ProductSearchSection.tsx | 104 +++++++ apps/admin/src/components/ui/carousel.tsx | 243 +++++++++++++++ package-lock.json | 106 +++++++ 11 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 apps/admin/docs/04-images-feature-implementation-plan.md create mode 100644 apps/admin/src/app/(dashboard)/images/page.tsx create mode 100644 apps/admin/src/app/api/upload-image/route.ts create mode 100644 apps/admin/src/components/images/ImageUploadSection.tsx create mode 100644 apps/admin/src/components/images/ProductImageCarousel.tsx create mode 100644 apps/admin/src/components/images/ProductSearchSection.tsx create mode 100644 apps/admin/src/components/ui/carousel.tsx diff --git a/apps/admin/docs/00-admin-dashboard-feature-checklist.md b/apps/admin/docs/00-admin-dashboard-feature-checklist.md index de5c92d..0e694ff 100644 --- a/apps/admin/docs/00-admin-dashboard-feature-checklist.md +++ b/apps/admin/docs/00-admin-dashboard-feature-checklist.md @@ -58,8 +58,8 @@ Features are grouped into **MVP** (required for launch) and **Post-MVP** (phased | 3.2 | Create product form | `[x]` UI | Backend: `products.create`. Form: name, slug, description, status, category, tags. | | 3.3 | Edit product form | `[x]` UI | Backend: `products.update`. Pre-populated form with all fields. | | 3.4 | Archive/restore product | `[x]` UI | Backend: `products.archive`. Confirmation dialog. Restore via edit status field. | -| 3.5 | Product image upload | `[ ]` BE+UI | Need Convex file storage upload flow → `products.addImage`. Currently `addImage` takes a URL; need `generateUploadUrl` + upload action. | -| 3.6 | Image gallery management | `[~]` UI | Backend: `addImage`, `deleteImage`, `reorderImages`. Drag-and-drop reorder UI. | +| 3.5 | Product image upload | `[x]` BE+UI | Cloudinary server-side upload via Next.js API route (`/api/upload-image`). Background removal (Image Processing API) with dual upload: processed or original. Structured `public_id` + `asset_folder` per product. | +| 3.6 | Image gallery management | `[x]` UI | Drag-and-drop reorder (`@dnd-kit`) with `reorderImages` mutation. Per-image delete with AlertDialog. "Add more" tile. Search-driven product selection with auto-clear. | | 3.7 | Variant management | `[~]` UI | Backend: `addVariant`, `updateVariant`, `deleteVariant`. Inline table or modal for CRUD. | | 3.8 | Stock quantity editing | `[~]` UI | Backend: `updateVariant` accepts `stockQuantity`. Inline edit in variant table. | | 3.9 | Price and compare-at-price editing | `[~]` UI | Backend: `updateVariant` accepts `price`, `compareAtPrice`. | diff --git a/apps/admin/docs/04-images-feature-implementation-plan.md b/apps/admin/docs/04-images-feature-implementation-plan.md new file mode 100644 index 0000000..be41aa2 --- /dev/null +++ b/apps/admin/docs/04-images-feature-implementation-plan.md @@ -0,0 +1,200 @@ +# 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 `` 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. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 64d2ed9..f76054c 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"], + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "res.cloudinary.com", + }, + ], + }, }; module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json index 5352de9..c76c302 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -12,6 +12,9 @@ "dependencies": { "@base-ui/react": "^1.2.0", "@clerk/nextjs": "^6.38.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/react": "^1.1.5", @@ -19,7 +22,9 @@ "@repo/types": "*", "@repo/utils": "*", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.9.0", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.400.0", "radix-ui": "^1.4.3", "react-hook-form": "^7.71.2", diff --git a/apps/admin/src/app/(dashboard)/images/page.tsx b/apps/admin/src/app/(dashboard)/images/page.tsx new file mode 100644 index 0000000..89a7a5d --- /dev/null +++ b/apps/admin/src/app/(dashboard)/images/page.tsx @@ -0,0 +1,116 @@ +"use client" + +import { useState } from "react" +import { useQuery } from "convex/react" +import { api } from "../../../../../../convex/_generated/api" +import type { Id } from "../../../../../../convex/_generated/dataModel" +import { ProductSearchSection } from "../../../components/images/ProductSearchSection" +import { ProductImageCarousel } from "../../../components/images/ProductImageCarousel" +import { ImageUploadSection } from "../../../components/images/ImageUploadSection" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" + +interface SelectedProduct { + _id: string + name: string +} + +export default function ImagesPage() { + const [selectedProduct, setSelectedProduct] = useState(null) + const [showUpload, setShowUpload] = useState(false) + + const productData = useQuery( + api.products.getByIdForAdmin, + selectedProduct ? { id: selectedProduct._id as Id<"products"> } : "skip", + ) + + function handleProductSelect(product: SelectedProduct) { + setSelectedProduct(product) + setShowUpload(false) + } + + function handleSearchClear() { + setSelectedProduct(null) + setShowUpload(false) + } + + function handleUploadSuccess() { + setShowUpload(false) + } + + const images = productData?.images ?? [] + const nextPosition = + images.length > 0 ? Math.max(...images.map((img) => img.position)) + 1 : 0 + + return ( +
+

Product images

+ + {/* Product search */} +
+

Select a product

+ +
+ + {/* Gallery */} + {selectedProduct && ( + <> + + +
+
+

{selectedProduct.name}

+

+ {productData === undefined + ? "Loading images…" + : `${images.length} image${images.length !== 1 ? "s" : ""}`} +

+
+ + {productData === undefined ? ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ) : ( + setShowUpload(true)} + /> + )} +
+ + {/* Upload section */} + {showUpload && productData && ( + <> + +
+

Add image

+

+ Select an image. The background will be removed automatically before upload. +

+ } + nextPosition={nextPosition} + onSuccess={handleUploadSuccess} + /> +
+ + )} + + )} + + {/* Empty state */} + {!selectedProduct && ( +

+ Search for a product above to manage its images. +

+ )} +
+ ) +} diff --git a/apps/admin/src/app/api/upload-image/route.ts b/apps/admin/src/app/api/upload-image/route.ts new file mode 100644 index 0000000..0d3eda0 --- /dev/null +++ b/apps/admin/src/app/api/upload-image/route.ts @@ -0,0 +1,53 @@ +import { v2 as cloudinary } from "cloudinary" +import { NextRequest, NextResponse } from "next/server" + +cloudinary.config({ + cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, + api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}) + +export async function POST(req: NextRequest) { + const formData = await req.formData() + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }) + } + + const productId = formData.get("productId") as string | null + const rawPosition = formData.get("position") as string | null + + // Build a structured public_id when productId + position are provided. + // position 0 → …/main, position N → …/gallery-N + let publicId: string | undefined + let assetFolder: string | undefined + if (productId && rawPosition !== null) { + const position = parseInt(rawPosition, 10) + const slot = position === 0 ? "main" : `gallery-${position}` + publicId = `the-pet-loft/products/${productId}/${slot}` + assetFolder = `the-pet-loft/products/${productId}` + } + + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + try { + const result = await new Promise<{ secure_url: string }>((resolve, reject) => { + cloudinary.uploader + .upload_stream( + { public_id: publicId, asset_folder: assetFolder, resource_type: "image", overwrite: true }, + (error, res) => { + if (error || !res) reject(error ?? new Error("Upload failed")) + else resolve(res as { secure_url: string }) + }, + ) + .end(buffer) + }) + + return NextResponse.json({ url: result.secure_url }) + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed" + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/admin/src/components/images/ImageUploadSection.tsx b/apps/admin/src/components/images/ImageUploadSection.tsx new file mode 100644 index 0000000..fb7a528 --- /dev/null +++ b/apps/admin/src/components/images/ImageUploadSection.tsx @@ -0,0 +1,284 @@ +"use client" + +import { useState, useRef } from "react" +import { useMutation } from "convex/react" +import { api } from "../../../../../convex/_generated/api" +import type { Id } from "../../../../../convex/_generated/dataModel" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { HugeiconsIcon } from "@hugeicons/react" +import { ImageUpload01Icon } from "@hugeicons/core-free-icons" +import { cn } from "@/lib/utils" + +interface ImageUploadSectionProps { + productId: Id<"products"> + nextPosition: number + onSuccess: () => void +} + +export function ImageUploadSection({ + productId, + nextPosition, + onSuccess, +}: ImageUploadSectionProps) { + const [originalFile, setOriginalFile] = useState(null) + const [localUrl, setLocalUrl] = useState(null) + const [processedBlob, setProcessedBlob] = useState(null) + const [processedUrl, setProcessedUrl] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSubmittingOriginal, setIsSubmittingOriginal] = useState(false) + const [processingError, setProcessingError] = useState(null) + const [uploadError, setUploadError] = useState(null) + const [isDragging, setIsDragging] = useState(false) + + const fileInputRef = useRef(null) + const addImage = useMutation(api.products.addImage) + + async function processFile(file: File) { + if (localUrl) URL.revokeObjectURL(localUrl) + if (processedUrl) URL.revokeObjectURL(processedUrl) + + setOriginalFile(file) + setLocalUrl(URL.createObjectURL(file)) + setProcessedBlob(null) + setProcessedUrl(null) + setProcessingError(null) + setUploadError(null) + setIsProcessing(true) + + const baseUrl = + process.env.NEXT_PUBLIC_IMAGE_PROCESSING_API_URL ?? "http://localhost:8000" + const formData = new FormData() + formData.append("file", file) + formData.append("format", "webp") + formData.append("quality", "95") + + try { + const res = await fetch(`${baseUrl}/api/remove-background`, { + method: "POST", + body: formData, + }) + + if (!res.ok) { + const errJson = await res.json().catch(() => ({})) + throw new Error((errJson as any).detail ?? "Background removal failed") + } + + const blob = await res.blob() + setProcessedBlob(blob) + setProcessedUrl(URL.createObjectURL(blob)) + } catch (err) { + setProcessingError(err instanceof Error ? err.message : "Processing failed") + } finally { + setIsProcessing(false) + } + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (file) processFile(file) + e.target.value = "" + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file && file.type.startsWith("image/")) processFile(file) + } + + async function uploadBlob(blob: Blob, filename: string) { + const uploadForm = new FormData() + uploadForm.append("file", blob, filename) + uploadForm.append("productId", productId) + uploadForm.append("position", String(nextPosition)) + + const uploadRes = await fetch("/api/upload-image", { + method: "POST", + body: uploadForm, + }) + + if (!uploadRes.ok) { + const errJson = await uploadRes.json().catch(() => ({})) + throw new Error((errJson as any).error ?? "Upload failed") + } + + const { url } = await uploadRes.json() + return url as string + } + + async function handleSubmit() { + if (!processedBlob) return + setIsSubmitting(true) + setUploadError(null) + + try { + const url = await uploadBlob(processedBlob, "product-image.webp") + await addImage({ productId, url, position: nextPosition }) + onSuccess() + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Upload failed") + } finally { + setIsSubmitting(false) + } + } + + async function handleSubmitOriginal() { + if (!originalFile) return + setIsSubmittingOriginal(true) + setUploadError(null) + + try { + const url = await uploadBlob(originalFile, originalFile.name) + await addImage({ productId, url, position: nextPosition }) + onSuccess() + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Upload failed") + } finally { + setIsSubmittingOriginal(false) + } + } + + return ( +
+ {/* Drop zone */} +
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + role="button" + tabIndex={0} + aria-label="Select or drop an image file" + onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()} + > + +
+

Click to select or drag and drop

+

PNG, JPEG, WebP

+
+
+ + + + {/* Side-by-side previews */} + {(localUrl || isProcessing) && ( +
+
+

Original

+ {localUrl && ( +
+ Original +
+ )} +
+ +
+

Background removed

+ {isProcessing ? ( + + ) : processedUrl ? ( +
+ Background removed +
+ ) : processingError ? ( +
+

{processingError}

+
+ ) : null} +
+
+ )} + + {uploadError &&

{uploadError}

} + +
+ + + +
+
+ ) +} diff --git a/apps/admin/src/components/images/ProductImageCarousel.tsx b/apps/admin/src/components/images/ProductImageCarousel.tsx new file mode 100644 index 0000000..644328b --- /dev/null +++ b/apps/admin/src/components/images/ProductImageCarousel.tsx @@ -0,0 +1,242 @@ +"use client" + +import { useState, useEffect } from "react" +import { + DndContext, + closestCenter, + PointerSensor, + KeyboardSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { useMutation } from "convex/react" +import { api } from "../../../../../convex/_generated/api" +import type { Id } from "../../../../../convex/_generated/dataModel" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { Delete02Icon, ImageAdd01Icon, DragDropVerticalIcon } from "@hugeicons/core-free-icons" + +interface ProductImage { + _id: Id<"productImages"> + url: string + alt?: string + position: number +} + +// ─── Drag handle ────────────────────────────────────────────────────────────── +// Separate component following the user's pattern: calls useSortable with the +// same id to get attributes + listeners for the drag trigger element only. + +function DragHandle({ id }: { id: string }) { + const { attributes, listeners } = useSortable({ id }) + return ( + + ) +} + +// ─── Sortable image card ─────────────────────────────────────────────────────── +// Each card is a single-column "table" with 3 rows, physically rotated 180°. +// The DOM order is [delete, image, drag handle]; after rotation the visual +// order becomes [drag handle, image, delete] — drag handle on top, delete on +// bottom — matching the desired layout. + +function SortableImageCard({ + image, + onDelete, +}: { + image: ProductImage + onDelete: (id: Id<"productImages">) => void +}) { + const { setNodeRef, transform, transition, isDragging } = useSortable({ + id: image._id, + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 10 : undefined, + } + + return ( + // Outer div: dnd-kit positioning (no rotation here to keep transforms clean) +
+ {/* Inner column: rotated 180° — children appear in reverse visual order */} +
+ {/* Row 3 in DOM → Row 1 visually (bottom in DOM = top after rotation) */} + {/* Delete button — appears at BOTTOM after rotation */} + + + {/* Row 2 in DOM → Row 2 visually (middle stays middle) */} + {/* Image — appears in CENTER */} +
+ {image.alt +
+ + {/* Row 1 in DOM → Row 3 visually (top in DOM = bottom after rotation) */} + {/* Drag handle — appears at TOP after rotation */} +
+ +
+
+
+ ) +} + +// ─── Gallery ────────────────────────────────────────────────────────────────── + +interface ProductImageCarouselProps { + images: ProductImage[] + onAddMore: () => void +} + +export function ProductImageCarousel({ images, onAddMore }: ProductImageCarouselProps) { + const [sortedImages, setSortedImages] = useState(() => + [...images].sort((a, b) => a.position - b.position), + ) + const [pendingDeleteId, setPendingDeleteId] = useState | null>(null) + const [isDeleting, setIsDeleting] = useState(false) + + const deleteImage = useMutation(api.products.deleteImage) + const reorderImages = useMutation(api.products.reorderImages) + + // Sync with server whenever images prop changes (after add/delete) + useEffect(() => { + setSortedImages([...images].sort((a, b) => a.position - b.position)) + }, [images]) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + + setSortedImages((prev) => { + const oldIndex = prev.findIndex((img) => img._id === active.id) + const newIndex = prev.findIndex((img) => img._id === over.id) + const reordered = arrayMove(prev, oldIndex, newIndex) + reorderImages({ + updates: reordered.map((img, i) => ({ id: img._id, position: i })), + }) + return reordered + }) + } + + async function handleConfirmDelete() { + if (!pendingDeleteId) return + setIsDeleting(true) + try { + await deleteImage({ id: pendingDeleteId }) + setPendingDeleteId(null) + } catch (err) { + console.error("Failed to delete image:", err) + } finally { + setIsDeleting(false) + } + } + + return ( + <> +
+ + img._id)} + strategy={horizontalListSortingStrategy} + > + {sortedImages.map((image) => ( + + ))} + + + + {/* Add more — outside sortable context, always at the end */} + +
+ + { + if (!open) setPendingDeleteId(null) + }} + > + + + Delete this image? + + This cannot be undone. The image will be permanently removed from this product. + + + + Cancel + + {isDeleting ? "Deleting…" : "Delete"} + + + + + + ) +} diff --git a/apps/admin/src/components/images/ProductSearchSection.tsx b/apps/admin/src/components/images/ProductSearchSection.tsx new file mode 100644 index 0000000..415d31c --- /dev/null +++ b/apps/admin/src/components/images/ProductSearchSection.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useState, useEffect } from "react" +import { useQuery } from "convex/react" +import { api } from "../../../../../convex/_generated/api" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { HugeiconsIcon } from "@hugeicons/react" +import { Search01Icon, Cancel01Icon } from "@hugeicons/core-free-icons" + +interface SearchProduct { + _id: string + name: string +} + +interface ProductSearchSectionProps { + onSelect: (product: SearchProduct) => void + onClear: () => void + selectedId?: string +} + +export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductSearchSectionProps) { + const [input, setInput] = useState("") + const [query, setQuery] = useState("") + + useEffect(() => { + const t = setTimeout(() => setQuery(input), 300) + return () => clearTimeout(t) + }, [input]) + + const isSearching = query.trim().length > 0 + + const results = useQuery( + api.products.search, + isSearching ? { query, limit: 3 } : "skip", + ) + + const isLoading = isSearching && results === undefined + + return ( +
+
+ + { + setInput(e.target.value) + if (e.target.value === "") onClear() + }} + className="pl-8 pr-8" + /> + {input && ( + + )} +
+ + {isLoading && ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ )} + + {!isLoading && isSearching && results && results.length === 0 && ( +

No products match “{query}”.

+ )} + + {!isLoading && results && results.length > 0 && ( +
    + {results.map((product: any) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/admin/src/components/ui/carousel.tsx b/apps/admin/src/components/ui/carousel.tsx new file mode 100644 index 0000000..ff4c833 --- /dev/null +++ b/apps/admin/src/components/ui/carousel.tsx @@ -0,0 +1,243 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/package-lock.json b/package-lock.json index b074ae5..fe551a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,9 @@ "dependencies": { "@base-ui/react": "^1.2.0", "@clerk/nextjs": "^6.38.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/react": "^1.1.5", @@ -54,7 +57,9 @@ "@repo/types": "*", "@repo/utils": "*", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.9.0", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.400.0", "radix-ui": "^1.4.3", "react-hook-form": "^7.71.2", @@ -823,6 +828,60 @@ "node": ">=18.17.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", @@ -10551,6 +10610,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cloudinary": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz", + "integrity": "sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -11117,6 +11188,35 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT", + "peer": true + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -14047,6 +14147,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",