From d44bad4c688df4cb9d7ccd65038dd7d6e79884bb Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Tue, 21 Apr 2026 18:08:10 +0200 Subject: [PATCH] feat(admin): multi-image carousel with add/remove in product editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite ImageUploadSlot to read the full images array via useField({ path: 'images', hasRows: true }), resolve each row's media id via /api/media/, and render prev/next arrows on hover, dot navigation, and a remove button. Uploading appends a new row via dispatchFields ADD_ROW. Dots are 14px black squares with a 1px yellow border and a 6px yellow inner square when active. Frontend already supports multi-image carousel (main.js) via data-images JSON on product cards — no frontend changes needed. Co-Authored-By: Claude Opus 4.7 --- .../src/components/admin/ImageUploadSlot.tsx | 202 +++++++++++++----- nextjs/src/components/admin/panel.css | 112 ++++++++++ 2 files changed, 266 insertions(+), 48 deletions(-) diff --git a/nextjs/src/components/admin/ImageUploadSlot.tsx b/nextjs/src/components/admin/ImageUploadSlot.tsx index 8547271..9a3491f 100644 --- a/nextjs/src/components/admin/ImageUploadSlot.tsx +++ b/nextjs/src/components/admin/ImageUploadSlot.tsx @@ -1,7 +1,7 @@ 'use client' import { useField, useForm } from '@payloadcms/ui' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' type MediaDoc = { id: number | string; url?: string | null; alt?: string | null } @@ -19,43 +19,88 @@ function extractMediaId(value: unknown): string | number | null { } export function ImageUploadSlot({ displayName }: Props) { - const { value: mediaValue, setValue: setMedia } = useField({ path: 'images.0.image' }) - const { dispatchFields } = useForm() + const { rows } = useField({ path: 'images', hasRows: true }) + const { dispatchFields, getDataByPath } = useForm() const fileInput = useRef(null) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) - const [resolved, setResolved] = useState(null) + const [cache, setCache] = useState>({}) + const [index, setIndex] = useState(0) - const mediaId = extractMediaId(mediaValue) + const rowCount = rows?.length ?? 0 + + const mediaIds = useMemo>(() => { + const ids: Array = [] + for (let i = 0; i < rowCount; i++) { + const raw = getDataByPath(`images.${i}.image`) + ids.push(extractMediaId(raw)) + } + return ids + }, [rowCount, getDataByPath]) useEffect(() => { let cancelled = false - if (mediaId == null) { - setResolved(null) - return - } - if (resolved && resolved.id === mediaId) return - if (mediaValue && typeof mediaValue === 'object' && 'url' in (mediaValue as object)) { - setResolved(mediaValue as MediaDoc) - return - } + const toFetch = mediaIds.filter((id): id is string | number => id != null && !cache[String(id)]) + if (!toFetch.length) return ;(async () => { - try { - const res = await fetch(`/api/media/${mediaId}`, { credentials: 'include' }) - if (!res.ok) return - const doc = await res.json() - if (!cancelled) setResolved({ id: doc.id, url: doc.url, alt: doc.alt }) - } catch { - /* ignore */ - } + const entries = await Promise.all( + toFetch.map(async (id) => { + try { + const res = await fetch(`/api/media/${id}`, { credentials: 'include' }) + if (!res.ok) return null + const doc = await res.json() + return [String(id), { id: doc.id, url: doc.url, alt: doc.alt }] as const + } catch { + return null + } + }), + ) + if (cancelled) return + const next: Record = {} + for (const e of entries) if (e) next[e[0]] = e[1] + if (Object.keys(next).length) setCache((prev) => ({ ...prev, ...next })) })() return () => { cancelled = true } - }, [mediaId, mediaValue, resolved]) + }, [mediaIds, cache]) + + useEffect(() => { + if (index >= rowCount && rowCount > 0) setIndex(rowCount - 1) + if (rowCount === 0 && index !== 0) setIndex(0) + }, [rowCount, index]) + + const resolved: MediaDoc[] = mediaIds.map((id) => (id != null ? cache[String(id)] : undefined)).filter(Boolean) as MediaDoc[] + const current = resolved[index] + const alt = current?.alt ?? displayName ?? 'Produit' const onPick = () => fileInput.current?.click() + const appendRow = useCallback( + (mediaId: number | string) => { + dispatchFields({ + type: 'ADD_ROW', + path: 'images', + rowIndex: rowCount, + subFieldState: { + image: { initialValue: mediaId, valid: true, value: mediaId }, + }, + }) + }, + [dispatchFields, rowCount], + ) + + const removeRow = useCallback( + (rowIndex: number) => { + dispatchFields({ + type: 'REMOVE_ROW', + path: 'images', + rowIndex, + }) + }, + [dispatchFields], + ) + const onFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] e.target.value = '' @@ -69,19 +114,9 @@ export function ImageUploadSlot({ displayName }: Props) { const res = await fetch('/api/media', { method: 'POST', body: fd, credentials: 'include' }) if (!res.ok) throw new Error(`upload ${res.status}`) const { doc } = await res.json() - if (mediaId == null) { - dispatchFields({ - type: 'ADD_ROW', - path: 'images', - rowIndex: 0, - subFieldState: { - image: { initialValue: doc.id, valid: true, value: doc.id }, - }, - }) - } else { - setMedia(doc.id) - } - setResolved({ id: doc.id, url: doc.url, alt: doc.alt }) + appendRow(doc.id) + setCache((prev) => ({ ...prev, [String(doc.id)]: { id: doc.id, url: doc.url, alt: doc.alt } })) + setIndex(rowCount) } catch (err) { setError(err instanceof Error ? err.message : 'upload failed') } finally { @@ -89,22 +124,93 @@ export function ImageUploadSlot({ displayName }: Props) { } } - const url = resolved?.url ?? '' - const alt = resolved?.alt ?? displayName ?? 'Produit' + const prev = (e: React.MouseEvent) => { + e.stopPropagation() + if (resolved.length < 2) return + setIndex((i) => (i - 1 + resolved.length) % resolved.length) + } + const next = (e: React.MouseEvent) => { + e.stopPropagation() + if (resolved.length < 2) return + setIndex((i) => (i + 1) % resolved.length) + } + const goTo = (e: React.MouseEvent, i: number) => { + e.stopPropagation() + setIndex(i) + } + const removeCurrent = (e: React.MouseEvent) => { + e.stopPropagation() + if (rowCount === 0) return + removeRow(index) + } return ( -
- {url ? ( - {alt} - ) : ( -
- Cliquez pour
uploader une image +
+
+ {current?.url ? ( + {alt} + ) : ( +
+ Cliquez pour
uploader une image +
+ )} + + {resolved.length > 1 && ( + <> + + + + )} + +
+ {error ? `Erreur: ${error}` : 'Cliquez pour uploader une nouvelle image'}
- )} -
- {error ? `Erreur: ${error}` : 'Cliquez pour changer l\u2019image'} + + {uploading &&
Upload…
}
- {uploading &&
Upload…
} + +
+ {resolved.map((_, i) => ( + + )} +
+ * { + pointer-events: auto; +} + +.rebours-admin-panel__img-dot { + width: 14px; + height: 14px; + background: #111; + border: 1px solid #e8a800; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.rebours-admin-panel__img-dot::before { + content: ''; + width: 6px; + height: 6px; + background: transparent; + transition: background 0.15s; +} + +.rebours-admin-panel__img-dot.is-active::before { + background: #e8a800; +} + +.rebours-admin-panel__img-remove { + margin-left: 0.75rem; + width: 1.6rem; + height: 1.6rem; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.25); + font-family: var(--font-mono); + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s; +} + +.rebours-admin-panel__img-remove:hover { + background: #c0392b; +} + /* Right: info */ .rebours-admin-panel__info-col { padding: 2.5rem var(--pad);