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
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:
parent
a41dfba9e6
commit
d44bad4c68
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user