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 @@ + +
+
+
+

Neue Website

+

+ Such dir einen Startpunkt. Templates enthalten fertige Seiten und Blöcke — du kannst alles + später anpassen. +

+
+ ← Zurück +
+ + + + {#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 }], + }, ]; // ═══════════════════════════════════════════════════════════════