feat(admin): multi-image carousel with add/remove in product editor
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m52s

Rewrite ImageUploadSlot to read the full images array via
useField({ path: 'images', hasRows: true }), resolve each row's
media id via /api/media/<id>, 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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-21 18:08:10 +02:00
parent a41dfba9e6
commit d44bad4c68
2 changed files with 266 additions and 48 deletions

View File

@ -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<unknown>({ path: 'images.0.image' })
const { dispatchFields } = useForm()
const { rows } = useField<unknown>({ path: 'images', hasRows: true })
const { dispatchFields, getDataByPath } = useForm()
const fileInput = useRef<HTMLInputElement | null>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [resolved, setResolved] = useState<MediaDoc | null>(null)
const [cache, setCache] = useState<Record<string, MediaDoc>>({})
const [index, setIndex] = useState(0)
const mediaId = extractMediaId(mediaValue)
const rowCount = rows?.length ?? 0
const mediaIds = useMemo<Array<string | number | null>>(() => {
const ids: Array<string | number | null> = []
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 () => {
const entries = await Promise.all(
toFetch.map(async (id) => {
try {
const res = await fetch(`/api/media/${mediaId}`, { credentials: 'include' })
if (!res.ok) return
const res = await fetch(`/api/media/${id}`, { credentials: 'include' })
if (!res.ok) return null
const doc = await res.json()
if (!cancelled) setResolved({ id: doc.id, url: doc.url, alt: doc.alt })
return [String(id), { id: doc.id, url: doc.url, alt: doc.alt }] as const
} catch {
/* ignore */
return null
}
}),
)
if (cancelled) return
const next: Record<string, MediaDoc> = {}
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<HTMLInputElement>) => {
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 (
<div className="rebours-admin-panel__img-slot" onClick={onPick} role="button" tabIndex={0}>
{url ? (
<img src={url} alt={alt} />
<div className="rebours-admin-panel__img-slot-wrap">
<div
className="rebours-admin-panel__img-slot"
onClick={onPick}
role="button"
tabIndex={0}
>
{current?.url ? (
<img src={current.url} alt={alt} />
) : (
<div className="rebours-admin-panel__img-placeholder">
Cliquez pour<br />uploader une image
</div>
)}
{resolved.length > 1 && (
<>
<button
type="button"
className="rebours-admin-panel__img-arrow rebours-admin-panel__img-arrow--prev"
onClick={prev}
aria-label="Image précédente"
>
</button>
<button
type="button"
className="rebours-admin-panel__img-arrow rebours-admin-panel__img-arrow--next"
onClick={next}
aria-label="Image suivante"
>
</button>
</>
)}
<div className="rebours-admin-panel__img-overlay">
{error ? `Erreur: ${error}` : 'Cliquez pour changer l\u2019image'}
{error ? `Erreur: ${error}` : 'Cliquez pour uploader une nouvelle image'}
</div>
{uploading && <div className="rebours-admin-panel__img-uploading">Upload</div>}
</div>
<div className="rebours-admin-panel__img-dots">
{resolved.map((_, i) => (
<button
key={i}
type="button"
className={`rebours-admin-panel__img-dot${i === index ? ' is-active' : ''}`}
onClick={(e) => goTo(e, i)}
aria-label={`Image ${i + 1}`}
/>
))}
{resolved.length > 0 && (
<button
type="button"
className="rebours-admin-panel__img-remove"
onClick={removeCurrent}
aria-label="Supprimer cette image"
title="Supprimer cette image"
>
</button>
)}
</div>
<input
ref={fileInput}
type="file"

View File

@ -112,6 +112,118 @@
letter-spacing: 0.1em;
}
.rebours-admin-panel__img-slot-wrap {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.rebours-admin-panel__img-slot-wrap .rebours-admin-panel__img-slot {
flex: 1;
min-height: 0;
}
.rebours-admin-panel__img-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2.4rem;
height: 2.4rem;
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: 1rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
z-index: 2;
}
.rebours-admin-panel__img-slot:hover .rebours-admin-panel__img-arrow {
opacity: 1;
}
.rebours-admin-panel__img-arrow:hover {
background: rgba(232, 168, 0, 0.85);
color: #111;
}
.rebours-admin-panel__img-arrow--prev {
left: 0.75rem;
}
.rebours-admin-panel__img-arrow--next {
right: 0.75rem;
}
.rebours-admin-panel__img-dots {
position: absolute;
bottom: 0.75rem;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 0.45rem;
z-index: 3;
pointer-events: none;
}
.rebours-admin-panel__img-dots > * {
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);