feat(website): M4 — forms + moduleEmbed

Adds two new block types and the server-side infrastructure for
untrusted input + cross-module data embedding.

Forms:
- packages/website-blocks/src/form: declarative fields (text, email,
  tel, url, textarea, number) with required / maxLength / placeholder
  per field. Honeypot hidden input in the renderer; public-mode POST
  to a same-origin SvelteKit proxy that forwards to mana-api.
- apps/api: website.submissions table (schema.ts + 0001_submissions.sql)
  + POST /public/submit/:siteSlug/:blockId. Loads the current published
  snapshot, finds the form block, validates payload against its
  declared fields (trim, type check, length cap), rejects honeypot
  submissions silently, rate-limits per IP (10 / 5 min) in-memory.
  Unknown keys are dropped — clients can only submit declared fields.
- Owner-facing: GET/DELETE /sites/:id/submissions + SubmissionsView
  component + /(app)/website/[siteId]/submissions route. Shows
  incoming submissions with status pill + payload preview + delete.
- apps/mana/.../routes/s/[siteSlug]/__submit/[blockId]/+server.ts:
  same-origin proxy so form posts don't trigger CORS and IP / user-
  agent headers are forwarded via SvelteKit's trusted getClientAddress.

M4 first-pass does NOT wire target-module delivery (contacts / notify).
Submissions stay in the inbox until owner-side tool handlers land
(M4.x). `target` enum is intentionally `['inbox']` only for now.

moduleEmbed:
- packages/website-blocks/src/moduleEmbed: source dropdown
  (picture.board | library.entries), max-items, layout (grid | list),
  optional filter object. The `resolved` field on props is populated at
  publish time by the editor-side resolver — public renderer reads it
  directly, no Dexie / API round-trip needed.
- apps/mana/.../website/embeds.ts: per-source resolvers. picture.board
  enforces `isPublic=true`; library.entries respects filter.isFavorite
  / kind / status so owners can expose a subset (e.g. "my favorites").
- buildSnapshot() walks the tree after assembly and fills in
  block.props.resolved for every moduleEmbed. Publish slower, public
  visits fast. No cross-service call at render time.

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

Apply Postgres with:
  psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:36:52 +02:00
parent 79d112657c
commit 57be0f61b1
20 changed files with 1817 additions and 2 deletions

View file

@ -0,0 +1,221 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { FormProps } from './schema';
let { block, mode }: BlockRenderProps<FormProps> = $props();
const isPublic = $derived(mode === 'public');
// Submission state — only active in public mode. In edit/preview the
// form is a static preview; clicking submit does nothing.
let values = $state<Record<string, string>>({});
let submitting = $state(false);
let submitted = $state(false);
let errorText = $state<string | null>(null);
// Honeypot — real users never fill this. Bots usually do.
let honeypot = $state('');
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (!isPublic) return; // edit/preview — no network call
if (honeypot.trim()) {
// Silent fail — behave like success to the bot.
submitted = true;
return;
}
submitting = true;
errorText = null;
try {
// Build payload from declared fields only (ignores any extra
// input) so clients can't inject surprise keys.
const payload: Record<string, string> = {};
for (const field of block.props.fields) {
payload[field.name] = values[field.name] ?? '';
}
// The site slug lives in the URL path: /s/<slug>/... The
// proxy route at /s/[slug]/__submit/[blockId] forwards to the
// mana-api public endpoint with CORS/rate-limit handled
// there. Works identically for the home page (`/s/slug`) and
// nested pages (`/s/slug/about`).
const pathParts = window.location.pathname.split('/');
const slug = pathParts[2] ?? '';
if (!slug) {
throw new Error('Konnte Website-Slug aus URL nicht ableiten');
}
const res = await fetch(`/s/${slug}/__submit/${block.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `Fehler (${res.status})`);
}
submitted = true;
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
} finally {
submitting = false;
}
}
</script>
<section class="wb-form" data-mode={mode}>
<div class="wb-form__inner">
{#if block.props.title}
<h2>{block.props.title}</h2>
{/if}
{#if block.props.description}
<p class="wb-form__description">{block.props.description}</p>
{/if}
{#if submitted}
<div class="wb-form__success">{block.props.successMessage}</div>
{:else}
<form onsubmit={onSubmit} novalidate>
{#each block.props.fields as field (field.name)}
<label class="wb-form__field">
<span>
{field.label}
{#if field.required}<span class="wb-form__required">*</span>{/if}
</span>
{#if field.type === 'textarea'}
<textarea
name={field.name}
rows="4"
placeholder={field.placeholder}
required={field.required}
maxlength={field.maxLength}
bind:value={values[field.name]}
></textarea>
{:else}
<input
name={field.name}
type={field.type}
placeholder={field.placeholder}
required={field.required}
maxlength={field.maxLength}
bind:value={values[field.name]}
/>
{/if}
{#if field.helpText}
<small>{field.helpText}</small>
{/if}
</label>
{/each}
<!-- Honeypot: hidden via CSS (not `type=hidden`), bots see + fill. -->
<label class="wb-form__honeypot" aria-hidden="true">
<span>Nicht ausfüllen</span>
<input type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
</label>
{#if errorText}
<p class="wb-form__error">{errorText}</p>
{/if}
<button type="submit" class="wb-form__submit" disabled={submitting}>
{submitting ? 'Sende…' : block.props.submitLabel}
</button>
</form>
{/if}
</div>
</section>
<style>
.wb-form {
padding: 3rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-form__inner {
max-width: 36rem;
width: 100%;
}
.wb-form h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.wb-form__description {
margin: 0 0 1.5rem;
opacity: 0.7;
line-height: 1.5;
}
.wb-form__success {
padding: 1rem 1.25rem;
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 0.5rem;
color: rgb(16, 185, 129);
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-form__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-form__field > span {
font-size: 0.8125rem;
font-weight: 500;
}
.wb-form__required {
color: rgb(220, 38, 38);
margin-left: 0.15rem;
}
.wb-form__field input,
.wb-form__field textarea {
padding: 0.625rem 0.75rem;
border-radius: var(--wb-radius, 0.375rem);
border: 1px solid var(--wb-border, rgba(127, 127, 127, 0.25));
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
color: inherit;
font-family: inherit;
font-size: 0.9375rem;
}
.wb-form__field textarea {
resize: vertical;
min-height: 5rem;
}
.wb-form__field small {
font-size: 0.75rem;
opacity: 0.6;
}
.wb-form__honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.wb-form__error {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.3);
border-radius: 0.375rem;
color: rgb(220, 38, 38);
font-size: 0.875rem;
}
.wb-form__submit {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 9999px;
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
color: var(--wb-primary-fg, white);
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
align-self: flex-start;
}
.wb-form__submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,287 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { FormProps, FormField } from './schema';
let { block, onChange }: BlockInspectorProps<FormProps> = $props();
const TYPE_LABELS: Record<FormField['type'], string> = {
text: 'Text',
email: 'E-Mail',
tel: 'Telefon',
url: 'URL',
number: 'Zahl',
textarea: 'Mehrzeilig',
};
function updateField(index: number, patch: Partial<FormField>) {
const next = block.props.fields.map((f, i) => (i === index ? { ...f, ...patch } : f));
onChange({ fields: next });
}
function addField() {
const existingNames = new Set(block.props.fields.map((f) => f.name));
let counter = 1;
let name = `feld_${counter}`;
while (existingNames.has(name)) name = `feld_${++counter}`;
const newField: FormField = {
name,
label: 'Neues Feld',
type: 'text',
required: false,
placeholder: '',
helpText: '',
maxLength: 500,
};
onChange({ fields: [...block.props.fields, newField] });
}
function removeField(index: number) {
onChange({ fields: block.props.fields.filter((_, i) => i !== index) });
}
function moveField(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= block.props.fields.length) return;
const next = [...block.props.fields];
[next[index], next[target]] = [next[target], next[index]];
onChange({ fields: next });
}
</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>Submit-Button</span>
<input
type="text"
value={block.props.submitLabel}
oninput={(e) => onChange({ submitLabel: e.currentTarget.value })}
/>
</label>
<label class="wb-field">
<span>Ziel</span>
<select disabled>
<option>Inbox (Owner)</option>
</select>
</label>
</div>
<label class="wb-field">
<span>Erfolgs-Nachricht</span>
<input
type="text"
value={block.props.successMessage}
oninput={(e) => onChange({ successMessage: e.currentTarget.value })}
/>
</label>
<div class="wb-fields">
<div class="wb-fields__head">
<span>Felder ({block.props.fields.length})</span>
<button class="wb-btn wb-btn--primary" onclick={addField}>+ Feld</button>
</div>
{#each block.props.fields as field, i (i)}
<div class="wb-form-field">
<div class="wb-form-field__head">
<span class="wb-form-field__idx">#{i + 1}</span>
<div class="wb-form-field__actions">
<button class="wb-btn wb-btn--icon" onclick={() => moveField(i, -1)} disabled={i === 0}
>↑</button
>
<button
class="wb-btn wb-btn--icon"
onclick={() => moveField(i, 1)}
disabled={i === block.props.fields.length - 1}>↓</button
>
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => removeField(i)}>
×
</button>
</div>
</div>
<div class="wb-row">
<label class="wb-field">
<span>Label</span>
<input
type="text"
value={field.label}
oninput={(e) => updateField(i, { label: e.currentTarget.value })}
/>
</label>
<label class="wb-field">
<span>Typ</span>
<select
value={field.type}
onchange={(e) => updateField(i, { type: e.currentTarget.value as FormField['type'] })}
>
{#each Object.entries(TYPE_LABELS) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
</label>
</div>
<div class="wb-row">
<label class="wb-field">
<span>Feld-Name (intern)</span>
<input
type="text"
value={field.name}
oninput={(e) => updateField(i, { name: e.currentTarget.value })}
pattern="^[a-z][a-z0-9_]*$"
/>
</label>
<label class="wb-checkbox">
<input
type="checkbox"
checked={field.required}
onchange={(e) => updateField(i, { required: e.currentTarget.checked })}
/>
<span>Pflicht</span>
</label>
</div>
<label class="wb-field">
<span>Placeholder</span>
<input
type="text"
value={field.placeholder}
oninput={(e) => updateField(i, { placeholder: e.currentTarget.value })}
/>
</label>
</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-field 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-size: 0.8125rem;
font-family: inherit;
}
.wb-field textarea {
resize: vertical;
min-height: 3.5rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.wb-fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-fields__head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.5rem;
}
.wb-form-field__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.wb-form-field__idx {
font-size: 0.7rem;
opacity: 0.5;
}
.wb-form-field__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 Form from './Form.svelte';
import FormInspector from './FormInspector.svelte';
import { FormSchema, FORM_DEFAULTS, type FormProps, type FormField } from './schema';
export const formBlockSpec: BlockSpec<FormProps> = {
type: 'form',
label: 'Formular',
icon: 'clipboard',
category: 'form',
schema: FormSchema,
schemaVersion: 1,
defaults: FORM_DEFAULTS,
Component: Form,
Inspector: FormInspector,
};
export type { FormProps, FormField };
export { FormSchema, FORM_DEFAULTS };

View file

@ -0,0 +1,76 @@
import { z } from 'zod';
export const FormFieldSchema = z.object({
/** Internal name — key in the submission payload. */
name: z
.string()
.min(1)
.max(40)
.regex(/^[a-z][a-z0-9_]*$/i),
/** Visible label above the input. */
label: z.string().min(1).max(120),
/** HTML input type. `textarea` renders `<textarea>`. */
type: z.enum(['text', 'email', 'tel', 'url', 'textarea', 'number']).default('text'),
required: z.boolean().default(false),
placeholder: z.string().max(120).default(''),
/** Optional helper line under the input. */
helpText: z.string().max(280).default(''),
/** Max length for text/textarea (default 1000). */
maxLength: z.number().int().positive().max(10_000).default(1000),
});
export type FormField = z.infer<typeof FormFieldSchema>;
export const FormSchema = z.object({
title: z.string().max(160).default(''),
description: z.string().max(480).default(''),
fields: z.array(FormFieldSchema).min(1).max(20).default([]),
submitLabel: z.string().min(1).max(40).default('Absenden'),
successMessage: z.string().min(1).max(400).default('Danke! Wir melden uns bald.'),
/**
* Where the submission goes. `inbox` stored server-side, owner sees
* it in /website/[id]/submissions (M4 default). Future targets
* (contacts, notify) land in M4.x when server-side tool handlers
* exist.
*/
target: z.enum(['inbox']).default('inbox'),
});
export type FormProps = z.infer<typeof FormSchema>;
export const FORM_DEFAULTS: FormProps = {
title: 'Kontakt',
description: '',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
placeholder: '',
helpText: '',
maxLength: 120,
},
{
name: 'email',
label: 'E-Mail',
type: 'email',
required: true,
placeholder: 'du@beispiel.de',
helpText: '',
maxLength: 200,
},
{
name: 'message',
label: 'Nachricht',
type: 'textarea',
required: true,
placeholder: '',
helpText: '',
maxLength: 2000,
},
],
submitLabel: 'Absenden',
successMessage: 'Danke! Wir melden uns bald.',
target: 'inbox',
};

View file

@ -43,6 +43,15 @@ export {
type GalleryProps,
type GalleryImage,
} from './gallery';
export { formBlockSpec, FormSchema, FORM_DEFAULTS, type FormProps, type FormField } from './form';
export {
moduleEmbedBlockSpec,
ModuleEmbedSchema,
MODULE_EMBED_DEFAULTS,
type ModuleEmbedProps,
type EmbedItem,
type EmbedSource,
} from './moduleEmbed';
export {
THEME_PRESETS,

View file

@ -0,0 +1,174 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { ModuleEmbedProps } from './schema';
let { block, mode }: BlockRenderProps<ModuleEmbedProps> = $props();
const isEdit = $derived(mode === 'edit');
const resolved = $derived(block.props.resolved);
const items = $derived(resolved?.items ?? []);
</script>
<section
class="wb-embed wb-embed--{block.props.layout}"
data-source={block.props.source}
data-mode={mode}
>
<div class="wb-embed__inner">
{#if block.props.title}
<h2>{block.props.title}</h2>
{/if}
{#if !resolved && isEdit}
<div class="wb-embed__placeholder">
Nicht aufgelöst. Quelle: {block.props.source}
{#if block.props.sourceId}
({block.props.sourceId})
{/if}. Beim Veröffentlichen werden die Inhalte gezogen.
</div>
{:else if resolved?.error}
<div class="wb-embed__error">Einbettung fehlgeschlagen: {resolved.error}</div>
{:else if items.length === 0}
<div class="wb-embed__empty">Keine Inhalte gefunden.</div>
{:else if block.props.layout === 'grid'}
<div class="wb-embed__grid">
{#each items as item, i (i)}
<a class="wb-embed-card" href={item.href ?? '#'} class:is-static={!item.href}>
{#if item.imageUrl}
<img src={item.imageUrl} alt={item.title} loading="lazy" />
{/if}
<div class="wb-embed-card__body">
<p class="wb-embed-card__title">{item.title}</p>
{#if item.subtitle}
<p class="wb-embed-card__subtitle">{item.subtitle}</p>
{/if}
</div>
</a>
{/each}
</div>
{:else}
<ul class="wb-embed__list">
{#each items as item, i (i)}
<li class="wb-embed-row">
{#if item.imageUrl}
<img src={item.imageUrl} alt={item.title} loading="lazy" />
{/if}
<div>
<a class="wb-embed-row__title" href={item.href ?? '#'}>{item.title}</a>
{#if item.subtitle}
<p>{item.subtitle}</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
</section>
<style>
.wb-embed {
padding: 2rem 1.5rem;
max-width: 72rem;
margin: 0 auto;
}
.wb-embed__inner {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-embed h2 {
margin: 0;
font-size: 1.5rem;
}
.wb-embed__placeholder,
.wb-embed__error,
.wb-embed__empty {
padding: 2rem 1rem;
text-align: center;
border: 1px dashed rgba(127, 127, 127, 0.25);
border-radius: 0.5rem;
font-size: 0.9375rem;
}
.wb-embed__placeholder {
opacity: 0.55;
font-style: italic;
}
.wb-embed__error {
border-color: rgba(248, 113, 113, 0.3);
color: rgb(248, 113, 113);
}
.wb-embed__empty {
opacity: 0.4;
}
.wb-embed__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
}
.wb-embed-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--wb-border, rgba(127, 127, 127, 0.15));
border-radius: var(--wb-radius, 0.5rem);
color: inherit;
text-decoration: none;
overflow: hidden;
}
.wb-embed-card.is-static {
pointer-events: none;
}
.wb-embed-card img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.wb-embed-card__body {
padding: 0.5rem 0.75rem 0.875rem;
}
.wb-embed-card__title {
margin: 0;
font-weight: 500;
}
.wb-embed-card__subtitle {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
opacity: 0.7;
}
.wb-embed__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-embed-row {
display: grid;
grid-template-columns: 4rem 1fr;
gap: 0.75rem;
padding: 0.625rem;
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
border-radius: var(--wb-radius, 0.5rem);
}
.wb-embed-row img {
width: 4rem;
height: 4rem;
object-fit: cover;
border-radius: 0.25rem;
}
.wb-embed-row__title {
color: inherit;
font-weight: 500;
text-decoration: none;
}
.wb-embed-row p {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,98 @@
<script lang="ts">
/**
* URL-style fallback for the pure-package inspector — the app-side
* override adds a board picker that queries Dexie.
*/
import type { BlockInspectorProps } from '../types';
import type { ModuleEmbedProps } from './schema';
let { block, onChange }: BlockInspectorProps<ModuleEmbedProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Quelle</span>
<select
value={block.props.source}
onchange={(e) => onChange({ source: e.currentTarget.value as ModuleEmbedProps['source'] })}
>
<option value="picture.board">Picture-Board</option>
<option value="library.entries">Bibliothek</option>
</select>
</label>
<label class="wb-field">
<span>Quellen-ID</span>
<input
type="text"
value={block.props.sourceId}
oninput={(e) => onChange({ sourceId: e.currentTarget.value })}
placeholder="Board-ID oder leer für 'alle'"
/>
</label>
<label class="wb-field">
<span>Titel (optional)</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 ModuleEmbedProps['layout'] })}
>
<option value="grid">Grid</option>
<option value="list">Liste</option>
</select>
</label>
<label class="wb-field">
<span>Max. Einträge</span>
<input
type="number"
min="1"
max="48"
value={block.props.maxItems}
oninput={(e) => onChange({ maxItems: parseInt(e.currentTarget.value, 10) || 12 })}
/>
</label>
</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 {
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-size: 0.8125rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,25 @@
import type { BlockSpec } from '../types';
import ModuleEmbed from './ModuleEmbed.svelte';
import ModuleEmbedInspectorFallback from './ModuleEmbedInspectorFallback.svelte';
import {
ModuleEmbedSchema,
MODULE_EMBED_DEFAULTS,
type ModuleEmbedProps,
type EmbedItem,
type EmbedSource,
} from './schema';
export const moduleEmbedBlockSpec: BlockSpec<ModuleEmbedProps> = {
type: 'moduleEmbed',
label: 'Modul einbetten',
icon: 'link',
category: 'embed',
schema: ModuleEmbedSchema,
schemaVersion: 1,
defaults: MODULE_EMBED_DEFAULTS,
Component: ModuleEmbed,
Inspector: ModuleEmbedInspectorFallback,
};
export type { ModuleEmbedProps, EmbedItem, EmbedSource };
export { ModuleEmbedSchema, MODULE_EMBED_DEFAULTS };

View file

@ -0,0 +1,68 @@
import { z } from 'zod';
/**
* Resolved item shape every embed provider returns items in this
* normalized form so the renderer doesn't care about the source.
*/
export const EmbedItemSchema = z.object({
title: z.string(),
subtitle: z.string().optional(),
imageUrl: z.string().optional(),
/** External link — for library entries, a page URL. */
href: z.string().optional(),
});
export type EmbedItem = z.infer<typeof EmbedItemSchema>;
export const EmbedResolvedSchema = z.object({
items: z.array(EmbedItemSchema),
/** If resolution failed, the error message surfaces in public mode. */
error: z.string().optional(),
/** ISO timestamp of when resolution happened. */
resolvedAt: z.string().optional(),
});
/**
* Supported embed sources. Add new sources here + a matching provider
* in the editor's publish resolver.
*/
export const EmbedSourceSchema = z.enum(['picture.board', 'library.entries']);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
export const ModuleEmbedSchema = z.object({
source: EmbedSourceSchema.default('picture.board'),
/** Target id — board id for picture, empty for "all entries" in library. */
sourceId: z.string().max(64).default(''),
/** Display title. Optional; renderer falls back to source default. */
title: z.string().max(160).default(''),
layout: z.enum(['grid', 'list']).default('grid'),
maxItems: z.number().int().min(1).max(48).default(12),
/**
* Optional filters depending on source. Library uses { isFavorite?,
* status?, kind? }; picture ignores them in M4.
*/
filter: z
.object({
isFavorite: z.boolean().optional(),
status: z.string().max(32).optional(),
kind: z.string().max(32).optional(),
})
.default({}),
/**
* Filled at publish time. The public renderer reads this directly
* no Dexie, no API round-trip. The editor shows a "nicht aufgelöst"
* placeholder when missing.
*/
resolved: EmbedResolvedSchema.optional(),
});
export type ModuleEmbedProps = z.infer<typeof ModuleEmbedSchema>;
export const MODULE_EMBED_DEFAULTS: ModuleEmbedProps = {
source: 'picture.board',
sourceId: '',
title: '',
layout: 'grid',
maxItems: 12,
filter: {},
};

View file

@ -7,6 +7,8 @@ import { ctaBlockSpec } from './cta';
import { faqBlockSpec } from './faq';
import { columnsBlockSpec } from './columns';
import { galleryBlockSpec } from './gallery';
import { formBlockSpec } from './form';
import { moduleEmbedBlockSpec } from './moduleEmbed';
/**
* The block registry single source of truth for every block type the
@ -23,6 +25,8 @@ export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
imageBlockSpec,
galleryBlockSpec,
faqBlockSpec,
formBlockSpec,
moduleEmbedBlockSpec,
columnsBlockSpec,
spacerBlockSpec,
] as unknown as readonly BlockSpec<unknown>[];