From f5671008a7f8d9fd27cde4198199fbc1dca33fab Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Tue, 21 Apr 2026 13:54:50 +0200 Subject: [PATCH] feat(admin): replace default edit view with visual preview editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overriding admin.components.views.edit.default makes /admin/collections/ products/:id and /create render the product-detail panel directly — text fields are contentEditable, the image is click-to-upload, and price is inline-editable in the checkout-price-line. Fields that don't fit the public template (slug, name, currency, availability, SEO, isPublished, sortOrder, stripeID) live in a collapsible "Réglages avancés" drawer below the panel. Co-Authored-By: Claude Opus 4.7 --- nextjs/src/collections/Products.ts | 7 +- .../src/components/admin/ImageUploadSlot.tsx | 75 ++++ .../src/components/admin/InlineEditable.tsx | 1 + nextjs/src/components/admin/PriceEditable.tsx | 64 +++ .../components/admin/ProductPreviewEditor.tsx | 133 +++++-- .../src/components/admin/SettingsDrawer.tsx | 128 ++++++ nextjs/src/components/admin/panel.css | 365 ++++++++++++++++++ 7 files changed, 732 insertions(+), 41 deletions(-) create mode 100644 nextjs/src/components/admin/ImageUploadSlot.tsx create mode 100644 nextjs/src/components/admin/PriceEditable.tsx create mode 100644 nextjs/src/components/admin/SettingsDrawer.tsx create mode 100644 nextjs/src/components/admin/panel.css diff --git a/nextjs/src/collections/Products.ts b/nextjs/src/collections/Products.ts index 6abb464..bd38c1d 100644 --- a/nextjs/src/collections/Products.ts +++ b/nextjs/src/collections/Products.ts @@ -10,16 +10,11 @@ export const Products: CollectionConfig = { useAsTitle: 'productDisplayName', defaultColumns: ['productDisplayName', 'index', 'type', 'price', 'isPublished'], group: 'Contenu', - livePreview: { - url: ({ data }) => - `${process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'}/collection/${data?.slug}?preview=true`, - }, components: { views: { edit: { - livePreview: { + default: { Component: '@/components/admin/ProductPreviewEditor#default', - tab: { label: 'Édition visuelle', href: '/preview' }, }, }, }, diff --git a/nextjs/src/components/admin/ImageUploadSlot.tsx b/nextjs/src/components/admin/ImageUploadSlot.tsx new file mode 100644 index 0000000..aaaa203 --- /dev/null +++ b/nextjs/src/components/admin/ImageUploadSlot.tsx @@ -0,0 +1,75 @@ +'use client' + +import { useField } from '@payloadcms/ui' +import { useRef, useState } from 'react' + +type MediaDoc = { id: number | string; url?: string | null; alt?: string | null } +type ImageEntry = { id?: string; image?: MediaDoc | string | null } | null | undefined + +type Props = { + displayName?: string +} + +function resolveFirstImage(entries: ImageEntry[] | undefined | null): { url: string; alt: string } { + const first = entries?.[0]?.image + if (!first) return { url: '', alt: '' } + if (typeof first === 'string') return { url: '', alt: '' } + return { url: first.url ?? '', alt: first.alt ?? '' } +} + +export function ImageUploadSlot({ displayName }: Props) { + const { value: images, setValue } = useField({ path: 'images' }) + const fileInput = useRef(null) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + + const { url, alt } = resolveFirstImage(images) + + const onPick = () => fileInput.current?.click() + + const onFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + setError(null) + setUploading(true) + try { + const fd = new FormData() + fd.append('file', file) + fd.append('_payload', JSON.stringify({ alt: displayName || file.name })) + const res = await fetch('/api/media', { method: 'POST', body: fd, credentials: 'include' }) + if (!res.ok) throw new Error(`upload ${res.status}`) + const { doc } = await res.json() + const media: MediaDoc = { id: doc.id, url: doc.url, alt: doc.alt } + const next = [{ image: media }, ...(images ?? []).slice(1)] + setValue(next) + } catch (err) { + setError(err instanceof Error ? err.message : 'upload failed') + } finally { + setUploading(false) + } + } + + return ( +
+ {url ? ( + {alt + ) : ( +
+ Cliquez pour
uploader une image +
+ )} +
+ {error ? `Erreur: ${error}` : 'Cliquez pour changer l\u2019image'} +
+ {uploading &&
Upload…
} + +
+ ) +} diff --git a/nextjs/src/components/admin/InlineEditable.tsx b/nextjs/src/components/admin/InlineEditable.tsx index 7e3a385..9099c2d 100644 --- a/nextjs/src/components/admin/InlineEditable.tsx +++ b/nextjs/src/components/admin/InlineEditable.tsx @@ -50,6 +50,7 @@ export function InlineEditable({ path, as: Tag = 'span', className, multiline, p onKeyDown={onKeyDown} onPaste={onPaste} data-placeholder={placeholder ?? ''} + data-as={Tag} > {value ?? ''} diff --git a/nextjs/src/components/admin/PriceEditable.tsx b/nextjs/src/components/admin/PriceEditable.tsx new file mode 100644 index 0000000..b060319 --- /dev/null +++ b/nextjs/src/components/admin/PriceEditable.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useField } from '@payloadcms/ui' +import { useEffect, useRef } from 'react' + +function formatEuros(cents: number | null | undefined): string { + if (cents == null) return '' + const euros = cents / 100 + return euros.toLocaleString('fr-FR', { maximumFractionDigits: 2 }) +} + +function parseEuros(text: string): number | null { + const cleaned = text.replace(/[^\d,.\s-]/g, '').replace(/\s/g, '').replace(',', '.') + if (!cleaned) return null + const n = Number(cleaned) + if (!Number.isFinite(n)) return null + return Math.round(n * 100) +} + +export function PriceEditable() { + const { value, setValue } = useField({ path: 'price' }) + const { value: currency } = useField({ path: 'currency' }) + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + const rendered = formatEuros(value) + if (ref.current.textContent !== rendered) ref.current.textContent = rendered + }, [value]) + + const commit = (e: React.FocusEvent) => { + const txt = e.currentTarget.textContent ?? '' + const cents = parseEuros(txt) + if (cents !== value) setValue(cents) + e.currentTarget.textContent = formatEuros(cents) + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + ;(e.currentTarget as HTMLElement).blur() + } + } + + const symbol = currency === 'USD' ? '$' : '€' + + return ( + + + {formatEuros(value)} + {' '} + {symbol} + + ) +} diff --git a/nextjs/src/components/admin/ProductPreviewEditor.tsx b/nextjs/src/components/admin/ProductPreviewEditor.tsx index eb46581..daa9a39 100644 --- a/nextjs/src/components/admin/ProductPreviewEditor.tsx +++ b/nextjs/src/components/admin/ProductPreviewEditor.tsx @@ -1,50 +1,113 @@ 'use client' import { useField } from '@payloadcms/ui' +import { ImageUploadSlot } from './ImageUploadSlot' 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 ?? '' } -} +import { PriceEditable } from './PriceEditable' +import { SettingsDrawer } from './SettingsDrawer' +import './panel.css' 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. +
+
+ Cliquez sur un texte ou sur l'image pour modifier. Les changements sont enregistrés automatiquement.
-
-
- } - nameNode={} - typeNode={} - materialsNode={} - yearNode={} - statusNode={} - descriptionNode={} - specsNode={} - notesNode={} - imageUrl={img.url} - imageAlt={img.alt || title} - /> + +
+
+ +
+ +
+

+ +

+

+ +

+
+ +
+
+ TYPE + + + +
+
+ MATÉRIAUX + + + +
+
+ ANNÉE + + + +
+
+ STATUS + + + +
+
+
+ +

+ +

+
+ +
+ + SPÉCIFICATIONS TECHNIQUES + +
+ +
+
+ +
+ + NOTES DE CONCEPTION + +
+ +
+
+ +
+
+ + ÉDITION UNIQUE — 1/1 +
+
[ COMMANDER CETTE PIÈCE ]
+
+ +
■ COLLECTION_001 — W.I.P
+ +
) } diff --git a/nextjs/src/components/admin/SettingsDrawer.tsx b/nextjs/src/components/admin/SettingsDrawer.tsx new file mode 100644 index 0000000..55359e8 --- /dev/null +++ b/nextjs/src/components/admin/SettingsDrawer.tsx @@ -0,0 +1,128 @@ +'use client' + +import { useField } from '@payloadcms/ui' + +function TextInput({ path, label }: { path: string; label: string }) { + const { value, setValue } = useField({ path }) + return ( +
+ + setValue(e.target.value)} + /> +
+ ) +} + +function TextAreaInput({ path, label }: { path: string; label: string }) { + const { value, setValue } = useField({ path }) + return ( +
+ +