feat(admin): visual inline editor for the homepage
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m55s
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m55s
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>
This commit is contained in:
parent
1dbcef4660
commit
b6fd89978e
127
nextjs/src/components/admin/HeroImageUploadSlot.tsx
Normal file
127
nextjs/src/components/admin/HeroImageUploadSlot.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useField } from '@payloadcms/ui'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
type MediaDoc = { id: number | string; url?: string | null; alt?: string | null }
|
||||
|
||||
function extractMediaId(value: unknown): string | number | null {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'number' || typeof value === 'string') return value
|
||||
if (typeof value === 'object' && 'id' in (value as object)) {
|
||||
return (value as { id: number | string }).id ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
path: string
|
||||
placeholder?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
/** Single-upload click-to-replace slot for the hero image. */
|
||||
export function HeroImageUploadSlot({ path, placeholder = 'Cliquez pour uploader une image', alt = 'Hero' }: Props) {
|
||||
const { value, setValue } = useField<unknown>({ path })
|
||||
const fileInput = useRef<HTMLInputElement | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [doc, setDoc] = useState<MediaDoc | null>(null)
|
||||
|
||||
const mediaId = extractMediaId(value)
|
||||
|
||||
// Fetch current media doc to display its URL.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (mediaId == null) {
|
||||
setDoc(null)
|
||||
return
|
||||
}
|
||||
if (doc && String(doc.id) === String(mediaId)) return
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/media/${mediaId}`, { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const fetched = await res.json()
|
||||
if (cancelled) return
|
||||
setDoc({ id: fetched.id, url: fetched.url, alt: fetched.alt })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [mediaId, doc])
|
||||
|
||||
const onPick = () => fileInput.current?.click()
|
||||
|
||||
const onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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: alt || 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: uploaded } = await res.json()
|
||||
setDoc({ id: uploaded.id, url: uploaded.url, alt: uploaded.alt })
|
||||
setValue(uploaded.id)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setValue(null)
|
||||
setDoc(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rebours-admin-home__img-slot"
|
||||
onClick={onPick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{doc?.url ? (
|
||||
<img src={doc.url} alt={doc.alt ?? alt} />
|
||||
) : (
|
||||
<div className="rebours-admin-home__img-placeholder">{placeholder}</div>
|
||||
)}
|
||||
|
||||
<div className="rebours-admin-home__img-overlay">
|
||||
{error ? `Erreur: ${error}` : 'Cliquez pour changer'}
|
||||
</div>
|
||||
|
||||
{uploading && <div className="rebours-admin-home__img-uploading">Upload…</div>}
|
||||
|
||||
{doc?.url && (
|
||||
<button
|
||||
type="button"
|
||||
className="rebours-admin-home__img-remove"
|
||||
onClick={clear}
|
||||
aria-label="Retirer l'image"
|
||||
title="Retirer l'image"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
nextjs/src/components/admin/HomePreviewEditor.tsx
Normal file
140
nextjs/src/components/admin/HomePreviewEditor.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { HeroImageUploadSlot } from './HeroImageUploadSlot'
|
||||
import { HomeSettingsDrawer } from './HomeSettingsDrawer'
|
||||
import { InlineEditable } from './InlineEditable'
|
||||
import './home-panel.css'
|
||||
|
||||
export default function HomePreviewEditor() {
|
||||
return (
|
||||
<div className="rebours-admin-home">
|
||||
<div className="rebours-admin-home__hint">
|
||||
Cliquez sur un texte ou sur une image pour modifier. Les changements sont enregistrés automatiquement.
|
||||
</div>
|
||||
|
||||
{/* ---------- Header (preview only) ---------- */}
|
||||
<header className="rebours-admin-home__header">
|
||||
<span className="rebours-admin-home__logo">REBOURS</span>
|
||||
<nav className="rebours-admin-home__nav">
|
||||
<span>COLLECTION_001</span>
|
||||
<span>CONTACT</span>
|
||||
<span className="rebours-admin-home__wip">
|
||||
<span className="rebours-admin-home__blink">■</span> W.I.P
|
||||
</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* ---------- HERO ---------- */}
|
||||
<section className="rebours-admin-home__hero">
|
||||
<div className="rebours-admin-home__hero-left">
|
||||
<p className="rebours-admin-home__label">
|
||||
<InlineEditable path="heroLabel" placeholder="// ARCHIVE_001 — 2026" />
|
||||
</p>
|
||||
<h1>
|
||||
<InlineEditable
|
||||
path="heroTitle"
|
||||
as="span"
|
||||
separator="|"
|
||||
placeholder="REBOURS|STUDIO"
|
||||
/>
|
||||
</h1>
|
||||
<p className="rebours-admin-home__hero-sub">
|
||||
<InlineEditable
|
||||
path="heroSubtitle"
|
||||
as="span"
|
||||
multiline
|
||||
placeholder={'Mobilier d\u2019art contemporain.\nSpace Age × Memphis.'}
|
||||
/>
|
||||
</p>
|
||||
<p className="rebours-admin-home__hero-sub rebours-admin-home__mono-sm">
|
||||
<InlineEditable
|
||||
path="heroStatus"
|
||||
as="span"
|
||||
multiline
|
||||
placeholder={'STATUS: [PROTOTYPE EN COURS]\nCOLLECTION_001 — BIENTÔT DISPONIBLE'}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rebours-admin-home__hero-right">
|
||||
<HeroImageUploadSlot path="heroImage" alt="Hero REBOURS Studio" placeholder="Image hero (optionnel)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* ---------- COLLECTION HEADER ---------- */}
|
||||
<section className="rebours-admin-home__collection">
|
||||
<div className="rebours-admin-home__collection-header">
|
||||
<p className="rebours-admin-home__label">
|
||||
<InlineEditable path="collectionLabel" placeholder="// COLLECTION_001" />
|
||||
</p>
|
||||
<span className="rebours-admin-home__label">
|
||||
N OBJETS —{' '}
|
||||
<InlineEditable path="collectionCta" placeholder="CLIQUER POUR OUVRIR" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="rebours-admin-home__grid-preview">
|
||||
<div className="rebours-admin-home__card-placeholder">PRODUIT 001</div>
|
||||
<div className="rebours-admin-home__card-placeholder">PRODUIT 002</div>
|
||||
<div className="rebours-admin-home__card-placeholder">PRODUIT 003</div>
|
||||
</div>
|
||||
<p className="rebours-admin-home__hint-inline">
|
||||
↳ Les produits sont gérés dans la section <strong>Produits</strong>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---------- CONTACT ---------- */}
|
||||
<section className="rebours-admin-home__newsletter">
|
||||
<div className="rebours-admin-home__nl-left">
|
||||
<p className="rebours-admin-home__label">
|
||||
<InlineEditable path="contactLabel" placeholder="// CONTACT" />
|
||||
</p>
|
||||
<h2>
|
||||
<InlineEditable
|
||||
path="contactTitle"
|
||||
as="span"
|
||||
separator="|"
|
||||
placeholder="UNE QUESTION ?|PARLONS-EN"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rebours-admin-home__nl-right">
|
||||
<p className="rebours-admin-home__mono-sm">
|
||||
<InlineEditable
|
||||
path="contactDescription"
|
||||
as="span"
|
||||
multiline
|
||||
placeholder={'Commandes sur mesure, questions techniques,\nou simplement dire bonjour.'}
|
||||
/>
|
||||
</p>
|
||||
<div className="rebours-admin-home__whatsapp-btn">
|
||||
<span className="rebours-admin-home__wa-icon">●</span>
|
||||
<InlineEditable
|
||||
path="whatsappButtonText"
|
||||
placeholder="CONTACTEZ-NOUS SUR WHATSAPP"
|
||||
/>
|
||||
</div>
|
||||
<p className="rebours-admin-home__mono-sm">
|
||||
<span className="rebours-admin-home__blink">■</span>{' '}
|
||||
<InlineEditable
|
||||
path="contactResponseTime"
|
||||
placeholder="RÉPONSE SOUS 24H"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- FOOTER ---------- */}
|
||||
<footer className="rebours-admin-home__footer">
|
||||
<InlineEditable path="footerText" placeholder="© 2026 REBOURS STUDIO — PARIS" />
|
||||
<nav>
|
||||
<span className="rebours-admin-home__muted">INSTAGRAM</span>
|
||||
/
|
||||
<span className="rebours-admin-home__muted">CONTACT</span>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
<HomeSettingsDrawer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
nextjs/src/components/admin/HomeSettingsDrawer.tsx
Normal file
48
nextjs/src/components/admin/HomeSettingsDrawer.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useField } from '@payloadcms/ui'
|
||||
|
||||
function TextInput({ path, label, placeholder }: { path: string; label: string; placeholder?: string }) {
|
||||
const { value, setValue } = useField<string>({ path })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextAreaInput({ path, label, placeholder }: { path: string; label: string; placeholder?: string }) {
|
||||
const { value, setValue } = useField<string>({ path })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>{label}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeSettingsDrawer() {
|
||||
return (
|
||||
<details className="rebours-admin-settings">
|
||||
<summary>Réglages avancés</summary>
|
||||
<div className="rebours-admin-settings__grid">
|
||||
<TextInput path="whatsappNumber" label="Numéro WhatsApp" placeholder="33651755191" />
|
||||
<TextInput path="contactResponseTime" label="Délai de réponse" placeholder="RÉPONSE SOUS 24H" />
|
||||
<TextInput path="instagramUrl" label="URL Instagram" placeholder="https://instagram.com/..." />
|
||||
<TextInput path="seoTitle" label="Titre SEO" />
|
||||
<TextAreaInput path="seoDescription" label="Description SEO" />
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@ -9,11 +9,26 @@ type Props = {
|
||||
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 }: Props) {
|
||||
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
|
||||
@ -22,19 +37,23 @@ export function InlineEditable({ path, as: Tag = 'span', className, multiline, p
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (typeof document !== 'undefined' && document.activeElement === el) return
|
||||
const next = value ?? ''
|
||||
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', !value)
|
||||
}, [value])
|
||||
el.classList.toggle('rebours-editable--empty', !raw)
|
||||
}, [value, separator])
|
||||
|
||||
const commit = (e: React.FocusEvent<HTMLElement>) => {
|
||||
const next = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
|
||||
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 (!multiline && e.key === 'Enter') {
|
||||
if (!isMultiline && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).blur()
|
||||
}
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
371
nextjs/src/components/admin/home-panel.css
Normal file
371
nextjs/src/components/admin/home-panel.css
Normal file
@ -0,0 +1,371 @@
|
||||
/* Scoped replica of the public homepage, for the Payload admin edit view.
|
||||
Root class: .rebours-admin-home */
|
||||
|
||||
.rebours-admin-home {
|
||||
--clr-bg: #c8c8c8;
|
||||
--clr-black: #111;
|
||||
--clr-white: #dcdcdc;
|
||||
--clr-card-bg: #d0d0d0;
|
||||
--clr-amber: #e8a800;
|
||||
--font-mono: 'Space Mono', ui-monospace, Menlo, monospace;
|
||||
--border: 1px solid #111;
|
||||
--pad: 2rem;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--clr-bg);
|
||||
color: var(--clr-black);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Neutralize admin crosshair cursor leaks */
|
||||
.rebours-admin-home,
|
||||
.rebours-admin-home * {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.rebours-admin-home__hint {
|
||||
padding: 0.65rem var(--pad);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
background: #b6b6b6;
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.rebours-admin-home__hint-inline {
|
||||
padding: 0.75rem var(--pad) 1.25rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- HEADER ---- */
|
||||
.rebours-admin-home__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 1.1rem var(--pad);
|
||||
border-bottom: var(--border);
|
||||
background: var(--clr-bg);
|
||||
}
|
||||
|
||||
.rebours-admin-home__logo {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.rebours-admin-home__nav {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--clr-black);
|
||||
}
|
||||
|
||||
.rebours-admin-home__wip {
|
||||
color: #555;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.rebours-admin-home__blink {
|
||||
color: var(--clr-amber);
|
||||
animation: rebours-blink 1.4s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes rebours-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- LABELS & UTILS ---- */
|
||||
.rebours-admin-home__label {
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-home__mono-sm {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.9;
|
||||
color: #555;
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.rebours-admin-home__muted {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.rebours-admin-home hr {
|
||||
border: none;
|
||||
border-top: var(--border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- HERO ---- */
|
||||
.rebours-admin-home__hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.rebours-admin-home__hero-left {
|
||||
padding: 3rem var(--pad);
|
||||
border-right: var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 1.4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rebours-admin-home__hero-left h1 {
|
||||
font-size: clamp(2.6rem, 5vw, 4.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-home__hero-left h1 .rebours-editable {
|
||||
white-space: pre-line;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rebours-admin-home__hero-sub {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.75;
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.rebours-admin-home__hero-right {
|
||||
background: #1c1c1c;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
/* ---- IMAGE SLOT ---- */
|
||||
.rebours-admin-home__img-slot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: 0.92;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-slot:hover img {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-overlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-slot:hover .rebours-admin-home__img-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-uploading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-remove {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-slot:hover .rebours-admin-home__img-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rebours-admin-home__img-remove:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* ---- COLLECTION ---- */
|
||||
.rebours-admin-home__collection {
|
||||
background: var(--clr-bg);
|
||||
}
|
||||
|
||||
.rebours-admin-home__collection-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem var(--pad);
|
||||
}
|
||||
|
||||
.rebours-admin-home__grid-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
border-top: 2px solid #111;
|
||||
border-left: 2px solid #111;
|
||||
}
|
||||
|
||||
.rebours-admin-home__card-placeholder {
|
||||
border-right: 2px solid #111;
|
||||
border-bottom: 2px solid #111;
|
||||
background: var(--clr-card-bg);
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
color: #777;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---- NEWSLETTER / CONTACT ---- */
|
||||
.rebours-admin-home__newsletter {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: var(--border);
|
||||
}
|
||||
|
||||
.rebours-admin-home__nl-left {
|
||||
padding: 3rem var(--pad);
|
||||
border-right: var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.rebours-admin-home__nl-left h2 {
|
||||
font-size: clamp(1.8rem, 3.5vw, 2.6rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-home__nl-left h2 .rebours-editable {
|
||||
white-space: pre-line;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rebours-admin-home__nl-right {
|
||||
padding: 3rem var(--pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.rebours-admin-home__whatsapp-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
background: #25D366;
|
||||
color: #fff;
|
||||
border: 2px solid #111;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1.1rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rebours-admin-home__wa-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---- FOOTER ---- */
|
||||
.rebours-admin-home__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.1rem var(--pad);
|
||||
border-top: var(--border);
|
||||
font-size: 0.75rem;
|
||||
background: var(--clr-bg);
|
||||
}
|
||||
|
||||
.rebours-admin-home__footer nav {
|
||||
color: var(--clr-black);
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 900px) {
|
||||
.rebours-admin-home__hero,
|
||||
.rebours-admin-home__newsletter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.rebours-admin-home__hero-left,
|
||||
.rebours-admin-home__nl-left {
|
||||
border-right: none;
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
.rebours-admin-home__hero-right {
|
||||
min-height: 280px;
|
||||
}
|
||||
.rebours-admin-home__grid-preview {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@ -5,76 +5,52 @@ export const HomePage: GlobalConfig = {
|
||||
label: 'Page d\u2019accueil',
|
||||
admin: {
|
||||
group: 'Contenu',
|
||||
livePreview: {
|
||||
url: () => `${process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'}/?preview=true`,
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: 'Hero',
|
||||
fields: [
|
||||
{ name: 'heroLabel', label: 'Label', type: 'text', defaultValue: '// ARCHIVE_001 — 2026' },
|
||||
{
|
||||
name: 'heroTitle',
|
||||
label: 'Titre',
|
||||
type: 'text',
|
||||
admin: { description: 'Utilisez | pour passer à la ligne' },
|
||||
name: 'previewPanel',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@/components/admin/HomePreviewEditor#default',
|
||||
},
|
||||
{ name: 'heroSubtitle', label: 'Sous-titre', type: 'textarea' },
|
||||
{ name: 'heroStatus', label: 'Statut', type: 'textarea' },
|
||||
},
|
||||
},
|
||||
|
||||
// ----- Hero -----
|
||||
{ name: 'heroLabel', label: 'Label', type: 'text', defaultValue: '// ARCHIVE_001 — 2026', admin: { hidden: true } },
|
||||
{ name: 'heroTitle', label: 'Titre', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'heroSubtitle', label: 'Sous-titre', type: 'textarea', admin: { hidden: true } },
|
||||
{ name: 'heroStatus', label: 'Statut', type: 'textarea', admin: { hidden: true } },
|
||||
{
|
||||
name: 'heroImage',
|
||||
label: 'Image hero',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: { description: 'Si vide, utilise la première image de la collection' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Collection',
|
||||
fields: [
|
||||
{ name: 'collectionLabel', type: 'text' },
|
||||
{ name: 'collectionCta', label: 'Texte CTA', type: 'text', defaultValue: 'CLIQUER POUR OUVRIR' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Contact',
|
||||
fields: [
|
||||
{ name: 'contactLabel', type: 'text' },
|
||||
{
|
||||
name: 'contactTitle',
|
||||
label: 'Titre',
|
||||
type: 'text',
|
||||
admin: { description: 'Utilisez | pour passer à la ligne' },
|
||||
},
|
||||
{ name: 'contactDescription', type: 'textarea' },
|
||||
{ name: 'whatsappNumber', label: 'Numéro WhatsApp', type: 'text', required: true, defaultValue: '33651755191' },
|
||||
{ name: 'whatsappButtonText', type: 'text' },
|
||||
{ name: 'contactResponseTime', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Footer',
|
||||
fields: [
|
||||
{ name: 'footerText', type: 'text' },
|
||||
{ name: 'instagramUrl', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
{ name: 'seoTitle', type: 'text' },
|
||||
{ name: 'seoDescription', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
],
|
||||
admin: { hidden: true },
|
||||
},
|
||||
|
||||
// ----- Collection -----
|
||||
{ name: 'collectionLabel', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'collectionCta', label: 'Texte CTA', type: 'text', defaultValue: 'CLIQUER POUR OUVRIR', admin: { hidden: true } },
|
||||
|
||||
// ----- Contact -----
|
||||
{ name: 'contactLabel', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'contactTitle', label: 'Titre', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'contactDescription', type: 'textarea', admin: { hidden: true } },
|
||||
{ name: 'whatsappNumber', label: 'Numéro WhatsApp', type: 'text', required: true, defaultValue: '33651755191', admin: { hidden: true } },
|
||||
{ name: 'whatsappButtonText', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'contactResponseTime', type: 'text', admin: { hidden: true } },
|
||||
|
||||
// ----- Footer -----
|
||||
{ name: 'footerText', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'instagramUrl', type: 'text', admin: { hidden: true } },
|
||||
|
||||
// ----- SEO -----
|
||||
{ name: 'seoTitle', type: 'text', admin: { hidden: true } },
|
||||
{ name: 'seoDescription', type: 'textarea', admin: { hidden: true } },
|
||||
],
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user