feat(admin): replace default edit view with visual preview editor
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m39s
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m39s
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 <noreply@anthropic.com>
This commit is contained in:
parent
7962975dbd
commit
f5671008a7
@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
75
nextjs/src/components/admin/ImageUploadSlot.tsx
Normal file
75
nextjs/src/components/admin/ImageUploadSlot.tsx
Normal file
@ -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<ImageEntry[]>({ path: 'images' })
|
||||
const fileInput = useRef<HTMLInputElement | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { url, alt } = resolveFirstImage(images)
|
||||
|
||||
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: 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 (
|
||||
<div className="rebours-admin-panel__img-slot" onClick={onPick} role="button" tabIndex={0}>
|
||||
{url ? (
|
||||
<img src={url} alt={alt || displayName || 'Produit'} />
|
||||
) : (
|
||||
<div className="rebours-admin-panel__img-placeholder">
|
||||
Cliquez pour<br />uploader une image
|
||||
</div>
|
||||
)}
|
||||
<div className="rebours-admin-panel__img-overlay">
|
||||
{error ? `Erreur: ${error}` : 'Cliquez pour changer l\u2019image'}
|
||||
</div>
|
||||
{uploading && <div className="rebours-admin-panel__img-uploading">Upload…</div>}
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 ?? ''}
|
||||
</Tag>
|
||||
|
||||
64
nextjs/src/components/admin/PriceEditable.tsx
Normal file
64
nextjs/src/components/admin/PriceEditable.tsx
Normal file
@ -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<number | null>({ path: 'price' })
|
||||
const { value: currency } = useField<string>({ path: 'currency' })
|
||||
const ref = useRef<HTMLSpanElement | null>(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<HTMLSpanElement>) => {
|
||||
const txt = e.currentTarget.textContent ?? ''
|
||||
const cents = parseEuros(txt)
|
||||
if (cents !== value) setValue(cents)
|
||||
e.currentTarget.textContent = formatEuros(cents)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).blur()
|
||||
}
|
||||
}
|
||||
|
||||
const symbol = currency === 'USD' ? '$' : '€'
|
||||
|
||||
return (
|
||||
<span className="checkout-price">
|
||||
<span
|
||||
ref={ref}
|
||||
className={`rebours-editable${!value ? ' rebours-editable--empty' : ''}`}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
data-placeholder="—"
|
||||
>
|
||||
{formatEuros(value)}
|
||||
</span>{' '}
|
||||
{symbol}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -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<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 className="rebours-admin-panel">
|
||||
<div className="rebours-admin-panel__hint">
|
||||
Cliquez sur un texte ou sur l'image pour 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 className="rebours-admin-panel__grid">
|
||||
<div className="rebours-admin-panel__img-col">
|
||||
<ImageUploadSlot displayName={displayName} />
|
||||
</div>
|
||||
|
||||
<div className="rebours-admin-panel__info-col">
|
||||
<p className="panel-index">
|
||||
<InlineEditable path="index" placeholder="PROJET_001" />
|
||||
</p>
|
||||
<h2>
|
||||
<InlineEditable path="productDisplayName" as="span" placeholder="Nom du produit" />
|
||||
</h2>
|
||||
<hr />
|
||||
|
||||
<div className="panel-meta">
|
||||
<div className="panel-meta-row">
|
||||
<span className="meta-key">TYPE</span>
|
||||
<span>
|
||||
<InlineEditable path="type" placeholder="LAMPE DE TABLE" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-meta-row">
|
||||
<span className="meta-key">MATÉRIAUX</span>
|
||||
<span>
|
||||
<InlineEditable path="materials" placeholder="Matériaux" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-meta-row">
|
||||
<span className="meta-key">ANNÉE</span>
|
||||
<span>
|
||||
<InlineEditable path="year" placeholder="2026" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-meta-row">
|
||||
<span className="meta-key">STATUS</span>
|
||||
<span className="red">
|
||||
<InlineEditable path="status" placeholder="PROTOTYPE [80%]" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<p className="panel-desc">
|
||||
<InlineEditable
|
||||
path="description"
|
||||
multiline
|
||||
placeholder="Description du produit…"
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
|
||||
<details className="accordion" open>
|
||||
<summary>
|
||||
SPÉCIFICATIONS TECHNIQUES <span>↓</span>
|
||||
</summary>
|
||||
<div className="accordion-body">
|
||||
<InlineEditable
|
||||
path="specs"
|
||||
as="div"
|
||||
multiline
|
||||
placeholder="Spécifications techniques…"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="accordion" open>
|
||||
<summary>
|
||||
NOTES DE CONCEPTION <span>↓</span>
|
||||
</summary>
|
||||
<div className="accordion-body">
|
||||
<InlineEditable
|
||||
path="notes"
|
||||
as="div"
|
||||
multiline
|
||||
placeholder="Notes de conception…"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<div className="checkout-price-line">
|
||||
<PriceEditable />
|
||||
<span className="checkout-edition">ÉDITION UNIQUE — 1/1</span>
|
||||
</div>
|
||||
<div className="checkout-btn">[ COMMANDER CETTE PIÈCE ]</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-footer">■ COLLECTION_001 — W.I.P</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsDrawer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
128
nextjs/src/components/admin/SettingsDrawer.tsx
Normal file
128
nextjs/src/components/admin/SettingsDrawer.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useField } from '@payloadcms/ui'
|
||||
|
||||
function TextInput({ path, label }: { path: string; label: string }) {
|
||||
const { value, setValue } = useField<string>({ path })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextAreaInput({ path, label }: { path: string; label: string }) {
|
||||
const { value, setValue } = useField<string>({ path })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>{label}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberInput({ path, label }: { path: string; label: string }) {
|
||||
const { value, setValue } = useField<number | null>({ path })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
path,
|
||||
label,
|
||||
options,
|
||||
}: {
|
||||
path: string
|
||||
label: string
|
||||
options: { label: string; value: string }[]
|
||||
}) {
|
||||
const { value, setValue } = useField<string>({ path })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>{label}</label>
|
||||
<select value={value ?? ''} onChange={(e) => setValue(e.target.value)}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxInput({ path, label }: { path: string; label: string }) {
|
||||
const { value, setValue } = useField<boolean>({ path })
|
||||
return (
|
||||
<label className="rebours-admin-settings__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => setValue(e.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StripeIDReadOnly() {
|
||||
const { value } = useField<string>({ path: 'stripeID' })
|
||||
return (
|
||||
<div className="rebours-admin-settings__field">
|
||||
<label>Stripe ID</label>
|
||||
<input type="text" value={value ?? ''} readOnly placeholder="Généré à la sauvegarde" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsDrawer() {
|
||||
return (
|
||||
<details className="rebours-admin-settings">
|
||||
<summary>Réglages avancés</summary>
|
||||
<div className="rebours-admin-settings__grid">
|
||||
<TextInput path="name" label="Nom technique" />
|
||||
<TextInput path="slug" label="URL (slug)" />
|
||||
<NumberInput path="sortOrder" label="Ordre d'affichage" />
|
||||
<SelectInput
|
||||
path="currency"
|
||||
label="Devise"
|
||||
options={[
|
||||
{ label: 'EUR', value: 'EUR' },
|
||||
{ label: 'USD', value: 'USD' },
|
||||
]}
|
||||
/>
|
||||
<SelectInput
|
||||
path="availability"
|
||||
label="Disponibilité"
|
||||
options={[
|
||||
{ label: 'En stock', value: 'https://schema.org/InStock' },
|
||||
{ label: 'Limitée', value: 'https://schema.org/LimitedAvailability' },
|
||||
{ label: 'Sur commande', value: 'https://schema.org/PreOrder' },
|
||||
{ label: 'Indisponible', value: 'https://schema.org/OutOfStock' },
|
||||
]}
|
||||
/>
|
||||
<CheckboxInput path="isPublished" label="Publié" />
|
||||
<TextInput path="seoTitle" label="Titre SEO" />
|
||||
<TextAreaInput path="seoDescription" label="Description SEO" />
|
||||
<StripeIDReadOnly />
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
365
nextjs/src/components/admin/panel.css
Normal file
365
nextjs/src/components/admin/panel.css
Normal file
@ -0,0 +1,365 @@
|
||||
/* Scoped replica of the /collection/[slug] product panel, for the Payload admin edit view.
|
||||
Root class: .rebours-admin-panel */
|
||||
|
||||
.rebours-admin-panel {
|
||||
--clr-bg: #c8c8c8;
|
||||
--clr-black: #111;
|
||||
--clr-white: #dcdcdc;
|
||||
--clr-red: #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);
|
||||
}
|
||||
|
||||
/* Make sure admin crosshair cursor rules don't leak here */
|
||||
.rebours-admin-panel, .rebours-admin-panel * {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.rebours-admin-panel__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-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
/* Left: image */
|
||||
.rebours-admin-panel__img-col {
|
||||
background: #1a1a1a;
|
||||
border-right: var(--border);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.rebours-admin-panel__img-slot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rebours-admin-panel__img-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: 0.92;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.rebours-admin-panel__img-slot:hover img {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.rebours-admin-panel__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-panel__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-panel__img-slot:hover .rebours-admin-panel__img-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rebours-admin-panel__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;
|
||||
}
|
||||
|
||||
/* Right: info */
|
||||
.rebours-admin-panel__info-col {
|
||||
padding: 2.5rem var(--pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.4rem;
|
||||
background: var(--clr-white);
|
||||
}
|
||||
|
||||
.rebours-admin-panel .panel-index {
|
||||
font-size: 0.72rem;
|
||||
color: #555;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-panel h2 {
|
||||
font-size: clamp(1.8rem, 3vw, 2.8rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-panel hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(0,0,0,0.15);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .panel-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .panel-meta-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.55rem 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.8rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .meta-key {
|
||||
color: #555;
|
||||
width: 7rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .red {
|
||||
color: var(--clr-red);
|
||||
}
|
||||
|
||||
.rebours-admin-panel .panel-desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.85;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .accordion {
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.rebours-admin-panel .accordion summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .accordion summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .accordion-body {
|
||||
font-size: 0.78rem;
|
||||
line-height: 2;
|
||||
color: #333;
|
||||
padding-bottom: 1rem;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Checkout-like price block */
|
||||
.rebours-admin-panel .checkout-price-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1.2rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .checkout-price {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .checkout-edition {
|
||||
font-size: 0.72rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .checkout-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: #e8a800;
|
||||
color: var(--clr-black);
|
||||
border: var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1.1rem 1.5rem;
|
||||
text-align: center;
|
||||
margin-top: 0.8rem;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.rebours-admin-panel .panel-footer {
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Inline-editable visual feedback */
|
||||
.rebours-editable {
|
||||
outline: none;
|
||||
transition: box-shadow 120ms ease, background 120ms ease;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
cursor: text;
|
||||
display: inline-block;
|
||||
min-width: 2ch;
|
||||
}
|
||||
|
||||
.rebours-editable[data-as='p'],
|
||||
.rebours-editable[data-as='div'],
|
||||
.rebours-editable[data-as='h2'] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rebours-editable:hover {
|
||||
background: rgba(0,0,0,0.04);
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.rebours-editable:focus {
|
||||
background: rgba(232, 168, 0, 0.08);
|
||||
box-shadow: inset 0 0 0 1px #e8a800;
|
||||
}
|
||||
|
||||
.rebours-editable--empty::before {
|
||||
content: attr(data-placeholder);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rebours-editable--empty:focus::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Settings drawer */
|
||||
.rebours-admin-settings {
|
||||
margin-top: 0;
|
||||
border-top: var(--border);
|
||||
background: #dcdcdc;
|
||||
}
|
||||
|
||||
.rebours-admin-settings > summary {
|
||||
padding: 1rem var(--pad);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.rebours-admin-settings > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rebours-admin-settings > summary::after {
|
||||
content: '▾';
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.rebours-admin-settings[open] > summary::after {
|
||||
content: '▴';
|
||||
}
|
||||
|
||||
.rebours-admin-settings__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem 1.5rem;
|
||||
padding: 1rem var(--pad) 1.5rem;
|
||||
border-top: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.rebours-admin-settings__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.rebours-admin-settings__field label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.rebours-admin-settings__field input,
|
||||
.rebours-admin-settings__field select,
|
||||
.rebours-admin-settings__field textarea {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
color: var(--clr-black);
|
||||
}
|
||||
|
||||
.rebours-admin-settings__field input:focus,
|
||||
.rebours-admin-settings__field select:focus,
|
||||
.rebours-admin-settings__field textarea:focus {
|
||||
outline: none;
|
||||
border-color: #e8a800;
|
||||
}
|
||||
|
||||
.rebours-admin-settings__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1.2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user