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

@ -1,7 +1,10 @@
<script lang="ts">
import { getBlockSpec, type Block } from '@mana/website-blocks';
import type { Component } from 'svelte';
import { getBlockSpec, type Block, type BlockInspectorProps } from '@mana/website-blocks';
import { blocksStore, InvalidBlockPropsError } from '../stores/blocks.svelte';
import type { WebsiteBlock } from '../types';
import ImageInspector from './ImageInspector.svelte';
import GalleryInspector from './GalleryInspector.svelte';
interface Props {
block: WebsiteBlock;
@ -12,6 +15,24 @@
const spec = $derived(getBlockSpec(block.type));
/**
* Some blocks need app-level features (image upload via mana-media)
* that can't live in the registry package (which is pure Svelte +
* Zod). Override table: block.type → app-side inspector component.
* Missing entries fall back to `spec.Inspector` from the registry.
*
* Typed as `Component<BlockInspectorProps<unknown>>` because each
* override targets a different block-type's props shape — Svelte's
* generic-component-assignability check can't know the union without
* widening.
*/
const CUSTOM_INSPECTORS: Record<string, Component<BlockInspectorProps<unknown>> | undefined> = {
image: ImageInspector as unknown as Component<BlockInspectorProps<unknown>>,
gallery: GalleryInspector as unknown as Component<BlockInspectorProps<unknown>>,
};
const CustomInspector = $derived(CUSTOM_INSPECTORS[block.type]);
let lastError = $state<string | null>(null);
// Typed as `unknown` to match the registry's Inspector contract
@ -60,7 +81,11 @@
</header>
<div class="wb-inspector__body">
{#if CustomInspector}
<CustomInspector block={asRegistryBlock(block)} {onChange} />
{:else}
<spec.Inspector block={asRegistryBlock(block)} {onChange} />
{/if}
</div>
{#if lastError}

View file

@ -11,10 +11,27 @@
let { blocks, mode, selectedBlockId, onSelect }: Props = $props();
// Top-level blocks for M1 — containers come in M3 (columns/rows).
const topLevel = $derived(
blocks.filter((b) => b.parentBlockId === null).sort((a, b) => a.order - b.order)
);
/**
* Build a parent→children map once, sort each bucket. The renderChild
* snippet below does the recursive lookup against this map so both
* top-level blocks and container children render through the same
* chrome (click-to-select, outline).
*/
const byParent = $derived.by(() => {
const map = new Map<string | null, WebsiteBlock[]>();
for (const b of blocks) {
const parent = b.parentBlockId;
const list = map.get(parent);
if (list) list.push(b);
else map.set(parent, [b]);
}
for (const list of map.values()) {
list.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
return map;
});
const topLevel = $derived(byParent.get(null) ?? []);
function asRegistryBlock(b: WebsiteBlock): BlockType<unknown> {
return {
@ -25,32 +42,49 @@
order: b.order,
parentBlockId: b.parentBlockId,
slotKey: b.slotKey,
// `children` is intentionally omitted at this level — containers
// look up their own via the `children` prop we pass below.
};
}
</script>
{#each topLevel as block (block.id)}
{#snippet renderBlock(block: WebsiteBlock)}
{@const spec = getBlockSpec(block.type)}
{#if spec}
{@const children = (byParent.get(block.id) ?? []).map(asRegistryBlock)}
{#if mode === 'edit'}
<div
class="wb-block-wrap wb-block-wrap--editable"
class:wb-block-wrap--selected={selectedBlockId === block.id}
role="button"
tabindex="0"
onclick={() => onSelect?.(block.id)}
onclick={(e) => {
e.stopPropagation();
onSelect?.(block.id);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSelect?.(block.id);
}
}}
>
<spec.Component block={asRegistryBlock(block)} {mode} />
<spec.Component
block={asRegistryBlock(block)}
{mode}
{children}
renderChild={renderInnerChild}
/>
</div>
{:else}
<div class="wb-block-wrap">
<spec.Component block={asRegistryBlock(block)} {mode} />
<spec.Component
block={asRegistryBlock(block)}
{mode}
{children}
renderChild={renderInnerChild}
/>
</div>
{/if}
{:else if mode === 'edit'}
@ -58,6 +92,17 @@
Unbekannter Block-Typ: {block.type}
</div>
{/if}
{/snippet}
{#snippet renderInnerChild(child: BlockType<unknown>)}
{@const fullBlock = blocks.find((b) => b.id === child.id)}
{#if fullBlock}
{@render renderBlock(fullBlock)}
{/if}
{/snippet}
{#each topLevel as block (block.id)}
{@render renderBlock(block)}
{/each}
<style>

View file

@ -0,0 +1,308 @@
<script lang="ts">
/**
* App-side gallery inspector with multi-image upload via mana-media.
* Overrides the URL-only fallback from @mana/website-blocks.
*/
import type { BlockInspectorProps } from '@mana/website-blocks';
import type { GalleryProps, GalleryImage } from '@mana/website-blocks';
import { uploadImage, UploadError } from '../upload';
let { block, onChange }: BlockInspectorProps<GalleryProps> = $props();
let uploading = $state(false);
let uploadError = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
async function handleFiles(files: FileList | File[]) {
uploading = true;
uploadError = null;
const added: GalleryImage[] = [];
try {
for (const file of Array.from(files)) {
const result = await uploadImage(file);
added.push({ url: result.url, altText: '', caption: '' });
}
onChange({ images: [...block.props.images, ...added] });
} catch (err) {
if (err instanceof UploadError) uploadError = err.message;
else uploadError = err instanceof Error ? err.message : String(err);
} finally {
uploading = false;
}
}
function onFileChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
if (target.files?.length) void handleFiles(target.files);
}
function onDrop(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer?.files?.length) void handleFiles(e.dataTransfer.files);
}
function updateImage(index: number, patch: Partial<GalleryImage>) {
const next = block.props.images.map((img, i) => (i === index ? { ...img, ...patch } : img));
onChange({ images: next });
}
function removeImage(index: number) {
onChange({ images: block.props.images.filter((_, i) => i !== index) });
}
function moveImage(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= block.props.images.length) return;
const next = [...block.props.images];
[next[index], next[target]] = [next[target], next[index]];
onChange({ images: 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 })}
placeholder="Optional"
/>
</label>
<div
class="wb-dropzone"
role="button"
tabindex="0"
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
onclick={() => fileInput?.click()}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput?.click();
}
}}
>
{#if uploading}
<span>Lade hoch…</span>
{:else}
<span>+ Bilder hinzufügen — ziehen oder klicken (mehrere möglich)</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
style="display: none"
onchange={onFileChange}
/>
{#if uploadError}
<p class="wb-error">{uploadError}</p>
{/if}
<div class="wb-row">
<label class="wb-field">
<span>Layout</span>
<select
value={block.props.layout}
onchange={(e) => onChange({ layout: e.currentTarget.value as GalleryProps['layout'] })}
>
<option value="grid">Grid</option>
<option value="masonry">Masonry</option>
</select>
</label>
<label class="wb-field">
<span>Spalten</span>
<select
value={String(block.props.columns)}
onchange={(e) =>
onChange({ columns: Number(e.currentTarget.value) as GalleryProps['columns'] })}
>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</label>
</div>
<div class="wb-row">
<label class="wb-field">
<span>Abstand</span>
<select
value={block.props.gap}
onchange={(e) => onChange({ gap: e.currentTarget.value as GalleryProps['gap'] })}
>
<option value="sm">Klein</option>
<option value="md">Mittel</option>
<option value="lg">Groß</option>
</select>
</label>
<label class="wb-checkbox">
<input
type="checkbox"
checked={block.props.lightbox}
onchange={(e) => onChange({ lightbox: e.currentTarget.checked })}
/>
<span>Lightbox (Vollbild)</span>
</label>
</div>
<div class="wb-images">
<div class="wb-images__head">Bilder ({block.props.images.length})</div>
{#each block.props.images as img, i (i)}
<div class="wb-image-row">
<img src={img.url} alt={img.altText} class="wb-image-row__thumb" />
<div class="wb-image-row__fields">
<input
type="text"
value={img.altText}
oninput={(e) => updateImage(i, { altText: e.currentTarget.value })}
placeholder="Alt-Text"
/>
<input
type="text"
value={img.caption}
oninput={(e) => updateImage(i, { caption: e.currentTarget.value })}
placeholder="Bildunterschrift"
/>
</div>
<div class="wb-image-row__actions">
<button class="wb-btn wb-btn--icon" onclick={() => moveImage(i, -1)} disabled={i === 0}>
</button>
<button
class="wb-btn wb-btn--icon"
onclick={() => moveImage(i, 1)}
disabled={i === block.props.images.length - 1}>↓</button
>
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => removeImage(i)}
>×</button
>
</div>
</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-field select,
.wb-image-row input {
width: 100%;
padding: 0.4rem 0.55rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 0.8125rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.wb-dropzone {
padding: 1rem;
text-align: center;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.8125rem;
cursor: pointer;
opacity: 0.75;
}
.wb-dropzone:hover {
background: rgba(99, 102, 241, 0.08);
border-color: rgba(99, 102, 241, 0.4);
opacity: 1;
}
.wb-images {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-images__head {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-image-row {
display: grid;
grid-template-columns: 3rem 1fr auto;
gap: 0.5rem;
align-items: start;
}
.wb-image-row__thumb {
width: 3rem;
height: 3rem;
object-fit: cover;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.2);
}
.wb-image-row__fields {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-image-row__actions {
display: flex;
gap: 0.25rem;
}
.wb-btn {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: inherit;
font-weight: 500;
}
.wb-btn--icon {
width: 1.5rem;
padding: 0;
line-height: 1.3;
}
.wb-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.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-error {
margin: 0;
padding: 0.4rem 0.6rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
</style>

View file

@ -0,0 +1,217 @@
<script lang="ts">
/**
* App-side image inspector with upload wired to mana-media. Replaces
* the URL-only fallback from @mana/website-blocks via the custom
* inspector registry (see inspector-overrides.ts).
*/
import type { BlockInspectorProps } from '@mana/website-blocks';
import type { ImageProps } from '@mana/website-blocks';
import { uploadImage, UploadError } from '../upload';
let { block, onChange }: BlockInspectorProps<ImageProps> = $props();
let uploading = $state(false);
let uploadError = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
async function handleFile(file: File) {
uploading = true;
uploadError = null;
try {
const result = await uploadImage(file);
onChange({ url: result.url });
} catch (err) {
if (err instanceof UploadError) uploadError = err.message;
else uploadError = err instanceof Error ? err.message : String(err);
} finally {
uploading = false;
}
}
function onFileChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const file = target.files?.[0];
if (file) void handleFile(file);
}
function onDrop(e: DragEvent) {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (file) void handleFile(file);
}
</script>
<div class="wb-inspector">
<div
class="wb-dropzone"
role="button"
tabindex="0"
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
onclick={() => fileInput?.click()}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput?.click();
}
}}
>
{#if uploading}
<span class="wb-dropzone__hint">Lade hoch…</span>
{:else if block.props.url}
<img src={block.props.url} alt={block.props.altText} class="wb-dropzone__preview" />
<span class="wb-dropzone__hint">Klicken / ziehen, um zu ersetzen</span>
{:else}
<span class="wb-dropzone__hint">Bild hier hinziehen oder klicken</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/*"
style="display: none"
onchange={onFileChange}
/>
{#if uploadError}
<p class="wb-error">{uploadError}</p>
{/if}
<label class="wb-field">
<span>Oder URL einsetzen</span>
<input
type="url"
value={block.props.url}
oninput={(e) => onChange({ url: e.currentTarget.value })}
placeholder="https://…"
/>
</label>
<label class="wb-field">
<span>Alt-Text *</span>
<input
type="text"
value={block.props.altText}
oninput={(e) => onChange({ altText: e.currentTarget.value })}
placeholder="Beschreibung für Screenreader"
/>
</label>
<label class="wb-field">
<span>Bildunterschrift</span>
<input
type="text"
value={block.props.caption}
oninput={(e) => onChange({ caption: e.currentTarget.value })}
/>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Seitenverhältnis</span>
<select
value={block.props.aspectRatio}
onchange={(e) =>
onChange({ aspectRatio: e.currentTarget.value as ImageProps['aspectRatio'] })}
>
<option value="auto">Auto</option>
<option value="21:9">21:9</option>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="1:1">1:1</option>
</select>
</label>
<label class="wb-field">
<span>Breite</span>
<select
value={block.props.width}
onchange={(e) => onChange({ width: e.currentTarget.value as ImageProps['width'] })}
>
<option value="narrow">Schmal</option>
<option value="container">Container</option>
<option value="full">Vollbreit</option>
</select>
</label>
</div>
<label class="wb-field">
<span>Füllung</span>
<select
value={block.props.fit}
onchange={(e) => onChange({ fit: e.currentTarget.value as ImageProps['fit'] })}
>
<option value="cover">Zuschneiden</option>
<option value="contain">Einpassen</option>
</select>
</label>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
min-height: 8rem;
}
.wb-dropzone:hover {
background: rgba(99, 102, 241, 0.08);
border-color: rgba(99, 102, 241, 0.4);
}
.wb-dropzone__hint {
font-size: 0.8125rem;
opacity: 0.6;
}
.wb-dropzone__preview {
max-width: 100%;
max-height: 8rem;
object-fit: contain;
border-radius: 0.25rem;
}
.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 {
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-size: 0.875rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.wb-error {
margin: 0;
padding: 0.4rem 0.6rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
</style>

View file

@ -0,0 +1,361 @@
<script lang="ts">
import { PRESET_LABELS, THEME_PRESETS, type ThemePreset } from '@mana/website-blocks/themes';
import { sitesStore } from '../stores/sites.svelte';
import type { Website, ThemeConfig } from '../types';
interface Props {
site: Website;
onClose: () => void;
}
let { site, onClose }: Props = $props();
// Working copy — committed on save so escape/close discards unsaved
// edits. The initial-value-only warnings are intentional here: this
// dialog is a snapshot-based form; it does not track further changes
// to `site` while open.
/* svelte-ignore state_referenced_locally */
let draftPreset = $state<ThemePreset>((site.theme?.preset ?? 'classic') as ThemePreset);
/* svelte-ignore state_referenced_locally */
let draftPrimary = $state(site.theme?.overrides?.primary ?? '');
/* svelte-ignore state_referenced_locally */
let draftBackground = $state(site.theme?.overrides?.background ?? '');
/* svelte-ignore state_referenced_locally */
let draftForeground = $state(site.theme?.overrides?.foreground ?? '');
/* svelte-ignore state_referenced_locally */
let draftFooterText = $state(site.footerConfig?.text ?? '');
let saving = $state(false);
const presets = Object.keys(PRESET_LABELS) as ThemePreset[];
const previewTokens = $derived(THEME_PRESETS[draftPreset]);
async function save() {
saving = true;
try {
const overrides: ThemeConfig['overrides'] = {};
if (draftPrimary) overrides.primary = draftPrimary;
if (draftBackground) overrides.background = draftBackground;
if (draftForeground) overrides.foreground = draftForeground;
const theme: ThemeConfig = {
preset: draftPreset,
...(Object.keys(overrides).length > 0 ? { overrides } : {}),
};
await sitesStore.updateSite(site.id, {
theme,
footerConfig: {
...site.footerConfig,
text: draftFooterText,
},
});
onClose();
} finally {
saving = false;
}
}
function resetOverrides() {
draftPrimary = '';
draftBackground = '';
draftForeground = '';
}
</script>
<div
class="wb-modal__backdrop"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="button"
tabindex="-1"
aria-label="Schließen"
></div>
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-settings-title">
<header class="wb-modal__head">
<h3 id="wb-settings-title">Website-Einstellungen</h3>
<button class="wb-modal__close" onclick={onClose} aria-label="Schließen">×</button>
</header>
<div class="wb-modal__body">
<section class="wb-section">
<h4>Theme</h4>
<div class="wb-presets">
{#each presets as preset (preset)}
{@const tokens = THEME_PRESETS[preset]}
<button
class="wb-preset"
class:wb-preset--active={draftPreset === preset}
onclick={() => (draftPreset = preset)}
>
<div
class="wb-preset__swatch"
style="background:{tokens.background};border-color:{tokens.border};"
>
<span class="wb-preset__dot" style="background:{tokens.primary};"></span>
<span class="wb-preset__text" style="color:{tokens.foreground};">Aa</span>
</div>
<span class="wb-preset__label">{PRESET_LABELS[preset]}</span>
</button>
{/each}
</div>
</section>
<section class="wb-section">
<div class="wb-section__head">
<h4>Farben überschreiben</h4>
<button class="wb-btn wb-btn--ghost wb-btn--sm" onclick={resetOverrides}>
Auf Preset zurücksetzen
</button>
</div>
<div class="wb-colors">
<label class="wb-color">
<span>Primär</span>
<input
type="color"
value={draftPrimary || previewTokens.primary}
oninput={(e) => (draftPrimary = e.currentTarget.value)}
/>
<input
type="text"
value={draftPrimary}
oninput={(e) => (draftPrimary = e.currentTarget.value)}
placeholder={previewTokens.primary}
/>
</label>
<label class="wb-color">
<span>Hintergrund</span>
<input
type="color"
value={draftBackground || previewTokens.background}
oninput={(e) => (draftBackground = e.currentTarget.value)}
/>
<input
type="text"
value={draftBackground}
oninput={(e) => (draftBackground = e.currentTarget.value)}
placeholder={previewTokens.background}
/>
</label>
<label class="wb-color">
<span>Text</span>
<input
type="color"
value={draftForeground || previewTokens.foreground}
oninput={(e) => (draftForeground = e.currentTarget.value)}
/>
<input
type="text"
value={draftForeground}
oninput={(e) => (draftForeground = e.currentTarget.value)}
placeholder={previewTokens.foreground}
/>
</label>
</div>
</section>
<section class="wb-section">
<h4>Footer</h4>
<label class="wb-field">
<span>Footer-Text</span>
<input
type="text"
value={draftFooterText}
oninput={(e) => (draftFooterText = e.currentTarget.value)}
placeholder="© 2026 — Meine Website"
/>
</label>
</section>
</div>
<footer class="wb-modal__foot">
<button class="wb-btn wb-btn--ghost" onclick={onClose} disabled={saving}>Abbrechen</button>
<button class="wb-btn wb-btn--primary" onclick={save} disabled={saving}>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</footer>
</div>
<style>
.wb-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 40;
border: none;
}
.wb-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 36rem);
max-height: 85vh;
background: rgb(15, 18, 24);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wb-modal__head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-modal__head h3 {
margin: 0;
font-size: 1rem;
}
.wb-modal__close {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: inherit;
padding: 0.1rem 0.5rem;
font-size: 1.1rem;
border-radius: 0.375rem;
cursor: pointer;
}
.wb-modal__body {
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.wb-modal__foot {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wb-section__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.wb-section h4 {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wb-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.wb-preset {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
cursor: pointer;
color: inherit;
}
.wb-preset--active {
border-color: rgba(99, 102, 241, 0.9);
}
.wb-preset__swatch {
width: 100%;
aspect-ratio: 2;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
}
.wb-preset__dot {
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
}
.wb-preset__text {
font-weight: 600;
font-size: 0.9rem;
}
.wb-preset__label {
font-size: 0.75rem;
opacity: 0.8;
}
.wb-colors {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-color {
display: grid;
grid-template-columns: 5rem 2.25rem 1fr;
gap: 0.5rem;
align-items: center;
font-size: 0.8125rem;
}
.wb-color input[type='color'] {
width: 2.25rem;
height: 2.25rem;
padding: 0.15rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
cursor: pointer;
}
.wb-color input[type='text'],
.wb-field input {
padding: 0.4rem 0.55rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 0.8125rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
opacity: 0.7;
}
.wb-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.wb-btn--sm {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
}
.wb-btn--ghost {
background: transparent;
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.9);
color: white;
}
.wb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,78 @@
/**
* Client-side upload helper for the website builder.
*
* Forwards the file to mana-media under `app=website` so uploads are
* scoped + listable in admin. Returns `{ mediaId, url }` the block
* stores the full URL (simpler for the public renderer, which doesn't
* have auth and can't re-resolve a mediaId on every render).
*
* The `url` is the CDN-friendly `/file/large` variant by default. If a
* block needs a different size (gallery thumbnails), call
* `mediaFileUrl(mediaId, variant)` with 'small' / 'medium' / 'large' /
* 'original'.
*/
import { browser } from '$app/environment';
function getMediaUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
.__PUBLIC_MANA_MEDIA_URL__;
if (injected) return injected;
}
return (
(import.meta as unknown as { env?: Record<string, string> }).env?.PUBLIC_MANA_MEDIA_URL ??
process.env.PUBLIC_MANA_MEDIA_URL ??
'http://localhost:3015'
);
}
export type MediaVariant = 'small' | 'medium' | 'large' | 'original';
export function mediaFileUrl(mediaId: string, variant: MediaVariant = 'large'): string {
return `${getMediaUrl()}/api/v1/media/${mediaId}/file/${variant}`;
}
export interface UploadResult {
mediaId: string;
url: string;
}
export class UploadError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'UploadError';
this.status = status;
}
}
/**
* Upload an image file. Throws `UploadError` on non-2xx responses or
* non-image content. Caller is responsible for rendering the error.
*/
export async function uploadImage(file: File): Promise<UploadResult> {
if (!file.type.startsWith('image/')) {
throw new UploadError('Bitte wähle ein Bild (PNG, JPG, WEBP, GIF).', 400);
}
if (file.size > 25 * 1024 * 1024) {
throw new UploadError('Datei zu groß (max 25 MB).', 400);
}
const formData = new FormData();
formData.append('file', file);
formData.append('app', 'website');
const res = await fetch(`${getMediaUrl()}/api/v1/media/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new UploadError(`Upload fehlgeschlagen (${res.status})`, res.status);
}
const data = (await res.json()) as { id?: string };
if (!data.id) throw new UploadError('Upload-Antwort ohne Media-ID', 500);
return { mediaId: data.id, url: mediaFileUrl(data.id, 'large') };
}

View file

@ -13,6 +13,7 @@
import InsertPalette from '../components/InsertPalette.svelte';
import PageList from '../components/PageList.svelte';
import PublishBar from '../components/PublishBar.svelte';
import SiteSettingsDialog from '../components/SiteSettingsDialog.svelte';
interface Props {
siteId: string;
@ -30,6 +31,7 @@
const pageBlocks = $derived(blocksForPage(blocks.value, props.pageId));
let selectedBlockId = $state<string | null>(null);
let showSettings = $state(false);
const selectedBlock = $derived(
selectedBlockId ? (pageBlocks.find((b) => b.id === selectedBlockId) ?? null) : null
@ -57,9 +59,20 @@
<aside class="wb-editor__left">
{#if site}
<div class="wb-editor__site-meta">
<div class="wb-editor__site-row">
<div class="wb-editor__site-id">
<p class="wb-editor__site-name">{site.name}</p>
<p class="wb-editor__site-slug">/s/{site.slug}</p>
</div>
<button
class="wb-editor__settings-btn"
onclick={() => (showSettings = true)}
title="Website-Einstellungen"
>
</button>
</div>
</div>
{/if}
<PageList siteId={props.siteId} pages={sitePages} activePageId={props.pageId} />
@ -99,6 +112,10 @@
</div>
</div>
{#if showSettings && site}
<SiteSettingsDialog {site} onClose={() => (showSettings = false)} />
{/if}
<style>
.wb-editor-layout {
display: flex;
@ -155,6 +172,32 @@
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-editor__site-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.wb-editor__site-id {
min-width: 0;
flex: 1 1 auto;
}
.wb-editor__settings-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex: 0 0 auto;
}
.wb-editor__settings-btn:hover {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.4);
}
.wb-editor__site-name {
margin: 0;
font-size: 0.9375rem;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { resolveTheme, themeCssVars, type ThemePreset } from '@mana/website-blocks/themes';
interface Props {
data: LayoutData;
@ -10,23 +11,14 @@
let { data, children }: Props = $props();
const site = $derived(data.snapshot.site);
const theme = $derived(site.theme);
// Theme preset → CSS variables. Three presets for M3+, classic for M2.
const themeVars = $derived.by(() => {
const preset = theme?.preset ?? 'classic';
const base =
preset === 'modern'
? { primary: '#6366f1', bg: '#0b0d12', fg: '#f5f6f8' }
: preset === 'warm'
? { primary: '#f97316', bg: '#1a140f', fg: '#f7ede2' }
: { primary: '#3b82f6', bg: '#ffffff', fg: '#0f172a' };
const overrides = theme?.overrides ?? {};
const primary = overrides.primary ?? base.primary;
const bg = overrides.background ?? base.bg;
const fg = overrides.foreground ?? base.fg;
return `--wb-primary:${primary};--wb-bg:${bg};--wb-fg:${fg};`;
});
const themeTokens = $derived(
resolveTheme(
((site.theme?.preset as ThemePreset) ?? 'classic') satisfies ThemePreset,
site.theme?.overrides
)
);
const themeVars = $derived(themeCssVars(themeTokens));
const navItems = $derived(site.navConfig?.items ?? []);
const footer = $derived(site.footerConfig);
@ -94,19 +86,19 @@
color: var(--wb-fg);
display: flex;
flex-direction: column;
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
font-family: var(--wb-font);
}
.wb-public :global(h1),
.wb-public :global(h2),
.wb-public :global(h3) {
font-family: var(--wb-font-heading);
}
.wb-public__nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(127, 127, 127, 0.15);
border-bottom: 1px solid var(--wb-border);
}
.wb-public__nav--minimal {
border-bottom: none;
@ -137,10 +129,10 @@
}
.wb-public__footer {
padding: 2rem 1.5rem;
border-top: 1px solid rgba(127, 127, 127, 0.15);
border-top: 1px solid var(--wb-border);
text-align: center;
font-size: 0.875rem;
opacity: 0.7;
color: var(--wb-muted);
}
.wb-public__footer ul {
list-style: none;

View file

@ -21,6 +21,10 @@
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./themes": {
"types": "./src/themes/index.ts",
"default": "./src/themes/index.ts"
}
},
"files": [

View file

@ -0,0 +1,94 @@
<script lang="ts">
import type { BlockRenderProps, Block } from '../types';
import type { ColumnsProps } from './schema';
import { columnSlotKeys } from './schema';
let { block, mode, children = [], renderChild }: BlockRenderProps<ColumnsProps> = $props();
const isEdit = $derived(mode === 'edit');
const slots = $derived(columnSlotKeys(block.props.count));
function childrenForSlot(slot: string): Block[] {
return (children ?? []).filter((c) => c.slotKey === slot);
}
</script>
<section
class="wb-columns wb-columns--gap-{block.props.gap} wb-columns--align-{block.props.align}"
class:wb-columns--2={block.props.count === 2}
class:wb-columns--3={block.props.count === 3}
class:wb-columns--stack={block.props.stackOnMobile}
data-mode={mode}
>
{#each slots as slot, idx (slot)}
{@const slotChildren = childrenForSlot(slot)}
<div
class="wb-column"
data-slot={slot}
data-slot-index={idx}
class:wb-column--empty={slotChildren.length === 0}
>
{#if slotChildren.length === 0 && isEdit}
<div class="wb-column__empty">Spalte {idx + 1}</div>
{/if}
{#each slotChildren as child (child.id)}
{#if renderChild}
{@render renderChild(child)}
{/if}
{/each}
</div>
{/each}
</section>
<style>
.wb-columns {
display: grid;
padding: 1.5rem;
max-width: 72rem;
margin: 0 auto;
}
.wb-columns--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wb-columns--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.wb-columns--gap-sm {
gap: 0.75rem;
}
.wb-columns--gap-md {
gap: 1.5rem;
}
.wb-columns--gap-lg {
gap: 3rem;
}
.wb-columns--align-start {
align-items: start;
}
.wb-columns--align-center {
align-items: center;
}
.wb-columns--align-stretch {
align-items: stretch;
}
.wb-column {
min-width: 0;
display: flex;
flex-direction: column;
}
.wb-column__empty {
padding: 1.5rem;
text-align: center;
border: 1px dashed rgba(127, 127, 127, 0.25);
border-radius: 0.5rem;
opacity: 0.4;
font-size: 0.8125rem;
font-style: italic;
}
@media (max-width: 720px) {
.wb-columns--stack {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { ColumnsProps } from './schema';
let { block, onChange }: BlockInspectorProps<ColumnsProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Spalten-Anzahl</span>
<select
value={String(block.props.count)}
onchange={(e) => onChange({ count: Number(e.currentTarget.value) as ColumnsProps['count'] })}
>
<option value="2">2 Spalten</option>
<option value="3">3 Spalten</option>
</select>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Abstand</span>
<select
value={block.props.gap}
onchange={(e) => onChange({ gap: e.currentTarget.value as ColumnsProps['gap'] })}
>
<option value="sm">Klein</option>
<option value="md">Mittel</option>
<option value="lg">Groß</option>
</select>
</label>
<label class="wb-field">
<span>Vertikale Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as ColumnsProps['align'] })}
>
<option value="start">Oben</option>
<option value="center">Mittig</option>
<option value="stretch">Gestreckt</option>
</select>
</label>
</div>
<label class="wb-checkbox">
<input
type="checkbox"
checked={block.props.stackOnMobile}
onchange={(e) => onChange({ stackOnMobile: e.currentTarget.checked })}
/>
<span>Auf Mobile untereinander stapeln</span>
</label>
<p class="wb-hint">
Blöcke in eine Spalte hinzufügen: wähle oben die "+" Palette und füge Block ein — neue Blöcke
werden der zuletzt aktiven Spalte zugewiesen. (In M4 kommt pro-Spalte-Insert-UI.)
</p>
</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 select {
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-size: 0.875rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.wb-hint {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 0.375rem;
font-size: 0.75rem;
opacity: 0.6;
line-height: 1.4;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Columns from './Columns.svelte';
import ColumnsInspector from './ColumnsInspector.svelte';
import { ColumnsSchema, COLUMNS_DEFAULTS, columnSlotKeys, type ColumnsProps } from './schema';
export const columnsBlockSpec: BlockSpec<ColumnsProps> = {
type: 'columns',
label: 'Spalten',
icon: 'columns',
category: 'layout',
schema: ColumnsSchema,
schemaVersion: 1,
defaults: COLUMNS_DEFAULTS,
Component: Columns,
Inspector: ColumnsInspector,
};
export type { ColumnsProps };
export { ColumnsSchema, COLUMNS_DEFAULTS, columnSlotKeys };

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
export const ColumnsSchema = z.object({
count: z.union([z.literal(2), z.literal(3)]).default(2),
gap: z.enum(['sm', 'md', 'lg']).default('md'),
align: z.enum(['start', 'center', 'stretch']).default('stretch'),
stackOnMobile: z.boolean().default(true),
});
export type ColumnsProps = z.infer<typeof ColumnsSchema>;
export const COLUMNS_DEFAULTS: ColumnsProps = {
count: 2,
gap: 'md',
align: 'stretch',
stackOnMobile: true,
};
/**
* Slot keys that a `columns` container accepts. Children of this block
* set `slotKey = 'col-0' | 'col-1' | 'col-2'` to be placed in that
* column.
*/
export function columnSlotKeys(count: 2 | 3): readonly string[] {
return count === 2 ? ['col-0', 'col-1'] : ['col-0', 'col-1', 'col-2'];
}

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',
};

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,
};

View file

@ -0,0 +1,266 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { GalleryProps } from './schema';
let { block, mode }: BlockRenderProps<GalleryProps> = $props();
const isEdit = $derived(mode === 'edit');
// Lightbox state — public-mode only; edit mode doesn't launch the
// modal because clicking an image is how the user selects the
// gallery block for editing.
let lightboxIndex = $state<number | null>(null);
function openLightbox(i: number) {
if (mode !== 'public' || !block.props.lightbox) return;
lightboxIndex = i;
}
function closeLightbox() {
lightboxIndex = null;
}
function nextImage() {
if (lightboxIndex === null) return;
lightboxIndex = (lightboxIndex + 1) % block.props.images.length;
}
function prevImage() {
if (lightboxIndex === null) return;
lightboxIndex = (lightboxIndex - 1 + block.props.images.length) % block.props.images.length;
}
function onKey(e: KeyboardEvent) {
if (lightboxIndex === null) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowRight') nextImage();
if (e.key === 'ArrowLeft') prevImage();
}
const activeImage = $derived(lightboxIndex !== null ? block.props.images[lightboxIndex] : null);
</script>
<svelte:window onkeydown={onKey} />
<section
class="wb-gallery wb-gallery--cols-{block.props.columns} wb-gallery--gap-{block.props.gap}"
class:wb-gallery--masonry={block.props.layout === 'masonry'}
data-mode={mode}
>
{#if block.props.title}
<h2 class="wb-gallery__title">{block.props.title}</h2>
{/if}
{#if block.props.images.length === 0 && isEdit}
<div class="wb-gallery__empty">Füge im Inspector Bilder hinzu.</div>
{:else}
<div class="wb-gallery__grid">
{#each block.props.images as image, i (i)}
<figure class="wb-gallery__item">
{#if mode === 'public' && block.props.lightbox}
<button
class="wb-gallery__item-trigger"
onclick={() => openLightbox(i)}
aria-label={image.altText || `Bild ${i + 1} öffnen`}
>
<img src={image.url} alt={image.altText} loading="lazy" />
</button>
{:else}
<img src={image.url} alt={image.altText} loading="lazy" />
{/if}
{#if image.caption}
<figcaption>{image.caption}</figcaption>
{/if}
</figure>
{/each}
</div>
{/if}
</section>
{#if activeImage && mode === 'public'}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="wb-lightbox"
role="dialog"
aria-modal="true"
aria-label="Vollbild-Ansicht"
tabindex="-1"
onclick={closeLightbox}
onkeydown={(e) => e.key === 'Escape' && closeLightbox()}
>
<button class="wb-lightbox__close" onclick={closeLightbox} aria-label="Schließen">×</button>
<button
class="wb-lightbox__nav wb-lightbox__nav--prev"
onclick={(e) => {
e.stopPropagation();
prevImage();
}}
aria-label="Vorheriges Bild"></button
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<figure
class="wb-lightbox__figure"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<img src={activeImage.url} alt={activeImage.altText} />
{#if activeImage.caption}
<figcaption>{activeImage.caption}</figcaption>
{/if}
</figure>
<button
class="wb-lightbox__nav wb-lightbox__nav--next"
onclick={(e) => {
e.stopPropagation();
nextImage();
}}
aria-label="Nächstes Bild"></button
>
</div>
{/if}
<style>
.wb-gallery {
padding: 2rem 1.5rem;
max-width: 72rem;
margin: 0 auto;
}
.wb-gallery__title {
margin: 0 0 1.5rem;
font-size: 1.75rem;
}
.wb-gallery__empty {
padding: 3rem 1rem;
text-align: center;
border: 1px dashed rgba(127, 127, 127, 0.25);
border-radius: 0.5rem;
opacity: 0.45;
font-style: italic;
}
.wb-gallery__grid {
display: grid;
}
.wb-gallery--cols-2 .wb-gallery__grid {
grid-template-columns: repeat(2, 1fr);
}
.wb-gallery--cols-3 .wb-gallery__grid {
grid-template-columns: repeat(3, 1fr);
}
.wb-gallery--cols-4 .wb-gallery__grid {
grid-template-columns: repeat(4, 1fr);
}
.wb-gallery--gap-sm .wb-gallery__grid {
gap: 0.375rem;
}
.wb-gallery--gap-md .wb-gallery__grid {
gap: 0.75rem;
}
.wb-gallery--gap-lg .wb-gallery__grid {
gap: 1.5rem;
}
.wb-gallery--masonry .wb-gallery__grid {
/* CSS masonry not universal; fallback to regular grid with row
flow. Switch to true masonry behind a feature flag later. */
grid-auto-rows: masonry;
}
@media (max-width: 640px) {
.wb-gallery--cols-2 .wb-gallery__grid,
.wb-gallery--cols-3 .wb-gallery__grid,
.wb-gallery--cols-4 .wb-gallery__grid {
grid-template-columns: repeat(2, 1fr);
}
}
.wb-gallery__item {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
color: inherit;
}
.wb-gallery__item-trigger {
display: block;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.wb-gallery__item img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 0.375rem;
transition: transform 0.2s ease;
}
.wb-gallery__item-trigger:hover img {
transform: scale(1.02);
}
.wb-gallery__item figcaption {
margin-top: 0.25rem;
font-size: 0.8125rem;
opacity: 0.7;
text-align: left;
}
.wb-lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
cursor: zoom-out;
}
.wb-lightbox__figure {
margin: 0;
max-width: min(90vw, 72rem);
max-height: 85vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
cursor: default;
}
.wb-lightbox__figure img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: 0.375rem;
}
.wb-lightbox__figure figcaption {
color: white;
font-size: 0.875rem;
opacity: 0.8;
text-align: center;
}
.wb-lightbox__close,
.wb-lightbox__nav {
position: absolute;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: white;
font-size: 1.5rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.wb-lightbox__close {
top: 1.5rem;
right: 1.5rem;
}
.wb-lightbox__nav--prev {
left: 1.5rem;
}
.wb-lightbox__nav--next {
right: 1.5rem;
}
.wb-lightbox__close:hover,
.wb-lightbox__nav:hover {
background: rgba(255, 255, 255, 0.18);
}
</style>

View file

@ -0,0 +1,168 @@
<script lang="ts">
/**
* URL-only fallback. The app-side inspector (with upload) overrides
* this via the custom-inspector registry — see
* apps/mana/apps/web/src/lib/modules/website/components/GalleryInspector.svelte
*/
import type { BlockInspectorProps } from '../types';
import type { GalleryProps, GalleryImage } from './schema';
let { block, onChange }: BlockInspectorProps<GalleryProps> = $props();
function updateImage(index: number, patch: Partial<GalleryImage>) {
const next = block.props.images.map((img, i) => (i === index ? { ...img, ...patch } : img));
onChange({ images: next });
}
function addImage() {
onChange({ images: [...block.props.images, { url: '', altText: '', caption: '' }] });
}
function removeImage(index: number) {
onChange({ images: block.props.images.filter((_, i) => i !== index) });
}
</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>
<div class="wb-row">
<label class="wb-field">
<span>Layout</span>
<select
value={block.props.layout}
onchange={(e) => onChange({ layout: e.currentTarget.value as GalleryProps['layout'] })}
>
<option value="grid">Grid</option>
<option value="masonry">Masonry</option>
</select>
</label>
<label class="wb-field">
<span>Spalten</span>
<select
value={String(block.props.columns)}
onchange={(e) =>
onChange({ columns: Number(e.currentTarget.value) as GalleryProps['columns'] })}
>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</label>
</div>
<div class="wb-images">
<div class="wb-images__head">
<span>Bilder ({block.props.images.length})</span>
<button class="wb-btn wb-btn--primary" onclick={addImage}>+ Bild</button>
</div>
{#each block.props.images as img, i (i)}
<div class="wb-image-row">
<input
type="url"
placeholder="https://…"
value={img.url}
oninput={(e) => updateImage(i, { url: e.currentTarget.value })}
/>
<input
type="text"
placeholder="Alt-Text"
value={img.altText}
oninput={(e) => updateImage(i, { altText: e.currentTarget.value })}
/>
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => removeImage(i)}>×</button>
</div>
{/each}
</div>
</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 {
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-size: 0.875rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.wb-images {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-images__head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-image-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.375rem;
}
.wb-image-row input {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 0.8125rem;
}
.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;
}
.wb-btn--danger:hover {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: rgb(248, 113, 113);
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Gallery from './Gallery.svelte';
import GalleryInspectorFallback from './GalleryInspectorFallback.svelte';
import { GallerySchema, GALLERY_DEFAULTS, type GalleryProps, type GalleryImage } from './schema';
export const galleryBlockSpec: BlockSpec<GalleryProps> = {
type: 'gallery',
label: 'Galerie',
icon: 'images',
category: 'media',
schema: GallerySchema,
schemaVersion: 1,
defaults: GALLERY_DEFAULTS,
Component: Gallery,
Inspector: GalleryInspectorFallback,
};
export type { GalleryProps, GalleryImage };
export { GallerySchema, GALLERY_DEFAULTS };

View file

@ -0,0 +1,29 @@
import { z } from 'zod';
export const GalleryImageSchema = z.object({
url: z.string().min(1).max(1024),
altText: z.string().max(280).default(''),
caption: z.string().max(280).default(''),
});
export type GalleryImage = z.infer<typeof GalleryImageSchema>;
export const GallerySchema = z.object({
title: z.string().max(160).default(''),
images: z.array(GalleryImageSchema).max(60).default([]),
layout: z.enum(['masonry', 'grid']).default('grid'),
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
gap: z.enum(['sm', 'md', 'lg']).default('md'),
lightbox: z.boolean().default(true),
});
export type GalleryProps = z.infer<typeof GallerySchema>;
export const GALLERY_DEFAULTS: GalleryProps = {
title: '',
images: [],
layout: 'grid',
columns: 3,
gap: 'md',
lightbox: true,
};

View file

@ -0,0 +1,98 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { ImageProps } from './schema';
let { block, mode }: BlockRenderProps<ImageProps> = $props();
const isEdit = $derived(mode === 'edit');
const aspectStyle = $derived.by(() => {
const a = block.props.aspectRatio;
if (a === 'auto') return '';
const [w, h] = a.split(':').map(Number);
return `aspect-ratio: ${w} / ${h};`;
});
</script>
<figure
class="wb-image"
class:wb-image--narrow={block.props.width === 'narrow'}
class:wb-image--container={block.props.width === 'container'}
class:wb-image--full={block.props.width === 'full'}
data-mode={mode}
>
{#if block.props.url}
<div
class="wb-image__frame"
class:wb-image__frame--cover={block.props.fit === 'cover'}
class:wb-image__frame--contain={block.props.fit === 'contain'}
style={aspectStyle}
>
<img src={block.props.url} alt={block.props.altText} loading="lazy" />
</div>
{:else if isEdit}
<div class="wb-image__placeholder">
<span>Lade im Inspector ein Bild hoch oder setze eine URL.</span>
</div>
{/if}
{#if block.props.caption}
<figcaption>{block.props.caption}</figcaption>
{/if}
</figure>
<style>
.wb-image {
margin: 0;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
}
.wb-image--narrow .wb-image__frame {
max-width: 32rem;
}
.wb-image--container .wb-image__frame {
max-width: 64rem;
}
.wb-image--full {
padding: 0;
}
.wb-image--full .wb-image__frame {
max-width: 100%;
width: 100%;
}
.wb-image__frame {
width: 100%;
overflow: hidden;
background: rgba(0, 0, 0, 0.04);
}
.wb-image__frame img {
width: 100%;
height: 100%;
display: block;
}
.wb-image__frame--cover img {
object-fit: cover;
}
.wb-image__frame--contain img {
object-fit: contain;
}
.wb-image__placeholder {
width: 100%;
max-width: 32rem;
padding: 3rem 1rem;
text-align: center;
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 0.5rem;
color: inherit;
opacity: 0.45;
font-style: italic;
}
figcaption {
margin-top: 0.5rem;
font-size: 0.875rem;
opacity: 0.7;
text-align: center;
}
</style>

View file

@ -0,0 +1,118 @@
<script lang="ts">
/**
* URL-only fallback inspector — shipped with the block so it works
* when consumers import the registry without wiring upload. The
* main Mana editor replaces this via registry override with an
* upload-enabled version.
*/
import type { BlockInspectorProps } from '../types';
import type { ImageProps } from './schema';
let { block, onChange }: BlockInspectorProps<ImageProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Bild-URL</span>
<input
type="url"
value={block.props.url}
oninput={(e) => onChange({ url: e.currentTarget.value })}
placeholder="https://…"
/>
</label>
<label class="wb-field">
<span>Alt-Text</span>
<input
type="text"
value={block.props.altText}
oninput={(e) => onChange({ altText: e.currentTarget.value })}
placeholder="Beschreibung für Screenreader"
/>
</label>
<label class="wb-field">
<span>Bildunterschrift</span>
<input
type="text"
value={block.props.caption}
oninput={(e) => onChange({ caption: e.currentTarget.value })}
/>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Seitenverhältnis</span>
<select
value={block.props.aspectRatio}
onchange={(e) =>
onChange({ aspectRatio: e.currentTarget.value as ImageProps['aspectRatio'] })}
>
<option value="auto">Auto</option>
<option value="21:9">21:9</option>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="1:1">1:1</option>
</select>
</label>
<label class="wb-field">
<span>Breite</span>
<select
value={block.props.width}
onchange={(e) => onChange({ width: e.currentTarget.value as ImageProps['width'] })}
>
<option value="narrow">Schmal</option>
<option value="container">Container</option>
<option value="full">Vollbreit</option>
</select>
</label>
</div>
<label class="wb-field">
<span>Füllung</span>
<select
value={block.props.fit}
onchange={(e) => onChange({ fit: e.currentTarget.value as ImageProps['fit'] })}
>
<option value="cover">Zuschneiden</option>
<option value="contain">Einpassen</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;
letter-spacing: 0.02em;
}
.wb-field input,
.wb-field select {
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-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,28 @@
import type { BlockSpec } from '../types';
import Image from './Image.svelte';
import { ImageSchema, IMAGE_DEFAULTS, type ImageProps } from './schema';
/**
* Image block. The inspector is provided by the consuming app (the
* editor), not by this package uploads depend on mana-media which
* lives outside @mana/website-blocks. The registry gets a
* placeholder inspector that just edits URL + alt text; the real
* upload-enabled inspector is injected via `overrideInspector()`
* once the editor boots.
*/
import ImageInspectorFallback from './ImageInspectorFallback.svelte';
export const imageBlockSpec: BlockSpec<ImageProps> = {
type: 'image',
label: 'Bild',
icon: 'image',
category: 'media',
schema: ImageSchema,
schemaVersion: 1,
defaults: IMAGE_DEFAULTS,
Component: Image,
Inspector: ImageInspectorFallback,
};
export type { ImageProps };
export { ImageSchema, IMAGE_DEFAULTS };

View file

@ -0,0 +1,24 @@
import { z } from 'zod';
export const ImageSchema = z.object({
url: z.string().max(1024).default(''),
altText: z.string().max(280).default(''),
caption: z.string().max(280).default(''),
/** 16:9 / 4:3 / 1:1 / auto — 'auto' uses the image's natural ratio. */
aspectRatio: z.enum(['auto', '16:9', '4:3', '1:1', '21:9']).default('auto'),
/** Max width constraint. 'container' = 48rem, 'full' = full bleed. */
width: z.enum(['narrow', 'container', 'full']).default('container'),
/** How to crop when aspect is fixed. */
fit: z.enum(['cover', 'contain']).default('cover'),
});
export type ImageProps = z.infer<typeof ImageSchema>;
export const IMAGE_DEFAULTS: ImageProps = {
url: '',
altText: '',
caption: '',
aspectRatio: 'auto',
width: 'container',
fit: 'cover',
};

View file

@ -26,3 +26,32 @@ export {
type RichTextProps,
} from './richText';
export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer';
export { imageBlockSpec, ImageSchema, IMAGE_DEFAULTS, type ImageProps } from './image';
export { ctaBlockSpec, CtaSchema, CTA_DEFAULTS, type CtaProps } from './cta';
export { faqBlockSpec, FaqSchema, FAQ_DEFAULTS, type FaqProps, type FaqItem } from './faq';
export {
columnsBlockSpec,
ColumnsSchema,
COLUMNS_DEFAULTS,
columnSlotKeys,
type ColumnsProps,
} from './columns';
export {
galleryBlockSpec,
GallerySchema,
GALLERY_DEFAULTS,
type GalleryProps,
type GalleryImage,
} from './gallery';
export {
THEME_PRESETS,
PRESET_LABELS,
CLASSIC_LIGHT,
MODERN_DARK,
WARM,
resolveTheme,
themeCssVars,
type ThemeTokens,
type ThemePreset,
} from './themes';

View file

@ -2,6 +2,11 @@ import type { BlockSpec } from './types';
import { heroBlockSpec } from './hero';
import { richTextBlockSpec } from './richText';
import { spacerBlockSpec } from './spacer';
import { imageBlockSpec } from './image';
import { ctaBlockSpec } from './cta';
import { faqBlockSpec } from './faq';
import { columnsBlockSpec } from './columns';
import { galleryBlockSpec } from './gallery';
/**
* The block registry single source of truth for every block type the
@ -14,6 +19,11 @@ import { spacerBlockSpec } from './spacer';
export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
heroBlockSpec,
richTextBlockSpec,
ctaBlockSpec,
imageBlockSpec,
galleryBlockSpec,
faqBlockSpec,
columnsBlockSpec,
spacerBlockSpec,
] as unknown as readonly BlockSpec<unknown>[];

View file

@ -0,0 +1,118 @@
/**
* Theme presets for the website builder.
*
* A preset is a named set of CSS variables (`--wb-primary`, `--wb-bg`,
* `--wb-fg`, plus spacing/type tokens). The public renderer injects
* these via inline `style` on the root block components read them
* through `var()` so they inherit the theme without hardcoding.
*
* Users pick a preset and can override individual colors via
* `site.theme.overrides`.
*/
import type { ThemePreset } from './types';
export interface ThemeTokens {
/** Primary brand color — buttons, links, accents. */
primary: string;
/** Primary color's contrast text (white on most primaries). */
primaryFg: string;
/** Page background. */
background: string;
/** Page text color. */
foreground: string;
/** Subtle surface (section bg, hero subtle). */
surface: string;
/** Border / divider color (dividers, card outlines). */
border: string;
/** Muted text (captions, metadata). */
muted: string;
/** Font family for headings + body. */
fontFamily: string;
/** Font family for headings if different. Empty = same as body. */
headingFontFamily: string;
/** Base radius for buttons + cards. */
radius: string;
}
export const CLASSIC_LIGHT: ThemeTokens = {
primary: '#3b82f6',
primaryFg: '#ffffff',
background: '#ffffff',
foreground: '#0f172a',
surface: '#f8fafc',
border: 'rgba(15, 23, 42, 0.08)',
muted: 'rgba(15, 23, 42, 0.6)',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
headingFontFamily: '',
radius: '0.5rem',
};
export const MODERN_DARK: ThemeTokens = {
primary: '#6366f1',
primaryFg: '#ffffff',
background: '#0b0d12',
foreground: '#f5f6f8',
surface: 'rgba(255, 255, 255, 0.04)',
border: 'rgba(255, 255, 255, 0.08)',
muted: 'rgba(255, 255, 255, 0.6)',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
headingFontFamily: 'ui-sans-serif, system-ui, sans-serif',
radius: '0.75rem',
};
export const WARM: ThemeTokens = {
primary: '#f97316',
primaryFg: '#ffffff',
background: '#fdf6ed',
foreground: '#3b2d1f',
surface: '#f7ede2',
border: 'rgba(59, 45, 31, 0.12)',
muted: 'rgba(59, 45, 31, 0.6)',
fontFamily: '"Merriweather", Georgia, serif',
headingFontFamily: '"Merriweather", Georgia, serif',
radius: '0.375rem',
};
export const THEME_PRESETS: Record<ThemePreset, ThemeTokens> = {
classic: CLASSIC_LIGHT,
modern: MODERN_DARK,
warm: WARM,
};
export const PRESET_LABELS: Record<ThemePreset, string> = {
classic: 'Klassisch (hell)',
modern: 'Modern (dunkel)',
warm: 'Warm (Serif)',
};
/**
* Merge preset tokens with per-site overrides.
*/
export function resolveTheme(
preset: ThemePreset,
overrides?: Partial<Pick<ThemeTokens, 'primary' | 'background' | 'foreground'>>
): ThemeTokens {
const base = THEME_PRESETS[preset];
return { ...base, ...(overrides ?? {}) };
}
/**
* Serialise tokens as a CSS-variable string for inline `style=`.
*/
export function themeCssVars(tokens: ThemeTokens): string {
return [
`--wb-primary:${tokens.primary}`,
`--wb-primary-fg:${tokens.primaryFg}`,
`--wb-bg:${tokens.background}`,
`--wb-fg:${tokens.foreground}`,
`--wb-surface:${tokens.surface}`,
`--wb-border:${tokens.border}`,
`--wb-muted:${tokens.muted}`,
`--wb-font:${tokens.fontFamily}`,
`--wb-font-heading:${tokens.headingFontFamily || tokens.fontFamily}`,
`--wb-radius:${tokens.radius}`,
].join(';');
}
export type { ThemePreset };

View file

@ -0,0 +1 @@
export type ThemePreset = 'classic' | 'modern' | 'warm';

View file

@ -1,4 +1,4 @@
import type { Component } from 'svelte';
import type { Component, Snippet } from 'svelte';
import type { ZodTypeAny, z } from 'zod';
/**
@ -19,6 +19,11 @@ export type BlockMode = 'edit' | 'preview' | 'public';
* A single block in the tree. Props are block-type-specific and validated
* against the registered Zod schema at write time (in stores) and at
* publish time (in the snapshot builder).
*
* When the renderer passes a block into a container component, the
* block's own children (blocks whose `parentBlockId === this.id`) are
* pre-arranged into `children`. Leaf blocks always have `children = []`
* or undefined.
*/
export interface Block<Props = unknown> {
id: string;
@ -28,6 +33,7 @@ export interface Block<Props = unknown> {
order: number;
parentBlockId: string | null;
slotKey: string | null;
children?: Block[];
}
/**
@ -36,13 +42,23 @@ export interface Block<Props = unknown> {
export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed';
/**
* Props passed to every block renderer. `onEdit` is only present in
* `edit` mode consumers must guard with `if (mode === 'edit' && onEdit)`.
* Props passed to every block renderer.
*
* `children` is the block's direct children (one level). For container
* blocks (columns, future tabs/accordion), consumers render each child
* by invoking the `renderChild` snippet this pushes the outer chrome
* (click-to-select in edit mode, cache tagging in public mode) back out
* to the renderer that owns it, so the container doesn't need to know
* about selection state or mode-specific wrappers.
*
* `onEdit` is only present in `edit` mode guard with
* `if (mode === 'edit' && onEdit)`.
*/
export interface BlockRenderProps<Props = unknown> {
block: Block<Props>;
mode: BlockMode;
children?: Block[];
renderChild?: Snippet<[Block]>;
onEdit?: (patch: Partial<Props>) => void;
}