diff --git a/nextjs/src/app/(payload)/admin/importMap.js b/nextjs/src/app/(payload)/admin/importMap.js index af86423..3c0c901 100644 --- a/nextjs/src/app/(payload)/admin/importMap.js +++ b/nextjs/src/app/(payload)/admin/importMap.js @@ -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 } diff --git a/nextjs/src/collections/Products.ts b/nextjs/src/collections/Products.ts index 75b96e6..b395ef9 100644 --- a/nextjs/src/collections/Products.ts +++ b/nextjs/src/collections/Products.ts @@ -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, diff --git a/nextjs/src/components/admin/InlineEditable.tsx b/nextjs/src/components/admin/InlineEditable.tsx new file mode 100644 index 0000000..7e3a385 --- /dev/null +++ b/nextjs/src/components/admin/InlineEditable.tsx @@ -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({ path }) + const ref = useRef(null) + + useEffect(() => { + if (ref.current && ref.current.textContent !== (value ?? '')) { + ref.current.textContent = value ?? '' + } + }, [value]) + + const commit = (e: React.FocusEvent | React.KeyboardEvent) => { + const next = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ') + if (next !== (value ?? '')) setValue(next) + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!multiline && e.key === 'Enter') { + e.preventDefault() + ;(e.currentTarget as HTMLElement).blur() + } + } + + const onPaste = (e: React.ClipboardEvent) => { + e.preventDefault() + const text = e.clipboardData.getData('text/plain') + document.execCommand('insertText', false, text) + } + + return ( + + {value ?? ''} + + ) +} diff --git a/nextjs/src/components/admin/ProductPanelInfo.tsx b/nextjs/src/components/admin/ProductPanelInfo.tsx new file mode 100644 index 0000000..a64afe0 --- /dev/null +++ b/nextjs/src/components/admin/ProductPanelInfo.tsx @@ -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 ( +
+
+
+ {imageAlt +
+
+
+

{indexNode}

+

{nameNode}

+
+
+
+ TYPE + {typeNode} +
+
+ MATÉRIAUX + {materialsNode} +
+
+ ANNÉE + {yearNode} +
+
+ STATUS + {statusNode} +
+
+
+

{descriptionNode}

+
+
+ + SPÉCIFICATIONS TECHNIQUES + +
{specsNode}
+
+
+ + NOTES DE CONCEPTION + +
{notesNode}
+
+
+
+ ) +} diff --git a/nextjs/src/components/admin/ProductPreviewEditor.tsx b/nextjs/src/components/admin/ProductPreviewEditor.tsx new file mode 100644 index 0000000..eb46581 --- /dev/null +++ b/nextjs/src/components/admin/ProductPreviewEditor.tsx @@ -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({ path: 'productDisplayName' }) + const { value: index } = useField({ path: 'index' }) + const { value: images } = useField({ path: 'images' }) + + const img = resolveImageUrl(images) + const title = displayName || 'Produit sans nom' + + return ( +
+
+ Cliquez sur un texte pour le modifier. Les changements sont enregistrés automatiquement. +
+
+
+ } + nameNode={} + typeNode={} + materialsNode={} + yearNode={} + statusNode={} + descriptionNode={} + specsNode={} + notesNode={} + imageUrl={img.url} + imageAlt={img.alt || title} + /> +
+
+
+ ) +} diff --git a/nextjs/src/components/admin/editor.css b/nextjs/src/components/admin/editor.css new file mode 100644 index 0000000..c07bf11 --- /dev/null +++ b/nextjs/src/components/admin/editor.css @@ -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; +}