feat(admin): click-to-edit visual editor for products
Some checks failed
Build & Deploy to K3s / build-and-deploy (push) Failing after 14m11s
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:
parent
aeabd79ac6
commit
8fc3b2365a
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
57
nextjs/src/components/admin/InlineEditable.tsx
Normal file
57
nextjs/src/components/admin/InlineEditable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
nextjs/src/components/admin/ProductPanelInfo.tsx
Normal file
77
nextjs/src/components/admin/ProductPanelInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
nextjs/src/components/admin/ProductPreviewEditor.tsx
Normal file
50
nextjs/src/components/admin/ProductPreviewEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
nextjs/src/components/admin/editor.css
Normal file
147
nextjs/src/components/admin/editor.css
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user