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,108 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { CtaProps } from './schema';
let { block, mode }: BlockRenderProps<CtaProps> = $props();
const isEdit = $derived(mode === 'edit');
</script>
<section
class="wb-cta"
class:wb-cta--left={block.props.align === 'left'}
class:wb-cta--center={block.props.align === 'center'}
class:wb-cta--bg-subtle={block.props.background === 'subtle'}
class:wb-cta--bg-primary={block.props.background === 'primary'}
data-mode={mode}
>
<div class="wb-cta__inner">
{#if block.props.title}
<h2>{block.props.title}</h2>
{:else if isEdit}
<h2 class="wb-placeholder">Titel im Inspector setzen</h2>
{/if}
{#if block.props.description}
<p class="wb-cta__description">{block.props.description}</p>
{/if}
<a
class="wb-cta__button wb-cta__button--{block.props.variant}"
href={block.props.buttonHref || '#'}
>
{block.props.buttonLabel || (isEdit ? 'Button-Label fehlt' : '')}
</a>
</div>
</section>
<style>
.wb-cta {
padding: 3rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-cta--bg-subtle {
background: rgba(255, 255, 255, 0.04);
}
.wb-cta--bg-primary {
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
color: white;
}
.wb-cta__inner {
max-width: 42rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wb-cta--center .wb-cta__inner {
align-items: center;
text-align: center;
}
.wb-cta--left .wb-cta__inner {
align-items: flex-start;
text-align: left;
}
.wb-cta h2 {
margin: 0;
font-size: 1.75rem;
line-height: 1.2;
}
.wb-cta__description {
margin: 0;
font-size: 1rem;
line-height: 1.5;
opacity: 0.8;
}
.wb-cta__button {
display: inline-block;
padding: 0.625rem 1.25rem;
border-radius: 9999px;
text-decoration: none;
font-weight: 500;
font-size: 0.9375rem;
margin-top: 0.5rem;
}
.wb-cta__button--primary {
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
color: white;
}
.wb-cta__button--secondary {
background: rgba(255, 255, 255, 0.1);
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.wb-cta__button--ghost {
background: transparent;
color: inherit;
border: 1px solid currentColor;
}
.wb-cta--bg-primary .wb-cta__button--primary {
background: white;
color: var(--wb-primary, rgb(99, 102, 241));
}
.wb-placeholder {
opacity: 0.35;
font-style: italic;
}
</style>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { CtaProps } from './schema';
let { block, onChange }: BlockInspectorProps<CtaProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Titel</span>
<input
type="text"
value={block.props.title}
oninput={(e) => onChange({ title: e.currentTarget.value })}
/>
</label>
<label class="wb-field">
<span>Beschreibung</span>
<textarea
rows="3"
value={block.props.description}
oninput={(e) => onChange({ description: e.currentTarget.value })}
></textarea>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Button-Label *</span>
<input
type="text"
value={block.props.buttonLabel}
oninput={(e) => onChange({ buttonLabel: e.currentTarget.value })}
/>
</label>
<label class="wb-field">
<span>Button-Link</span>
<input
type="text"
value={block.props.buttonHref}
oninput={(e) => onChange({ buttonHref: e.currentTarget.value })}
placeholder="https://… oder /kontakt"
/>
</label>
</div>
<div class="wb-row">
<label class="wb-field">
<span>Variante</span>
<select
value={block.props.variant}
onchange={(e) => onChange({ variant: e.currentTarget.value as CtaProps['variant'] })}
>
<option value="primary">Primär</option>
<option value="secondary">Sekundär</option>
<option value="ghost">Ghost</option>
</select>
</label>
<label class="wb-field">
<span>Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as CtaProps['align'] })}
>
<option value="center">Zentriert</option>
<option value="left">Linksbündig</option>
</select>
</label>
</div>
<label class="wb-field">
<span>Hintergrund</span>
<select
value={block.props.background}
onchange={(e) => onChange({ background: e.currentTarget.value as CtaProps['background'] })}
>
<option value="none">Kein</option>
<option value="subtle">Dezent</option>
<option value="primary">Primärfarbe</option>
</select>
</label>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-field input,
.wb-field select,
.wb-field textarea {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.875rem;
}
.wb-field textarea {
resize: vertical;
min-height: 4.5rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Cta from './Cta.svelte';
import CtaInspector from './CtaInspector.svelte';
import { CtaSchema, CTA_DEFAULTS, type CtaProps } from './schema';
export const ctaBlockSpec: BlockSpec<CtaProps> = {
type: 'cta',
label: 'Call-to-Action',
icon: 'megaphone',
category: 'content',
schema: CtaSchema,
schemaVersion: 1,
defaults: CTA_DEFAULTS,
Component: Cta,
Inspector: CtaInspector,
};
export type { CtaProps };
export { CtaSchema, CTA_DEFAULTS };

View file

@ -0,0 +1,23 @@
import { z } from 'zod';
export const CtaSchema = z.object({
title: z.string().max(160).default(''),
description: z.string().max(480).default(''),
buttonLabel: z.string().min(1).max(60).default('Los gehts'),
buttonHref: z.string().max(512).default('#'),
variant: z.enum(['primary', 'secondary', 'ghost']).default('primary'),
align: z.enum(['left', 'center']).default('center'),
background: z.enum(['none', 'subtle', 'primary']).default('subtle'),
});
export type CtaProps = z.infer<typeof CtaSchema>;
export const CTA_DEFAULTS: CtaProps = {
title: 'Bereit loszulegen?',
description: '',
buttonLabel: 'Jetzt starten',
buttonHref: '#',
variant: 'primary',
align: 'center',
background: 'subtle',
};