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'
|
'use client'
|
||||||
|
|
||||||
import { useField, useForm } from '@payloadcms/ui'
|
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 }
|
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) {
|
export function ImageUploadSlot({ displayName }: Props) {
|
||||||
const { value: mediaValue, setValue: setMedia } = useField<unknown>({ path: 'images.0.image' })
|
const { rows } = useField<unknown>({ path: 'images', hasRows: true })
|
||||||
const { dispatchFields } = useForm()
|
const { dispatchFields, getDataByPath } = useForm()
|
||||||
const fileInput = useRef<HTMLInputElement | null>(null)
|
const fileInput = useRef<HTMLInputElement | null>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
if (mediaId == null) {
|
const toFetch = mediaIds.filter((id): id is string | number => id != null && !cache[String(id)])
|
||||||
setResolved(null)
|
if (!toFetch.length) return
|
||||||
return
|
|
||||||
}
|
|
||||||
if (resolved && resolved.id === mediaId) return
|
|
||||||
if (mediaValue && typeof mediaValue === 'object' && 'url' in (mediaValue as object)) {
|
|
||||||
setResolved(mediaValue as MediaDoc)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
const entries = await Promise.all(
|
||||||
const res = await fetch(`/api/media/${mediaId}`, { credentials: 'include' })
|
toFetch.map(async (id) => {
|
||||||
if (!res.ok) return
|
try {
|
||||||
const doc = await res.json()
|
const res = await fetch(`/api/media/${id}`, { credentials: 'include' })
|
||||||
if (!cancelled) setResolved({ id: doc.id, url: doc.url, alt: doc.alt })
|
if (!res.ok) return null
|
||||||
} catch {
|
const doc = await res.json()
|
||||||
/* ignore */
|
return [String(id), { id: doc.id, url: doc.url, alt: doc.alt }] as const
|
||||||
}
|
} catch {
|
||||||
|
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 () => {
|
return () => {
|
||||||
cancelled = true
|
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 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 onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@ -69,19 +114,9 @@ export function ImageUploadSlot({ displayName }: Props) {
|
|||||||
const res = await fetch('/api/media', { method: 'POST', body: fd, credentials: 'include' })
|
const res = await fetch('/api/media', { method: 'POST', body: fd, credentials: 'include' })
|
||||||
if (!res.ok) throw new Error(`upload ${res.status}`)
|
if (!res.ok) throw new Error(`upload ${res.status}`)
|
||||||
const { doc } = await res.json()
|
const { doc } = await res.json()
|
||||||
if (mediaId == null) {
|
appendRow(doc.id)
|
||||||
dispatchFields({
|
setCache((prev) => ({ ...prev, [String(doc.id)]: { id: doc.id, url: doc.url, alt: doc.alt } }))
|
||||||
type: 'ADD_ROW',
|
setIndex(rowCount)
|
||||||
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 })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'upload failed')
|
setError(err instanceof Error ? err.message : 'upload failed')
|
||||||
} finally {
|
} finally {
|
||||||
@ -89,22 +124,93 @@ export function ImageUploadSlot({ displayName }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = resolved?.url ?? ''
|
const prev = (e: React.MouseEvent) => {
|
||||||
const alt = resolved?.alt ?? displayName ?? 'Produit'
|
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 (
|
return (
|
||||||
<div className="rebours-admin-panel__img-slot" onClick={onPick} role="button" tabIndex={0}>
|
<div className="rebours-admin-panel__img-slot-wrap">
|
||||||
{url ? (
|
<div
|
||||||
<img src={url} alt={alt} />
|
className="rebours-admin-panel__img-slot"
|
||||||
) : (
|
onClick={onPick}
|
||||||
<div className="rebours-admin-panel__img-placeholder">
|
role="button"
|
||||||
Cliquez pour<br />uploader une image
|
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 uploader une nouvelle image'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="rebours-admin-panel__img-overlay">
|
{uploading && <div className="rebours-admin-panel__img-uploading">Upload…</div>}
|
||||||
{error ? `Erreur: ${error}` : 'Cliquez pour changer l\u2019image'}
|
|
||||||
</div>
|
</div>
|
||||||
{uploading && <div className="rebours-admin-panel__img-uploading">Upload…</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
|
<input
|
||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -112,6 +112,118 @@
|
|||||||
letter-spacing: 0.1em;
|
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 */
|
/* Right: info */
|
||||||
.rebours-admin-panel__info-col {
|
.rebours-admin-panel__info-col {
|
||||||
padding: 2.5rem var(--pad);
|
padding: 2.5rem var(--pad);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user