rebours/nextjs/src/components/admin/InlineEditable.tsx
ordinarthur b6fd89978e
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m55s
feat(admin): visual inline editor for the homepage
The Page d'accueil global now uses the same edit-in-place pattern as
products: hero (with image upload), collection header, contact, and
footer are all clickable-to-edit directly on a scoped replica of the
live page. Technical fields (WhatsApp number, Instagram URL, response
time, SEO) move to a "Réglages avancés" drawer.

- HomePreviewEditor + home-panel.css mirror the public layout
- HeroImageUploadSlot: single-image click-to-replace variant
- HomeSettingsDrawer for non-visual fields
- InlineEditable: add `separator` prop so stored `|` line-breaks render
  as newlines in the editor and roundtrip on save (used for heroTitle,
  contactTitle)
- Remove unused ProductPanelInfo + editor.css

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:53:45 +02:00

83 lines
2.5 KiB
TypeScript

'use client'
import { useField } from '@payloadcms/ui'
import { useLayoutEffect, useRef } from 'react'
type Props = {
path: string
as?: 'span' | 'h2' | 'p' | 'div'
className?: string
multiline?: boolean
placeholder?: string
/**
* Stored separator that should display as a line-break in the editor.
* When set (e.g. '|'), the stored value "FOO|BAR" is shown as two lines,
* and newlines typed by the user are serialized back to the separator.
* Implies `multiline`.
*/
separator?: string
}
export function InlineEditable({
path,
as: Tag = 'span',
className,
multiline,
placeholder,
separator,
}: Props) {
const { value, setValue } = useField<string>({ path })
const ref = useRef<HTMLElement | null>(null)
const isMultiline = multiline || !!separator
// Sync textContent from value. React doesn't reconcile children of
// contentEditable elements, so we drive the DOM imperatively — and
// never clobber the DOM while the user is actively typing in it.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
if (typeof document !== 'undefined' && document.activeElement === el) return
const raw = value ?? ''
// Convert stored separator to newlines for display.
const next = separator ? raw.split(separator).join('\n') : raw
if (el.textContent !== next) el.textContent = next
el.classList.toggle('rebours-editable--empty', !raw)
}, [value, separator])
const commit = (e: React.FocusEvent<HTMLElement>) => {
let next = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
// Serialize newlines back to the stored separator.
if (separator) next = next.replace(/\n/g, separator)
if (next !== (value ?? '')) setValue(next)
e.currentTarget.classList.toggle('rebours-editable--empty', !next)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (!isMultiline && 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 : ''}`}
contentEditable
suppressContentEditableWarning
spellCheck={false}
onBlur={commit}
onKeyDown={onKeyDown}
onPaste={onPaste}
data-placeholder={placeholder ?? ''}
data-as={Tag}
/>
)
}