feat(admin): click-to-edit visual editor for products
Some checks failed
Build & Deploy to K3s / build-and-deploy (push) Failing after 14m11s

New "Édition visuelle" tab on the product edit view renders the
product panel with each text field wrapped in a contentEditable
InlineEditable that calls useField.setValue on blur. Combined with
the collection's existing autosave, changes persist automatically
without a manual save.

- InlineEditable: contentEditable wrapper backed by useField
- ProductPanelInfo: presentational product-panel JSX
- ProductPreviewEditor: default-exported custom view component
  registered at admin.components.views.edit.livePreview
- Image is read-only; slug/price/SEO still edited via default form tab

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-21 12:09:45 +02:00
parent aeabd79ac6
commit 8fc3b2365a
6 changed files with 343 additions and 0 deletions

View File

@ -1,6 +1,8 @@
import { default as default_7060fcf8de405c49f267d6b95f29dcb7 } from '@/components/admin/ProductPreviewEditor'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@/components/admin/ProductPreviewEditor#default": default_7060fcf8de405c49f267d6b95f29dcb7,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View File

@ -14,6 +14,16 @@ export const Products: CollectionConfig = {
url: ({ data }) =>
`${process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'}/collection/${data?.slug}?preview=true`,
},
components: {
views: {
edit: {
livePreview: {
Component: '@/components/admin/ProductPreviewEditor#default',
tab: { label: 'Édition visuelle', href: '/preview' },
},
},
},
},
},
access: {
read: () => true,

View File

@ -0,0 +1,57 @@
'use client'
import { useField } from '@payloadcms/ui'
import { useEffect, useRef } from 'react'
type Props = {
path: string
as?: 'span' | 'h2' | 'p' | 'div'
className?: string
multiline?: boolean
placeholder?: string
}
export function InlineEditable({ path, as: Tag = 'span', className, multiline, placeholder }: Props) {
const { value, setValue } = useField<string>({ path })
const ref = useRef<HTMLElement | null>(null)
useEffect(() => {
if (ref.current && ref.current.textContent !== (value ?? '')) {
ref.current.textContent = value ?? ''
}
}, [value])
const commit = (e: React.FocusEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => {
const next = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
if (next !== (value ?? '')) setValue(next)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (!multiline && e.key === 'Enter') {
e.preventDefault()
;(e.currentTarget as HTMLElement).blur()
}
}
const onPaste = (e: React.ClipboardEvent<HTMLElement>) => {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
}
return (
<Tag
ref={ref as never}
className={`rebours-editable${className ? ' ' + className : ''}${!value ? ' rebours-editable--empty' : ''}`}
contentEditable
suppressContentEditableWarning
spellCheck={false}
onBlur={commit}
onKeyDown={onKeyDown}
onPaste={onPaste}
data-placeholder={placeholder ?? ''}
>
{value ?? ''}
</Tag>
)
}

View File

@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
type Props = {
indexNode: ReactNode
nameNode: ReactNode
typeNode: ReactNode
materialsNode: ReactNode
yearNode: ReactNode
statusNode: ReactNode
descriptionNode: ReactNode
specsNode: ReactNode
notesNode: ReactNode
imageUrl?: string | null
imageAlt?: string
}
export function ProductPanelInfo({
indexNode,
nameNode,
typeNode,
materialsNode,
yearNode,
statusNode,
descriptionNode,
specsNode,
notesNode,
imageUrl,
imageAlt,
}: Props) {
return (
<div className="panel-inner">
<div className="panel-img-col">
<div className="panel-gallery">
<img src={imageUrl ?? ''} alt={imageAlt ?? 'Image produit REBOURS Studio'} />
</div>
</div>
<div className="panel-info-col">
<p className="panel-index">{indexNode}</p>
<h2>{nameNode}</h2>
<hr />
<div className="panel-meta">
<div className="panel-meta-row">
<span className="meta-key">TYPE</span>
<span>{typeNode}</span>
</div>
<div className="panel-meta-row">
<span className="meta-key">MATÉRIAUX</span>
<span>{materialsNode}</span>
</div>
<div className="panel-meta-row">
<span className="meta-key">ANNÉE</span>
<span>{yearNode}</span>
</div>
<div className="panel-meta-row">
<span className="meta-key">STATUS</span>
<span className="red">{statusNode}</span>
</div>
</div>
<hr />
<p className="panel-desc">{descriptionNode}</p>
<hr />
<details className="accordion" open>
<summary>
SPÉCIFICATIONS TECHNIQUES <span></span>
</summary>
<div className="accordion-body">{specsNode}</div>
</details>
<details className="accordion" open>
<summary>
NOTES DE CONCEPTION <span></span>
</summary>
<div className="accordion-body">{notesNode}</div>
</details>
</div>
</div>
)
}

View File

@ -0,0 +1,50 @@
'use client'
import { useField } from '@payloadcms/ui'
import { InlineEditable } from './InlineEditable'
import { ProductPanelInfo } from './ProductPanelInfo'
import './editor.css'
type MediaLike = { url?: string | null; alt?: string | null } | string | null | undefined
type ImageEntry = { image?: MediaLike } | null | undefined
function resolveImageUrl(entries: ImageEntry[] | undefined | null): { url: string; alt: string } {
const first = entries?.[0]?.image
if (!first || typeof first === 'string') return { url: '', alt: '' }
return { url: first.url ?? '', alt: first.alt ?? '' }
}
export default function ProductPreviewEditor() {
const { value: displayName } = useField<string>({ path: 'productDisplayName' })
const { value: index } = useField<string>({ path: 'index' })
const { value: images } = useField<ImageEntry[]>({ path: 'images' })
const img = resolveImageUrl(images)
const title = displayName || 'Produit sans nom'
return (
<div className="rebours-visual-editor">
<div className="rebours-visual-editor__hint">
Cliquez sur un texte pour le modifier. Les changements sont enregistrés automatiquement.
</div>
<div className="rebours-visual-editor__stage">
<div className="product-panel product-panel--static" aria-label={title}>
<ProductPanelInfo
indexNode={<InlineEditable path="index" placeholder="PROJET_001" />}
nameNode={<InlineEditable path="productDisplayName" placeholder="Nom du produit" />}
typeNode={<InlineEditable path="type" placeholder="LAMPE DE TABLE" />}
materialsNode={<InlineEditable path="materials" placeholder="Matériaux" />}
yearNode={<InlineEditable path="year" placeholder="2026" />}
statusNode={<InlineEditable path="status" placeholder="PROTOTYPE [80%]" />}
descriptionNode={<InlineEditable path="description" multiline placeholder="Description du produit…" />}
specsNode={<InlineEditable path="specs" multiline placeholder="Spécifications techniques…" />}
notesNode={<InlineEditable path="notes" multiline placeholder="Notes de conception…" />}
imageUrl={img.url}
imageAlt={img.alt || title}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,147 @@
.rebours-visual-editor {
padding: 2rem;
background: #0a0a0a;
color: #ededed;
min-height: 100%;
font-family: 'Space Mono', ui-monospace, Menlo, monospace;
}
.rebours-visual-editor__hint {
max-width: 960px;
margin: 0 auto 1.5rem;
padding: 0.75rem 1rem;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.6);
}
.rebours-visual-editor__stage {
max-width: 1200px;
margin: 0 auto;
background: #111;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.rebours-visual-editor .product-panel--static {
position: static;
width: 100%;
transform: none;
background: #111;
color: #ededed;
padding: 2rem;
}
.rebours-visual-editor .panel-inner {
display: grid;
grid-template-columns: minmax(260px, 1fr) 1fr;
gap: 2rem;
align-items: start;
}
.rebours-visual-editor .panel-img-col img {
width: 100%;
height: auto;
display: block;
background: #1a1a1a;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.rebours-visual-editor .panel-info-col h2 {
font-size: 1.75rem;
line-height: 1.15;
margin: 0.25rem 0 0.75rem;
letter-spacing: 0.02em;
}
.rebours-visual-editor .panel-index {
font-size: 0.72rem;
letter-spacing: 0.12em;
opacity: 0.6;
margin: 0;
}
.rebours-visual-editor hr {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 0.75rem 0;
}
.rebours-visual-editor .panel-meta {
display: grid;
gap: 0.35rem;
font-size: 0.78rem;
}
.rebours-visual-editor .panel-meta-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 0.5rem;
}
.rebours-visual-editor .meta-key {
opacity: 0.5;
letter-spacing: 0.08em;
}
.rebours-visual-editor .red {
color: #ff3b2f;
}
.rebours-visual-editor .panel-desc {
font-size: 0.9rem;
line-height: 1.55;
}
.rebours-visual-editor .accordion {
margin-top: 0.5rem;
}
.rebours-visual-editor .accordion summary {
cursor: pointer;
font-size: 0.75rem;
letter-spacing: 0.08em;
padding: 0.5rem 0;
display: flex;
justify-content: space-between;
opacity: 0.85;
}
.rebours-visual-editor .accordion-body {
font-size: 0.82rem;
line-height: 1.55;
padding: 0.25rem 0 0.75rem;
white-space: pre-wrap;
}
.rebours-editable {
outline: none;
transition: box-shadow 120ms ease, background 120ms ease;
border-radius: 2px;
padding: 2px 4px;
margin: -2px -4px;
cursor: text;
}
.rebours-editable:hover {
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
}
.rebours-editable:focus {
background: rgba(255, 255, 255, 0.06);
box-shadow: inset 0 0 0 1px #ff3b2f;
}
.rebours-editable--empty::before {
content: attr(data-placeholder);
opacity: 0.3;
pointer-events: none;
}
.rebours-editable--empty:focus::before {
content: none;
}