mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
3edf680ea0
commit
13efae8cd2
14 changed files with 1486 additions and 235 deletions
|
|
@ -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';
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>2–40 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>
|
||||
|
|
|
|||
|
|
@ -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 (2–40 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
153
apps/mana/apps/web/src/lib/modules/website/templates/event.ts
Normal file
153
apps/mana/apps/web/src/lib/modules/website/templates/event.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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';
|
||||
113
apps/mana/apps/web/src/lib/modules/website/templates/linktree.ts
Normal file
113
apps/mana/apps/web/src/lib/modules/website/templates/linktree.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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[];
|
||||
}
|
||||
332
apps/mana/apps/web/src/lib/modules/website/tools.ts
Normal file
332
apps/mana/apps/web/src/lib/modules/website/tools.ts
Normal 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 };
|
||||
12
apps/mana/apps/web/src/routes/(app)/website/new/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/website/new/+page.svelte
Normal 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>
|
||||
|
|
@ -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 }],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue