Puck visual-editor spike: HD-Blöcke (Hero/Text/FeatureGrid/ProductGrid/CTA), OKLCH-Branding, Edit↔Vorschau

This commit is contained in:
2026-06-21 06:10:17 +00:00
commit 900c9f78d1
11 changed files with 2228 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+13
View File
@@ -0,0 +1,13 @@
# build
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
RUN printf 'server{listen 80;root /usr/share/nginx/html;location / {try_files $uri $uri/ /index.html;}}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK CMD wget -qO- http://localhost/ || exit 1
+16
View File
@@ -0,0 +1,16 @@
# HD Puck-Editor-Spike
Schneller Prototyp: visueller Drag-&-Drop-Editor mit **@measured/puck**, HD-gebrandete Blöcke
(Hero, Rich-Text, Feature-Grid, **Produkt-Grid**, CTA), OKLCH-Petrol/Terrakotta, Fraunces + Public Sans.
Zweck: das **Editor-Gefühl** vergleichen mit dem hd-commerce-Eigenbau-Editor. Persistenz im Browser
(localStorage), Toggle Editor ↔ Vorschau, Reset. Kein Backend — reiner Editor-Spike.
Nächster Schritt (Commerce): Medusa v2 als Backend ODER hd-commerce-Engine für Cart/Checkout.
```bash
npm install && npm run dev # http://localhost:5173
npm run build # dist/ (static, via nginx-Dockerfile deploybar)
```
Hinweis: Puck ist nach `@puckeditor/core` umgezogen — für den Spike bewusst noch `@measured/puck@0.19`.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HD · Puck-Editor-Spike</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1916
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "hd-puck-spike",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@measured/puck": "^0.19.0",
"@fontsource-variable/fraunces": "^5.1.0",
"@fontsource-variable/public-sans": "^5.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.10"
}
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useState } from "react";
import { Puck, Render } from "@measured/puck";
import "@measured/puck/puck.css";
import { config } from "./puck.config.jsx";
const KEY = "hd-puck-spike-data";
const seed = {
content: [
{ type: "Hero", props: { id: "hero-1" } },
{ type: "ProductGrid", props: { id: "pg-1" } },
{ type: "FeatureGrid", props: { id: "fg-1" } },
{ type: "CTA", props: { id: "cta-1" } },
],
root: { props: {} },
};
function load() {
try { const r = localStorage.getItem(KEY); if (r) return JSON.parse(r); } catch (e) {}
return seed;
}
export default function App() {
const [mode, setMode] = useState("edit");
const [data, setData] = useState(load);
const bar = (
<div style={{ position: "fixed", zIndex: 1000, top: 12, right: 12, display: "flex", gap: 8 }}>
<button onClick={() => setMode(mode === "edit" ? "view" : "edit")}
style={{ border: 0, borderRadius: 8, padding: ".55em 1em", fontWeight: 700, cursor: "pointer",
background: "var(--ink)", color: "var(--paper)" }}>
{mode === "edit" ? "▶ Vorschau" : "✎ Editor"}
</button>
<button onClick={() => { localStorage.removeItem(KEY); setData(seed); }}
style={{ border: "1px solid var(--line)", borderRadius: 8, padding: ".55em 1em", fontWeight: 600,
cursor: "pointer", background: "var(--paper)", color: "var(--ink)" }}>
Reset
</button>
</div>
);
if (mode === "view") {
return (<>{bar}<Render config={config} data={data} /></>);
}
return (
<>
{bar}
<Puck config={config} data={data}
onPublish={(d) => { setData(d); localStorage.setItem(KEY, JSON.stringify(d)); setMode("view"); }}
onChange={(d) => { setData(d); localStorage.setItem(KEY, JSON.stringify(d)); }} />
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./tokens.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(<App />);
+148
View File
@@ -0,0 +1,148 @@
import React from "react";
const Section = ({ bg, children }) => (
<section className="hd-section" style={bg ? { background: "var(--sand)" } : undefined}>
<div className="hd-wrap">{children}</div>
</section>
);
export const config = {
categories: {
layout: { title: "Inhalt", components: ["Hero", "Text", "FeatureGrid", "CTA"] },
commerce: { title: "Commerce", components: ["ProductGrid"] },
},
components: {
Hero: {
label: "Hero",
fields: {
kicker: { type: "text", label: "Kicker" },
headline: { type: "text", label: "Überschrift" },
lead: { type: "textarea", label: "Lead" },
cta: { type: "text", label: "Button-Text" },
},
defaultProps: {
kicker: "Brittas Nähkiste",
headline: "Kurzwaren, die Freude machen.",
lead: "Handverlesene Nähbedarf-Auswahl — schnell geliefert, ehrlich beraten.",
cta: "Zum Shop",
},
render: ({ kicker, headline, lead, cta }) => (
<Section>
<div className="hd-kicker">{kicker}</div>
<h1 className="hd-h1">{headline}</h1>
<p className="hd-lead">{lead}</p>
{cta && <a className="hd-btn" href="#">{cta}</a>}
</Section>
),
},
Text: {
label: "Rich-Text",
fields: {
heading: { type: "text", label: "Überschrift" },
body: { type: "textarea", label: "Text" },
},
defaultProps: {
heading: "Über uns",
body: "Wir nähen seit 1998 — und beraten jeden Kauf so, als wäre es für uns selbst.",
},
render: ({ heading, body }) => (
<Section>
{heading && <h2 className="hd-h2">{heading}</h2>}
<p className="hd-lead">{body}</p>
</Section>
),
},
FeatureGrid: {
label: "Feature-Grid",
fields: {
columns: { type: "number", label: "Spalten" },
items: {
type: "array",
label: "Features",
arrayFields: {
title: { type: "text", label: "Titel" },
body: { type: "textarea", label: "Text" },
},
defaultItemProps: { title: "Feature", body: "Beschreibung" },
},
},
defaultProps: {
columns: 3,
items: [
{ title: "Schneller Versand", body: "DHL-Versand am selben Werktag bis 14 Uhr." },
{ title: "Echte Beratung", body: "Persönlich per Mail & Telefon — kein Callcenter." },
{ title: "DSGVO-konform", body: "Hosting in der EU, keine US-Tracker." },
],
},
render: ({ columns, items }) => (
<Section bg>
<div className="hd-grid" style={{ gridTemplateColumns: `repeat(${columns || 3}, 1fr)` }}>
{(items || []).map((it, i) => (
<div className="hd-feat" key={i}>
<h3>{it.title}</h3>
<p>{it.body}</p>
</div>
))}
</div>
</Section>
),
},
ProductGrid: {
label: "Produkt-Grid",
fields: {
heading: { type: "text", label: "Überschrift" },
items: {
type: "array",
label: "Produkte",
arrayFields: {
name: { type: "text", label: "Name" },
price: { type: "text", label: "Preis" },
},
defaultItemProps: { name: "Produkt", price: "9,90 €" },
},
},
defaultProps: {
heading: "Beliebt diese Woche",
items: [
{ name: "Stoffschere Solingen", price: "24,90 €" },
{ name: "Garn-Set 24 Farben", price: "12,50 €" },
{ name: "Stecknadeln (200 Stk.)", price: "4,90 €" },
{ name: "Schnittmuster Tasche", price: "7,90 €" },
],
},
render: ({ heading, items }) => (
<Section>
{heading && <h2 className="hd-h2">{heading}</h2>}
<div className="hd-grid" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
{(items || []).map((p, i) => (
<div className="hd-card" key={i}>
<div className="ph" />
<div className="bd">
<h4>{p.name}</h4>
<span className="price">{p.price}</span>
<button className="cart">In den Warenkorb</button>
</div>
</div>
))}
</div>
</Section>
),
},
CTA: {
label: "CTA-Banner",
fields: {
headline: { type: "text", label: "Überschrift" },
cta: { type: "text", label: "Button-Text" },
},
defaultProps: { headline: "Bereit zum Stöbern?", cta: "Jetzt einkaufen" },
render: ({ headline, cta }) => (
<Section>
<div className="hd-cta">
<h2 className="hd-h2">{headline}</h2>
<a className="hd-btn" href="#">{cta}</a>
</div>
</Section>
),
},
},
};
+39
View File
@@ -0,0 +1,39 @@
/* HD-Tokens (OKLCH) — hell/editorial, Petrol + Terrakotta-Akzent */
@import "@fontsource-variable/fraunces";
@import "@fontsource-variable/public-sans";
:root{
--paper: oklch(0.985 0.004 95);
--sand: oklch(0.96 0.012 80);
--ink: oklch(0.27 0.02 250);
--slate: oklch(0.52 0.02 250);
--petrol: oklch(0.52 0.07 215);
--petrol-deep: oklch(0.40 0.07 220);
--terra: oklch(0.62 0.13 45);
--line: oklch(0.88 0.01 95);
--serif: "Fraunces Variable", Georgia, serif;
--sans: "Public Sans Variable", system-ui, sans-serif;
}
*{box-sizing:border-box}
body{margin:0;font-family:var(--sans);color:var(--ink);background:var(--paper)}
.hd-section{padding:72px 28px}
.hd-wrap{max-width:1080px;margin:0 auto}
.hd-kicker{font-family:var(--sans);font-weight:700;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--terra)}
.hd-kicker::before{content:"_ ";color:var(--terra)}
.hd-h1{font-family:var(--serif);font-weight:560;font-size:clamp(2.2rem,5vw,3.6rem);line-height:1.04;letter-spacing:-.01em;margin:.3em 0}
.hd-h2{font-family:var(--serif);font-weight:560;font-size:clamp(1.6rem,3.4vw,2.4rem);line-height:1.1;margin:0 0 .4em}
.hd-lead{font-size:1.15rem;color:var(--slate);max-width:60ch;line-height:1.6}
.hd-btn{display:inline-block;margin-top:1.4em;background:var(--petrol);color:var(--paper);text-decoration:none;font-weight:600;padding:.85em 1.5em;border-radius:8px}
.hd-btn:hover{background:var(--petrol-deep)}
.hd-grid{display:grid;gap:24px}
.hd-feat{border-top:2px solid var(--terra);padding-top:16px}
.hd-feat h3{font-family:var(--serif);font-weight:560;font-size:1.25rem;margin:.2em 0 .3em}
.hd-feat p{color:var(--slate);margin:0;line-height:1.55}
.hd-card{background:var(--paper);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.hd-card .ph{aspect-ratio:4/3;background:linear-gradient(135deg,var(--sand),oklch(0.9 0.03 215))}
.hd-card .bd{padding:14px 16px 18px}
.hd-card h4{font-family:var(--serif);font-weight:560;margin:0 0 .25em;font-size:1.05rem}
.hd-card .price{font-weight:700;color:var(--petrol-deep)}
.hd-card .cart{margin-top:10px;width:100%;border:0;background:var(--ink);color:var(--paper);padding:.6em;border-radius:7px;font-weight:600;cursor:pointer}
.hd-cta{background:var(--petrol-deep);color:var(--paper);border-radius:16px;padding:48px 36px;text-align:center}
.hd-cta .hd-h2{color:var(--paper)}
.hd-cta .hd-btn{background:var(--terra)}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });