diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts
index 7f55725fd..0250488eb 100644
--- a/apps/mana/apps/web/src/lib/app-registry/apps.ts
+++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts
@@ -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';
+ },
},
],
});
diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts
index 188aa30ef..85ed3d105 100644
--- a/apps/mana/apps/web/src/lib/data/tools/init.ts
+++ b/apps/mana/apps/web/src/lib/data/tools/init.ts
@@ -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;
}
diff --git a/apps/mana/apps/web/src/lib/modules/website/ListView.svelte b/apps/mana/apps/web/src/lib/modules/website/ListView.svelte
index f6b4bda33..dbb287874 100644
--- a/apps/mana/apps/web/src/lib/modules/website/ListView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/website/ListView.svelte
@@ -1,74 +1,8 @@
+
+
+
+
+
+ {#each SITE_TEMPLATES as template (template.id)}
+ -
+
+
+ {/each}
+
+
+ {#if selected}
+
+ Mit "{selected.name}" starten
+
+
+ {#if error}
+ {error}
+ {/if}
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts b/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts
index 251111bd8..79d0035ba 100644
--- a/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts
@@ -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();
+ 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
diff --git a/apps/mana/apps/web/src/lib/modules/website/templates/blank.ts b/apps/mana/apps/web/src/lib/modules/website/templates/blank.ts
new file mode 100644
index 000000000..3ae36e59a
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/templates/blank.ts
@@ -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: [],
+ },
+ ],
+};
diff --git a/apps/mana/apps/web/src/lib/modules/website/templates/event.ts b/apps/mana/apps/web/src/lib/modules/website/templates/event.ts
new file mode 100644
index 000000000..c76dca06b
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/templates/event.ts
@@ -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,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+};
diff --git a/apps/mana/apps/web/src/lib/modules/website/templates/index.ts b/apps/mana/apps/web/src/lib/modules/website/templates/index.ts
new file mode 100644
index 000000000..9a35b92c0
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/templates/index.ts
@@ -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 = (() => {
+ const map: Record = {};
+ 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';
diff --git a/apps/mana/apps/web/src/lib/modules/website/templates/linktree.ts b/apps/mana/apps/web/src/lib/modules/website/templates/linktree.ts
new file mode 100644
index 000000000..773bfffbb
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/templates/linktree.ts
@@ -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',
+ },
+ },
+ ],
+ },
+ ],
+};
diff --git a/apps/mana/apps/web/src/lib/modules/website/templates/portfolio.ts b/apps/mana/apps/web/src/lib/modules/website/templates/portfolio.ts
new file mode 100644
index 000000000..d54b885a3
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/templates/portfolio.ts
@@ -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 geht’s?',
+ helpText: '',
+ maxLength: 2000,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+};
diff --git a/apps/mana/apps/web/src/lib/modules/website/templates/types.ts b/apps/mana/apps/web/src/lib/modules/website/templates/types.ts
new file mode 100644
index 000000000..3acb2d7b2
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/templates/types.ts
@@ -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;
+}
+
+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[];
+}
diff --git a/apps/mana/apps/web/src/lib/modules/website/tools.ts b/apps/mana/apps/web/src/lib/modules/website/tools.ts
new file mode 100644
index 000000000..e10c1fded
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/website/tools.ts
@@ -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 | 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 | 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 };
diff --git a/apps/mana/apps/web/src/routes/(app)/website/new/+page.svelte b/apps/mana/apps/web/src/routes/(app)/website/new/+page.svelte
new file mode 100644
index 000000000..11a02d3e8
--- /dev/null
+++ b/apps/mana/apps/web/src/routes/(app)/website/new/+page.svelte
@@ -0,0 +1,12 @@
+
+
+
+ Neue Website – Mana
+
+
+
+
+
diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts
index 8df6e47df..cacc0b9e6 100644
--- a/packages/shared-ai/src/tools/schemas.ts
+++ b/packages/shared-ai/src/tools/schemas.ts
@@ -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 }],
+ },
];
// ═══════════════════════════════════════════════════════════════