feat(website): M3 — 5 more blocks, containers, upload, themes

Expands the builder from 3 M1 blocks to 8. Containers (columns) and
media blocks (image, gallery) are the structural additions; cta and faq
round out the content coverage.

packages/website-blocks:
- image, cta, faq, columns (container), gallery — each with Zod schema,
  renderer (mode-aware for edit/preview/public), and fallback inspector.
- Block type extended with optional `children` + `renderChild` snippet
  so containers render their children through the same chrome the
  outer renderer provides (click-to-select, public-path tagging).
- themes/: 3 presets (classic light, modern dark, warm) with
  `resolveTheme` + `themeCssVars` helpers. Public layout now emits
  CSS vars via `style=` on the root; block components read
  `var(--wb-primary)` / `var(--wb-bg)` / `var(--wb-fg)` / etc.
- Registry updated; new exports + `./themes` subpath export.

apps/mana/apps/web/src/lib/modules/website:
- upload.ts: multipart POST to mana-media with `app=website` scope,
  returns { mediaId, url }. 25 MB cap, non-image rejection client-side.
- components/ImageInspector + GalleryInspector: app-side overrides
  wired to upload. Registered via `CUSTOM_INSPECTORS` in BlockInspector
  so block.type → app-side inspector, fallback to registry otherwise.
- components/SiteSettingsDialog: theme preset picker + color overrides
  for primary/bg/fg + footer text. Mounted from a ⚙ button in the
  editor's left pane.
- components/BlockRenderer: rebuilt around a byParent map + recursive
  `renderBlock` snippet so container blocks can render their children
  through the same click-to-select wrapper as top-level blocks.
- routes/s/[siteSlug]: rename `[[...path]]` → `[...path]` (SvelteKit
  treats rest segments as optional automatically — double-bracket form
  errored at sync time). +page.svelte renders snapshot trees
  recursively so published pages match the editor.

apps/api: unchanged.

Validation:
- pnpm run validate:all: all 6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green
- website-blocks tsc: green

Plan: docs/plans/website-builder.md (M3 block shipped)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:27:49 +02:00
parent 25c3bb6cdf
commit 7a4f8894e1
36 changed files with 2899 additions and 40 deletions

View file

@ -0,0 +1,100 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { FaqProps } from './schema';
let { block, mode }: BlockRenderProps<FaqProps> = $props();
const isEdit = $derived(mode === 'edit');
</script>
<section class="wb-faq" data-mode={mode}>
<div class="wb-faq__inner">
{#if block.props.title}
<h2>{block.props.title}</h2>
{/if}
{#if block.props.items.length === 0 && isEdit}
<p class="wb-placeholder">Füge Fragen im Inspector hinzu.</p>
{:else}
<div class="wb-faq__list">
{#each block.props.items as item, i (i)}
<details open={block.props.defaultOpen}>
<summary>{item.question}</summary>
<div class="wb-faq__answer">
{#each item.answer.split(/\n{2,}/) as paragraph, j (j)}
<p>{paragraph}</p>
{/each}
</div>
</details>
{/each}
</div>
{/if}
</div>
</section>
<style>
.wb-faq {
padding: 3rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-faq__inner {
max-width: 48rem;
width: 100%;
}
.wb-faq h2 {
margin: 0 0 1.5rem;
font-size: 1.75rem;
line-height: 1.2;
}
.wb-faq__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
details {
border: 1px solid rgba(127, 127, 127, 0.2);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.02);
}
summary {
cursor: pointer;
font-weight: 500;
font-size: 1rem;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
summary::-webkit-details-marker {
display: none;
}
summary::after {
content: '+';
font-size: 1.25rem;
opacity: 0.5;
transition: transform 0.2s;
}
details[open] summary::after {
content: '';
transform: rotate(180deg);
}
.wb-faq__answer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(127, 127, 127, 0.1);
opacity: 0.85;
}
.wb-faq__answer p {
margin: 0 0 0.5rem;
line-height: 1.5;
}
.wb-faq__answer p:last-child {
margin-bottom: 0;
}
.wb-placeholder {
opacity: 0.4;
font-style: italic;
}
</style>

View file

@ -0,0 +1,201 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { FaqProps, FaqItem } from './schema';
let { block, onChange }: BlockInspectorProps<FaqProps> = $props();
function updateItem(index: number, patch: Partial<FaqItem>) {
const next = block.props.items.map((item, i) => (i === index ? { ...item, ...patch } : item));
onChange({ items: next });
}
function addItem() {
onChange({ items: [...block.props.items, { question: 'Neue Frage', answer: 'Die Antwort.' }] });
}
function removeItem(index: number) {
onChange({ items: block.props.items.filter((_, i) => i !== index) });
}
function moveItem(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= block.props.items.length) return;
const next = [...block.props.items];
[next[index], next[target]] = [next[target], next[index]];
onChange({ items: next });
}
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Überschrift</span>
<input
type="text"
value={block.props.title}
oninput={(e) => onChange({ title: e.currentTarget.value })}
/>
</label>
<label class="wb-checkbox">
<input
type="checkbox"
checked={block.props.defaultOpen}
onchange={(e) => onChange({ defaultOpen: e.currentTarget.checked })}
/>
<span>Alle standardmäßig ausgeklappt</span>
</label>
<div class="wb-items">
<div class="wb-items__header">
<span>Fragen ({block.props.items.length})</span>
<button class="wb-btn wb-btn--primary" onclick={addItem}>+ Frage</button>
</div>
{#each block.props.items as item, i (i)}
<div class="wb-item">
<div class="wb-item__head">
<span class="wb-item__index">#{i + 1}</span>
<div class="wb-item__actions">
<button
class="wb-btn wb-btn--icon"
onclick={() => moveItem(i, -1)}
disabled={i === 0}
title="Nach oben">↑</button
>
<button
class="wb-btn wb-btn--icon"
onclick={() => moveItem(i, 1)}
disabled={i === block.props.items.length - 1}
title="Nach unten">↓</button
>
<button
class="wb-btn wb-btn--icon wb-btn--danger"
onclick={() => removeItem(i)}
title="Löschen">×</button
>
</div>
</div>
<input
type="text"
value={item.question}
oninput={(e) => updateItem(i, { question: e.currentTarget.value })}
placeholder="Frage"
/>
<textarea
rows="3"
value={item.answer}
oninput={(e) => updateItem(i, { answer: e.currentTarget.value })}
placeholder="Antwort"
></textarea>
</div>
{/each}
</div>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field,
.wb-checkbox {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-field input,
.wb-item input,
.wb-item textarea {
width: 100%;
padding: 0.4rem 0.6rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.8125rem;
}
.wb-item textarea {
resize: vertical;
min-height: 3.5rem;
}
.wb-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-items__header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-item {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.5rem;
}
.wb-item__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.wb-item__index {
font-size: 0.7rem;
opacity: 0.5;
}
.wb-item__actions {
display: flex;
gap: 0.25rem;
}
.wb-btn {
padding: 0.3rem 0.65rem;
border-radius: 0.375rem;
border: none;
font-size: 0.75rem;
cursor: pointer;
font-weight: 500;
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.85);
color: white;
}
.wb-btn--icon {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit;
width: 1.75rem;
padding: 0;
line-height: 1.3;
}
.wb-btn--icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
}
.wb-btn--danger:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: rgb(248, 113, 113);
}
.wb-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Faq from './Faq.svelte';
import FaqInspector from './FaqInspector.svelte';
import { FaqSchema, FAQ_DEFAULTS, type FaqProps, type FaqItem } from './schema';
export const faqBlockSpec: BlockSpec<FaqProps> = {
type: 'faq',
label: 'FAQ',
icon: 'question',
category: 'content',
schema: FaqSchema,
schemaVersion: 1,
defaults: FAQ_DEFAULTS,
Component: Faq,
Inspector: FaqInspector,
};
export type { FaqProps, FaqItem };
export { FaqSchema, FAQ_DEFAULTS };

View file

@ -0,0 +1,24 @@
import { z } from 'zod';
export const FaqItemSchema = z.object({
question: z.string().min(1).max(280),
answer: z.string().min(1).max(2000),
});
export type FaqItem = z.infer<typeof FaqItemSchema>;
export const FaqSchema = z.object({
title: z.string().max(160).default('FAQ'),
items: z.array(FaqItemSchema).max(50).default([]),
defaultOpen: z.boolean().default(false),
});
export type FaqProps = z.infer<typeof FaqSchema>;
export const FAQ_DEFAULTS: FaqProps = {
title: 'Häufige Fragen',
items: [
{ question: 'Beispielfrage?', answer: 'Die Antwort. Klick in den Inspector zum Bearbeiten.' },
],
defaultOpen: false,
};