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',
|
useAsTitle: 'productDisplayName',
|
||||||
defaultColumns: ['productDisplayName', 'index', 'type', 'price', 'isPublished'],
|
defaultColumns: ['productDisplayName', 'index', 'type', 'price', 'isPublished'],
|
||||||
group: 'Contenu',
|
group: 'Contenu',
|
||||||
livePreview: {
|
|
||||||
url: ({ data }) =>
|
|
||||||
`${process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'}/collection/${data?.slug}?preview=true`,
|
|
||||||
},
|
|
||||||
components: {
|
components: {
|
||||||
views: {
|
views: {
|
||||||
edit: {
|
edit: {
|
||||||
livePreview: {
|
default: {
|
||||||
Component: '@/components/admin/ProductPreviewEditor#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}
|
onKeyDown={onKeyDown}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
data-placeholder={placeholder ?? ''}
|
data-placeholder={placeholder ?? ''}
|
||||||
|
data-as={Tag}
|
||||||
>
|
>
|
||||||
{value ?? ''}
|
{value ?? ''}
|
||||||
</Tag>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useField } from '@payloadcms/ui'
|
import { useField } from '@payloadcms/ui'
|
||||||
|
import { ImageUploadSlot } from './ImageUploadSlot'
|
||||||
import { InlineEditable } from './InlineEditable'
|
import { InlineEditable } from './InlineEditable'
|
||||||
import { ProductPanelInfo } from './ProductPanelInfo'
|
import { PriceEditable } from './PriceEditable'
|
||||||
import './editor.css'
|
import { SettingsDrawer } from './SettingsDrawer'
|
||||||
|
import './panel.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() {
|
export default function ProductPreviewEditor() {
|
||||||
const { value: displayName } = useField<string>({ path: 'productDisplayName' })
|
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 (
|
return (
|
||||||
<div className="rebours-visual-editor">
|
<div className="rebours-admin-panel">
|
||||||
<div className="rebours-visual-editor__hint">
|
<div className="rebours-admin-panel__hint">
|
||||||
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.
|
||||||
</div>
|
</div>
|
||||||
<div className="rebours-visual-editor__stage">
|
|
||||||
<div className="product-panel product-panel--static" aria-label={title}>
|
<div className="rebours-admin-panel__grid">
|
||||||
<ProductPanelInfo
|
<div className="rebours-admin-panel__img-col">
|
||||||
indexNode={<InlineEditable path="index" placeholder="PROJET_001" />}
|
<ImageUploadSlot displayName={displayName} />
|
||||||
nameNode={<InlineEditable path="productDisplayName" placeholder="Nom du produit" />}
|
</div>
|
||||||
typeNode={<InlineEditable path="type" placeholder="LAMPE DE TABLE" />}
|
|
||||||
materialsNode={<InlineEditable path="materials" placeholder="Matériaux" />}
|
<div className="rebours-admin-panel__info-col">
|
||||||
yearNode={<InlineEditable path="year" placeholder="2026" />}
|
<p className="panel-index">
|
||||||
statusNode={<InlineEditable path="status" placeholder="PROTOTYPE [80%]" />}
|
<InlineEditable path="index" placeholder="PROJET_001" />
|
||||||
descriptionNode={<InlineEditable path="description" multiline placeholder="Description du produit…" />}
|
</p>
|
||||||
specsNode={<InlineEditable path="specs" multiline placeholder="Spécifications techniques…" />}
|
<h2>
|
||||||
notesNode={<InlineEditable path="notes" multiline placeholder="Notes de conception…" />}
|
<InlineEditable path="productDisplayName" as="span" placeholder="Nom du produit" />
|
||||||
imageUrl={img.url}
|
</h2>
|
||||||
imageAlt={img.alt || title}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SettingsDrawer />
|
||||||
</div>
|
</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