feat(website): M5 — AI tools + starter templates

Two things:

1. AI tools (9) in the website module — writes go through the standard
   proposal flow, reads run auto during planning.
   - shared-ai/src/tools/schemas.ts: AI_TOOL_CATALOG entries with
     defaultPolicy propose/auto.
   - webapp modules/website/tools.ts: execute functions wired to the
     existing stores. ModuleTool[] registered in data/tools/init.ts.
   - Propose: create_website, apply_website_template, create_website_page,
     add_website_block, update_website_block, publish_website
   - Auto: list_websites, list_website_pages, list_website_blocks
   Server-side mana-tool-registry integration (mana-mcp, mana-ai) is
   a M5.x follow-up — webapp flow unblocks the missions-based use case.

2. Starter templates — clone into a fresh site with new UUIDs.
   - templates/types.ts: SiteTemplate shape with localId / parentLocalId
     so container→child references survive the clone.
   - 4 templates: portfolio (4 pages), personal-linktree (1 page, 6 CTAs),
     event (3 pages incl. RSVP form), blank (1 empty page). Deferred:
     smb-corporate + product-landing (need team/pricing/testimonials
     blocks, M6+).
   - sitesStore.applyTemplate: walks template, bulk-inserts new rows,
     remaps parent refs. Sets navConfig items from template pages.
   - TemplatePicker component + /website/new route. Replaces the old
     quick-create modal; ListView now links to /new. AppRegistry
     context-menu action points there too.

AiProposalInbox integration deferred — the component doesn't exist in
the webapp yet (the plan mentions it aspirationally). defaultPolicy
'propose' is already set so writes stage correctly once the UI catches
up.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 15:14:45 +02:00
parent 3edf680ea0
commit 13efae8cd2
14 changed files with 1486 additions and 235 deletions

View file

@ -1339,10 +1339,9 @@ registerApp({
id: 'new-site',
label: 'Neue Website',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'website', action: 'new' } })
),
action: () => {
window.location.href = '/website/new';
},
},
],
});

View file

@ -45,6 +45,7 @@ import { quizTools } from '$lib/modules/quiz/tools';
import { invoicesTools } from '$lib/modules/invoices/tools';
import { libraryTools } from '$lib/modules/library/tools';
import { broadcastTools } from '$lib/modules/broadcast/tools';
import { websiteTools } from '$lib/modules/website/tools';
let initialized = false;
@ -91,5 +92,6 @@ export function initTools(): void {
registerTools(invoicesTools);
registerTools(libraryTools);
registerTools(broadcastTools);
registerTools(websiteTools);
initialized = true;
}

View file

@ -1,74 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllSites } from './queries';
import { sitesStore, InvalidSlugError, DuplicateSlugError } from './stores/sites.svelte';
import { isValidSlug } from './constants';
const sites = useAllSites();
let showCreate = $state(false);
let draftName = $state('');
let draftSlug = $state('');
let creating = $state(false);
let createError = $state<string | null>(null);
function openCreate() {
draftName = '';
draftSlug = '';
createError = null;
showCreate = true;
}
function closeCreate() {
showCreate = false;
}
/** Suggest a slug from the name — lowercase, hyphens for spaces. */
function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
function onNameInput(value: string) {
draftName = value;
// Auto-fill slug from name if user hasn't customized the slug yet.
if (!draftSlug || draftSlug === slugify(draftName.slice(0, draftName.length - 1))) {
draftSlug = slugify(value);
}
}
async function submit() {
if (!draftName.trim()) {
createError = 'Bitte gib einen Namen ein.';
return;
}
if (!isValidSlug(draftSlug)) {
createError = 'Slug ist ungültig oder reserviert.';
return;
}
creating = true;
createError = null;
try {
const { site, homePageId } = await sitesStore.createSite({
slug: draftSlug,
name: draftName.trim(),
});
showCreate = false;
await goto(`/website/${site.id}/edit/${homePageId}`);
} catch (err) {
if (err instanceof InvalidSlugError) createError = err.message;
else if (err instanceof DuplicateSlugError) createError = err.message;
else createError = err instanceof Error ? err.message : String(err);
} finally {
creating = false;
}
}
function formatRelative(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
@ -88,16 +22,16 @@
<div>
<h2>Deine Websites</h2>
<p class="wb-list__hint">
Block-Editor, veröffentlichen unter <code>mana.how</code>. M1 — Publish kommt in M2.
Block-Editor, veröffentlichen unter <code>mana.how</code>.
</p>
</div>
<button class="wb-list__new" onclick={openCreate}>+ Neue Website</button>
<a class="wb-list__new" href="/website/new">+ Neue Website</a>
</header>
{#if sites.value.length === 0}
<div class="wb-list__empty">
<p>Noch keine Website. Leg mit einer leeren Seite los.</p>
<button class="wb-list__new" onclick={openCreate}>+ Neue Website</button>
<p>Noch keine Website. Wähl ein Template oder starte blank.</p>
<a class="wb-list__new" href="/website/new">+ Neue Website</a>
</div>
{:else}
<div class="wb-list__grid">
@ -123,60 +57,6 @@
{/if}
</div>
{#if showCreate}
<div
class="wb-modal__backdrop"
onclick={closeCreate}
onkeydown={(e) => e.key === 'Escape' && closeCreate()}
role="button"
tabindex="-1"
></div>
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-create-title">
<h3 id="wb-create-title">Neue Website</h3>
<label class="wb-field">
<span>Name</span>
<!-- svelte-ignore a11y_autofocus — modal field; no navigation context to interfere -->
<input
type="text"
value={draftName}
oninput={(e) => onNameInput(e.currentTarget.value)}
placeholder="Meine Website"
autofocus
/>
</label>
<label class="wb-field">
<span>Slug (URL)</span>
<div class="wb-slug-input">
<span class="wb-slug-prefix">/s/</span>
<input
type="text"
value={draftSlug}
oninput={(e) => (draftSlug = e.currentTarget.value.toLowerCase())}
placeholder="meine-website"
/>
</div>
<small class="wb-field__hint"
>240 Kleinbuchstaben/Zahlen/Bindestrich. Reservierte Slugs wie "api", "app" sind gesperrt.</small
>
</label>
{#if createError}
<p class="wb-error">{createError}</p>
{/if}
<div class="wb-modal__actions">
<button class="wb-btn wb-btn--ghost" onclick={closeCreate} disabled={creating}>
Abbrechen
</button>
<button class="wb-btn wb-btn--primary" onclick={submit} disabled={creating}>
{creating ? 'Wird erstellt…' : 'Anlegen'}
</button>
</div>
</div>
{/if}
<style>
.wb-list {
padding: 1.5rem;
@ -209,9 +89,9 @@
border-radius: 9999px;
background: rgba(99, 102, 241, 0.9);
color: white;
border: none;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
.wb-list__empty {
@ -286,108 +166,4 @@
background: rgba(245, 158, 11, 0.18);
color: rgb(252, 211, 77);
}
/* Modal */
.wb-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
z-index: 40;
border: none;
}
.wb-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(90vw, 28rem);
padding: 1.5rem;
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;
gap: 1rem;
}
.wb-modal h3 {
margin: 0;
font-size: 1.125rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-field input {
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__hint {
font-size: 0.75rem;
opacity: 0.5;
}
.wb-slug-input {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.wb-slug-prefix {
font-size: 0.875rem;
opacity: 0.6;
font-family: ui-monospace, monospace;
}
.wb-slug-input input {
border: none;
background: transparent;
padding-left: 0;
}
.wb-error {
margin: 0;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
.wb-modal__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.wb-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.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,349 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { SITE_TEMPLATES, type SiteTemplate } from '../templates';
import {
sitesStore,
InvalidSlugError,
DuplicateSlugError,
UnknownTemplateError,
} from '../stores/sites.svelte';
import { isValidSlug } from '../constants';
let selected = $state<SiteTemplate | null>(null);
let draftName = $state('');
let draftSlug = $state('');
let creating = $state(false);
let error = $state<string | null>(null);
function slugify(v: string): string {
return v
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
function onNameInput(v: string) {
draftName = v;
if (!draftSlug || draftSlug === slugify(draftName.slice(0, draftName.length - 1))) {
draftSlug = slugify(v);
}
}
function pick(template: SiteTemplate) {
selected = template;
error = null;
if (!draftName) draftName = template.name;
if (!draftSlug) draftSlug = slugify(template.name);
}
async function submit() {
error = null;
if (!selected) {
error = 'Bitte ein Template auswählen.';
return;
}
if (!draftName.trim() || !isValidSlug(draftSlug)) {
error = 'Name und Slug sind erforderlich (240 Kleinbuchstaben/Zahlen/Bindestrich).';
return;
}
creating = true;
try {
const result = await sitesStore.applyTemplate(selected.id, {
name: draftName.trim(),
slug: draftSlug,
});
await goto(`/website/${result.siteId}/edit/${result.homePageId}`);
} catch (err) {
if (
err instanceof InvalidSlugError ||
err instanceof DuplicateSlugError ||
err instanceof UnknownTemplateError
) {
error = err.message;
} else {
error = err instanceof Error ? err.message : String(err);
}
} finally {
creating = false;
}
}
</script>
<div class="wb-templates">
<header class="wb-templates__head">
<div>
<h2>Neue Website</h2>
<p>
Such dir einen Startpunkt. Templates enthalten fertige Seiten und Blöcke — du kannst alles
später anpassen.
</p>
</div>
<a class="wb-templates__back" href="/website">← Zurück</a>
</header>
<ul class="wb-templates__grid">
{#each SITE_TEMPLATES as template (template.id)}
<li>
<button
class="wb-template"
class:wb-template--selected={selected?.id === template.id}
onclick={() => pick(template)}
>
<div class="wb-template__preview" data-tag={template.tag}>
<span class="wb-template__tag">{template.tag}</span>
</div>
<div class="wb-template__body">
<h3>{template.name}</h3>
<p>{template.description}</p>
<small>{template.pages.length} Seite{template.pages.length === 1 ? '' : 'n'}</small>
</div>
</button>
</li>
{/each}
</ul>
{#if selected}
<section class="wb-templates__config" aria-labelledby="wb-config-title">
<h3 id="wb-config-title">Mit "{selected.name}" starten</h3>
<div class="wb-row">
<label class="wb-field">
<span>Name</span>
<input
type="text"
value={draftName}
oninput={(e) => onNameInput(e.currentTarget.value)}
placeholder="Meine Website"
/>
</label>
<label class="wb-field">
<span>Slug (URL)</span>
<div class="wb-slug">
<span class="wb-slug__prefix">/s/</span>
<input
type="text"
value={draftSlug}
oninput={(e) => (draftSlug = e.currentTarget.value.toLowerCase())}
/>
</div>
</label>
</div>
{#if error}
<p class="wb-error">{error}</p>
{/if}
<div class="wb-actions">
<button class="wb-btn wb-btn--ghost" onclick={() => (selected = null)} disabled={creating}>
Abbrechen
</button>
<button class="wb-btn wb-btn--primary" onclick={submit} disabled={creating}>
{creating ? 'Erstelle…' : `Mit "${selected.name}" starten`}
</button>
</div>
</section>
{/if}
</div>
<style>
.wb-templates {
padding: 1.5rem;
max-width: 64rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.wb-templates__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.wb-templates__head h2 {
margin: 0;
font-size: 1.375rem;
}
.wb-templates__head p {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
opacity: 0.65;
max-width: 36rem;
}
.wb-templates__back {
color: inherit;
text-decoration: none;
opacity: 0.6;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
.wb-templates__back:hover {
opacity: 1;
}
.wb-templates__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 1rem;
}
.wb-template {
display: flex;
flex-direction: column;
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
overflow: hidden;
padding: 0;
cursor: pointer;
color: inherit;
text-align: left;
transition:
border-color 0.15s,
background 0.15s;
}
.wb-template:hover {
border-color: rgba(99, 102, 241, 0.5);
}
.wb-template--selected {
border-color: rgba(99, 102, 241, 1) !important;
background: rgba(99, 102, 241, 0.06);
}
.wb-template__preview {
height: 7rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.15));
}
.wb-template__preview[data-tag='event'] {
background: linear-gradient(135deg, rgba(244, 63, 94, 0.18), rgba(249, 115, 22, 0.15));
}
.wb-template__preview[data-tag='geschäft'] {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.18), rgba(20, 184, 166, 0.15));
}
.wb-template__preview[data-tag='leer'] {
background: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.03) 10px,
rgba(255, 255, 255, 0.06) 10px,
rgba(255, 255, 255, 0.06) 20px
);
}
.wb-template__tag {
padding: 0.2rem 0.65rem;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.3);
color: white;
font-size: 0.75rem;
text-transform: lowercase;
}
.wb-template__body {
padding: 0.75rem 0.875rem;
}
.wb-template__body h3 {
margin: 0;
font-size: 1rem;
}
.wb-template__body p {
margin: 0.25rem 0 0.5rem;
font-size: 0.8125rem;
opacity: 0.7;
line-height: 1.4;
}
.wb-template__body small {
font-size: 0.7rem;
opacity: 0.55;
}
.wb-templates__config {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-templates__config h3 {
margin: 0;
font-size: 1rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.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 {
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-slug {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.wb-slug__prefix {
font-size: 0.875rem;
opacity: 0.55;
font-family: ui-monospace, monospace;
}
.wb-slug input {
border: none;
padding-left: 0;
background: transparent;
}
.wb-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.wb-error {
margin: 0;
color: rgb(248, 113, 113);
font-size: 0.8125rem;
}
.wb-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.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

@ -1,8 +1,9 @@
import { emitDomainEvent } from '$lib/data/events';
import { authStore } from '$lib/stores/auth.svelte';
import { getActiveSpaceId } from '$lib/data/scope';
import { websitesTable, websitePagesTable } from '../collections';
import { websitesTable, websitePagesTable, websiteBlocksTable } from '../collections';
import { toWebsite } from '../queries';
import { getTemplate, type SiteTemplate } from '../templates';
import {
publishSnapshot,
unpublishSnapshot,
@ -10,7 +11,7 @@ import {
PublishError,
type PublishResult,
} from '../publish';
import type { LocalWebsite, LocalWebsitePage, ThemeConfig } from '../types';
import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock, ThemeConfig } from '../types';
import {
DEFAULT_THEME,
DEFAULT_NAV,
@ -42,6 +43,13 @@ export class DuplicateSlugError extends Error {
}
}
export class UnknownTemplateError extends Error {
constructor(templateId: string) {
super(`Unknown template "${templateId}"`);
this.name = 'UnknownTemplateError';
}
}
export const sitesStore = {
/**
* Create a new site + a default home page in one transaction. Throws
@ -184,6 +192,120 @@ export const sitesStore = {
emitDomainEvent('WebsiteUnpublished', 'website', 'websites', id, { siteId: id });
},
/**
* Clone a starter template into a new site. Template pages + blocks
* are inserted with fresh UUIDs; the template's `localId` graph is
* rewritten to the new parentBlockId chain so container child
* relationships survive.
*/
async applyTemplate(
templateId: string,
input: { slug: string; name: string }
): Promise<{ siteId: string; homePageId: string }> {
if (!isValidSlug(input.slug)) {
throw new InvalidSlugError(input.slug);
}
const existing = await websitesTable.where('slug').equals(input.slug).toArray();
if (existing.some((s) => !s.deletedAt)) {
throw new DuplicateSlugError(input.slug);
}
const template: SiteTemplate | undefined = getTemplate(templateId);
if (!template) throw new UnknownTemplateError(templateId);
const now = new Date().toISOString();
const siteId = crypto.randomUUID();
const newSite: LocalWebsite = {
id: siteId,
slug: input.slug,
name: input.name,
theme: DEFAULT_THEME,
navConfig: {
items: template.pages.map((p) => ({ label: p.title, pagePath: p.path })),
},
footerConfig: DEFAULT_FOOTER,
settings: DEFAULT_SETTINGS,
publishedVersion: null,
draftUpdatedAt: now,
createdAt: now,
updatedAt: now,
};
const pageRows: LocalWebsitePage[] = [];
const blockRows: LocalWebsiteBlock[] = [];
let homePageId: string | null = null;
for (const page of template.pages) {
const pageId = crypto.randomUUID();
if (page.path === '/' || !homePageId) homePageId = pageId;
pageRows.push({
id: pageId,
siteId,
path: page.path,
title: page.title,
seo: {},
order: page.order,
createdAt: now,
updatedAt: now,
});
// Resolve template-local parent refs → real UUIDs in a second pass.
const idMap = new Map<string, string>();
for (const block of page.blocks) {
idMap.set(block.localId, crypto.randomUUID());
}
let order = 1024;
for (const block of page.blocks) {
const id = idMap.get(block.localId)!;
const parentId = block.parentLocalId ? (idMap.get(block.parentLocalId) ?? null) : null;
blockRows.push({
id,
pageId,
parentBlockId: parentId,
slotKey: block.slotKey ?? null,
type: block.type,
props: block.props,
schemaVersion: 1,
order,
createdAt: now,
updatedAt: now,
});
order += 1024;
}
}
if (!homePageId) {
// Edge case: a template with zero pages. Shouldn't happen
// (blank has one page) but guard anyway.
homePageId = crypto.randomUUID();
pageRows.push({
id: homePageId,
siteId,
path: '/',
title: 'Start',
seo: {},
order: 1024,
createdAt: now,
updatedAt: now,
});
}
await websitesTable.add(newSite);
if (pageRows.length > 0) await websitePagesTable.bulkAdd(pageRows);
if (blockRows.length > 0) await websiteBlocksTable.bulkAdd(blockRows);
emitDomainEvent('WebsiteCreated', 'website', 'websites', siteId, {
siteId,
slug: input.slug,
name: input.name,
templateId,
});
return { siteId, homePageId };
},
/**
* Roll back to a historical snapshot. The server flips `is_current`;
* we also update `publishedVersion` locally to reflect the new live

View file

@ -0,0 +1,20 @@
import type { SiteTemplate } from './types';
/**
* Blank-canvas template one empty home page. For advanced users who
* want to build from scratch without copying template content.
*/
export const blankTemplate: SiteTemplate = {
id: 'blank',
name: 'Leer',
description: 'Eine leere Startseite — bau von Grund auf.',
tag: 'leer',
pages: [
{
path: '/',
title: 'Start',
order: 1024,
blocks: [],
},
],
};

View file

@ -0,0 +1,153 @@
import type { SiteTemplate } from './types';
/**
* Event microsite Hochzeit, Geburtstag, Konferenz, Workshop.
* Drei Seiten: Start · Programm · RSVP (Anmeldeformular).
*/
export const eventTemplate: SiteTemplate = {
id: 'event',
name: 'Event',
description: 'Hochzeit, Geburtstag, Konferenz — mit Programm und RSVP-Formular.',
tag: 'event',
pages: [
{
path: '/',
title: 'Start',
order: 1024,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
eyebrow: 'Save the Date',
title: '[Event-Name]',
subtitle: '[Datum] · [Ort]. Wir freuen uns auf dich.',
ctaLabel: 'Jetzt anmelden',
ctaHref: '/anmelden',
align: 'center',
background: 'gradient',
},
},
{
localId: 'intro',
type: 'richText',
props: {
content:
'Kurze Einleitung zum Event — worum geht es, wer ist eingeladen, was erwartet die Gäste.\n\nEin zweiter Absatz mit weiteren Details ist optional.',
align: 'center',
size: 'md',
},
},
{
localId: 'faq',
type: 'faq',
props: {
title: 'Häufige Fragen',
defaultOpen: false,
items: [
{ question: 'Wo findet das Event statt?', answer: '[Adresse]' },
{ question: 'Gibt es Parkmöglichkeiten?', answer: 'Ja, direkt vor Ort.' },
{
question: 'Wie ist die Kleiderordnung?',
answer: 'Smart casual — komm so, wie du dich wohlfühlst.',
},
],
},
},
],
},
{
path: '/programm',
title: 'Programm',
order: 2048,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
title: 'Programm',
subtitle: 'So läuft der Tag ab.',
align: 'left',
background: 'subtle',
},
},
{
localId: 'agenda',
type: 'richText',
props: {
content:
'**15:00** · Empfang mit Welcome-Drink\n\n**16:00** · Trauung / Eröffnung\n\n**17:30** · Fotoshooting, Sektempfang\n\n**19:00** · Abendessen\n\n**21:00** · Party bis open end',
align: 'left',
size: 'lg',
},
},
],
},
{
path: '/anmelden',
title: 'Anmelden',
order: 3072,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
title: 'RSVP',
subtitle: 'Bitte melde dich bis [Datum] an.',
align: 'center',
background: 'subtle',
},
},
{
localId: 'form',
type: 'form',
props: {
title: '',
description: '',
submitLabel: 'Anmeldung senden',
successMessage: 'Danke für deine Anmeldung! Wir freuen uns auf dich.',
target: 'inbox',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
placeholder: '',
helpText: '',
maxLength: 120,
},
{
name: 'email',
label: 'E-Mail',
type: 'email',
required: true,
placeholder: '',
helpText: 'Für die Bestätigung.',
maxLength: 200,
},
{
name: 'plusOne',
label: 'Begleitung',
type: 'text',
required: false,
placeholder: 'Name der Begleitung',
helpText: 'Leer lassen, wenn du alleine kommst.',
maxLength: 120,
},
{
name: 'notes',
label: 'Anmerkungen',
type: 'textarea',
required: false,
placeholder: 'Allergien, Unverträglichkeiten, Sonstiges',
helpText: '',
maxLength: 1000,
},
],
},
},
],
},
],
};

View file

@ -0,0 +1,24 @@
import { portfolioTemplate } from './portfolio';
import { linktreeTemplate } from './linktree';
import { eventTemplate } from './event';
import { blankTemplate } from './blank';
import type { SiteTemplate } from './types';
export const SITE_TEMPLATES: readonly SiteTemplate[] = [
portfolioTemplate,
linktreeTemplate,
eventTemplate,
blankTemplate,
];
const BY_ID: Record<string, SiteTemplate> = (() => {
const map: Record<string, SiteTemplate> = {};
for (const tpl of SITE_TEMPLATES) map[tpl.id] = tpl;
return map;
})();
export function getTemplate(id: string): SiteTemplate | undefined {
return BY_ID[id];
}
export type { SiteTemplate, SiteTemplatePage, SiteTemplateBlock } from './types';

View file

@ -0,0 +1,113 @@
import type { SiteTemplate } from './types';
/**
* One-pager with hero + 6 CTAs. Classic "link-in-bio" layout for
* creators, streamers, podcasters.
*/
export const linktreeTemplate: SiteTemplate = {
id: 'personal-linktree',
name: 'Link-Sammlung',
description:
'Eine Seite mit mehreren Call-to-Action-Buttons — für Link-in-Bio, Creator-Profile, Linkhub.',
tag: 'privat',
pages: [
{
path: '/',
title: 'Start',
order: 1024,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
eyebrow: '',
title: '[Dein Name]',
subtitle: 'Meine Plattformen und Projekte auf einen Blick.',
ctaLabel: '',
ctaHref: '',
align: 'center',
background: 'gradient',
},
},
{
localId: 'cta-1',
type: 'cta',
props: {
title: 'YouTube',
description: '',
buttonLabel: 'Kanal abonnieren',
buttonHref: 'https://youtube.com/@example',
variant: 'primary',
align: 'center',
background: 'none',
},
},
{
localId: 'cta-2',
type: 'cta',
props: {
title: 'Instagram',
description: '',
buttonLabel: '@handle folgen',
buttonHref: 'https://instagram.com/example',
variant: 'secondary',
align: 'center',
background: 'none',
},
},
{
localId: 'cta-3',
type: 'cta',
props: {
title: 'Newsletter',
description: 'Einmal pro Woche, null Spam.',
buttonLabel: 'Abonnieren',
buttonHref: '#',
variant: 'primary',
align: 'center',
background: 'subtle',
},
},
{
localId: 'cta-4',
type: 'cta',
props: {
title: 'Podcast',
description: '',
buttonLabel: 'Hören',
buttonHref: '#',
variant: 'secondary',
align: 'center',
background: 'none',
},
},
{
localId: 'cta-5',
type: 'cta',
props: {
title: 'Shop',
description: '',
buttonLabel: 'Produkte ansehen',
buttonHref: '#',
variant: 'ghost',
align: 'center',
background: 'none',
},
},
{
localId: 'cta-6',
type: 'cta',
props: {
title: 'Kontakt',
description: '',
buttonLabel: 'E-Mail schreiben',
buttonHref: 'mailto:hallo@example.com',
variant: 'ghost',
align: 'center',
background: 'none',
},
},
],
},
],
};

View file

@ -0,0 +1,166 @@
import type { SiteTemplate } from './types';
export const portfolioTemplate: SiteTemplate = {
id: 'portfolio',
name: 'Portfolio',
description: 'Startseite · Über mich · Arbeiten · Kontakt. Für Kreative, Freelancer, Entwickler.',
tag: 'privat',
pages: [
{
path: '/',
title: 'Start',
order: 1024,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
eyebrow: 'Portfolio',
title: 'Hallo, ich bin [Dein Name]',
subtitle:
'Designer · Entwickler · Freelancer. Hier sind einige Projekte, an denen ich zuletzt gearbeitet habe.',
ctaLabel: 'Arbeiten ansehen',
ctaHref: '/arbeiten',
align: 'center',
background: 'gradient',
},
},
{
localId: 'intro',
type: 'richText',
props: {
content:
'Kurze Einführung in dein Schaffen. Was treibt dich an, welche Art Projekte übernimmst du, wofür stehst du.',
align: 'center',
size: 'md',
},
},
],
},
{
path: '/ueber-mich',
title: 'Über mich',
order: 2048,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
title: 'Über mich',
subtitle: 'Ein bisschen mehr Hintergrund zu meiner Arbeit.',
align: 'left',
background: 'subtle',
},
},
{
localId: 'text',
type: 'richText',
props: {
content:
'Erzähl deine Geschichte. Woher kommst du? Was hast du studiert / wo gelernt? Welche Projekte haben dich geprägt?\n\nDieser Block akzeptiert mehrere Absätze — trenne sie mit einer leeren Zeile.',
align: 'left',
size: 'md',
},
},
],
},
{
path: '/arbeiten',
title: 'Arbeiten',
order: 3072,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
title: 'Ausgewählte Arbeiten',
subtitle: 'Ein Querschnitt meiner letzten Projekte.',
align: 'center',
background: 'none',
},
},
{
localId: 'gallery',
type: 'gallery',
props: {
title: '',
images: [],
layout: 'grid',
columns: 3,
gap: 'md',
lightbox: true,
},
},
{
localId: 'boards',
type: 'moduleEmbed',
props: {
source: 'picture.board',
sourceId: '',
title: 'Picture-Board einbetten',
layout: 'grid',
maxItems: 12,
filter: {},
},
},
],
},
{
path: '/kontakt',
title: 'Kontakt',
order: 4096,
blocks: [
{
localId: 'hero',
type: 'hero',
props: {
title: 'Kontakt',
subtitle: 'Projekt-Anfrage? Kollaboration? Schreib mir.',
align: 'center',
background: 'subtle',
},
},
{
localId: 'form',
type: 'form',
props: {
title: '',
description: '',
submitLabel: 'Senden',
successMessage: 'Danke! Ich melde mich bald bei dir.',
target: 'inbox',
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: 'Worum gehts?',
helpText: '',
maxLength: 2000,
},
],
},
},
],
},
],
};

View file

@ -0,0 +1,30 @@
/**
* Starter-template shapes. Every template lists pages + blocks in a
* tree-friendly form (blocks carry a `localId`; children reference
* parents by `parentLocalId`). The apply-step generates fresh UUIDs and
* rewrites the parent references.
*/
export interface SiteTemplateBlock {
/** Template-local id so child blocks can reference a parent without a UUID yet. */
localId: string;
parentLocalId?: string;
slotKey?: string;
type: string;
props: Record<string, unknown>;
}
export interface SiteTemplatePage {
path: string;
title: string;
order: number;
blocks: SiteTemplateBlock[];
}
export interface SiteTemplate {
id: string;
name: string;
description: string;
tag: 'privat' | 'event' | 'geschäft' | 'leer';
pages: SiteTemplatePage[];
}

View file

@ -0,0 +1,332 @@
/**
* Website tools AI-accessible operations over the block-tree CMS.
*
* Mirrors the `website` entries in @mana/shared-ai's AI_TOOL_CATALOG.
* Policy (auto vs propose) is derived there; this file just provides
* the execute functions.
*
* Propose (user-approval required):
* - create_website brand-new site + default home page
* - apply_website_template new site from a starter template
* - create_website_page add a page to a site
* - add_website_block insert a block on a page
* - update_website_block patch block props
* - publish_website push current draft to /s/{slug}
*
* Auto (reads):
* - list_websites
* - list_website_pages
* - list_website_blocks
*/
import type { ModuleTool } from '$lib/data/tools/types';
import {
sitesStore,
InvalidSlugError,
DuplicateSlugError,
UnknownTemplateError,
} from './stores/sites.svelte';
import { pagesStore, InvalidPathError, DuplicatePathError } from './stores/pages.svelte';
import { blocksStore, InvalidBlockPropsError } from './stores/blocks.svelte';
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types';
function stringOrEmpty(v: unknown): string {
return typeof v === 'string' ? v : '';
}
export const websiteTools: ModuleTool[] = [
// ─── Propose: write operations ─────────────────────────
{
name: 'create_website',
module: 'website',
description: 'Erstellt eine neue Website mit Startseite. Gibt siteId und homePageId zurueck.',
parameters: [
{ name: 'name', type: 'string', description: 'Anzeigename', required: true },
{ name: 'slug', type: 'string', description: 'URL-Slug', required: true },
],
async execute(params) {
const name = stringOrEmpty(params.name).trim();
const slug = stringOrEmpty(params.slug).trim().toLowerCase();
if (!name) return { success: false, message: 'name darf nicht leer sein' };
if (!slug) return { success: false, message: 'slug darf nicht leer sein' };
try {
const { site, homePageId } = await sitesStore.createSite({ name, slug });
return {
success: true,
data: { siteId: site.id, homePageId, publicUrl: `/s/${site.slug}` },
message: `Website "${site.name}" angelegt (/s/${site.slug})`,
};
} catch (err) {
if (err instanceof InvalidSlugError || err instanceof DuplicateSlugError) {
return { success: false, message: err.message };
}
throw err;
}
},
},
{
name: 'apply_website_template',
module: 'website',
description:
'Erstellt eine neue Website aus einem Template (portfolio, personal-linktree, event, blank).',
parameters: [
{
name: 'templateId',
type: 'string',
description: 'Template-Kennung',
required: true,
enum: ['portfolio', 'personal-linktree', 'event', 'blank'],
},
{ name: 'name', type: 'string', description: 'Website-Name', required: true },
{ name: 'slug', type: 'string', description: 'URL-Slug', required: true },
],
async execute(params) {
const templateId = stringOrEmpty(params.templateId);
const name = stringOrEmpty(params.name).trim();
const slug = stringOrEmpty(params.slug).trim().toLowerCase();
if (!name || !slug) {
return { success: false, message: 'name und slug sind pflicht' };
}
try {
const { siteId, homePageId } = await sitesStore.applyTemplate(templateId, { name, slug });
return {
success: true,
data: { siteId, homePageId, templateId },
message: `Template "${templateId}" angewendet — Website "${name}" (/s/${slug}) bereit`,
};
} catch (err) {
if (
err instanceof InvalidSlugError ||
err instanceof DuplicateSlugError ||
err instanceof UnknownTemplateError
) {
return { success: false, message: err.message };
}
throw err;
}
},
},
{
name: 'create_website_page',
module: 'website',
description: 'Fuegt einer bestehenden Website eine neue Seite hinzu.',
parameters: [
{ name: 'siteId', type: 'string', description: 'ID der Website', required: true },
{ name: 'path', type: 'string', description: 'URL-Pfad (z.B. /ueber-uns)', required: true },
{ name: 'title', type: 'string', description: 'Seitentitel', required: true },
],
async execute(params) {
const siteId = stringOrEmpty(params.siteId);
const path = stringOrEmpty(params.path).trim().toLowerCase();
const title = stringOrEmpty(params.title).trim();
if (!siteId || !path || !title) {
return { success: false, message: 'siteId, path und title sind pflicht' };
}
try {
const page = await pagesStore.createPage({ siteId, path, title });
return {
success: true,
data: { pageId: page.id, siteId, path: page.path },
message: `Seite "${title}" unter ${path} angelegt`,
};
} catch (err) {
if (err instanceof InvalidPathError || err instanceof DuplicatePathError) {
return { success: false, message: err.message };
}
throw err;
}
},
},
{
name: 'add_website_block',
module: 'website',
description:
'Fuegt einer Seite einen neuen Block hinzu. Props sind typ-spezifisch; ohne props werden die Defaults des Block-Typs verwendet.',
parameters: [
{ name: 'pageId', type: 'string', description: 'ID der Seite', required: true },
{ name: 'type', type: 'string', description: 'Block-Typ', required: true },
{ name: 'props', type: 'object', description: 'Block-Props (JSON)', required: false },
{
name: 'parentBlockId',
type: 'string',
description: 'ID des Parent-Containers',
required: false,
},
{ name: 'slotKey', type: 'string', description: 'Slot im Container', required: false },
],
async execute(params) {
const pageId = stringOrEmpty(params.pageId);
const type = stringOrEmpty(params.type);
if (!pageId || !type) {
return { success: false, message: 'pageId und type sind pflicht' };
}
const props = (params.props as Record<string, unknown> | undefined) ?? undefined;
const parentBlockId = (params.parentBlockId as string | undefined) ?? null;
const slotKey = (params.slotKey as string | undefined) ?? null;
try {
const block = await blocksStore.addBlock({ pageId, type, props, parentBlockId, slotKey });
return {
success: true,
data: { blockId: block.id, pageId, type: block.type },
message: `Block "${type}" hinzugefuegt`,
};
} catch (err) {
if (err instanceof InvalidBlockPropsError) {
return { success: false, message: `Invalid props fuer "${type}": ${err.message}` };
}
throw err;
}
},
},
{
name: 'update_website_block',
module: 'website',
description:
'Aktualisiert die props eines Blocks. patch ersetzt nur genannte Felder — der Rest bleibt.',
parameters: [
{ name: 'blockId', type: 'string', description: 'ID des Blocks', required: true },
{ name: 'patch', type: 'object', description: 'Felder-Patch (JSON)', required: true },
],
async execute(params) {
const blockId = stringOrEmpty(params.blockId);
const patch = params.patch as Record<string, unknown> | undefined;
if (!blockId || !patch || typeof patch !== 'object') {
return { success: false, message: 'blockId und patch (Objekt) sind pflicht' };
}
try {
await blocksStore.updateBlockProps(blockId, patch);
return {
success: true,
data: { blockId, fields: Object.keys(patch) },
message: `Block ${blockId} aktualisiert`,
};
} catch (err) {
if (err instanceof InvalidBlockPropsError) {
return { success: false, message: `Validierung fehlgeschlagen: ${err.message}` };
}
throw err;
}
},
},
{
name: 'publish_website',
module: 'website',
description:
'Veroeffentlicht die aktuelle Draft-Version. Besucher sehen die neuen Inhalte unter /s/{slug} binnen Sekunden.',
parameters: [{ name: 'siteId', type: 'string', description: 'ID der Website', required: true }],
async execute(params) {
const siteId = stringOrEmpty(params.siteId);
if (!siteId) return { success: false, message: 'siteId erforderlich' };
try {
const result = await sitesStore.publishSite(siteId);
return {
success: true,
data: result,
message: `Veroeffentlicht — ${result.publicUrl}`,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, message: `Publish fehlgeschlagen: ${msg}` };
}
},
},
// ─── Auto: read operations ─────────────────────────────
{
name: 'list_websites',
module: 'website',
description: 'Listet alle Websites im aktiven Space (id, slug, name, ob veroeffentlicht).',
parameters: [],
async execute() {
const locals = await websitesTable.toArray();
const visible = locals.filter((s) => !s.deletedAt);
const rows = visible.map((s) => ({
id: s.id,
slug: s.slug,
name: s.name,
published: Boolean(s.publishedVersion),
updatedAt: s.updatedAt ?? null,
}));
return {
success: true,
data: { sites: rows },
message: `${rows.length} Website(s) gelistet`,
};
},
},
{
name: 'list_website_pages',
module: 'website',
description: 'Listet die Seiten einer Website (id, path, title, order).',
parameters: [{ name: 'siteId', type: 'string', description: 'ID der Website', required: true }],
async execute(params) {
const siteId = stringOrEmpty(params.siteId);
if (!siteId) return { success: false, message: 'siteId erforderlich' };
const locals = await websitePagesTable.where('siteId').equals(siteId).toArray();
const visible = locals
.filter((p: LocalWebsitePage) => !p.deletedAt)
.sort((a, b) => a.order - b.order);
return {
success: true,
data: {
pages: visible.map((p) => ({
id: p.id,
path: p.path,
title: p.title,
order: p.order,
})),
},
message: `${visible.length} Seite(n)`,
};
},
},
{
name: 'list_website_blocks',
module: 'website',
description:
'Listet die Bloecke einer Seite (id, type, parentBlockId, slotKey, order, props-Snapshot).',
parameters: [{ name: 'pageId', type: 'string', description: 'ID der Seite', required: true }],
async execute(params) {
const pageId = stringOrEmpty(params.pageId);
if (!pageId) return { success: false, message: 'pageId erforderlich' };
const locals = (await websiteBlocksTable
.where('pageId')
.equals(pageId)
.toArray()) as LocalWebsiteBlock[];
const visible = locals.filter((b) => !b.deletedAt).sort((a, b) => a.order - b.order);
return {
success: true,
data: {
blocks: visible.map((b) => ({
id: b.id,
type: b.type,
parentBlockId: b.parentBlockId,
slotKey: b.slotKey,
order: b.order,
props: b.props,
})),
},
message: `${visible.length} Block/Bloecke`,
};
},
},
];
// Silence unused-var: keep LocalWebsite imported so type augmentation elsewhere
// can reach it if needed later.
export type { LocalWebsite };

View file

@ -0,0 +1,12 @@
<script lang="ts">
import TemplatePicker from '$lib/modules/website/components/TemplatePicker.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Neue Website Mana</title>
</svelte:head>
<RoutePage appId="website" backHref="/website" title="Neue Website">
<TemplatePicker />
</RoutePage>

View file

@ -1518,6 +1518,159 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
},
],
},
// ── Website ───────────────────────────────────────────────
{
name: 'create_website',
module: 'website',
description:
'Erstellt eine neue Website im aktiven Space mit einer Startseite. Gibt siteId und homePageId zurueck.',
defaultPolicy: 'propose',
parameters: [
{
name: 'name',
type: 'string',
description: 'Anzeigename der Website',
required: true,
},
{
name: 'slug',
type: 'string',
description: '2-40 Kleinbuchstaben/Zahlen/Bindestrich — wird Teil der URL (/s/{slug})',
required: true,
},
],
},
{
name: 'apply_website_template',
module: 'website',
description:
'Erstellt eine neue Website aus einem Template (portfolio, personal-linktree, event, blank). Kopiert alle Seiten und Bloecke mit neuen IDs.',
defaultPolicy: 'propose',
parameters: [
{
name: 'templateId',
type: 'string',
description: 'Template-Kennung',
required: true,
enum: ['portfolio', 'personal-linktree', 'event', 'blank'],
},
{ name: 'name', type: 'string', description: 'Website-Name', required: true },
{ name: 'slug', type: 'string', description: 'URL-Slug', required: true },
],
},
{
name: 'create_website_page',
module: 'website',
description:
'Fuegt einer existierenden Website eine neue Seite hinzu (z.B. /ueber-uns, /kontakt).',
defaultPolicy: 'propose',
parameters: [
{ name: 'siteId', type: 'string', description: 'ID der Website', required: true },
{
name: 'path',
type: 'string',
description: 'URL-Pfad mit fuehrendem Slash (z.B. /ueber-uns)',
required: true,
},
{ name: 'title', type: 'string', description: 'Seitentitel', required: true },
],
},
{
name: 'add_website_block',
module: 'website',
description:
'Fuegt einer Seite einen neuen Block hinzu. Block-Typen: hero, richText, cta, image, gallery, faq, form, moduleEmbed, columns, spacer. `props` ist ein JSON-Objekt mit den typ-spezifischen Feldern.',
defaultPolicy: 'propose',
parameters: [
{ name: 'pageId', type: 'string', description: 'ID der Zielseite', required: true },
{
name: 'type',
type: 'string',
description: 'Block-Typ',
required: true,
enum: [
'hero',
'richText',
'cta',
'image',
'gallery',
'faq',
'form',
'moduleEmbed',
'columns',
'spacer',
],
},
{
name: 'props',
type: 'object',
description:
'Typ-spezifische Eigenschaften. Leer = verwendet die Defaults. Beispiel fuer hero: { title, subtitle, ctaLabel, ctaHref }.',
required: false,
},
{
name: 'parentBlockId',
type: 'string',
description:
'Falls der Block in einem Container liegt (z.B. columns), die ID des Containers.',
required: false,
},
{
name: 'slotKey',
type: 'string',
description: 'Slot-Key innerhalb des Containers, z.B. col-0 / col-1.',
required: false,
},
],
},
{
name: 'update_website_block',
module: 'website',
description:
'Aktualisiert die props eines Blocks. `patch` ist ein JSON-Objekt mit nur den zu aendernden Feldern — alles andere bleibt unveraendert.',
defaultPolicy: 'propose',
parameters: [
{ name: 'blockId', type: 'string', description: 'ID des Blocks', required: true },
{
name: 'patch',
type: 'object',
description: 'Die zu aendernden props-Felder',
required: true,
},
],
},
{
name: 'publish_website',
module: 'website',
description:
'Veroeffentlicht die aktuelle Draft-Version der Website unter /s/{slug}. Vorher sollte der Inhalt vom Nutzer geprueft werden.',
defaultPolicy: 'propose',
parameters: [{ name: 'siteId', type: 'string', description: 'ID der Website', required: true }],
},
{
name: 'list_websites',
module: 'website',
description:
'Listet alle Websites im aktiven Space (id, slug, name, published-Status). Auto-Policy: laeuft waehrend Planning.',
defaultPolicy: 'auto',
parameters: [],
},
{
name: 'list_website_pages',
module: 'website',
description: 'Listet die Seiten einer Website (id, path, title, order). Auto-Policy.',
defaultPolicy: 'auto',
parameters: [{ name: 'siteId', type: 'string', description: 'ID der Website', required: true }],
},
{
name: 'list_website_blocks',
module: 'website',
description:
'Listet die Bloecke einer Seite (id, type, parentBlockId, order, props-Snapshot). Auto-Policy.',
defaultPolicy: 'auto',
parameters: [{ name: 'pageId', type: 'string', description: 'ID der Seite', required: true }],
},
];
// ═══════════════════════════════════════════════════════════════