feat(webapp): wire isParallelSafe in Companion chat + Mission runner

Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:

  isParallelSafe: (name) =>
    AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'

Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.

Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:11:24 +02:00
parent a64a7e39cf
commit 54a12ffd5c
59 changed files with 5629 additions and 218 deletions

View file

@ -3,18 +3,18 @@ import { defineConfig } from 'drizzle-kit';
/** /**
* Drizzle config for the unified mana-api. * Drizzle config for the unified mana-api.
* *
* Most modules in apps/api inline their schemas in routes.ts and create * Managed schemas accumulate as modules adopt managed migrations. Each
* tables out-of-band (or piggyback on schemas owned by other services). * schema's generated SQL lives under `drizzle/{schema}/`. Expand the
* This config currently only manages the `research` schema introduced for * `schema` array and `schemaFilter` when a new module joins.
* the deep-research feature; expand the `schema` glob and `schemaFilter` *
* as more modules adopt managed migrations. * Currently managed: `research`, `website`.
*/ */
export default defineConfig({ export default defineConfig({
schema: './src/modules/research/schema.ts', schema: ['./src/modules/research/schema.ts', './src/modules/website/schema.ts'],
out: './drizzle/research', out: './drizzle',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
}, },
schemaFilter: ['research'], schemaFilter: ['research', 'website'],
}); });

View file

@ -0,0 +1,38 @@
-- Website module — initial schema (manually authored to match
-- apps/api/src/modules/website/schema.ts).
--
-- Lives in mana_platform under its own pgSchema. M2 scope: published
-- snapshots only — editor data (sites/pages/blocks) lives in the
-- generic mana-sync store and is not mirrored here. See
-- docs/plans/website-builder.md §D5 + §D6.
--
-- Apply with:
-- psql "$DATABASE_URL" -f apps/api/drizzle/website/0000_init.sql
CREATE SCHEMA IF NOT EXISTS "website";
CREATE TABLE IF NOT EXISTS "website"."published_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"site_id" uuid NOT NULL,
"slug" text NOT NULL,
"blob" jsonb NOT NULL,
"is_current" boolean NOT NULL DEFAULT false,
"published_at" timestamptz NOT NULL DEFAULT now(),
"published_by" uuid NOT NULL,
"space_id" uuid
);
-- Rollback / history query path: "all snapshots of this site, newest first".
CREATE INDEX IF NOT EXISTS "published_snapshots_site_idx"
ON "website"."published_snapshots" ("site_id", "published_at" DESC);
-- Public resolver path: "current snapshot by slug".
CREATE INDEX IF NOT EXISTS "published_snapshots_slug_current_idx"
ON "website"."published_snapshots" ("slug", "is_current");
-- Hard invariant: exactly one current row per slug. The publish endpoint
-- wraps the old→false + new→true flip in a transaction; this partial
-- unique index catches any code path that would violate it.
CREATE UNIQUE INDEX IF NOT EXISTS "published_snapshots_slug_current_unique_idx"
ON "website"."published_snapshots" ("slug")
WHERE "is_current" = true;

View file

@ -42,6 +42,7 @@ import { presiRoutes } from './modules/presi/routes';
import { researchRoutes } from './modules/research/routes'; import { researchRoutes } from './modules/research/routes';
import { whoRoutes } from './modules/who/routes'; import { whoRoutes } from './modules/who/routes';
import { websiteRoutes } from './modules/website/routes'; import { websiteRoutes } from './modules/website/routes';
import { websitePublicRoutes } from './modules/website/public-routes';
import { wetterRoutes } from './modules/wetter/routes'; import { wetterRoutes } from './modules/wetter/routes';
const PORT = parseInt(process.env.PORT || '3060', 10); const PORT = parseInt(process.env.PORT || '3060', 10);
@ -56,8 +57,10 @@ app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('mana-api')); app.route('/health', healthRoute('mana-api'));
app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 }));
// Public routes — no auth required (weather data is public) // Public routes — no auth required (weather data is public, published
// websites are by definition public).
app.route('/api/v1/wetter', wetterRoutes); app.route('/api/v1/wetter', wetterRoutes);
app.route('/api/v1/website/public', websitePublicRoutes);
app.use('/api/*', authMiddleware()); app.use('/api/*', authMiddleware());

View file

@ -0,0 +1,55 @@
/**
* Public website routes UNAUTHENTICATED.
*
* Mounted at `/api/v1/website/public/*` BEFORE the global
* `authMiddleware` in apps/api/src/index.ts so anonymous visitors can
* fetch published snapshots.
*/
import { Hono } from 'hono';
import { and, eq } from 'drizzle-orm';
import { db, publishedSnapshots } from './schema';
import { errorResponse } from '../../lib/responses';
const routes = new Hono();
/**
* GET /api/v1/website/public/sites/:slug
*
* Returns the currently-published snapshot blob (404 if not found).
* The blob includes all pages the SvelteKit route picks the right
* one by path client-side / in its server-load. One round trip serves
* the whole site.
*/
routes.get('/sites/:slug', async (c) => {
const slug = c.req.param('slug');
if (!slug) return errorResponse(c, 'slug required', 400);
const rows = await db
.select({
id: publishedSnapshots.id,
slug: publishedSnapshots.slug,
blob: publishedSnapshots.blob,
publishedAt: publishedSnapshots.publishedAt,
})
.from(publishedSnapshots)
.where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true)))
.limit(1);
if (!rows[0]) return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' });
// Conservative caching: short freshness window, aggressive stale-while-
// revalidate. Publish endpoint will purge by tag in M6; until then CF
// respects the max-age on the edge layer.
c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400');
c.header('Cache-Tag', `site-${rows[0].id}`);
return c.json({
snapshotId: rows[0].id,
slug: rows[0].slug,
publishedAt: rows[0].publishedAt.toISOString(),
blob: rows[0].blob,
});
});
export const websitePublicRoutes = routes;

View file

@ -0,0 +1,246 @@
/**
* Publish + unpublish endpoints.
*
* Scoped to *authenticated* users who can publish their own site. The
* public read path lives in `public-routes.ts` and is mounted outside
* the auth gate.
*/
import { Hono } from 'hono';
import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm';
import type { AuthVariables } from '@mana/shared-hono';
import { errorResponse, validationError } from '../../lib/responses';
import { db, publishedSnapshots } from './schema';
import { isValidSlug } from './reserved-slugs';
const routes = new Hono<{ Variables: AuthVariables }>();
// Permissive schema — block props are client-trusted in M2; server-side
// Zod validation per block spec arrives in a later phase (see plan D8).
const SnapshotBlockSchema: z.ZodType<unknown> = z.lazy(() =>
z.object({
id: z.string().uuid(),
type: z.string().min(1).max(64),
schemaVersion: z.number().int().min(1),
slotKey: z.string().max(64).nullable(),
props: z.unknown(),
children: z.array(SnapshotBlockSchema),
})
);
const SnapshotPageSchema = z.object({
id: z.string().uuid(),
path: z.string().min(1).max(256),
title: z.string().min(1).max(256),
seo: z
.object({
title: z.string().max(256).optional(),
description: z.string().max(1024).optional(),
ogImage: z.string().max(1024).optional(),
noindex: z.boolean().optional(),
})
.passthrough(),
blocks: z.array(SnapshotBlockSchema),
});
const SnapshotSiteSchema = z.object({
id: z.string().uuid(),
slug: z.string().min(2).max(40),
name: z.string().min(1).max(128),
theme: z.unknown(),
navConfig: z.unknown(),
footerConfig: z.unknown(),
settings: z.unknown(),
});
const DraftSnapshotSchema = z.object({
version: z.literal('1'),
site: SnapshotSiteSchema,
pages: z.array(SnapshotPageSchema).min(1),
});
// ─── POST /sites/:id/publish ────────────────────────────
routes.post('/sites/:id/publish', async (c) => {
const userId = c.get('userId');
// Space id flows in via an explicit header (mana-auth doesn't yet
// embed the active space in JWT claims). Nullable — full membership
// check lands in M6; M2 stores whatever the client declares.
const spaceIdHeader = c.req.header('X-Mana-Space');
const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null;
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return validationError(c, parsed.error.issues);
const draft = parsed.data;
if (draft.site.id !== siteId) {
return errorResponse(c, 'Site id mismatch between path and body', 400, {
code: 'SITE_ID_MISMATCH',
});
}
if (!isValidSlug(draft.site.slug)) {
return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, {
code: 'INVALID_SLUG',
});
}
// Check slug conflict: is another site currently published with this slug?
const conflicting = await db
.select({ id: publishedSnapshots.id, siteId: publishedSnapshots.siteId })
.from(publishedSnapshots)
.where(
and(eq(publishedSnapshots.slug, draft.site.slug), eq(publishedSnapshots.isCurrent, true))
)
.limit(1);
if (conflicting[0] && conflicting[0].siteId !== siteId) {
return errorResponse(
c,
`Slug "${draft.site.slug}" is already taken by another published site`,
409,
{ code: 'SLUG_TAKEN' }
);
}
// Atomic flip: old→false, new→true. The partial unique index on
// (slug WHERE is_current=true) catches any concurrent publishers
// racing for the same slug.
const now = new Date().toISOString();
const blob = {
...draft,
publishedAt: now,
publishedBy: userId,
};
try {
const result = await db.transaction(async (tx) => {
await tx
.update(publishedSnapshots)
.set({ isCurrent: false })
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)));
const [row] = await tx
.insert(publishedSnapshots)
.values({
siteId,
slug: draft.site.slug,
blob,
isCurrent: true,
publishedBy: userId,
spaceId,
})
.returning({ id: publishedSnapshots.id, publishedAt: publishedSnapshots.publishedAt });
return row;
});
if (!result) throw new Error('Insert returned no row');
return c.json(
{
snapshotId: result.id,
publishedAt: result.publishedAt.toISOString(),
publicUrl: `/s/${draft.site.slug}`,
},
201
);
} catch (err) {
// Postgres unique-constraint violation → slug conflict we didn't
// catch in the pre-check (classic race).
if (err instanceof Error && /unique/i.test(err.message)) {
return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, {
code: 'SLUG_TAKEN',
});
}
throw err;
}
});
// ─── POST /sites/:id/unpublish ──────────────────────────
routes.post('/sites/:id/unpublish', async (c) => {
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const updated = await db
.update(publishedSnapshots)
.set({ isCurrent: false })
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)))
.returning({ id: publishedSnapshots.id });
if (updated.length === 0) {
return errorResponse(c, 'No current snapshot to unpublish', 404, {
code: 'NOT_PUBLISHED',
});
}
return c.json({ unpublished: updated.length });
});
// ─── GET /sites/:id/snapshots ───────────────────────────
// Rollback-list: the last 10 snapshots of this site, newest first.
routes.get('/sites/:id/snapshots', async (c) => {
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const rows = await db
.select({
id: publishedSnapshots.id,
publishedAt: publishedSnapshots.publishedAt,
publishedBy: publishedSnapshots.publishedBy,
isCurrent: publishedSnapshots.isCurrent,
slug: publishedSnapshots.slug,
})
.from(publishedSnapshots)
.where(eq(publishedSnapshots.siteId, siteId))
.orderBy(desc(publishedSnapshots.publishedAt))
.limit(10);
return c.json({
snapshots: rows.map((r) => ({
id: r.id,
publishedAt: r.publishedAt.toISOString(),
publishedBy: r.publishedBy,
isCurrent: r.isCurrent,
slug: r.slug,
})),
});
});
// ─── POST /sites/:id/rollback/:snapshotId ──────────────
// Flip is_current to point at a historical snapshot.
routes.post('/sites/:id/rollback/:snapshotId', async (c) => {
const siteId = c.req.param('id');
const snapshotId = c.req.param('snapshotId');
if (!siteId || !snapshotId) return errorResponse(c, 'siteId and snapshotId required', 400);
// Verify the snapshot belongs to this site.
const target = await db
.select({ id: publishedSnapshots.id, slug: publishedSnapshots.slug })
.from(publishedSnapshots)
.where(and(eq(publishedSnapshots.id, snapshotId), eq(publishedSnapshots.siteId, siteId)))
.limit(1);
if (!target[0]) {
return errorResponse(c, 'Snapshot not found for this site', 404, { code: 'NOT_FOUND' });
}
await db.transaction(async (tx) => {
await tx
.update(publishedSnapshots)
.set({ isCurrent: false })
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)));
await tx
.update(publishedSnapshots)
.set({ isCurrent: true })
.where(eq(publishedSnapshots.id, snapshotId));
});
return c.json({ rolledBack: true, slug: target[0].slug });
});
export const websitePublishRoutes = routes;

View file

@ -0,0 +1,42 @@
/**
* Reserved slugs server-authoritative list. The client carries a
* mirror copy in `apps/mana/apps/web/src/lib/modules/website/constants.ts`
* for fast pre-flight UX, but this list is the one that matters at
* publish time.
*
* Rule: any slug that would shadow a SvelteKit route or collide with
* a well-known subdomain goes here. When a new top-level route is
* added, append its segment here in the same PR.
*/
export const RESERVED_SLUGS: readonly string[] = [
'app',
'api',
'auth',
'admin',
'settings',
'docs',
'blog',
'www',
'mail',
'dashboard',
'login',
'logout',
'register',
'signup',
'signin',
'account',
'billing',
'help',
'support',
's', // the public-renderer prefix /s/<slug>
];
/** Same regex as the client uses — 2-40 lowercase alphanumerics + hyphens. */
const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
export function isValidSlug(slug: string): boolean {
if (!SLUG_REGEX.test(slug)) return false;
if (RESERVED_SLUGS.includes(slug.toLowerCase())) return false;
return true;
}

View file

@ -0,0 +1,53 @@
/**
* Website module block-tree CMS backend.
*
* M1 scope (this file): health + reserved-slug validation endpoint.
*
* CRUD for sites / pages / blocks goes through the generic mana-sync
* pipeline (local-first); this module only hosts compute-style
* endpoints that need server authority:
* - slug / path validation (reserved-list check)
* - publish (M2) builds snapshot, writes published_snapshots,
* purges Cloudflare cache
* - submit (M4) unauthenticated form endpoint
* - published-snapshot read (M2) powers the public /s/[slug] renderer
*
* See docs/plans/website-builder.md.
*/
import { Hono } from 'hono';
import type { AuthVariables } from '@mana/shared-hono';
import { RESERVED_SLUGS, isValidSlug } from './reserved-slugs';
import { websitePublishRoutes } from './publish';
const routes = new Hono<{ Variables: AuthVariables }>();
routes.get('/health', (c) => c.json({ status: 'ok', module: 'website', milestone: 'M2' }));
/**
* Slug validation endpoint mirrors the client-side check in
* constants.ts but with server-authoritative reserved-list. Used by
* the editor before create to surface a clear error early.
*/
routes.get('/slugs/check', (c) => {
const slug = c.req.query('slug') ?? '';
const reserved = RESERVED_SLUGS.includes(slug.toLowerCase());
const valid = isValidSlug(slug);
return c.json({
slug,
valid,
reserved,
reason: !valid ? (reserved ? 'reserved' : 'format') : null,
});
});
/**
* Expose the reserved-slug list so future tools (AI agents, import
* scripts) don't hard-code their own copy.
*/
routes.get('/slugs/reserved', (c) => c.json({ reserved: RESERVED_SLUGS }));
// ─── Publish + rollback (authenticated) ────────────────
routes.route('/', websitePublishRoutes);
export const websiteRoutes = routes;

View file

@ -0,0 +1,69 @@
/**
* Website module DB schema (Drizzle / pgSchema 'website')
*
* Server-side store for **published snapshots only**. Editor data (sites,
* pages, blocks) is local-first and lives in IndexedDB + the generic
* mana-sync Postgres pool. We don't mirror it into a dedicated table
* the publish endpoint receives the assembled blob from the client.
*
* See docs/plans/website-builder.md §D5 (draft/published as two
* snapshots) and §D6 (public-serving via SvelteKit-SSR).
*
* Storage model:
* - One row per publish event. `is_current=true` marks the row served
* to the public there is always exactly one current row per slug
* (enforced by a partial unique index).
* - Older rows stay for rollback (M2). GC job in M7 deletes rows
* beyond a retention window (last 10 per site, say).
* - `slug` is denormalized from the site so public resolution is a
* single index lookup without JOIN.
* - No FK to a `sites` table because sites live in the sync store, not
* here. Orphaning is acceptable: if a user deletes their site, the
* client calls POST /sites/:id/unpublish to flip is_current off.
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import { pgSchema, uuid, text, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core';
import { getConnection } from '../../lib/db';
export const websiteSchema = pgSchema('website');
/**
* One row per publish. Content is a fully-resolved JSON blob that the
* public renderer serves verbatim (no JOINs, no downstream queries).
*/
export const publishedSnapshots = websiteSchema.table(
'published_snapshots',
{
id: uuid('id').defaultRandom().primaryKey(),
/** Site id from the client. Untethered UUID (no FK). */
siteId: uuid('site_id').notNull(),
/** Denormalized from site.slug at publish time. Unique per is_current. */
slug: text('slug').notNull(),
/** Full resolved snapshot — shape mirrors PublishedSnapshot in publish.ts. */
blob: jsonb('blob').notNull(),
/** True for the row served to the public. Exactly one per slug. */
isCurrent: boolean('is_current').notNull().default(false),
publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(),
/** User who pressed the publish button. */
publishedBy: uuid('published_by').notNull(),
/**
* Space the site belongs to. Nullable in M2 because mana-auth
* doesn't yet thread the active space into JWT claims the
* client can pass it via `X-Mana-Space`, but we don't hard-require
* it until server-side membership check lands (M6).
*/
spaceId: uuid('space_id'),
},
(table) => [
index('published_snapshots_site_idx').on(table.siteId, table.publishedAt),
index('published_snapshots_slug_current_idx').on(table.slug, table.isCurrent),
]
);
export const db = drizzle(getConnection(), {
schema: { publishedSnapshots },
});
export type PublishedSnapshotRow = typeof publishedSnapshots.$inferSelect;
export type NewPublishedSnapshot = typeof publishedSnapshots.$inferInsert;

View file

@ -77,6 +77,7 @@
"@mana/shared-utils": "workspace:*", "@mana/shared-utils": "workspace:*",
"@mana/spiral-db": "workspace:*", "@mana/spiral-db": "workspace:*",
"@mana/wallpaper-generator": "workspace:*", "@mana/wallpaper-generator": "workspace:*",
"@mana/website-blocks": "workspace:*",
"@quotes/content": "workspace:*", "@quotes/content": "workspace:*",
"@tiptap/core": "^3.22.4", "@tiptap/core": "^3.22.4",
"@tiptap/extension-image": "^3.22.4", "@tiptap/extension-image": "^3.22.4",

View file

@ -77,6 +77,7 @@ import {
ArrowClockwise, ArrowClockwise,
Flask, Flask,
Exam, Exam,
Globe,
} from '@mana/shared-icons'; } from '@mana/shared-icons';
// ── Apps with entity capabilities ─────────────────────────── // ── Apps with entity capabilities ───────────────────────────
@ -1325,6 +1326,27 @@ registerApp({
}, },
}); });
registerApp({
id: 'website',
name: 'Website',
color: '#6366f1',
icon: Globe,
views: {
list: { load: () => import('$lib/modules/website/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-site',
label: 'Neue Website',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'website', action: 'new' } })
),
},
],
});
registerApp({ registerApp({
id: 'quiz', id: 'quiz',
name: 'Quiz', name: 'Quiz',

View file

@ -40,6 +40,7 @@ import { getAgent } from '../agents/store';
import { DEFAULT_AGENT_NAME } from '../agents/types'; import { DEFAULT_AGENT_NAME } from '../agents/types';
import type { Mission, MissionIteration, PlanStep } from './types'; import type { Mission, MissionIteration, PlanStep } from './types';
import { import {
AI_TOOL_CATALOG_BY_NAME,
buildSystemPrompt, buildSystemPrompt,
runPlannerLoop, runPlannerLoop,
runPrePlanGuardrails, runPrePlanGuardrails,
@ -266,6 +267,12 @@ async function runMissionInner(
tools: availableTools, tools: availableTools,
model: deps.model ?? 'google/gemini-2.5-flash', model: deps.model ?? 'google/gemini-2.5-flash',
maxRounds: MAX_PLANNER_ROUNDS, maxRounds: MAX_PLANNER_ROUNDS,
// Fan-out read tools when the planner requests several in
// one round. Writes (propose policy) stay sequential so the
// proposal inbox shows the LLM's intended ordering and the
// pre-execute guardrail can reason about state built up by
// prior steps in the same round.
isParallelSafe: (name) => AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto',
}, },
onToolCall: async (call: ToolCallRequest): Promise<ToolResult> => { onToolCall: async (call: ToolCallRequest): Promise<ToolResult> => {
await checkCancel(); await checkCancel();

View file

@ -103,6 +103,7 @@ import { articlesModuleConfig } from '$lib/modules/articles/module.config';
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config'; import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config'; import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { websiteModuleConfig } from '$lib/modules/website/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -162,6 +163,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
invoicesModuleConfig, invoicesModuleConfig,
broadcastModuleConfig, broadcastModuleConfig,
wetterModuleConfig, wetterModuleConfig,
websiteModuleConfig,
aiModuleConfig, aiModuleConfig,
]; ];

View file

@ -14,6 +14,7 @@
import { import {
runPlannerLoop, runPlannerLoop,
AI_TOOL_CATALOG, AI_TOOL_CATALOG,
AI_TOOL_CATALOG_BY_NAME,
type ChatMessage, type ChatMessage,
type ToolCallRequest, type ToolCallRequest,
type ToolResult, type ToolResult,
@ -104,6 +105,11 @@ export async function runCompanionChat(
model: 'google/gemini-2.5-flash', model: 'google/gemini-2.5-flash',
maxRounds: MAX_TOOL_ROUNDS, maxRounds: MAX_TOOL_ROUNDS,
temperature: 0.7, temperature: 0.7,
// Parallelise reads (auto-policy tools) when the LLM
// fans out multiple list_*/get_* calls in one round.
// Writes (propose policy) stay sequential to preserve
// user-visible intent order in the proposal inbox.
isParallelSafe: (name) => AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto',
}, },
onToolCall: async (call: ToolCallRequest): Promise<ToolResult> => { onToolCall: async (call: ToolCallRequest): Promise<ToolResult> => {
const startedAt = Date.now(); const startedAt = Date.now();

View file

@ -0,0 +1,393 @@
<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();
const diffMin = Math.floor((now - then) / 60_000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `vor ${diffH} Std`;
const diffD = Math.floor(diffH / 24);
if (diffD < 30) return `vor ${diffD} Tg`;
return new Date(iso).toLocaleDateString('de-DE');
}
</script>
<div class="wb-list">
<header class="wb-list__header">
<div>
<h2>Deine Websites</h2>
<p class="wb-list__hint">
Block-Editor, veröffentlichen unter <code>mana.how</code>. M1 — Publish kommt in M2.
</p>
</div>
<button class="wb-list__new" onclick={openCreate}>+ Neue Website</button>
</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>
</div>
{:else}
<div class="wb-list__grid">
{#each sites.value as site (site.id)}
<a class="wb-card" href="/website/{site.id}">
<div class="wb-card__body">
<h3>{site.name}</h3>
<p class="wb-card__slug">/s/{site.slug}</p>
</div>
<div class="wb-card__meta">
<span
class="wb-pill"
class:wb-pill--green={site.publishedVersion}
class:wb-pill--amber={!site.publishedVersion}
>
{site.publishedVersion ? 'Veröffentlicht' : 'Entwurf'}
</span>
<span class="wb-card__time">{formatRelative(site.updatedAt)}</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
{#if showCreate}
<div
class="wb-modal__backdrop"
onclick={closeCreate}
onkeydown={(e) => e.key === 'Escape' && closeCreate()}
role="button"
tabindex="-1"
></div>
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-create-title">
<h3 id="wb-create-title">Neue Website</h3>
<label class="wb-field">
<span>Name</span>
<!-- svelte-ignore a11y_autofocus — modal field; no navigation context to interfere -->
<input
type="text"
value={draftName}
oninput={(e) => onNameInput(e.currentTarget.value)}
placeholder="Meine Website"
autofocus
/>
</label>
<label class="wb-field">
<span>Slug (URL)</span>
<div class="wb-slug-input">
<span class="wb-slug-prefix">/s/</span>
<input
type="text"
value={draftSlug}
oninput={(e) => (draftSlug = e.currentTarget.value.toLowerCase())}
placeholder="meine-website"
/>
</div>
<small class="wb-field__hint"
>240 Kleinbuchstaben/Zahlen/Bindestrich. Reservierte Slugs wie "api", "app" sind gesperrt.</small
>
</label>
{#if createError}
<p class="wb-error">{createError}</p>
{/if}
<div class="wb-modal__actions">
<button class="wb-btn wb-btn--ghost" onclick={closeCreate} disabled={creating}>
Abbrechen
</button>
<button class="wb-btn wb-btn--primary" onclick={submit} disabled={creating}>
{creating ? 'Wird erstellt…' : 'Anlegen'}
</button>
</div>
</div>
{/if}
<style>
.wb-list {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.wb-list__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.wb-list__header h2 {
margin: 0;
font-size: 1.25rem;
}
.wb-list__hint {
margin: 0.25rem 0 0;
font-size: 0.875rem;
opacity: 0.6;
}
.wb-list__hint code {
background: rgba(255, 255, 255, 0.06);
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.wb-list__new {
padding: 0.5rem 1rem;
border-radius: 9999px;
background: rgba(99, 102, 241, 0.9);
color: white;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.wb-list__empty {
padding: 3rem 1.5rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
}
.wb-list__empty p {
margin: 0;
opacity: 0.7;
}
.wb-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 1rem;
}
.wb-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
text-decoration: none;
color: inherit;
min-height: 8rem;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.wb-card:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(99, 102, 241, 0.4);
}
.wb-card h3 {
margin: 0;
font-size: 1rem;
}
.wb-card__slug {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
opacity: 0.55;
font-family: ui-monospace, monospace;
}
.wb-card__meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.wb-card__time {
font-size: 0.75rem;
opacity: 0.5;
}
.wb-pill {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
font-weight: 500;
}
.wb-pill--green {
background: rgba(16, 185, 129, 0.18);
color: rgb(110, 231, 183);
}
.wb-pill--amber {
background: rgba(245, 158, 11, 0.18);
color: rgb(252, 211, 77);
}
/* Modal */
.wb-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
z-index: 40;
border: none;
}
.wb-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(90vw, 28rem);
padding: 1.5rem;
background: rgb(15, 18, 24);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
z-index: 50;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-modal h3 {
margin: 0;
font-size: 1.125rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
}
.wb-field input {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.875rem;
}
.wb-field__hint {
font-size: 0.75rem;
opacity: 0.5;
}
.wb-slug-input {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.wb-slug-prefix {
font-size: 0.875rem;
opacity: 0.6;
font-family: ui-monospace, monospace;
}
.wb-slug-input input {
border: none;
background: transparent;
padding-left: 0;
}
.wb-error {
margin: 0;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
.wb-modal__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.wb-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.wb-btn--ghost {
background: transparent;
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.9);
color: white;
}
.wb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,6 @@
import { db } from '$lib/data/database';
import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types';
export const websitesTable = db.table<LocalWebsite>('websites');
export const websitePagesTable = db.table<LocalWebsitePage>('websitePages');
export const websiteBlocksTable = db.table<LocalWebsiteBlock>('websiteBlocks');

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { getBlockSpec, type Block } from '@mana/website-blocks';
import { blocksStore, InvalidBlockPropsError } from '../stores/blocks.svelte';
import type { WebsiteBlock } from '../types';
interface Props {
block: WebsiteBlock;
onDeleted?: () => void;
}
let { block, onDeleted }: Props = $props();
const spec = $derived(getBlockSpec(block.type));
let lastError = $state<string | null>(null);
// Typed as `unknown` to match the registry's Inspector contract
// (`Partial<unknown>`); the underlying store accepts anything the
// block's Zod schema validates.
async function onChange(patch: unknown) {
lastError = null;
try {
await blocksStore.updateBlockProps(block.id, patch as Record<string, unknown>);
} catch (err) {
if (err instanceof InvalidBlockPropsError) {
lastError = `Validation failed: ${err.message}`;
} else {
lastError = err instanceof Error ? err.message : String(err);
}
}
}
async function onDelete() {
if (!confirm('Diesen Block löschen?')) return;
await blocksStore.deleteBlock(block.id);
onDeleted?.();
}
function asRegistryBlock(b: WebsiteBlock): Block<unknown> {
return {
id: b.id,
type: b.type,
props: b.props,
schemaVersion: b.schemaVersion,
order: b.order,
parentBlockId: b.parentBlockId,
slotKey: b.slotKey,
};
}
</script>
<div class="wb-inspector">
{#if spec}
<header class="wb-inspector__head">
<div>
<p class="wb-inspector__kind">{spec.category}</p>
<h3>{spec.label}</h3>
</div>
<button class="wb-inspector__delete" onclick={onDelete} title="Block löschen"> × </button>
</header>
<div class="wb-inspector__body">
<spec.Inspector block={asRegistryBlock(block)} {onChange} />
</div>
{#if lastError}
<p class="wb-inspector__error">{lastError}</p>
{/if}
{:else}
<p class="wb-inspector__empty">Unbekannter Block-Typ: {block.type}</p>
{/if}
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
}
.wb-inspector__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-inspector__head h3 {
margin: 0;
font-size: 1rem;
}
.wb-inspector__kind {
margin: 0 0 0.1rem;
font-size: 0.7rem;
opacity: 0.5;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wb-inspector__delete {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit;
padding: 0.1rem 0.5rem;
font-size: 1.15rem;
line-height: 1;
border-radius: 0.375rem;
cursor: pointer;
opacity: 0.7;
}
.wb-inspector__delete:hover {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.5);
color: rgb(248, 113, 113);
opacity: 1;
}
.wb-inspector__body {
flex: 1 1 auto;
overflow-y: auto;
}
.wb-inspector__error {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
.wb-inspector__empty {
padding: 1rem;
opacity: 0.6;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { getBlockSpec, type BlockMode, type Block as BlockType } from '@mana/website-blocks';
import type { WebsiteBlock } from '../types';
interface Props {
blocks: WebsiteBlock[];
mode: BlockMode;
selectedBlockId?: string | null;
onSelect?: (blockId: string) => void;
}
let { blocks, mode, selectedBlockId, onSelect }: Props = $props();
// Top-level blocks for M1 — containers come in M3 (columns/rows).
const topLevel = $derived(
blocks.filter((b) => b.parentBlockId === null).sort((a, b) => a.order - b.order)
);
function asRegistryBlock(b: WebsiteBlock): BlockType<unknown> {
return {
id: b.id,
type: b.type,
props: b.props,
schemaVersion: b.schemaVersion,
order: b.order,
parentBlockId: b.parentBlockId,
slotKey: b.slotKey,
};
}
</script>
{#each topLevel as block (block.id)}
{@const spec = getBlockSpec(block.type)}
{#if spec}
{#if mode === 'edit'}
<div
class="wb-block-wrap wb-block-wrap--editable"
class:wb-block-wrap--selected={selectedBlockId === block.id}
role="button"
tabindex="0"
onclick={() => onSelect?.(block.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect?.(block.id);
}
}}
>
<spec.Component block={asRegistryBlock(block)} {mode} />
</div>
{:else}
<div class="wb-block-wrap">
<spec.Component block={asRegistryBlock(block)} {mode} />
</div>
{/if}
{:else if mode === 'edit'}
<div class="wb-block-wrap wb-block-wrap--unknown">
Unbekannter Block-Typ: {block.type}
</div>
{/if}
{/each}
<style>
.wb-block-wrap {
position: relative;
}
.wb-block-wrap--editable {
cursor: pointer;
outline: 2px solid transparent;
outline-offset: -2px;
transition: outline-color 0.12s ease;
}
.wb-block-wrap--editable:hover {
outline-color: rgba(99, 102, 241, 0.35);
}
.wb-block-wrap--selected {
outline-color: rgba(99, 102, 241, 0.9) !important;
}
.wb-block-wrap--unknown {
padding: 1rem;
background: rgba(248, 113, 113, 0.1);
border: 1px dashed rgba(248, 113, 113, 0.5);
color: rgb(248, 113, 113);
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
}
</style>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { getAllBlockSpecs } from '@mana/website-blocks';
interface Props {
onInsert: (type: string) => void;
}
let { onInsert }: Props = $props();
const specs = getAllBlockSpecs();
</script>
<div class="wb-palette">
<p class="wb-palette__label">Block einfügen</p>
<div class="wb-palette__grid">
{#each specs as spec (spec.type)}
<button class="wb-palette__btn" onclick={() => onInsert(spec.type)} title={spec.label}>
<span class="wb-palette__name">{spec.label}</span>
<span class="wb-palette__category">{spec.category}</span>
</button>
{/each}
</div>
</div>
<style>
.wb-palette {
padding: 0.75rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
}
.wb-palette__label {
margin: 0 0 0.5rem;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wb-palette__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
gap: 0.5rem;
}
.wb-palette__btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.5rem 0.625rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.375rem;
color: inherit;
cursor: pointer;
text-align: left;
transition:
background 0.12s,
border-color 0.12s;
}
.wb-palette__btn:hover {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.4);
}
.wb-palette__name {
font-size: 0.8125rem;
font-weight: 500;
}
.wb-palette__category {
font-size: 0.65rem;
opacity: 0.5;
text-transform: uppercase;
letter-spacing: 0.03em;
}
</style>

View file

@ -0,0 +1,259 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pagesStore, InvalidPathError, DuplicatePathError } from '../stores/pages.svelte';
import type { WebsitePage } from '../types';
interface Props {
siteId: string;
pages: WebsitePage[];
activePageId: string;
}
let { siteId, pages, activePageId }: Props = $props();
let showAdd = $state(false);
let draftPath = $state('');
let draftTitle = $state('');
let addError = $state<string | null>(null);
function startAdd() {
draftPath = '/';
draftTitle = '';
addError = null;
showAdd = true;
}
async function submitAdd() {
addError = null;
try {
const page = await pagesStore.createPage({
siteId,
path: draftPath,
title: draftTitle || 'Ohne Titel',
});
showAdd = false;
await goto(`/website/${siteId}/edit/${page.id}`);
} catch (err) {
if (err instanceof InvalidPathError) addError = err.message;
else if (err instanceof DuplicatePathError) addError = err.message;
else addError = err instanceof Error ? err.message : String(err);
}
}
async function deletePageById(pageId: string, ev: Event) {
ev.preventDefault();
ev.stopPropagation();
if (pages.length <= 1) {
alert('Mindestens eine Seite muss bestehen bleiben.');
return;
}
if (!confirm('Seite wirklich löschen?')) return;
await pagesStore.deletePage(pageId);
// If the active page was deleted, navigate to another one.
if (pageId === activePageId) {
const next = pages.find((p) => p.id !== pageId);
if (next) await goto(`/website/${siteId}/edit/${next.id}`);
}
}
</script>
<div class="wb-pages">
<div class="wb-pages__header">
<p class="wb-pages__label">Seiten</p>
<button class="wb-pages__add" onclick={startAdd} title="Neue Seite">+</button>
</div>
<ul class="wb-pages__list">
{#each pages as p (p.id)}
<li>
<a
class="wb-pages__item"
class:wb-pages__item--active={p.id === activePageId}
href="/website/{siteId}/edit/{p.id}"
>
<div>
<span class="wb-pages__title">{p.title}</span>
<span class="wb-pages__path">{p.path}</span>
</div>
<button
class="wb-pages__delete"
onclick={(e) => deletePageById(p.id, e)}
title="Seite löschen">×</button
>
</a>
</li>
{/each}
</ul>
{#if showAdd}
<div class="wb-pages__form">
<label class="wb-field">
<span>Titel</span>
<!-- svelte-ignore a11y_autofocus — inline add-page form; modal-style focus is expected -->
<input
type="text"
value={draftTitle}
oninput={(e) => (draftTitle = e.currentTarget.value)}
placeholder="Über uns"
autofocus
/>
</label>
<label class="wb-field">
<span>Pfad</span>
<input
type="text"
value={draftPath}
oninput={(e) => (draftPath = e.currentTarget.value.toLowerCase())}
placeholder="/ueber-uns"
/>
</label>
{#if addError}
<p class="wb-error">{addError}</p>
{/if}
<div class="wb-pages__form-actions">
<button class="wb-btn wb-btn--ghost" onclick={() => (showAdd = false)}>Abbrechen</button>
<button class="wb-btn wb-btn--primary" onclick={submitAdd}>Anlegen</button>
</div>
</div>
{/if}
</div>
<style>
.wb-pages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-pages__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.wb-pages__label {
margin: 0;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wb-pages__add {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: inherit;
padding: 0 0.5rem;
font-size: 1rem;
line-height: 1.5;
border-radius: 0.375rem;
cursor: pointer;
}
.wb-pages__add:hover {
background: rgba(99, 102, 241, 0.15);
}
.wb-pages__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-pages__item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
transition: background 0.12s;
}
.wb-pages__item:hover {
background: rgba(255, 255, 255, 0.04);
}
.wb-pages__item--active {
background: rgba(99, 102, 241, 0.2);
}
.wb-pages__title {
display: block;
font-size: 0.875rem;
}
.wb-pages__path {
display: block;
font-size: 0.7rem;
opacity: 0.55;
font-family: ui-monospace, monospace;
margin-top: 0.1rem;
}
.wb-pages__delete {
background: transparent;
border: none;
color: inherit;
opacity: 0;
cursor: pointer;
font-size: 1rem;
padding: 0 0.25rem;
transition: opacity 0.12s;
}
.wb-pages__item:hover .wb-pages__delete {
opacity: 0.5;
}
.wb-pages__delete:hover {
opacity: 1 !important;
color: rgb(248, 113, 113);
}
.wb-pages__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.7rem;
font-weight: 500;
opacity: 0.7;
}
.wb-field input {
padding: 0.4rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
color: inherit;
font-size: 0.8125rem;
}
.wb-pages__form-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
}
.wb-btn {
padding: 0.35rem 0.75rem;
border-radius: 0.375rem;
border: none;
font-size: 0.75rem;
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-error {
margin: 0;
font-size: 0.75rem;
color: rgb(248, 113, 113);
}
</style>

View file

@ -0,0 +1,202 @@
<script lang="ts">
import { sitesStore } from '../stores/sites.svelte';
import { PublishError } from '../publish';
import type { Website } from '../types';
interface Props {
site: Website;
}
let { site }: Props = $props();
let publishing = $state(false);
let unpublishing = $state(false);
let lastError = $state<string | null>(null);
const hasDraftAhead = $derived.by(() => {
if (!site.publishedVersion) return site.draftUpdatedAt !== null;
// Sheet-optimization: exact comparison not possible (publishedVersion
// is a UUID, not a timestamp), so we compare draftUpdatedAt against
// site.updatedAt set by the publish flow. If draft is stale vs. the
// last mutation, show "up to date".
return (
site.draftUpdatedAt !== null &&
site.updatedAt !== null &&
site.draftUpdatedAt > site.updatedAt
);
});
async function onPublish() {
publishing = true;
lastError = null;
try {
const result = await sitesStore.publishSite(site.id);
console.info('[website] published', result);
} catch (err) {
if (err instanceof PublishError) {
lastError = err.message;
} else {
lastError = err instanceof Error ? err.message : String(err);
}
} finally {
publishing = false;
}
}
async function onUnpublish() {
if (!confirm('Website offline nehmen? Besucher sehen dann 404.')) return;
unpublishing = true;
lastError = null;
try {
await sitesStore.unpublishSite(site.id);
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
} finally {
unpublishing = false;
}
}
const publicUrl = $derived(`/s/${site.slug}`);
</script>
<div class="wb-publishbar">
<div class="wb-publishbar__status">
{#if site.publishedVersion}
<span class="wb-pill wb-pill--green">Live</span>
<a class="wb-publishbar__link" href={publicUrl} target="_blank" rel="noopener">
{publicUrl}
</a>
{#if hasDraftAhead}
<span class="wb-pill wb-pill--amber">Unveröffentlichte Änderungen</span>
{/if}
{:else}
<span class="wb-pill wb-pill--gray">Entwurf</span>
<span class="wb-publishbar__hint">Noch nicht veröffentlicht</span>
{/if}
</div>
<div class="wb-publishbar__actions">
{#if site.publishedVersion}
<button
class="wb-btn wb-btn--ghost"
onclick={onUnpublish}
disabled={unpublishing || publishing}
>
{unpublishing ? 'Offline…' : 'Offline nehmen'}
</button>
<button
class="wb-btn wb-btn--primary"
onclick={onPublish}
disabled={publishing || !hasDraftAhead}
>
{publishing ? 'Veröffentliche…' : 'Änderungen veröffentlichen'}
</button>
{:else}
<button class="wb-btn wb-btn--primary" onclick={onPublish} disabled={publishing}>
{publishing ? 'Veröffentliche…' : 'Veröffentlichen'}
</button>
{/if}
</div>
{#if lastError}
<p class="wb-publishbar__error" role="alert">{lastError}</p>
{/if}
</div>
<style>
.wb-publishbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.wb-publishbar__status {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
min-width: 0;
}
.wb-publishbar__actions {
display: flex;
gap: 0.5rem;
}
.wb-publishbar__link {
color: inherit;
opacity: 0.7;
font-size: 0.8125rem;
text-decoration: none;
font-family: ui-monospace, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wb-publishbar__link:hover {
opacity: 1;
color: rgb(129, 140, 248);
}
.wb-publishbar__hint {
font-size: 0.8125rem;
opacity: 0.5;
}
.wb-publishbar__error {
flex-basis: 100%;
margin: 0;
padding: 0.5rem 0.75rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
.wb-pill {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
font-weight: 500;
}
.wb-pill--green {
background: rgba(16, 185, 129, 0.18);
color: rgb(110, 231, 183);
}
.wb-pill--amber {
background: rgba(245, 158, 11, 0.18);
color: rgb(252, 211, 77);
}
.wb-pill--gray {
background: rgba(255, 255, 255, 0.08);
opacity: 0.7;
}
.wb-btn {
padding: 0.4rem 0.85rem;
border-radius: 0.375rem;
border: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.wb-btn--ghost {
background: transparent;
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.wb-btn--ghost:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.1);
border-color: rgba(248, 113, 113, 0.4);
color: rgb(248, 113, 113);
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.9);
color: white;
}
.wb-btn--primary:hover:not(:disabled) {
background: rgba(99, 102, 241, 1);
}
.wb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,64 @@
import type { ThemeConfig, NavConfig, FooterConfig, SiteSettings } from './types';
/**
* Reserved slugs that cannot be used as site slugs. Enforced client-side
* in the store pre-check and (authoritatively) in the backend publish
* endpoint. See docs/plans/website-builder.md §D11 a site with
* slug='api' would shadow the API route tree.
*/
export const RESERVED_SLUGS: readonly string[] = [
'app',
'api',
'auth',
'admin',
'settings',
'docs',
'blog',
'www',
'mail',
'dashboard',
'login',
'logout',
'register',
'signup',
'signin',
'account',
'billing',
'help',
'support',
's', // conflicts with the public-renderer prefix /s/<slug>
];
export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug.toLowerCase());
}
/**
* Slug regex lowercase alphanumerics + hyphens, 2-40 chars, no leading
* or trailing hyphen. Mirrored in the backend for authoritative checks.
*/
export const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
export function isValidSlug(slug: string): boolean {
return SLUG_REGEX.test(slug) && !isReservedSlug(slug);
}
export const DEFAULT_THEME: ThemeConfig = {
preset: 'classic',
};
export const DEFAULT_NAV: NavConfig = {
items: [],
};
export const DEFAULT_FOOTER: FooterConfig = {
text: '',
links: [],
};
export const DEFAULT_SETTINGS: SiteSettings = {};
export const DEFAULT_HOME_PAGE = {
path: '/',
title: 'Start',
} as const;

View file

@ -0,0 +1,53 @@
export { sitesStore, InvalidSlugError, DuplicateSlugError } from './stores/sites.svelte';
export {
pagesStore,
InvalidPathError,
DuplicatePathError,
isValidPath,
} from './stores/pages.svelte';
export { blocksStore, InvalidBlockPropsError } from './stores/blocks.svelte';
export {
useAllSites,
useAllPages,
useAllBlocks,
findSite,
findPage,
pagesForSite,
blocksForPage,
toWebsite,
toWebsitePage,
toWebsiteBlock,
buildBlockTree,
} from './queries';
export { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
export {
RESERVED_SLUGS,
SLUG_REGEX,
isReservedSlug,
isValidSlug,
DEFAULT_THEME,
DEFAULT_NAV,
DEFAULT_FOOTER,
DEFAULT_SETTINGS,
DEFAULT_HOME_PAGE,
} from './constants';
export type {
LocalWebsite,
LocalWebsitePage,
LocalWebsiteBlock,
Website,
WebsitePage,
WebsiteBlock,
ThemeConfig,
ThemePreset,
NavConfig,
NavItem,
FooterConfig,
FooterLink,
SiteSettings,
PageSeo,
} from './types';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const websiteModuleConfig: ModuleConfig = {
appId: 'website',
tables: [{ name: 'websites' }, { name: 'websitePages' }, { name: 'websiteBlocks' }],
};

View file

@ -0,0 +1,305 @@
/**
* Snapshot builder + publish client.
*
* `buildSnapshot(siteId)` walks Dexie to produce the deterministic blob
* the publish endpoint stores. `publishSite()` and `unpublishSite()`
* thin wrappers around the api + sync-path.
*
* Determinism: pages sorted by (order ASC, id ASC), block trees by the
* same key. Same draft state same blob bytes, regardless of which
* client or session does the publish. Important because we might later
* skip re-publish-if-no-change in the UI, and because a byte-identical
* blob means CF cache keys stay stable.
*
* See docs/plans/website-builder.md §D5.
*/
import { getManaApiUrl } from '$lib/api/config';
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import type {
LocalWebsite,
LocalWebsiteBlock,
ThemeConfig,
NavConfig,
FooterConfig,
SiteSettings,
PageSeo,
} from './types';
// ─── Snapshot shape ──────────────────────────────────────
export const SNAPSHOT_VERSION = '1' as const;
export interface SnapshotBlockNode {
id: string;
type: string;
schemaVersion: number;
slotKey: string | null;
props: unknown;
children: SnapshotBlockNode[];
}
export interface SnapshotPage {
id: string;
path: string;
title: string;
seo: PageSeo;
blocks: SnapshotBlockNode[];
}
export interface SnapshotSite {
id: string;
slug: string;
name: string;
theme: ThemeConfig;
navConfig: NavConfig;
footerConfig: FooterConfig;
settings: SiteSettings;
}
/**
* Client-built snapshot. Server augments with `publishedAt` and
* `publishedBy` before storage those fields live on the row, not in
* the client-sent payload, so they can't be spoofed.
*/
export interface DraftSnapshot {
version: typeof SNAPSHOT_VERSION;
site: SnapshotSite;
pages: SnapshotPage[];
}
/**
* The shape served by the public endpoint client-built blob plus the
* server-authored publication fields.
*/
export interface PublishedSnapshot extends DraftSnapshot {
publishedAt: string;
publishedBy: string;
snapshotId: string;
}
// ─── Build (Dexie → DraftSnapshot) ───────────────────────
export class SnapshotBuildError extends Error {
constructor(message: string) {
super(message);
this.name = 'SnapshotBuildError';
}
}
/**
* Assemble the draft snapshot for `siteId` from Dexie. Throws if the
* site, a required table, or any referenced record is missing.
*/
export async function buildSnapshot(siteId: string): Promise<DraftSnapshot> {
const site = await websitesTable.get(siteId);
if (!site || site.deletedAt) {
throw new SnapshotBuildError(`Site ${siteId} not found`);
}
const allPages = await websitePagesTable.where('siteId').equals(siteId).toArray();
const livePages = allPages
.filter((p) => !p.deletedAt)
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
if (livePages.length === 0) {
throw new SnapshotBuildError(
`Site ${siteId} has no pages — add at least one before publishing`
);
}
const pageIds = livePages.map((p) => p.id);
const allBlocks = await websiteBlocksTable.where('pageId').anyOf(pageIds).toArray();
const liveBlocks = allBlocks.filter((b) => !b.deletedAt);
// Group blocks by pageId for fast tree building.
const blocksByPage = new Map<string, LocalWebsiteBlock[]>();
for (const block of liveBlocks) {
const existing = blocksByPage.get(block.pageId);
if (existing) existing.push(block);
else blocksByPage.set(block.pageId, [block]);
}
const pages: SnapshotPage[] = livePages.map((p) => ({
id: p.id,
path: p.path,
title: p.title,
seo: p.seo ?? {},
blocks: buildBlockTree(blocksByPage.get(p.id) ?? []),
}));
return {
version: SNAPSHOT_VERSION,
site: toSnapshotSite(site),
pages,
};
}
function toSnapshotSite(site: LocalWebsite): SnapshotSite {
return {
id: site.id,
slug: site.slug,
name: site.name,
theme: site.theme,
navConfig: site.navConfig,
footerConfig: site.footerConfig,
settings: site.settings,
};
}
/**
* Build the block tree deterministically. Top-level blocks (parentBlockId
* null) come first, ordered by (order ASC, id ASC); each child list uses
* the same ordering. Unreachable blocks (parentBlockId points at a row
* that isn't in `blocks`) are dropped — they'd be orphans that the
* renderer couldn't show anyway.
*/
export function buildBlockTree(blocks: LocalWebsiteBlock[]): SnapshotBlockNode[] {
const byId = new Map<string, LocalWebsiteBlock>();
for (const b of blocks) byId.set(b.id, b);
const childrenByParent = new Map<string | null, LocalWebsiteBlock[]>();
for (const b of blocks) {
const parent = b.parentBlockId ?? null;
// Drop orphans: a non-null parentBlockId that doesn't resolve.
if (parent !== null && !byId.has(parent)) continue;
const list = childrenByParent.get(parent);
if (list) list.push(b);
else childrenByParent.set(parent, [b]);
}
// Stable sort every bucket.
for (const list of childrenByParent.values()) {
list.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
function walk(parentId: string | null): SnapshotBlockNode[] {
const children = childrenByParent.get(parentId) ?? [];
return children.map((b) => ({
id: b.id,
type: b.type,
schemaVersion: b.schemaVersion,
slotKey: b.slotKey ?? null,
props: b.props,
children: walk(b.id),
}));
}
return walk(null);
}
// ─── Publish client ──────────────────────────────────────
/**
* Returned by the API on successful publish.
*/
export interface PublishResult {
snapshotId: string;
publishedAt: string;
publicUrl: string;
}
export class PublishError extends Error {
readonly code: string;
readonly status: number;
constructor(message: string, code: string, status: number) {
super(message);
this.name = 'PublishError';
this.code = code;
this.status = status;
}
}
/**
* Call the publish endpoint. Caller must pass a mana-auth JWT and the
* active space id (mana-auth doesn't embed the active space in claims
* yet see §M6 of the plan).
*/
export async function publishSnapshot(
siteId: string,
jwt: string,
spaceId: string | null
): Promise<PublishResult> {
const draft = await buildSnapshot(siteId);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
};
if (spaceId) headers['X-Mana-Space'] = spaceId;
const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/publish`, {
method: 'POST',
headers,
body: JSON.stringify(draft),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string };
throw new PublishError(
body.error ?? `Publish failed (${res.status})`,
body.code ?? 'UNKNOWN',
res.status
);
}
return (await res.json()) as PublishResult;
}
/**
* Mark the site's public snapshot as no-longer-current the public
* renderer will serve 404 on the next request.
*/
export async function unpublishSnapshot(siteId: string, jwt: string): Promise<void> {
const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/unpublish`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok && res.status !== 404) {
throw new PublishError(`Unpublish failed (${res.status})`, 'UNKNOWN', res.status);
}
}
/**
* Fetch the site's snapshot history (for rollback UI).
*/
export interface SnapshotHistoryEntry {
id: string;
publishedAt: string;
publishedBy: string;
isCurrent: boolean;
slug: string;
}
export async function fetchSnapshotHistory(
siteId: string,
jwt: string
): Promise<SnapshotHistoryEntry[]> {
const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/snapshots`, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok) {
throw new PublishError(`History fetch failed (${res.status})`, 'UNKNOWN', res.status);
}
const body = (await res.json()) as { snapshots: SnapshotHistoryEntry[] };
return body.snapshots;
}
export async function rollbackSnapshot(
siteId: string,
snapshotId: string,
jwt: string
): Promise<void> {
const res = await fetch(
`${getManaApiUrl()}/api/v1/website/sites/${siteId}/rollback/${snapshotId}`,
{
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
}
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string };
throw new PublishError(
body.error ?? `Rollback failed (${res.status})`,
body.code ?? 'UNKNOWN',
res.status
);
}
}

View file

@ -0,0 +1,140 @@
/**
* Reactive queries and pure helpers for the Website module.
*
* All queries return the full scoped collection consumers filter by
* id/siteId/pageId with `$derived`. Dexie's liveQuery re-runs on every
* write to the backing table; it does NOT re-run when caller-side
* parameters change, which is why we don't accept id params here.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import type {
LocalWebsite,
LocalWebsitePage,
LocalWebsiteBlock,
Website,
WebsitePage,
WebsiteBlock,
} from './types';
import { DEFAULT_THEME, DEFAULT_NAV, DEFAULT_FOOTER, DEFAULT_SETTINGS } from './constants';
// ─── Type Converters ─────────────────────────────────────
export function toWebsite(local: LocalWebsite): Website {
const now = new Date().toISOString();
return {
id: local.id,
slug: local.slug,
name: local.name,
theme: local.theme ?? DEFAULT_THEME,
navConfig: local.navConfig ?? DEFAULT_NAV,
footerConfig: local.footerConfig ?? DEFAULT_FOOTER,
settings: local.settings ?? DEFAULT_SETTINGS,
publishedVersion: local.publishedVersion ?? null,
draftUpdatedAt: local.draftUpdatedAt ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toWebsitePage(local: LocalWebsitePage): WebsitePage {
const now = new Date().toISOString();
return {
id: local.id,
siteId: local.siteId,
path: local.path,
title: local.title,
seo: local.seo ?? {},
order: local.order,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toWebsiteBlock(local: LocalWebsiteBlock): WebsiteBlock {
const now = new Date().toISOString();
return {
id: local.id,
pageId: local.pageId,
parentBlockId: local.parentBlockId ?? null,
slotKey: local.slotKey ?? null,
type: local.type,
props: local.props,
schemaVersion: local.schemaVersion,
order: local.order,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllSites() {
return useLiveQueryWithDefault(async () => {
const locals = await scopedForModule<LocalWebsite, string>('website', 'websites').toArray();
const visible = locals.filter((s) => !s.deletedAt);
return visible.map(toWebsite).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}, [] as Website[]);
}
export function useAllPages() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWebsitePage>('websitePages').toArray();
const visible = locals.filter((p) => !p.deletedAt);
return visible.map(toWebsitePage).sort((a, b) => a.order - b.order);
}, [] as WebsitePage[]);
}
export function useAllBlocks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWebsiteBlock>('websiteBlocks').toArray();
const visible = locals.filter((b) => !b.deletedAt);
return visible.map(toWebsiteBlock).sort((a, b) => a.order - b.order);
}, [] as WebsiteBlock[]);
}
// ─── Pure Helpers (filter inside views) ──────────────────
export function findSite(sites: Website[], id: string | null | undefined): Website | null {
if (!id) return null;
return sites.find((s) => s.id === id) ?? null;
}
export function findPage(pages: WebsitePage[], id: string | null | undefined): WebsitePage | null {
if (!id) return null;
return pages.find((p) => p.id === id) ?? null;
}
export function pagesForSite(
pages: WebsitePage[],
siteId: string | null | undefined
): WebsitePage[] {
if (!siteId) return [];
return pages.filter((p) => p.siteId === siteId).sort((a, b) => a.order - b.order);
}
export function blocksForPage(
blocks: WebsiteBlock[],
pageId: string | null | undefined
): WebsiteBlock[] {
if (!pageId) return [];
return blocks.filter((b) => b.pageId === pageId).sort((a, b) => a.order - b.order);
}
/**
* Arrange flat block list into a tree keyed by parentBlockId. Top-level
* blocks live under the `null` key. Children are sorted by `order`.
*/
export function buildBlockTree(blocks: WebsiteBlock[]): Map<string | null, WebsiteBlock[]> {
const tree = new Map<string | null, WebsiteBlock[]>();
const sorted = [...blocks].sort((a, b) => a.order - b.order);
for (const block of sorted) {
const parent = block.parentBlockId;
const existing = tree.get(parent);
if (existing) existing.push(block);
else tree.set(parent, [block]);
}
return tree;
}

View file

@ -0,0 +1,163 @@
import { emitDomainEvent } from '$lib/data/events';
import { requireBlockSpec, safeValidateBlockProps } from '@mana/website-blocks';
import { websitesTable, websitePagesTable, websiteBlocksTable } from '../collections';
import type { LocalWebsiteBlock } from '../types';
export interface AddBlockInput {
pageId: string;
type: string;
parentBlockId?: string | null;
slotKey?: string | null;
/**
* Override defaults from the block spec. Must satisfy the block type's
* Zod schema throws `InvalidBlockPropsError` otherwise.
*/
props?: unknown;
/**
* Optional explicit order. If omitted, the block is appended to the
* end of the page / parent's children.
*/
order?: number;
}
export class InvalidBlockPropsError extends Error {
readonly zodError: unknown;
constructor(type: string, zodError: unknown) {
super(`Invalid props for block type "${type}".`);
this.name = 'InvalidBlockPropsError';
this.zodError = zodError;
}
}
async function touchSiteForPage(pageId: string): Promise<void> {
const page = await websitePagesTable.get(pageId);
if (!page) return;
const now = new Date().toISOString();
await websitesTable.update(page.siteId, { draftUpdatedAt: now, updatedAt: now });
}
async function nextOrder(pageId: string, parentBlockId: string | null): Promise<number> {
const siblings = await websiteBlocksTable.where('pageId').equals(pageId).toArray();
const live = siblings.filter((b) => !b.deletedAt && (b.parentBlockId ?? null) === parentBlockId);
const maxOrder = live.reduce((m, b) => Math.max(m, b.order), 0);
return maxOrder + 1024;
}
export const blocksStore = {
/**
* Insert a block. Props default to the block type's `defaults` from
* the registry; if `input.props` is given, it must validate against
* the block's Zod schema.
*/
async addBlock(input: AddBlockInput) {
const spec = requireBlockSpec(input.type);
const parentId = input.parentBlockId ?? null;
const slotKey = input.slotKey ?? null;
const props = input.props ?? spec.defaults;
const validated = safeValidateBlockProps(input.type, props);
if (!validated.success) {
throw new InvalidBlockPropsError(input.type, validated.error);
}
const order = input.order ?? (await nextOrder(input.pageId, parentId));
const now = new Date().toISOString();
const id = crypto.randomUUID();
const newBlock: LocalWebsiteBlock = {
id,
pageId: input.pageId,
parentBlockId: parentId,
slotKey,
type: input.type,
props: validated.data,
schemaVersion: spec.schemaVersion,
order,
createdAt: now,
updatedAt: now,
};
await websiteBlocksTable.add(newBlock);
await touchSiteForPage(input.pageId);
emitDomainEvent('WebsiteBlockAdded', 'website', 'websiteBlocks', id, {
blockId: id,
pageId: input.pageId,
type: input.type,
});
return newBlock;
},
/**
* Patch a block's props. The resulting props (existing patch) are
* re-validated against the block's schema.
*/
async updateBlockProps(id: string, patch: Record<string, unknown>) {
const existing = await websiteBlocksTable.get(id);
if (!existing) throw new Error(`Block ${id} not found`);
const nextProps = { ...(existing.props as Record<string, unknown>), ...patch };
const validated = safeValidateBlockProps(existing.type, nextProps);
if (!validated.success) {
throw new InvalidBlockPropsError(existing.type, validated.error);
}
const now = new Date().toISOString();
await websiteBlocksTable.update(id, {
props: validated.data,
updatedAt: now,
});
await touchSiteForPage(existing.pageId);
emitDomainEvent('WebsiteBlockUpdated', 'website', 'websiteBlocks', id, {
blockId: id,
pageId: existing.pageId,
fields: Object.keys(patch),
});
},
async deleteBlock(id: string) {
const existing = await websiteBlocksTable.get(id);
if (!existing) return;
const now = new Date().toISOString();
await websiteBlocksTable.update(id, {
deletedAt: now,
updatedAt: now,
});
await touchSiteForPage(existing.pageId);
emitDomainEvent('WebsiteBlockDeleted', 'website', 'websiteBlocks', id, {
blockId: id,
pageId: existing.pageId,
});
},
/**
* Move a block to a new position within its current parent. Same
* fractional-index logic as pages.
*/
async reorderBlock(id: string, beforeOrder: number | null, afterOrder: number | null) {
let newOrder: number;
if (beforeOrder === null && afterOrder === null) {
newOrder = 1024;
} else if (beforeOrder === null) {
newOrder = (afterOrder as number) / 2;
} else if (afterOrder === null) {
newOrder = beforeOrder + 1024;
} else {
newOrder = (beforeOrder + afterOrder) / 2;
}
const existing = await websiteBlocksTable.get(id);
if (!existing) return;
const now = new Date().toISOString();
await websiteBlocksTable.update(id, {
order: newOrder,
updatedAt: now,
});
await touchSiteForPage(existing.pageId);
},
};

View file

@ -0,0 +1,149 @@
import { emitDomainEvent } from '$lib/data/events';
import { websitesTable, websitePagesTable } from '../collections';
import type { LocalWebsitePage, PageSeo } from '../types';
export interface CreatePageInput {
siteId: string;
path: string;
title: string;
seo?: PageSeo;
}
export class InvalidPathError extends Error {
constructor(path: string) {
super(
`"${path}" is not a valid page path — must start with "/" and contain only lowercase alphanumerics, hyphens, and additional segments.`
);
this.name = 'InvalidPathError';
}
}
export class DuplicatePathError extends Error {
constructor(path: string) {
super(`A page with path "${path}" already exists on this site.`);
this.name = 'DuplicatePathError';
}
}
const PATH_REGEX = /^\/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*)?$/;
export function isValidPath(path: string): boolean {
return PATH_REGEX.test(path);
}
function touchSite(siteId: string): Promise<void> {
const now = new Date().toISOString();
return websitesTable
.update(siteId, { draftUpdatedAt: now, updatedAt: now })
.then(() => undefined);
}
export const pagesStore = {
async createPage(input: CreatePageInput) {
if (!isValidPath(input.path)) {
throw new InvalidPathError(input.path);
}
const siblings = await websitePagesTable.where('siteId').equals(input.siteId).toArray();
const livePaths = siblings.filter((p) => !p.deletedAt);
if (livePaths.some((p) => p.path === input.path)) {
throw new DuplicatePathError(input.path);
}
const maxOrder = livePaths.reduce((m, p) => Math.max(m, p.order), 0);
const now = new Date().toISOString();
const id = crypto.randomUUID();
const newPage: LocalWebsitePage = {
id,
siteId: input.siteId,
path: input.path,
title: input.title,
seo: input.seo ?? {},
order: maxOrder + 1024,
createdAt: now,
updatedAt: now,
};
await websitePagesTable.add(newPage);
await touchSite(input.siteId);
emitDomainEvent('WebsitePageCreated', 'website', 'websitePages', id, {
pageId: id,
siteId: input.siteId,
path: input.path,
});
return newPage;
},
async updatePage(id: string, patch: Partial<Pick<LocalWebsitePage, 'path' | 'title' | 'seo'>>) {
if (patch.path !== undefined && !isValidPath(patch.path)) {
throw new InvalidPathError(patch.path);
}
const now = new Date().toISOString();
const existing = await websitePagesTable.get(id);
if (!existing) throw new Error(`Page ${id} not found`);
await websitePagesTable.update(id, {
...patch,
updatedAt: now,
});
await touchSite(existing.siteId);
emitDomainEvent('WebsitePageUpdated', 'website', 'websitePages', id, {
pageId: id,
siteId: existing.siteId,
fields: Object.keys(patch),
});
},
async deletePage(id: string) {
const existing = await websitePagesTable.get(id);
if (!existing) return;
const now = new Date().toISOString();
await websitePagesTable.update(id, {
deletedAt: now,
updatedAt: now,
});
await touchSite(existing.siteId);
emitDomainEvent('WebsitePageDeleted', 'website', 'websitePages', id, {
pageId: id,
siteId: existing.siteId,
});
},
/**
* Move a page to a new position by recomputing its `order` field.
* `beforeOrder` and `afterOrder` are the orders of the pages that
* should sit immediately before and after the moved page pass
* `null` on either side if the page becomes first or last.
*/
async reorderPage(id: string, beforeOrder: number | null, afterOrder: number | null) {
let newOrder: number;
if (beforeOrder === null && afterOrder === null) {
newOrder = 1024;
} else if (beforeOrder === null) {
// moving to first
newOrder = (afterOrder as number) / 2;
} else if (afterOrder === null) {
// moving to last
newOrder = beforeOrder + 1024;
} else {
newOrder = (beforeOrder + afterOrder) / 2;
}
const now = new Date().toISOString();
const existing = await websitePagesTable.get(id);
if (!existing) return;
await websitePagesTable.update(id, {
order: newOrder,
updatedAt: now,
});
await touchSite(existing.siteId);
},
};

View file

@ -0,0 +1,209 @@
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 { toWebsite } from '../queries';
import {
publishSnapshot,
unpublishSnapshot,
rollbackSnapshot,
PublishError,
type PublishResult,
} from '../publish';
import type { LocalWebsite, LocalWebsitePage, ThemeConfig } from '../types';
import {
DEFAULT_THEME,
DEFAULT_NAV,
DEFAULT_FOOTER,
DEFAULT_SETTINGS,
DEFAULT_HOME_PAGE,
isValidSlug,
} from '../constants';
export interface CreateSiteInput {
slug: string;
name: string;
theme?: ThemeConfig;
}
export class InvalidSlugError extends Error {
constructor(slug: string) {
super(
`"${slug}" is not a valid slug — use 2-40 lowercase alphanumerics or hyphens, avoid reserved names.`
);
this.name = 'InvalidSlugError';
}
}
export class DuplicateSlugError extends Error {
constructor(slug: string) {
super(`A site with slug "${slug}" already exists in this space.`);
this.name = 'DuplicateSlugError';
}
}
export const sitesStore = {
/**
* Create a new site + a default home page in one transaction. Throws
* `InvalidSlugError` on bad slugs and `DuplicateSlugError` when the
* slug collides within the active space.
*/
async createSite(input: CreateSiteInput) {
if (!isValidSlug(input.slug)) {
throw new InvalidSlugError(input.slug);
}
// Slug dedupe within the current active space. We query via scope
// so the check is automatically limited to records the user can
// see; without scope, users in different spaces could trigger
// false positives.
const existing = await websitesTable.where('slug').equals(input.slug).toArray();
const liveDuplicate = existing.find((s) => !s.deletedAt);
if (liveDuplicate) {
throw new DuplicateSlugError(input.slug);
}
const now = new Date().toISOString();
const siteId = crypto.randomUUID();
const pageId = crypto.randomUUID();
const newSite: LocalWebsite = {
id: siteId,
slug: input.slug,
name: input.name,
theme: input.theme ?? DEFAULT_THEME,
navConfig: DEFAULT_NAV,
footerConfig: DEFAULT_FOOTER,
settings: DEFAULT_SETTINGS,
publishedVersion: null,
draftUpdatedAt: now,
createdAt: now,
updatedAt: now,
};
const homePage: LocalWebsitePage = {
id: pageId,
siteId,
path: DEFAULT_HOME_PAGE.path,
title: DEFAULT_HOME_PAGE.title,
seo: {},
order: 1024,
createdAt: now,
updatedAt: now,
};
await websitesTable.add(newSite);
await websitePagesTable.add(homePage);
emitDomainEvent('WebsiteCreated', 'website', 'websites', siteId, {
siteId,
slug: input.slug,
name: input.name,
});
return { site: toWebsite(newSite), homePageId: pageId };
},
async updateSite(
id: string,
patch: Partial<Pick<LocalWebsite, 'name' | 'theme' | 'navConfig' | 'footerConfig' | 'settings'>>
) {
const now = new Date().toISOString();
await websitesTable.update(id, {
...patch,
updatedAt: now,
draftUpdatedAt: now,
});
emitDomainEvent('WebsiteUpdated', 'website', 'websites', id, {
siteId: id,
fields: Object.keys(patch),
});
},
async deleteSite(id: string) {
const now = new Date().toISOString();
await websitesTable.update(id, {
deletedAt: now,
updatedAt: now,
});
// Best-effort: unpublish so the public URL stops serving. Failures
// here don't block the soft-delete — the GC job in M7 will clean
// orphan snapshots.
try {
const token = await authStore.getValidToken();
if (token) await unpublishSnapshot(id, token);
} catch {
// swallow — soft-delete succeeded locally
}
emitDomainEvent('WebsiteDeleted', 'website', 'websites', id, { siteId: id });
},
/**
* Publish the current draft to the public `/s/{slug}` URL. Returns
* the snapshot id + public URL on success. Updates
* `site.publishedVersion` locally so the editor reflects the new
* state immediately.
*/
async publishSite(id: string): Promise<PublishResult> {
const token = await authStore.getValidToken();
if (!token) throw new PublishError('Not signed in', 'NO_TOKEN', 401);
const result = await publishSnapshot(id, token, getActiveSpaceId());
const now = new Date().toISOString();
await websitesTable.update(id, {
publishedVersion: result.snapshotId,
updatedAt: now,
});
emitDomainEvent('WebsitePublished', 'website', 'websites', id, {
siteId: id,
snapshotId: result.snapshotId,
publishedAt: result.publishedAt,
publicUrl: result.publicUrl,
});
return result;
},
/**
* Take the site offline. Leaves the local draft untouched.
*/
async unpublishSite(id: string): Promise<void> {
const token = await authStore.getValidToken();
if (!token) throw new PublishError('Not signed in', 'NO_TOKEN', 401);
await unpublishSnapshot(id, token);
const now = new Date().toISOString();
await websitesTable.update(id, {
publishedVersion: null,
updatedAt: now,
});
emitDomainEvent('WebsiteUnpublished', 'website', 'websites', id, { siteId: id });
},
/**
* Roll back to a historical snapshot. The server flips `is_current`;
* we also update `publishedVersion` locally to reflect the new live
* version.
*/
async rollback(siteId: string, snapshotId: string): Promise<void> {
const token = await authStore.getValidToken();
if (!token) throw new PublishError('Not signed in', 'NO_TOKEN', 401);
await rollbackSnapshot(siteId, snapshotId, token);
const now = new Date().toISOString();
await websitesTable.update(siteId, {
publishedVersion: snapshotId,
updatedAt: now,
});
emitDomainEvent('WebsiteRolledBack', 'website', 'websites', siteId, {
siteId,
snapshotId,
});
},
};

View file

@ -0,0 +1,145 @@
/**
* Website module types block-tree CMS for the Mana website builder.
*
* Three tables, all space-scoped, all plaintext (public content by
* design see docs/plans/website-builder.md §D4):
* - websites: root entity per space (slug, theme, nav, footer)
* - websitePages: multi-page support (path + seo + order)
* - websiteBlocks: block-tree via parentBlockId + fractional order
*
* Block.props is untyped (`unknown`) at the TS level because the shape
* depends on block.type. Runtime validation goes through
* `@mana/website-blocks`'s registry (Zod schemas per type).
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Theme / Nav / Footer config shapes ──────────────────
export type ThemePreset = 'classic' | 'modern' | 'warm';
export interface ThemeConfig {
preset: ThemePreset;
overrides?: {
primary?: string;
background?: string;
foreground?: string;
};
}
export interface NavItem {
label: string;
pagePath: string;
}
export interface NavConfig {
items: NavItem[];
}
export interface FooterLink {
label: string;
href: string;
}
export interface FooterConfig {
text: string;
links: FooterLink[];
}
export interface SiteSettings {
favicon?: string | null;
defaultSeo?: {
title?: string;
description?: string;
};
analytics?: {
plausibleDomain?: string;
};
}
// ─── SEO (per page) ──────────────────────────────────────
export interface PageSeo {
title?: string;
description?: string;
ogImage?: string;
noindex?: boolean;
}
// ─── Local records (Dexie) ───────────────────────────────
export interface LocalWebsite extends BaseRecord {
slug: string;
name: string;
theme: ThemeConfig;
navConfig: NavConfig;
footerConfig: FooterConfig;
settings: SiteSettings;
/** UUID pointing at the currently-live published_snapshots row. */
publishedVersion: string | null;
/** Bumped on every draft mutation — surfaces "unveröffentlichte Änderungen". */
draftUpdatedAt: string | null;
}
export interface LocalWebsitePage extends BaseRecord {
siteId: string;
/** URL path — '/' for home, '/about' for subpage. */
path: string;
title: string;
seo: PageSeo;
/** Fractional index for reorder-without-reindex. */
order: number;
}
export interface LocalWebsiteBlock extends BaseRecord {
pageId: string;
parentBlockId: string | null;
/** Slot name within a container block (future — unused in M1). */
slotKey: string | null;
/** Block type id — must match a registered spec in @mana/website-blocks. */
type: string;
/** Block-type-specific props. Runtime-validated against the registry. */
props: unknown;
schemaVersion: number;
order: number;
}
// ─── Domain types (UI-facing, nulls coalesced) ───────────
export interface Website {
id: string;
slug: string;
name: string;
theme: ThemeConfig;
navConfig: NavConfig;
footerConfig: FooterConfig;
settings: SiteSettings;
publishedVersion: string | null;
draftUpdatedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface WebsitePage {
id: string;
siteId: string;
path: string;
title: string;
seo: PageSeo;
order: number;
createdAt: string;
updatedAt: string;
}
export interface WebsiteBlock {
id: string;
pageId: string;
parentBlockId: string | null;
slotKey: string | null;
type: string;
props: unknown;
schemaVersion: number;
order: number;
createdAt: string;
updatedAt: string;
}

View file

@ -0,0 +1,187 @@
<script lang="ts">
import {
useAllSites,
useAllPages,
useAllBlocks,
findSite,
pagesForSite,
blocksForPage,
} from '../queries';
import { blocksStore } from '../stores/blocks.svelte';
import BlockRenderer from '../components/BlockRenderer.svelte';
import BlockInspector from '../components/BlockInspector.svelte';
import InsertPalette from '../components/InsertPalette.svelte';
import PageList from '../components/PageList.svelte';
import PublishBar from '../components/PublishBar.svelte';
interface Props {
siteId: string;
pageId: string;
}
let props: Props = $props();
const sites = useAllSites();
const pages = useAllPages();
const blocks = useAllBlocks();
const site = $derived(findSite(sites.value, props.siteId));
const sitePages = $derived(pagesForSite(pages.value, props.siteId));
const pageBlocks = $derived(blocksForPage(blocks.value, props.pageId));
let selectedBlockId = $state<string | null>(null);
const selectedBlock = $derived(
selectedBlockId ? (pageBlocks.find((b) => b.id === selectedBlockId) ?? null) : null
);
// Clear selection when switching page.
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
props.pageId;
selectedBlockId = null;
});
async function addBlock(type: string) {
const block = await blocksStore.addBlock({ pageId: props.pageId, type });
selectedBlockId = block.id;
}
</script>
<div class="wb-editor-layout">
{#if site}
<PublishBar {site} />
{/if}
<div class="wb-editor">
<aside class="wb-editor__left">
{#if site}
<div class="wb-editor__site-meta">
<p class="wb-editor__site-name">{site.name}</p>
<p class="wb-editor__site-slug">/s/{site.slug}</p>
</div>
{/if}
<PageList siteId={props.siteId} pages={sitePages} activePageId={props.pageId} />
<div class="wb-editor__palette">
<InsertPalette onInsert={addBlock} />
</div>
</aside>
<main class="wb-editor__center">
{#if pageBlocks.length === 0}
<div class="wb-editor__empty">
<h3>Leere Seite</h3>
<p>Füge links einen Block ein, um loszulegen.</p>
</div>
{:else}
<div class="wb-editor__preview">
<BlockRenderer
blocks={pageBlocks}
mode="edit"
{selectedBlockId}
onSelect={(id) => (selectedBlockId = id)}
/>
</div>
{/if}
</main>
<aside class="wb-editor__right">
{#if selectedBlock}
<BlockInspector block={selectedBlock} onDeleted={() => (selectedBlockId = null)} />
{:else}
<p class="wb-editor__inspector-empty">
Wähle einen Block in der Vorschau, um ihn zu bearbeiten.
</p>
{/if}
</aside>
</div>
</div>
<style>
.wb-editor-layout {
display: flex;
flex-direction: column;
height: 100%;
}
.wb-editor {
display: grid;
grid-template-columns: 16rem 1fr 20rem;
gap: 1px;
flex: 1 1 auto;
min-height: 0;
background: rgba(255, 255, 255, 0.06);
}
.wb-editor__left,
.wb-editor__right {
background: rgb(15, 18, 24);
padding: 1rem;
overflow-y: auto;
}
.wb-editor__left {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-editor__center {
background: rgb(10, 12, 16);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.wb-editor__preview {
flex: 1 1 auto;
}
.wb-editor__empty {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.5rem;
padding: 3rem 1.5rem;
opacity: 0.6;
}
.wb-editor__empty h3 {
margin: 0;
}
.wb-editor__empty p {
margin: 0;
font-size: 0.875rem;
}
.wb-editor__site-meta {
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-editor__site-name {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.wb-editor__site-slug {
margin: 0.1rem 0 0;
font-size: 0.75rem;
opacity: 0.55;
font-family: ui-monospace, monospace;
}
.wb-editor__palette {
margin-top: auto;
}
.wb-editor__inspector-empty {
font-size: 0.8125rem;
opacity: 0.5;
}
@media (max-width: 960px) {
.wb-editor {
grid-template-columns: 1fr;
grid-auto-rows: min-content;
}
.wb-editor__left,
.wb-editor__right {
max-height: 50vh;
}
}
</style>

View file

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

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { useAllPages, pagesForSite } from '$lib/modules/website/queries';
const siteId = $derived($page.params.siteId ?? '');
const pages = useAllPages();
const sitePages = $derived(pagesForSite(pages.value, siteId));
// Redirect to the first (ordered) page's editor as soon as pages load.
$effect(() => {
if (siteId && sitePages.length > 0) {
const first = sitePages[0];
void goto(`/website/${siteId}/edit/${first.id}`, { replaceState: true });
}
});
</script>
<svelte:head>
<title>Website - Mana</title>
</svelte:head>
<div class="wb-redirect">
<p>Öffne Editor…</p>
</div>
<style>
.wb-redirect {
padding: 3rem;
text-align: center;
opacity: 0.5;
}
</style>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/stores';
import EditorView from '$lib/modules/website/views/EditorView.svelte';
import { RoutePage } from '$lib/components/shell';
import { useAllSites, findSite } from '$lib/modules/website/queries';
const siteId = $derived($page.params.siteId ?? '');
const pageId = $derived($page.params.pageId ?? '');
const sites = useAllSites();
const site = $derived(findSite(sites.value, siteId));
const title = $derived(site ? `${site.name} Editor` : 'Website Editor');
</script>
<svelte:head>
<title>{title} - Mana</title>
</svelte:head>
<RoutePage appId="website" backHref="/website" title={site?.name ?? 'Website'}>
{#key pageId}
<EditorView {siteId} {pageId} />
{/key}
</RoutePage>

View file

@ -0,0 +1,53 @@
/**
* Public website renderer loads the current published snapshot and
* hands it to every child route.
*
* This route is OUTSIDE the (app) group no auth, no AuthGate, no
* Dexie. Everything is SSR from Postgres via the mana-api public
* endpoint.
*/
import { error } from '@sveltejs/kit';
import { getManaApiUrl } from '$lib/api/config';
import type { LayoutServerLoad } from './$types';
import type { SnapshotSite, SnapshotPage } from '$lib/modules/website/publish';
interface PublicSnapshotResponse {
snapshotId: string;
slug: string;
publishedAt: string;
blob: {
version: string;
site: SnapshotSite;
pages: SnapshotPage[];
publishedAt: string;
publishedBy: string;
};
}
export const load: LayoutServerLoad = async ({ params, fetch, setHeaders }) => {
const slug = params.siteSlug;
if (!slug) error(404, 'Not found');
const res = await fetch(`${getManaApiUrl()}/api/v1/website/public/sites/${slug}`, {
headers: { Accept: 'application/json' },
});
if (res.status === 404) error(404, 'Website not found');
if (!res.ok) error(502, 'Upstream error fetching published site');
const payload = (await res.json()) as PublicSnapshotResponse;
// Mirror the edge-cache hint from the API so that SvelteKit's own
// adapter respects it. `page` data isn't personalized for public
// sites, so the short freshness window is safe.
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400',
});
return {
snapshot: payload.blob,
snapshotId: payload.snapshotId,
publishedAt: payload.publishedAt,
};
};

View file

@ -0,0 +1,156 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
interface Props {
data: LayoutData;
children: Snippet;
}
let { data, children }: Props = $props();
const site = $derived(data.snapshot.site);
const theme = $derived(site.theme);
// Theme preset → CSS variables. Three presets for M3+, classic for M2.
const themeVars = $derived.by(() => {
const preset = theme?.preset ?? 'classic';
const base =
preset === 'modern'
? { primary: '#6366f1', bg: '#0b0d12', fg: '#f5f6f8' }
: preset === 'warm'
? { primary: '#f97316', bg: '#1a140f', fg: '#f7ede2' }
: { primary: '#3b82f6', bg: '#ffffff', fg: '#0f172a' };
const overrides = theme?.overrides ?? {};
const primary = overrides.primary ?? base.primary;
const bg = overrides.background ?? base.bg;
const fg = overrides.foreground ?? base.fg;
return `--wb-primary:${primary};--wb-bg:${bg};--wb-fg:${fg};`;
});
const navItems = $derived(site.navConfig?.items ?? []);
const footer = $derived(site.footerConfig);
</script>
<svelte:head>
<title>{site.name}</title>
{#if site.settings?.favicon}
<link rel="icon" href={site.settings.favicon} />
{/if}
{#if site.settings?.defaultSeo?.description}
<meta name="description" content={site.settings.defaultSeo.description} />
{/if}
</svelte:head>
<div class="wb-public" style={themeVars}>
{#if navItems.length > 0}
<nav class="wb-public__nav">
<a class="wb-public__brand" href="/s/{site.slug}">{site.name}</a>
<ul>
{#each navItems as item (item.pagePath)}
<li>
<a href={`/s/${site.slug}${item.pagePath === '/' ? '' : item.pagePath}`}>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
{:else}
<nav class="wb-public__nav wb-public__nav--minimal">
<a class="wb-public__brand" href="/s/{site.slug}">{site.name}</a>
</nav>
{/if}
<main class="wb-public__main">
{@render children()}
</main>
{#if footer && (footer.text || (footer.links && footer.links.length > 0))}
<footer class="wb-public__footer">
{#if footer.text}
<p>{footer.text}</p>
{/if}
{#if footer.links && footer.links.length > 0}
<ul>
{#each footer.links as link (link.href)}
<li><a href={link.href}>{link.label}</a></li>
{/each}
</ul>
{/if}
</footer>
{/if}
</div>
<style>
:global(html),
:global(body) {
margin: 0;
padding: 0;
}
.wb-public {
min-height: 100vh;
background: var(--wb-bg);
color: var(--wb-fg);
display: flex;
flex-direction: column;
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
.wb-public__nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(127, 127, 127, 0.15);
}
.wb-public__nav--minimal {
border-bottom: none;
}
.wb-public__brand {
font-weight: 600;
color: inherit;
text-decoration: none;
font-size: 1.05rem;
}
.wb-public__nav ul {
list-style: none;
display: flex;
gap: 1.5rem;
margin: 0;
padding: 0;
}
.wb-public__nav a {
color: inherit;
text-decoration: none;
font-size: 0.9375rem;
}
.wb-public__nav a:hover {
color: var(--wb-primary);
}
.wb-public__main {
flex: 1 1 auto;
}
.wb-public__footer {
padding: 2rem 1.5rem;
border-top: 1px solid rgba(127, 127, 127, 0.15);
text-align: center;
font-size: 0.875rem;
opacity: 0.7;
}
.wb-public__footer ul {
list-style: none;
display: flex;
justify-content: center;
gap: 1.25rem;
padding: 0;
margin: 0.75rem 0 0;
}
.wb-public__footer a {
color: inherit;
}
</style>

View file

@ -0,0 +1,23 @@
/**
* Public page resolver picks the right page from the snapshot blob
* by path. No extra fetch: the parent +layout.server.ts already loaded
* the full snapshot.
*/
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, parent }) => {
const { snapshot } = await parent();
const rest = params.path ?? '';
// `/` for the home route, `/about` / `/docs/foo` otherwise. Strip a
// trailing slash (except the root itself).
const targetPath = '/' + rest.replace(/\/$/, '');
const page = snapshot.pages.find((p) => p.path === targetPath);
if (!page) error(404, `Page "${targetPath}" not found`);
return {
page,
};
};

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { getBlockSpec } from '@mana/website-blocks';
import type { PageServerData } from './$types';
import type { SnapshotBlockNode } from '$lib/modules/website/publish';
interface Props {
data: PageServerData;
}
let { data }: Props = $props();
const page = $derived(data.page);
const blocks = $derived(page.blocks as SnapshotBlockNode[]);
// Adapter: the snapshot's `SnapshotBlockNode` already carries a
// `children: SnapshotBlockNode[]`, but the registry's Block type
// expects the flat fields from the Dexie row. We rebuild on the fly
// rather than changing the registry contract — M2 only renders
// top-level blocks (containers arrive in M3).
function asRegistryBlock(b: SnapshotBlockNode, order: number) {
return {
id: b.id,
type: b.type,
props: b.props,
schemaVersion: b.schemaVersion,
order,
parentBlockId: null,
slotKey: b.slotKey,
};
}
</script>
<svelte:head>
<title>{page.seo?.title ?? page.title}</title>
{#if page.seo?.description}
<meta name="description" content={page.seo.description} />
{/if}
{#if page.seo?.noindex}
<meta name="robots" content="noindex,nofollow" />
{/if}
{#if page.seo?.ogImage}
<meta property="og:image" content={page.seo.ogImage} />
{/if}
</svelte:head>
{#each blocks as block, i (block.id)}
{@const spec = getBlockSpec(block.type)}
{#if spec}
<spec.Component block={asRegistryBlock(block, i)} mode="public" />
{/if}
{/each}

View file

@ -0,0 +1,776 @@
# Website Builder — Block-Tree CMS für Privat + Firma
_Started 2026-04-23._
Ein Modul `website`, mit dem Nutzer (privat) und Firmen (Space mit mehreren Mitgliedern) mehrseitige Websites bauen, live bearbeiten und unter `mana.how`-Domains veröffentlichen. Kein Drag-Drop-Canvas wie Framer/Webflow, sondern ein **Block-Baum-Editor** mit Zod-validierten Block-Typen — dieselben Svelte-Komponenten rendern im Editor, in der Live-Preview und im öffentlichen Seitenaufruf. Content aus anderen Mana-Modulen (picture, library, news, …) wird per `moduleEmbed`-Block direkt eingebettet.
Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Zielzustand direkt, keine Legacy-Reste.
## Ziel in einem Satz
Jeder Mana-Nutzer (oder jede Firma via Space) kann eine vollständige Website mit beliebig vielen Seiten über einen Block-Baum-Editor bauen, Daten aus seinen Mana-Modulen einbetten, und unter `/s/{slug}`, später `{slug}.mana.how` oder einer Custom-Domain veröffentlichen — mit SSR-Rendering aus denselben Svelte-Komponenten, ohne separaten Astro-Build-Pfad.
## Nicht-Ziele
- **Kein Free-Form Canvas.** Keine absolute Positionierung, keine Pixel-CSS. Layout über Block-Typen, Theme-Variablen und wenige Container (`columns`, `rows`, `spacer`).
- **Kein dualer Renderer.** Keine Svelte-Komponenten **und** Astro-Komponenten für dieselben Blöcke. Der öffentliche Renderer ist SvelteKit-SSR, der Editor rendert dieselben Components.
- **Keine Admin-UI-Nutzung des bestehenden `mana-landing-builder` Services** für User-Sites. Der Service bleibt für Org-Landing-Pages (andere Code-Pfade, andere Zielgruppe). Wir beschreiben in M6 optional eine Konsolidierung.
- **Kein Plugin-System.** Block-Typen sind intern und in `packages/website-blocks` versioniert. Dritt-Blöcke erst wenn Bedarf real wird.
- **Kein Markdown-Editor-Ersatz.** RichText-Blöcke nutzen einen kuratierten Satz Tiptap-Extensions, nicht Markdown. Ein Export-zu-Markdown ist möglich, aber nicht Teil des Write-Pfades.
- **Keine E-Commerce-Primitive.** Shop, Warenkorb, Checkout: nicht Scope. Pricing-Blöcke sind Display-only.
- **Keine Versionierung auf Block-Ebene.** Sites haben `draft` und `published` als zwei konsistente Snapshots; kein per-Block-History-Browser.
## Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ Editor (auth-gated, local-first) │
│ apps/mana/apps/web/src/routes/(app)/website/… │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ Seitenbaum + │ │ Block-Baum + │ │ Inspector │ │
│ │ Seite-Settings│ │ Live Preview │ │ (Zod → Form) │ │
│ └───────┬───────┘ └───────┬───────┘ └────────┬─────────┘ │
│ └──── Dexie (websites/pages/blocks) ───┘ │
│ │ │
│ ▼ │
│ encryptRecord(plaintext) → table.add() │
│ │ │
│ ▼ │
│ _pendingChanges (appId='website') │
└──────────────────────────┬───────────────────────────────────┘
│ (sync engine, same pipe as every module)
mana-sync → Postgres (website.* schema)
│ read path for public visitors
┌──────────────────────────────────────────────────────────────┐
│ Public renderer (no auth, SSR) │
│ apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/… │
│ │
│ +page.server.ts │
│ └─ resolveSite(siteSlug, path) │
│ └─ reads published snapshot from Postgres (no Dexie) │
│ │ │
│ ▼ │
│ +page.svelte renders <BlockRenderer mode="public" />
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Form submissions (no auth) │
│ POST /api/v1/website/sites/:id/submit/:blockId │
│ → validate against stored block schema (Zod) │
│ → write to target module via mana-tool-registry handler │
│ → record in websiteSubmissions for audit │
└──────────────────────────────────────────────────────────────┘
```
## Entscheidungen
Explizit, begründet, und als Ankerpunkt für spätere Zweifel.
### D1 — Ein Block-Typ, eine Svelte-Komponente, drei Render-Modi
Jeder Block-Typ (`hero`, `richText`, `image`, `gallery`, `form`, …) lebt als **genau eine** Svelte-Komponente in `packages/website-blocks/src/{type}/{Type}.svelte`. Die Komponente bekommt `{ block, mode }`, wobei `mode ∈ 'edit' | 'preview' | 'public'`. Im `edit`-Mode werden Inline-Editing-Controls sichtbar; im `public`-Mode reine Anzeige.
**Warum:** Ein Codepfad bedeutet: wenn der Editor eine Change rendert, sieht der Besucher später exakt dasselbe. Dual-Rendering (Svelte-Editor + Astro-Public) wie in `shared-landing-ui` heute erzeugt garantiert Drift.
**Konsequenz:** Der SvelteKit-Public-Renderer ist SSR, nicht statisch. Für Performance siehe D9 (Caching).
### D2 — Block-Schema ist SSOT für Rendering, Validierung, UI, AI-Tools
Pro Block-Typ ein Zod-Schema in `packages/website-blocks/src/{type}/schema.ts`. Das Schema ist gleichzeitig:
1. **Datenbank-Validierung** (Store schreibt, Server validiert)
2. **Inspector-Formular** (Auto-Generierung via `zod-to-form`-Utility)
3. **AI-Tool-Input** (über `mana-tool-registry`, siehe D7)
4. **Persistenz-Schema-Migrationen** (jedes Block-Schema hat `version`, Upgrader)
**Warum:** Schema und Renderer werden immer zusammen geändert. Wenn sie getrennt leben, bekommen wir stille UI-Abweichungen vom Datenmodell. Zod als eine Quelle schließt das aus.
**Block-Paket-Skizze:**
```
packages/website-blocks/src/
├── hero/
│ ├── schema.ts # HeroBlockSchema (Zod, v1)
│ ├── Hero.svelte # Renderer (mode-aware)
│ ├── Hero.inspector.ts # optional: custom inspector (sonst auto)
│ └── index.ts
├── richText/…
├── image/…
├── gallery/…
├── form/…
├── moduleEmbed/…
├── columns/…
├── spacer/…
├── cta/…
├── faq/…
├── registry.ts # { type → { schema, Component, icon, category } }
└── index.ts
```
### D3 — Block-Baum über `parentBlockId`, Reihenfolge über `order`
Blöcke speichern `parentBlockId` (nullable — Top-Level auf einer Seite) und `order` (double-linked via fractional indexing, kein Reindex bei Insert). Container-Blöcke (`columns`, `rows`) haben mehrere Slots; `slotKey` ist optional auf dem Child.
**Warum:** Flache Tabelle mit `parentBlockId` ist die konventionelle, gut-getestete Repräsentation eines Baums in einem CRDT-fähigen System (wir haben field-level LWW via mana-sync). Alternativen:
- *JSON-Blob für den ganzen Baum*: einfach, aber jedes Move eines Blocks schreibt den gesamten Baum. Konfliktverlust garantiert bei Co-Editing.
- *Nested Set / Path-Enumeration*: schnell für Lese-Queries, aber Writes sind teuer und Konflikte weh tun.
Flach + `parentBlockId` + fractional index = feld-weise LWW ist pro Block funktionabel, Co-Editing zweier Member am selben Block ist sicher (Timestamp entscheidet pro Feld).
### D4 — Drei Tabellen plus optional Submissions, alle space-scoped, alle plaintext
```
websites { id, spaceId, slug, name, theme, navConfig, footerConfig,
publishedVersion, draftUpdatedAt, settings }
websitePages { id, siteId, path, title, seo, order }
websiteBlocks { id, pageId, parentBlockId, type, slotKey, props, order, schemaVersion }
websiteSubmissions { id, siteId, blockId, payload, targetModule, targetRecordId,
status, createdAt, ip, userAgent }
```
Alle Felder plaintext. Begründung: Site-Content ist öffentlich — es für den Autor zu verschlüsseln wäre sinnfrei, und macht SSR im Public-Path unmöglich (der Server hat keinen MK). Form-Submissions können sensible Daten enthalten; die landen nach Validierung in den Zielmodulen (z.B. `contacts`), dort sind die existenten Encryption-Regeln gültig. Der Submission-Audit-Row (`payload`) wird nach erfolgreicher Weitergabe geleert (siehe M2).
### D5 — Publish-Modell: `draft` + `published` als zwei separate Snapshots
Jedes `website` hat einen `publishedVersion` (UUID). Editor schreibt immer gegen den Draft (= die Live-Tabellenzeilen). Auf "Publish" wird ein Snapshot erzeugt: `websitePublishedSnapshots { siteId, version, blob }` — das `blob` ist ein vollständig aufgelöster, deterministisch serialisierter Baum (JSON). Der Public-Renderer liest nur dieses Blob.
**Warum:**
- Editor kann beliebig herumspielen, ohne dass Besucher halbfertige Seiten sehen.
- Rollback ist trivial: `publishedVersion` zeigt auf älteren Snapshot.
- Public-Read ist **ein** Query (`SELECT blob WHERE siteId AND version`), kein JOIN über drei Tabellen.
- Snapshots sind unveränderlich — gut cachebar (D9).
**Alternative verworfen:** "Live-Edit = sofort live". Katastrophal für Firmen-Nutzung, wo mehrere Member über Stunden editieren. Draft/Publish ist der nicht-verhandelbare Standard.
### D6 — Public-Serving über SvelteKit-Route, nicht via `mana-landing-builder`
`apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts` lädt Site + Page + BlockTree aus Postgres und rendert SSR. `mana-landing-builder` wird **nicht** erweitert — der Service bleibt für den separaten Org-Landing-Pages-Use-Case (admin-only), in M6 wird entschieden, ob er fusioniert oder abgelöst wird.
**Warum:**
- Astro-Static-Export in `mana-landing-builder` zwingt zu dualem Rendering (D1 verletzt).
- SvelteKit-SSR mit Caching (D9) ist für Hunderttausende User-Sites schnell genug.
- Statischer Export lohnt erst bei hohem Traffic pro Site — dann pro Site opt-in, nicht default.
**Subdomain-Handling (Phase 3):** SvelteKit-Host-Handler im Hook `hooks.server.ts` erkennt `{slug}.mana.how` und rewritet intern auf `/s/{slug}/…`. Wildcard-Cert existiert bereits.
### D7 — AI-Tools via `mana-tool-registry`, nicht separat
Sobald `mana-tool-registry` (siehe `docs/plans/mana-mcp-and-personas.md` M1) steht, registriert `website` seine Tools dort: `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`. Policy-Hint pro Tool: `write` für CRUD, `destructive` für `delete_page`/`delete_site` (nicht MCP-exponiert).
**Warum:** Alle AI-Writes laufen zwingend durch denselben Tool-Layer. Kein paralleles "AI-kann-Websites-bauen"-Subsystem.
**Reihenfolge:** `mana-tool-registry` M1 muss stehen, bevor Website-AI-Tools registriert werden. Bis dahin: Editor ohne AI. Website-AI-Tools landen als Teil von M5.
### D8 — Form-Submissions schreiben über Tool-Registry-Handler, nicht direkt in Ziel-Tabellen
Ein `form`-Block hat `targetModule` (z.B. `'contacts'`) und `targetAction` (z.B. `'create_contact'`). Der Submit-Endpoint:
1. Validiert Payload gegen das im Block gespeicherte Zod-Schema
2. Speichert Audit-Row in `websiteSubmissions` (Status: `'received'`)
3. Ruft den entsprechenden Tool-Handler aus `mana-tool-registry` auf (`ctx`: site-owner user/space)
4. Updated Audit-Row mit `targetRecordId` und `'delivered'`
**Warum:** Der Tool-Registry-Handler kennt bereits Encryption, RLS, Validation des Zielmoduls. Duplizieren wäre erzwungener Legacy-Einstiegspunkt.
**Abgrenzung:** Unauthentifizierter Submit-Endpoint → Rate-Limiting via Edge (Cloudflare) und Captcha-Block-Typ (in M6, nicht M1).
### D9 — Caching: Published-Snapshot mit Cache-Tag, Invalidation bei Publish
Published-Blob wird mit `Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400` geliefert, plus `Cache-Tag: site-{siteId}`. Bei `website.publish` → Cloudflare Purge der Tag-Gruppe.
**Warum:** Keine statische Build-Stufe nötig; Edge-Cache bei Cloudflare liefert millisekunden-Responses für populäre Sites. Bei Edits ist die neue Version nach Publish binnen weniger Sekunden live.
**Alternative verworfen:** Redis-Cache in der App. Doppelter Infrastruktur-Aufwand, CF macht es gratis.
### D10 — Multi-Tenant über Spaces, Editing-Permission über Membership
Ein `website` gehört zu einer `spaceId`. Jedes Mitglied des Spaces kann editieren + publishen. Rollen (editor-only, viewer-only) kommen später, wenn `space_members.role` nicht-trivial wird.
**Warum:** Wir verwenden, was es gibt. Spaces-RLS ist getestet. Ein eigenes `website_members` wäre parallele Permission-Ebene → Drift garantiert.
**Privat vs. Firma-Distinction:** Kein eigenes "ist eine Firma"-Flag. Ein Space mit einem Member = Privat, ein Space mit 2+ Membern = Firma. Die UI kann in Phase 2 auf `spaceMemberCount > 1` reagieren, um Team-Workflows zu zeigen.
### D11 — Slugs: space-scoped unique, reserved-Liste hart
`websites.slug` ist unique pro `spaceId`. Öffentliche URL in Phase 1 ist `/s/{siteSlug}` global unique (nicht space-scoped) — und deswegen gibt es *auch* eine globale Unique-Constraint auf `slug` wenn `isPublished=true`.
**Reserved slugs:** `app`, `api`, `auth`, `admin`, `settings`, `docs`, `blog`, `www`, `mail`, `dashboard`, plus alle existierenden Modulnamen. Liste in `apps/api/src/modules/website/reserved-slugs.ts`, erzwungen bei Write und in Migration gecheckt.
**Warum:** Eine Site mit `slug=api` würde die API-Route verschatten. Lieber strikt + reserviert Namen.
### D12 — Media-Assets über bestehendes `shared-uload`, kein eigenes `websiteAssets`
Bilder, Dateien, Cover: Upload über `shared-uload` → MinIO, Rückgabe der URL. Der `image`-Block speichert `{ url, altText, focalPoint }`. Cleanup: wir führen keine Reference-Counting-Tabelle in Phase 1. Bei `delete site` bleiben Assets liegen (ok, sie sind im Space-Bucket, Storage ist billig). GC-Job in M7.
**Warum:** Ein eigenes `websiteAssets` mit Reference-Counting wäre sauberer, aber ein GC-Job reicht als Aufräumer und verzögert erst mal Komplexität.
### D13 — Kein Legacy-Fork der `shared-landing-ui` Astro-Sections
Die 13 existierenden Astro-Sections (`HeroSection`, `FeatureSection`, …) werden **nicht** nach Svelte portiert oder geteilt. Wir schreiben die Block-Renderer neu. Die visuellen Patterns darf man inspirieren, aber Code teilen = duales Rendering (D1 verletzt).
**Warum:** Die Astro-Sections haben andere Constraints (Astro-Islands, build-time-data). Teilen würde beide Seiten einschränken.
**Konsequenz:** `shared-landing-ui` bleibt für Org-Landing-Pages. In M6 diskutieren wir die Konsolidierung ehrlich.
## Komponenten
### Komponente 1 — `packages/website-blocks`
Neues Workspace-Paket. Reine Svelte-Components + Zod-Schemata, keine Dexie/Netzwerk-Abhängigkeiten. Nutzbar vom Editor (Dexie-Kontext) **und** vom Public-Renderer (Postgres-Snapshot-Kontext) — beide Seiten geben `{ block, mode, children }`, der Renderer kümmert sich nicht um Datenquelle.
**Public API (Skizze):**
```ts
// packages/website-blocks/src/registry.ts
export interface BlockSpec<Props = unknown> {
type: string; // 'hero', 'richText', …
schema: ZodSchema<Props>;
schemaVersion: number;
Component: SvelteComponent<{
block: Block<Props>;
mode: 'edit' | 'preview' | 'public';
children?: Block[]; // nur bei Containern
onEdit?: (patch: Partial<Props>) => void; // im edit-Mode
}>;
icon: string; // Lucide-Name
category: 'content' | 'media' | 'layout' | 'form' | 'embed';
defaults: Props; // Initialwerte beim Einfügen
upgraders?: Record<number, (old: unknown) => Props>; // v1→v2 migrations
}
export const blockRegistry: Record<string, BlockSpec>;
```
**Block-Coverage M1:** `hero`, `richText`, `image`, `spacer`, `cta`, `columns` (2/3-spalt), `gallery`. Sieben Typen reichen für brauchbare One-Pager.
**Block-Coverage M4 expand:** `form`, `moduleEmbed`, `pricing`, `faq`, `testimonials`, `team`, `contact`, `footer`. Fünfzehn Typen decken alle 13 `shared-landing-ui`-Sections plus neuen Bedarf.
### Komponente 2 — `apps/mana/apps/web/src/lib/modules/website`
Standard-Modul-Struktur, wie jedes andere Modul im Repo:
```
apps/mana/apps/web/src/lib/modules/website/
├── types.ts # LocalWebsite, LocalWebsitePage, LocalWebsiteBlock
├── collections.ts # websitesTable, websitePagesTable, websiteBlocksTable
├── queries.ts # useSite(id), usePage(id), useBlocks(pageId), useBlockTree(pageId)
├── stores/
│ ├── sites.svelte.ts # createSite, updateSite, deleteSite, publishSite
│ ├── pages.svelte.ts # createPage, updatePage, deletePage, reorderPages
│ └── blocks.svelte.ts # addBlock, updateBlock, deleteBlock, moveBlock
├── components/
│ ├── BlockRenderer.svelte # rekursiv, nutzt blockRegistry
│ ├── BlockTreeEditor.svelte # Seitenleiste: Baum + Insert-Palette
│ ├── BlockInspector.svelte # rechts: Zod-schema → Formular
│ ├── InsertPalette.svelte # "+" zwischen Blöcken
│ ├── PagePicker.svelte
│ ├── SiteSettingsDialog.svelte # Theme, Nav, Footer, SEO-Defaults
│ ├── PublishBar.svelte # "Unveröffentlichte Änderungen" + Publish-Button
│ └── TemplatePicker.svelte # Starter-Templates
├── views/
│ ├── SitesListView.svelte # alle Sites des Spaces
│ ├── SiteEditorView.svelte # drei-Pane Editor
│ └── SiteSettingsView.svelte
├── tools.ts # AI-Tool-Registrierungen (aktiviert erst in M5)
├── constants.ts # THEME_PRESETS, RESERVED_SLUGS (client-copy)
├── module.config.ts # { appId: 'website', tables: [...] }
└── index.ts
```
**Routes:**
```
apps/mana/apps/web/src/routes/(app)/website/
├── +page.svelte # SitesListView
├── new/+page.svelte # Template-Picker oder Blank
└── [siteId]/
├── +layout.svelte # lädt site, stellt Context
├── +page.svelte # redirect auf /edit
├── edit/
│ └── [pageId]/+page.svelte # SiteEditorView
├── settings/+page.svelte # SiteSettingsView
└── submissions/+page.svelte # Eingegangene Form-Submissions
```
### Komponente 3 — Public-Renderer-Routes
```
apps/mana/apps/web/src/routes/s/
└── [siteSlug]/
├── +layout.server.ts # resolve site, throw 404 if unpublished
├── +layout.svelte # theme vars, nav, footer
└── [[...path]]/
├── +page.server.ts # resolve page by path, 404 if missing
└── +page.svelte # <BlockRenderer mode="public" />
```
**Resolver-Logik (+layout.server.ts):**
```ts
export const load = async ({ params, setHeaders }) => {
const snapshot = await db
.select()
.from(publishedSnapshotsTable)
.where(and(
eq(publishedSnapshotsTable.slug, params.siteSlug),
eq(publishedSnapshotsTable.isCurrent, true)
))
.limit(1);
if (!snapshot[0]) error(404);
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
'cache-tag': `site-${snapshot[0].siteId}`,
});
return { site: snapshot[0].blob };
};
```
Snapshot-Blob-Format:
```ts
interface PublishedSnapshot {
version: string;
site: { id, slug, name, theme, navConfig, footerConfig, settings };
pages: Array<{
id, path, title, seo,
blocks: BlockTreeNode[]; // rekursiver Baum, bereits auflösend
}>;
publishedAt: string;
publishedBy: string;
}
```
### Komponente 4 — `apps/api/src/modules/website`
Backend-Routes im unified `@mana/api`:
```
apps/api/src/modules/website/
├── routes.ts # Hono router
├── publish.ts # POST /sites/:id/publish
├── submit.ts # POST /sites/:id/submit/:blockId (unauth)
├── snapshots.ts # query helpers for published snapshots
├── reserved-slugs.ts # SSOT
└── tools.ts # Tool-Registry registrations (M5)
```
**Endpoints:**
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/v1/website/sites/:id/publish` | JWT (space-member) | Snapshot erzeugen, `publishedVersion` setzen, CF-Cache purgen |
| `POST` | `/api/v1/website/sites/:id/submit/:blockId` | None | Form-Submission annehmen, validieren, weitergeben |
| `GET` | `/api/v1/website/sites/:id/submissions` | JWT (space-member) | Submissions listen |
| `DELETE` | `/api/v1/website/sites/:id/submissions/:subId` | JWT (space-member) | Submission löschen |
Keine CRUD-Endpoints für Pages/Blocks — das läuft über den normalen Sync-Pfad (Dexie → mana-sync → Postgres) wie bei allen anderen Modulen.
### Komponente 5 — Starter-Templates
Sechs handkuratierte Templates in `apps/mana/apps/web/src/lib/modules/website/templates/`:
| Template | Zielgruppe | Seiten | Blöcke |
|----------|-----------|--------|--------|
| `portfolio` | Kreative, Freelancer | Start, Über mich, Arbeiten, Kontakt | hero + gallery + richText + form |
| `personal-linktree` | Privatnutzer, Creator | Start (Single-Page) | hero + 8× cta |
| `event` | Hochzeit, Geburtstag, Konferenz | Start, Programm, Anreise, RSVP | hero + richText + form |
| `smb-corporate` | Kleinbetrieb | Start, Leistungen, Team, Kontakt | hero + 3×columns + team + contact |
| `product-landing` | Firmen-Produktseite | Start (Single-Page, lang) | hero + features + testimonials + pricing + faq + cta |
| `blank` | Fortgeschritten | 1 leere Seite | — |
Templates sind JSON in `templates/{name}.json`: `{ site, pages[], blocks[] }`. Apply-Funktion klont mit neuen UUIDs in den Ziel-Space. Templates sind statisch im Build, nicht in DB — kein Admin-Flow zum Editieren im MVP (M6 evtl.).
### Komponente 6 — Inspector-Autoform
`components/BlockInspector.svelte` rendert Formulare aus Zod-Schemas via kleiner Utility `zodToForm(schema)` in `packages/website-blocks/src/inspector/`. Mapping:
| Zod | UI |
|-----|----|
| `z.string()` | `<input type="text">` |
| `z.string().long()` (custom brand) | `<textarea>` |
| `z.string().url()` | `<input type="url">` |
| `z.enum([...])` | `<select>` |
| `z.boolean()` | `<input type="checkbox">` |
| `z.number()` | `<input type="number">` |
| `z.object({ ... })` | Gruppen-Fieldset (rekursiv) |
| `z.array(z.object({...}))` | Liste mit Add/Remove + Drag-Reorder |
| Block-spezifisches Custom | Override via `Block.inspector.ts` |
**Warum Auto-Formular, nicht pro-Block-Handschrift:** 15 Blöcke × 8 Felder = 120 Formfelder handgeschrieben wäre Copy-Paste. Auto-Gen deckt 80%, Override-Mechanismus für Spezialfälle (Farbwähler, Icon-Picker, Module-Source-Picker).
### Komponente 7 — `moduleEmbed`-Block
Spezialblock, der Daten aus anderen Modulen zieht. Props:
```ts
{
source: 'picture.board' | 'library.entries' | 'news.feed' | 'cards.deck' | 'library.kind';
sourceId: string; // board-id, deck-id, feed-id
filter?: { kind?, tag?, limit?, order? };
layout: 'grid' | 'list' | 'carousel';
}
```
**Source-Provider-Pattern:** Jedes Modul, das einbettbare Daten liefert, registriert einen Provider:
```ts
// apps/api/src/modules/website/embed-providers.ts
export interface EmbedProvider {
source: string; // 'picture.board'
resolve: (sourceId: string, filter: unknown, ctx: EmbedContext)
=> Promise<EmbedResult>;
}
```
Public-Renderer ruft bei Publish die Provider und inlined das Ergebnis in den Snapshot (statischer Ansatz) **oder** ruft sie pro Request (dynamischer Ansatz). **Default: statisch beim Publish**, weil einfacher zu cachen. Opt-in dynamisch pro Block (Use-Case: "Letzte 5 Artikel", tagesaktuell). Dynamisch = Cache-Tag zusätzlich `embed-{source}-{sourceId}`, Purge bei Source-Änderung.
**Permission-Check im Provider:** Provider bekommt `EmbedContext { ownerUserId, siteId, isPublic: true }`. Source-Daten werden nur inlined, wenn das Original als "public" markiert ist (pro Modul unterschiedlich: picture `board.isPublic`, library `entry.visibility='public'`, …). Ohne Public-Flag → Provider returnt leer oder wirft "needs_public".
### Komponente 8 — Policies: Tier-Gating
`packages/shared-branding/src/mana-apps.ts` bekommt einen neuen Entry:
```ts
website: {
id: 'website',
name: 'Website',
description: 'Baukasten für deine Website',
requiredTier: 'public', // alle angemeldeten User
url: '/website',
// …
}
```
**Limits pro Tier (konfiguriert in `website.limits.ts`):**
| Tier | Max Sites | Max Pages/Site | Max Blocks/Page | Custom Domain |
|------|-----------|----------------|-----------------|---------------|
| `public` | 1 | 1 | 20 | ❌ |
| `beta` | 3 | 5 | 50 | ❌ |
| `alpha` | 10 | unlimited | unlimited | ❌ |
| `founder` | unlimited | unlimited | unlimited | ✅ |
Enforcement in den Stores (pre-create check via JWT-Claim). Feature-Flag-Banner im Editor, der höheres Tier bewirbt.
## Datenmodell
### Dexie-Tabellen (IndexedDB, client-side)
```ts
// database.ts — neue Version block
db.version(NEW).stores({
// …existing…
websites: 'id, spaceId, slug, publishedVersion, updatedAt, deletedAt',
websitePages: 'id, siteId, path, order, updatedAt, deletedAt',
websiteBlocks: 'id, pageId, parentBlockId, order, type, updatedAt, deletedAt',
websiteSubmissions: 'id, siteId, blockId, createdAt, status',
});
```
### Postgres-Schemas (mana_platform, neu)
```sql
-- Schema: website.* (isoliert via pgSchema)
CREATE TABLE website.sites (
id UUID PRIMARY KEY,
space_id UUID NOT NULL REFERENCES space.spaces(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
theme JSONB NOT NULL, -- { preset, overrides }
nav_config JSONB NOT NULL, -- { items: [{label,pagePath}] }
footer_config JSONB NOT NULL,
settings JSONB NOT NULL, -- { favicon, defaultSeo, analytics? }
published_version UUID, -- FK → website.published_snapshots(id)
draft_updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (space_id, slug)
);
CREATE UNIQUE INDEX sites_published_slug_idx
ON website.sites (slug)
WHERE published_version IS NOT NULL AND deleted_at IS NULL;
CREATE TABLE website.pages (
id UUID PRIMARY KEY,
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- '/' for home, '/about' for subpage
title TEXT NOT NULL,
seo JSONB NOT NULL, -- { title, description, ogImage, noindex }
"order" DOUBLE PRECISION NOT NULL, -- fractional index
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (site_id, path)
);
CREATE TABLE website.blocks (
id UUID PRIMARY KEY,
page_id UUID NOT NULL REFERENCES website.pages(id) ON DELETE CASCADE,
parent_block_id UUID REFERENCES website.blocks(id) ON DELETE CASCADE,
slot_key TEXT, -- for container-blocks
type TEXT NOT NULL, -- registry type id
props JSONB NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
"order" DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX blocks_page_parent_idx ON website.blocks (page_id, parent_block_id, "order");
CREATE TABLE website.published_snapshots (
id UUID PRIMARY KEY,
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
slug TEXT NOT NULL, -- duplicated for fast public lookup
blob JSONB NOT NULL, -- full snapshot
is_current BOOLEAN NOT NULL DEFAULT FALSE,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_by UUID NOT NULL REFERENCES platform.users(id)
);
CREATE UNIQUE INDEX snapshots_current_slug_idx
ON website.published_snapshots (slug)
WHERE is_current = TRUE;
CREATE TABLE website.submissions (
id UUID PRIMARY KEY,
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
block_id UUID NOT NULL, -- no FK: block could be deleted, submission stays for audit
payload JSONB NOT NULL, -- cleared after target-module write
target_module TEXT NOT NULL,
target_action TEXT NOT NULL,
target_record_id UUID,
status TEXT NOT NULL, -- 'received' | 'delivered' | 'failed'
error_message TEXT,
ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX submissions_site_created_idx ON website.submissions (site_id, created_at DESC);
```
### RLS
```sql
-- sites, pages, blocks: nur Space-Member lesen/schreiben
ALTER TABLE website.sites ENABLE ROW LEVEL SECURITY;
CREATE POLICY sites_space_member ON website.sites
USING (space_id IN (SELECT space_id FROM space.memberships WHERE user_id = current_user_id()));
-- Analog für pages/blocks (JOIN über site)
-- published_snapshots: öffentlicher Read (kein Auth), Write nur durch publish-Endpoint (service-role)
ALTER TABLE website.published_snapshots ENABLE ROW LEVEL SECURITY;
CREATE POLICY snapshots_public_read ON website.published_snapshots
FOR SELECT USING (is_current = TRUE);
CREATE POLICY snapshots_service_write ON website.published_snapshots
FOR ALL TO service_role USING (TRUE);
-- submissions: nur Space-Member lesen; Write via service-role aus Submit-Endpoint
ALTER TABLE website.submissions ENABLE ROW LEVEL SECURITY;
CREATE POLICY submissions_space_member_read ON website.submissions
FOR SELECT USING (site_id IN (
SELECT id FROM website.sites WHERE space_id IN
(SELECT space_id FROM space.memberships WHERE user_id = current_user_id())
));
```
### Encryption-Registry
```ts
// apps/mana/apps/web/src/lib/data/crypto/registry.ts
websites: plaintext(['id', 'spaceId', 'slug', 'name', 'theme', 'navConfig',
'footerConfig', 'settings', 'publishedVersion',
'draftUpdatedAt', 'createdAt', 'updatedAt', 'deletedAt',
'userId']),
websitePages: plaintext(['id', 'siteId', 'path', 'title', 'seo', 'order',
'createdAt', 'updatedAt', 'deletedAt', 'userId']),
websiteBlocks: plaintext(['id', 'pageId', 'parentBlockId', 'slotKey', 'type',
'props', 'schemaVersion', 'order', 'createdAt',
'updatedAt', 'deletedAt', 'userId']),
websiteSubmissions: plaintext(['id', 'siteId', 'blockId', 'payload',
'targetModule', 'targetAction', 'targetRecordId',
'status', 'errorMessage', 'ip', 'userAgent',
'createdAt', 'userId']),
```
**Explizit plaintext** (nicht implizit leer), damit `pnpm run check:crypto` (die CI-Gate aus dem spaces-foundation-Sprint) den Ausschluss aktiv bestätigt.
## No-Legacy-Residues
Explizite Anti-Patterns, gegen die wir uns committen:
1. **Kein duales Rendering.** Es gibt **eine** Svelte-Komponente pro Block-Typ. Kein paralleles Astro oder React, keine "nur für Public"-Version. Wenn jemals statischer Build gewünscht ist, wird SvelteKit im prerender-Mode genutzt, nicht ein separates Build-Tool.
2. **Kein JSON-Blob-im-JSON-Blob.** Der Block-Baum ist als Zeilen in `website.blocks` gespeichert, nicht als `site.tree = {...massive nested JSON...}`. Das Published-Snapshot-Blob ist die einzige Denormalisierung und existiert nur lesbar.
3. **Keine eigene Tool-Registry für Website-AI.** Sobald `mana-tool-registry` steht, läuft alles darüber. Kein `packages/website-ai-tools` parallel zu `packages/mana-tool-registry`.
4. **Keine Direct-Writes in Zielmodule bei Form-Submissions.** Submit-Endpoint ruft Tool-Handler, niemals direkt `contactsTable.insert()`. Ein Code-Pfad.
5. **Kein shared-landing-ui-Fork.** Auch nicht "nur die Hero-Section kopieren, weil sie schon gut ist". Wir schreiben neu oder abstrahieren später bei M6-Konsolidierung.
6. **Keine Legacy-slug-Konflikte.** `reserved-slugs.ts` ist SSOT und wird in Migration-Script validiert: bei Migration werden alle existierenden `slug` gegen die Liste geprüft; fehlschlag → harter Exit. Wir haben noch nicht live, also null Konflikt erwartet.
7. **Kein "website_user_role" als Parallel-Permission-System.** Space-Membership ist die einzige Permission. Wenn feinere Rollen kommen, dann auf `space.memberships` — nicht als Duplikat in website.
8. **Kein impliziter "publish on save".** Draft und Published sind ausdrücklich getrennt. Publish ist ein expliziter User-Trigger. Kein Auto-Publish, keine "automatisch nach 5 Minuten publish"-Heuristik.
9. **Kein lokaler LiveQuery im Public-Renderer.** Der Public-Renderer nutzt ausschließlich Postgres. Dexie ist für den Editor, Ende.
10. **Keine `window.ANALYTICS.track(...)`-Einbau in Block-Renderer.** Analytics-Einbau (später M7) läuft über einen dedizierten `analytics`-Block oder via `settings.analytics``<script>`-Injection im Layout. Kein Pepper streuen.
11. **Keine Inline-CSS-Properties als Block-Props.** Kein `block.props.style = 'margin-top: 42px'`. Styling nur über Theme-Variables und ausgewählte Design-Tokens (`spacing: 'tight'|'normal'|'loose'`). Wenn Nutzer freies CSS braucht, ist das ein Pro-Feature in M7+.
12. **Keine Parallel-Slug-Resolution.** Eine einzige Funktion in `apps/api/src/modules/website/snapshots.ts` resolved `siteSlug → snapshot`. Nicht in SvelteKit-Hook plus Server-Route plus Cache-Layer. Ein Ort.
## Milestones
Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich, typechecked, `pnpm run validate:all` grün.
### M1 — Foundation (Schema + Editor-Skelett + 3 Blöcke)
- [ ] `packages/website-blocks/` scaffold: `registry.ts`, `BlockSpec`-Typ, Inspector-Autoform-Utility
- [ ] Drei Block-Typen: `hero`, `richText`, `spacer` — Schema + Component + Tests
- [ ] Dexie-Schema-Bump: `websites`, `websitePages`, `websiteBlocks`
- [ ] Drizzle-Schema: `website.sites`, `website.pages`, `website.blocks` (ohne snapshots, submissions)
- [ ] Encryption-Registry-Einträge (plaintext) + check:crypto grün
- [ ] `apps/mana/apps/web/src/lib/modules/website/` — module.config.ts, collections, queries, stores
- [ ] Route `/(app)/website`: Sites-Liste, Create-Site-Flow, Single-Page-Editor
- [ ] Editor-UI (drei-Pane): Seiten-Liste links, Preview Mitte, Inspector rechts
- [ ] Speichern zur Dexie → mana-sync → Postgres (kein Publish, kein Public-Route)
- [ ] `apps/api/src/modules/website/routes.ts` — Health-Endpoint + Validation-Stub
- [ ] App-Registry-Eintrag in `packages/shared-branding/src/mana-apps.ts`, Tier: `public`, Limits: 1 Site, 1 Page, 20 Blöcke
**Exit criteria:** Ein Nutzer legt eine Site an, fügt Hero + RichText + Spacer ein, Änderungen synchronisieren zu Postgres, beim Reload ist alles wieder da. Kein Public-Rendering.
### M2 — Publish + Public-Renderer + Mehrseitigkeit
- [ ] Drizzle-Schema: `website.published_snapshots`
- [ ] `apps/api/src/modules/website/publish.ts` — Publish-Endpoint:
- [ ] Baue Snapshot-Blob aus current draft
- [ ] Insert in `published_snapshots`, setze `is_current=TRUE` (old: FALSE), setze `sites.published_version`
- [ ] Cloudflare-Cache-Purge via API (Tag: `site-{id}`)
- [ ] `apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/` — Public-Render-Routen
- [ ] Cache-Header + Cache-Tag setzen
- [ ] Mehrseitigkeit: Page-Management im Editor (Add/Rename/Delete/Reorder), Nav-Config
- [ ] Limits-Enforcement in Stores (Pre-Check gegen Tier)
- [ ] `PublishBar.svelte` mit "Unveröffentlichte Änderungen"-Indikator
- [ ] Rollback-UI: letzte 10 Snapshots listen, ein Klick "auf diese Version zurück"
- [ ] Tests: Snapshot-Deterministik (zweimal publish → identisches Blob bei unverändertem Draft)
**Exit criteria:** Nutzer publisht. `https://<dev>/s/<slug>/` serviert die Site öffentlich, nicht authentifiziert. Rollback funktioniert.
### M3 — Block-Coverage-Expand (Layout + Media)
- [ ] Blöcke ergänzen: `image`, `gallery`, `cta`, `columns` (container), `faq`
- [ ] `columns`-Container: rendert Slots, Drop-Zone pro Slot im Edit-Mode
- [ ] Image-Upload via shared-uload-Integration im Inspector
- [ ] Gallery-Masonry mit Lightbox im Public-Mode
- [ ] Theme-System: `packages/website-blocks/src/themes/` — 3 Presets (classic, modern, warm)
- [ ] Theme-Preview + Farb-Customization im SiteSettingsDialog
- [ ] Tests pro Block: Schema-Validation, Component-Snapshot in allen drei Modes
**Exit criteria:** Ein Nutzer baut aus den jetzt 8 Block-Typen eine echte Landingpage, wählt ein Theme, publisht.
### M4 — Forms + moduleEmbed
- [ ] Drizzle-Schema: `website.submissions`
- [ ] `form`-Block: Field-Editor im Inspector (Name/Label/Type/Required), Target-Module-Picker
- [ ] Submit-Endpoint `POST /api/v1/website/sites/:id/submit/:blockId`:
- [ ] Rate-Limit via Hono-Middleware (10 req/min per IP)
- [ ] Schema-Validation gegen gespeicherten Block
- [ ] Call zu mana-tool-registry-Handler (Start-Implementation noch vor M5)
- [ ] Submission-Record + optionale E-Mail-Notification via mana-notify
- [ ] `moduleEmbed`-Block:
- [ ] Source-Provider-Interface
- [ ] Provider für `picture.board`, `library.entries`, `news.feed`
- [ ] Im Publish-Snapshot werden Provider aufgerufen und Ergebnis inlined (statischer Ansatz)
- [ ] Permission-Check: nur `isPublic` Content wird inlined
- [ ] Submissions-Liste im Editor (`/website/[id]/submissions`)
- [ ] Weitere Blöcke: `pricing`, `testimonials`, `team`, `contact` (konzeptuell: `form` + layout)
**Exit criteria:** Kontaktformular auf einer Site schreibt neuen `contacts`-Record beim Site-Owner. Picture-Board wird als Gallery auf der öffentlichen Seite angezeigt.
### M5 — AI-Tools + Templates
**Voraussetzung:** `mana-tool-registry` M1 aus mana-mcp-Plan hat gelandet.
- [ ] Website-Tools registrieren: `website.create_site`, `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`
- [ ] Policy-Hints: `create_site` propose, `add_block`/`update_block` propose, `publish` propose, `delete_*` destructive (nicht MCP-exponiert)
- [ ] Starter-Templates (6 Stück, siehe Komponente 5) als JSON in `templates/`
- [ ] TemplatePicker im `/website/new`-Flow
- [ ] AI-Proposal-Inbox für Website-Änderungen (`<AiProposalInbox module="website" />`)
- [ ] Integrations-Test: AI-Mission "Baue mir eine Portfolio-Seite aus meinen 3 besten Boards" produziert Proposals, User approves, Site landet in Editor
**Exit criteria:** Template-Flow + AI-Mission-Flow produziert brauchbare Sites.
### M6 — Subdomain-Publishing + Custom-Domain-Foundation
- [ ] SvelteKit-Hook `hooks.server.ts`: Host-Header → rewrite `{slug}.mana.how``/s/{slug}/…`
- [ ] Wildcard-DNS + TLS-Check im Staging
- [ ] Custom-Domain-Schema: `website.custom_domains { site_id, hostname, status, tls_status, verified_at }`
- [ ] DNS-Verify-Flow: CNAME-Record auf `custom.mana.how`, TXT-Record mit Challenge
- [ ] Cloudflare-SaaS-Hostname-Integration (API-Call bei Verify-Success)
- [ ] Tier-Gate: Custom-Domain nur für `founder`
- [ ] `mana-landing-builder` Konsolidierungs-Entscheidung:
- [ ] Untersuchen: kann Org-Landing-Page als spezial `spaceKind='organization'`-Site im neuen System leben?
- [ ] Wenn ja: Org-Landing-Pages migrieren, `mana-landing-builder` → deprecation note, löschen nach Datenmigration
- [ ] Wenn nein: Gründe dokumentieren, beide Systeme parallel halten
**Exit criteria:** `{slug}.mana.how` funktioniert. Founder-User kann eigene Domain verbinden.
### M7 — Observability, GC, Analytics
- [ ] Prometheus-Metrics: `website_publish_total`, `website_submissions_total`, `website_render_duration_seconds`, `website_cache_hit_ratio`
- [ ] Orphan-Asset-GC: Job findet uload-Assets, die in keinem Block mehr referenziert sind, löscht nach 30d Grace-Period
- [ ] `analytics`-Block-Typ: Plausible/Simple Analytics Snippet als Opt-In
- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten)
- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen (behält nur IDs + Status für Audit)
- [ ] Dashboards in `docs/observability/website.md`
**Exit criteria:** Betrieb ist beobachtbar, Storage wächst nicht unbegrenzt.
## Risiken + Mitigation
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|--------|-----|--------|------------|
| Block-Schema-Migration bei Version-Bump ist schmerzhaft | Mittel | Mittel | `schemaVersion` + `upgraders`-Map pro Block, Migrations-Utility im Build zwingt forward-migration; Tests pro Upgrade |
| Published-Snapshot-Blob wird zu groß (Performance) | Mittel | Mittel | Hart-Limit auf Gesamtgröße (5MB), UI warnt bei 80%; `moduleEmbed` dynamisch als Fallback bei großen Galleries |
| Fractional Index läuft in Precision-Probleme bei häufigem Reorder | Niedrig | Mittel | Nach 1000 Reorders pro Page automatischer Rebalance-Job; Library wie `fractional-indexing` mit `jitter` |
| CF-Cache-Purge-API down → stale Inhalte | Niedrig | Niedrig | Bei Purge-Fehler: Fallback auf `max-age=60`, Nutzer sieht neue Version in ≤1min; Alert bei wiederholtem Fehler |
| Form-Spam auf Submit-Endpoint | Hoch | Mittel | Rate-Limit per IP, Honeypot-Feld pro Form, Captcha-Block-Typ in M7 |
| Reserved-Slug-Liste wird erweitert, alte Sites brechen | Niedrig (pre-launch) | Hoch (post-launch) | Pre-launch: wir haben null Konflikt. Post-launch: neue Reserved-Slugs nur mit Migration + Slug-Rewrite für Betroffene |
| moduleEmbed-Provider sharen versehentlich private Daten | Mittel | Hoch | Hart kodierter `isPublic`-Check pro Provider, Integrations-Test: Embed-Provider mit `isPublic=false` returnt null |
| Editor-Co-Editing zwei Member → Konflikte | Mittel | Niedrig | mana-sync field-level LWW löst es pro Feld; bei Baum-Konflikten (beide adden Block an gleicher Position) → fractional index bricht Tie |
| Svelte 5 Block-Components leaken Renderer-State zwischen `mode`-Wechsel | Niedrig | Niedrig | Unit-Tests pro Block mit allen drei Modes; `$effect` cleanup disziplinieren |
| AI generiert Block-Props, die Schema-Validation brechen | Mittel | Niedrig | Tool-Handler validiert vor Apply; fehlende Required-Fields → Proposal mit Warnung, User kann ergänzen |
## Offene Entscheidungen (später)
- **I18n pro Site:** Mehrsprachige Sites (DE/EN/IT)? Könnte über `pages.path` mit Locale-Prefix gemacht werden (`/de/about` vs. `/en/about`). Nicht in Scope M1M5; Entscheidung in M6-Zeitraum mit realer Nutzer-Nachfrage.
- **Custom-Code-Blöcke:** `<script>`-Injection oder `<iframe>`-Embed wäre mächtig, aber security-relevant (XSS). Wenn, dann nur für `founder` und sandboxed. Frühestens M7.
- **Versionierung über Publish hinaus:** Full Version-History (wie Git) pro Site? Derzeit nur "letzte 10 Snapshots". Wenn Kunden fragen, ausbauen.
- **A/B-Testing:** Zwei Varianten einer Page gegen ein Traffic-Split testen. Nice-to-have, aber erst wenn Analytics steht.
- **Staging-Deploy pro Site:** Eine Staging-URL (`/s/{slug}/preview?token=…`) zum Teilen vor Publish. Könnte Hand-in-Hand mit Collaboration (Member commentiert "das Hero sieht schief aus") kommen.
- **Export:** "Export als statische HTML/CSS/JS"-Download — sinnvoll als Lock-In-Gegenmittel, nicht trivial (alle Assets + Routen + Forms). Eher nein im MVP.
- **Konsolidierung mit `mana-landing-builder`:** Entscheidung in M6 wie oben. Solange parallel, beide als bewusst getrennte Systeme dokumentieren.
## Referenzen
- [`docs/plans/spaces-foundation.md`](spaces-foundation.md) — Space-Scoping, Membership-Model
- [`docs/plans/space-scoped-data-model.md`](space-scoped-data-model.md) — space-scoped RLS-Pattern
- [`docs/plans/mana-mcp-and-personas.md`](mana-mcp-and-personas.md) — `packages/mana-tool-registry` als SSOT für AI/MCP-Tools
- [`docs/plans/library-module.md`](library-module.md) — Module-Pattern-Beispiel für diskriminierte Typen
- [`apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`](../../apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md) — Sync-Engine-Deep-Dive
- [`.claude/guidelines/sveltekit-web.md`](../../.claude/guidelines/sveltekit-web.md) — Svelte 5 Runes Conventions
- [`.claude/guidelines/database.md`](../../.claude/guidelines/database.md) — pgSchema-Pattern, Drizzle
## Shipping Log
(Leer — wird befüllt, während M1 → M7 gehen.)
| Phase | Purpose | Commit |
| --- | --- | --- |
| — | — | — |

View file

@ -260,6 +260,12 @@ export const APP_ICONS = {
// while still reading as "chronological" in the AI Workbench family. // while still reading as "chronological" in the AI Workbench family.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="tl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#ea580c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#tl)"/><line x1="34" y1="22" x2="34" y2="78" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.55"/><circle cx="34" cy="30" r="5" fill="white"/><circle cx="34" cy="50" r="5" fill="white"/><circle cx="34" cy="70" r="5" fill="white"/><rect x="44" y="26" width="32" height="8" rx="2" fill="white" fill-opacity="0.95"/><rect x="44" y="46" width="26" height="8" rx="2" fill="white" fill-opacity="0.8"/><rect x="44" y="66" width="30" height="8" rx="2" fill="white" fill-opacity="0.9"/></svg>` `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="tl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#ea580c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#tl)"/><line x1="34" y1="22" x2="34" y2="78" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.55"/><circle cx="34" cy="30" r="5" fill="white"/><circle cx="34" cy="50" r="5" fill="white"/><circle cx="34" cy="70" r="5" fill="white"/><rect x="44" y="26" width="32" height="8" rx="2" fill="white" fill-opacity="0.95"/><rect x="44" y="46" width="26" height="8" rx="2" fill="white" fill-opacity="0.8"/><rect x="44" y="66" width="30" height="8" rx="2" fill="white" fill-opacity="0.9"/></svg>`
), ),
website: svgToDataUrl(
// Browser window with three stacked content blocks — the "Baukasten"
// theme. Indigo→violet gradient, sibling to broadcast (communication
// family) but leaning more towards the creative-publishing cluster.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#8b5cf6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wb)"/><rect x="18" y="22" width="64" height="56" rx="5" fill="white"/><rect x="18" y="22" width="64" height="10" rx="5" fill="white" fill-opacity="0.75"/><circle cx="25" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><circle cx="31" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><circle cx="37" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><rect x="24" y="38" width="52" height="12" rx="2" fill="#6366f1" fill-opacity="0.85"/><rect x="24" y="54" width="24" height="18" rx="2" fill="#6366f1" fill-opacity="0.35"/><rect x="52" y="54" width="24" height="8" rx="2" fill="#6366f1" fill-opacity="0.55"/><rect x="52" y="64" width="24" height="8" rx="2" fill="#6366f1" fill-opacity="0.45"/></svg>`
),
spaces: svgToDataUrl( spaces: svgToDataUrl(
// Three people-silhouettes clustered in the tile — the Spaces primitive // Three people-silhouettes clustered in the tile — the Spaces primitive
// is about shared workspaces, so the icon emphasises "group". Teal→indigo // is about shared workspaces, so the icon emphasises "group". Teal→indigo

View file

@ -1105,6 +1105,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta', status: 'beta',
requiredTier: 'beta', requiredTier: 'beta',
}, },
{
id: 'website',
name: 'Website',
description: {
de: 'Baukasten für deine Website',
en: 'Website builder',
},
longDescription: {
de: 'Mehrseitige Websites mit einem Block-Editor bauen — Hero, Text, Galerien, Formulare — und Inhalte aus anderen Mana-Modulen (Picture-Boards, Bibliothek, News) direkt einbetten. Für Privat (Portfolio, Event-Seite) und für Firmen (Space mit mehreren Membern).',
en: 'Build multi-page websites with a block editor — hero, text, galleries, forms — and embed content from your other Mana modules (picture boards, library, news) directly. For personal use (portfolio, event page) and for organisations (shared space with multiple members).',
},
icon: APP_ICONS.website,
color: '#6366f1',
comingSoon: false,
status: 'development',
requiredTier: 'public',
},
{ {
id: 'spaces', id: 'spaces',
name: 'Spaces', name: 'Spaces',

View file

@ -72,6 +72,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'storage', 'storage',
'uload', 'uload',
'landing', // future 'landing', // future
'website',
'presi', 'presi',
'cards', 'cards',
'picture', 'picture',
@ -104,6 +105,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'invoices', 'invoices',
'finance', 'finance',
'landing', // future — Paket C (Vereinswebsite) 'landing', // future — Paket C (Vereinswebsite)
'website',
'presi', 'presi',
'cards', 'cards',
'quotes', 'quotes',
@ -123,6 +125,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'mail', 'mail',
'storage', 'storage',
'uload', 'uload',
'website',
'recipes', 'recipes',
'food', 'food',
'places', 'places',
@ -146,6 +149,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'storage', 'storage',
'mail', 'mail',
'uload', 'uload',
'website',
'news', 'news',
'news-research', 'news-research',
'research-lab', 'research-lab',
@ -169,6 +173,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'storage', 'storage',
'mail', 'mail',
'uload', 'uload',
'website',
'invoices', 'invoices',
'finance', 'finance',
'times', 'times',

View file

@ -0,0 +1,44 @@
{
"name": "@mana/website-blocks",
"version": "1.0.0",
"private": true,
"description": "Block-tree primitives for the Mana website builder — Svelte components + Zod schemas, mode-aware rendering for editor/preview/public.",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./registry": {
"svelte": "./src/registry.ts",
"types": "./src/registry.ts",
"default": "./src/registry.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
}
},
"files": [
"src"
],
"scripts": {
"lint": "eslint .",
"test": "vitest run"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.16.0",
"typescript": "^5.7.3",
"vitest": "^2.0.0"
},
"dependencies": {
"zod": "^3.23.8"
}
}

View file

@ -0,0 +1,105 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { HeroProps } from './schema';
let { block, mode }: BlockRenderProps<HeroProps> = $props();
const isEdit = $derived(mode === 'edit');
</script>
<section
class="wb-hero"
class:wb-hero--left={block.props.align === 'left'}
class:wb-hero--center={block.props.align === 'center'}
class:wb-hero--bg-subtle={block.props.background === 'subtle'}
class:wb-hero--bg-gradient={block.props.background === 'gradient'}
data-mode={mode}
>
<div class="wb-hero__inner">
{#if block.props.eyebrow}
<p class="wb-hero__eyebrow">{block.props.eyebrow}</p>
{:else if isEdit}
<p class="wb-hero__eyebrow wb-placeholder">Eyebrow (optional)</p>
{/if}
<h1 class="wb-hero__title">
{block.props.title || (isEdit ? 'Klick in den Inspector, um den Titel zu setzen' : '')}
</h1>
{#if block.props.subtitle}
<p class="wb-hero__subtitle">{block.props.subtitle}</p>
{:else if isEdit}
<p class="wb-hero__subtitle wb-placeholder">Untertitel (optional)</p>
{/if}
{#if block.props.ctaLabel && block.props.ctaHref}
<a class="wb-hero__cta" href={block.props.ctaHref}>{block.props.ctaLabel}</a>
{:else if isEdit && (block.props.ctaLabel || block.props.ctaHref)}
<span class="wb-hero__cta wb-placeholder" aria-disabled="true">
{block.props.ctaLabel || 'Call-to-Action Label fehlt'}
</span>
{/if}
</div>
</section>
<style>
.wb-hero {
padding: 4rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-hero--bg-subtle {
background: rgba(255, 255, 255, 0.04);
}
.wb-hero--bg-gradient {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.18), rgba(168, 85, 247, 0.18));
}
.wb-hero__inner {
max-width: 64rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-hero--center .wb-hero__inner {
align-items: center;
text-align: center;
}
.wb-hero--left .wb-hero__inner {
align-items: flex-start;
text-align: left;
}
.wb-hero__eyebrow {
font-size: 0.875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
margin: 0;
}
.wb-hero__title {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.1;
margin: 0;
}
.wb-hero__subtitle {
font-size: 1.125rem;
line-height: 1.5;
opacity: 0.8;
max-width: 48rem;
margin: 0;
}
.wb-hero__cta {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
background: rgba(99, 102, 241, 0.9);
color: white;
text-decoration: none;
font-weight: 500;
margin-top: 0.5rem;
}
.wb-placeholder {
opacity: 0.35;
font-style: italic;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { HeroProps } from './schema';
let { block, onChange }: BlockInspectorProps<HeroProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Eyebrow</span>
<input
type="text"
value={block.props.eyebrow}
oninput={(e) => onChange({ eyebrow: e.currentTarget.value })}
placeholder="Optional — kleiner Text über dem Titel"
/>
</label>
<label class="wb-field">
<span>Titel *</span>
<input
type="text"
value={block.props.title}
oninput={(e) => onChange({ title: e.currentTarget.value })}
required
/>
</label>
<label class="wb-field">
<span>Untertitel</span>
<textarea
rows="3"
value={block.props.subtitle}
oninput={(e) => onChange({ subtitle: e.currentTarget.value })}
></textarea>
</label>
<div class="wb-row">
<label class="wb-field">
<span>CTA Label</span>
<input
type="text"
value={block.props.ctaLabel}
oninput={(e) => onChange({ ctaLabel: e.currentTarget.value })}
placeholder="z.B. Los geht's"
/>
</label>
<label class="wb-field">
<span>CTA Link</span>
<input
type="url"
value={block.props.ctaHref}
oninput={(e) => onChange({ ctaHref: e.currentTarget.value })}
placeholder="https://…"
/>
</label>
</div>
<div class="wb-row">
<label class="wb-field">
<span>Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as HeroProps['align'] })}
>
<option value="center">Zentriert</option>
<option value="left">Linksbündig</option>
</select>
</label>
<label class="wb-field">
<span>Hintergrund</span>
<select
value={block.props.background}
onchange={(e) => onChange({ background: e.currentTarget.value as HeroProps['background'] })}
>
<option value="none">Kein</option>
<option value="subtle">Dezent</option>
<option value="gradient">Gradient</option>
</select>
</label>
</div>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field input,
.wb-field textarea,
.wb-field select {
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 textarea {
resize: vertical;
min-height: 4.5rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Hero from './Hero.svelte';
import HeroInspector from './HeroInspector.svelte';
import { HeroSchema, HERO_DEFAULTS, type HeroProps } from './schema';
export const heroBlockSpec: BlockSpec<HeroProps> = {
type: 'hero',
label: 'Hero',
icon: 'heading',
category: 'content',
schema: HeroSchema,
schemaVersion: 1,
defaults: HERO_DEFAULTS,
Component: Hero,
Inspector: HeroInspector,
};
export type { HeroProps };
export { HeroSchema, HERO_DEFAULTS };

View file

@ -0,0 +1,23 @@
import { z } from 'zod';
export const HeroSchema = z.object({
eyebrow: z.string().max(120).default(''),
title: z.string().min(1).max(240),
subtitle: z.string().max(480).default(''),
ctaLabel: z.string().max(60).default(''),
ctaHref: z.string().max(512).default(''),
align: z.enum(['left', 'center']).default('center'),
background: z.enum(['none', 'subtle', 'gradient']).default('subtle'),
});
export type HeroProps = z.infer<typeof HeroSchema>;
export const HERO_DEFAULTS: HeroProps = {
eyebrow: '',
title: 'Dein Titel',
subtitle: 'Eine kurze Beschreibung — was macht diese Seite relevant?',
ctaLabel: '',
ctaHref: '',
align: 'center',
background: 'subtle',
};

View file

@ -0,0 +1,28 @@
export type {
Block,
BlockMode,
BlockCategory,
BlockRenderProps,
BlockInspectorProps,
BlockSpec,
PropsOf,
InferProps,
} from './types';
export {
BLOCK_SPECS,
getBlockSpec,
requireBlockSpec,
getAllBlockSpecs,
validateBlockProps,
safeValidateBlockProps,
} from './registry';
export { heroBlockSpec, HeroSchema, HERO_DEFAULTS, type HeroProps } from './hero';
export {
richTextBlockSpec,
RichTextSchema,
RICH_TEXT_DEFAULTS,
type RichTextProps,
} from './richText';
export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer';

View file

@ -0,0 +1,68 @@
import type { BlockSpec } from './types';
import { heroBlockSpec } from './hero';
import { richTextBlockSpec } from './richText';
import { spacerBlockSpec } from './spacer';
/**
* The block registry single source of truth for every block type the
* website builder knows about. Editor insert palette, renderer, inspector,
* schema validation, and future AI tools all consume this map.
*
* Adding a new block = create a folder under `src/{type}/`, export a
* `BlockSpec` from its index, and list it here.
*/
export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
heroBlockSpec,
richTextBlockSpec,
spacerBlockSpec,
] as unknown as readonly BlockSpec<unknown>[];
const BY_TYPE: Record<string, BlockSpec<unknown>> = (() => {
const map: Record<string, BlockSpec<unknown>> = {};
for (const spec of BLOCK_SPECS) {
if (map[spec.type]) {
throw new Error(`[website-blocks] duplicate block type "${spec.type}"`);
}
map[spec.type] = spec as BlockSpec<unknown>;
}
return map;
})();
export function getBlockSpec(type: string): BlockSpec<unknown> | undefined {
return BY_TYPE[type];
}
export function requireBlockSpec(type: string): BlockSpec<unknown> {
const spec = BY_TYPE[type];
if (!spec) throw new Error(`[website-blocks] unknown block type "${type}"`);
return spec;
}
export function getAllBlockSpecs(): readonly BlockSpec<unknown>[] {
return BLOCK_SPECS;
}
/**
* Validate props against a block type's schema. Returns the parsed props
* (with defaults applied) on success, or throws with the Zod error.
*/
export function validateBlockProps(type: string, props: unknown): unknown {
const spec = requireBlockSpec(type);
return spec.schema.parse(props);
}
/**
* Safe-validate: returns `{ success, data, error }` without throwing.
* Used at boundaries (submit endpoint, snapshot builder) where we want
* to collect all errors rather than fail on the first one.
*/
export function safeValidateBlockProps(
type: string,
props: unknown
): { success: true; data: unknown } | { success: false; error: unknown } {
const spec = getBlockSpec(type);
if (!spec) return { success: false, error: new Error(`Unknown block type "${type}"`) };
const parsed = spec.schema.safeParse(props);
if (parsed.success) return { success: true, data: parsed.data };
return { success: false, error: parsed.error };
}

View file

@ -0,0 +1,73 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { RichTextProps } from './schema';
let { block, mode }: BlockRenderProps<RichTextProps> = $props();
const paragraphs = $derived(
block.props.content
.split(/\n{2,}/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
);
const isEdit = $derived(mode === 'edit');
</script>
<section
class="wb-richtext"
class:wb-richtext--left={block.props.align === 'left'}
class:wb-richtext--center={block.props.align === 'center'}
class:wb-richtext--sm={block.props.size === 'sm'}
class:wb-richtext--md={block.props.size === 'md'}
class:wb-richtext--lg={block.props.size === 'lg'}
data-mode={mode}
>
<div class="wb-richtext__inner">
{#if paragraphs.length === 0 && isEdit}
<p class="wb-placeholder">Leerer Text — öffne den Inspector und fang an zu schreiben.</p>
{:else}
{#each paragraphs as paragraph, i (i)}
<p>{paragraph}</p>
{/each}
{/if}
</div>
</section>
<style>
.wb-richtext {
padding: 2rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-richtext__inner {
max-width: 48rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-richtext--left .wb-richtext__inner {
text-align: left;
}
.wb-richtext--center .wb-richtext__inner {
text-align: center;
}
.wb-richtext p {
margin: 0;
line-height: 1.65;
}
.wb-richtext--sm p {
font-size: 0.9375rem;
}
.wb-richtext--md p {
font-size: 1.0625rem;
}
.wb-richtext--lg p {
font-size: 1.25rem;
}
.wb-placeholder {
opacity: 0.35;
font-style: italic;
}
</style>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { RichTextProps } from './schema';
let { block, onChange }: BlockInspectorProps<RichTextProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Text</span>
<textarea
rows="10"
value={block.props.content}
oninput={(e) => onChange({ content: e.currentTarget.value })}
placeholder="Leere Zeile = neuer Absatz. Markdown folgt in M3."
></textarea>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as RichTextProps['align'] })}
>
<option value="left">Linksbündig</option>
<option value="center">Zentriert</option>
</select>
</label>
<label class="wb-field">
<span>Schriftgröße</span>
<select
value={block.props.size}
onchange={(e) => onChange({ size: e.currentTarget.value as RichTextProps['size'] })}
>
<option value="sm">Klein</option>
<option value="md">Normal</option>
<option value="lg">Groß</option>
</select>
</label>
</div>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field textarea,
.wb-field select {
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 textarea {
resize: vertical;
min-height: 6rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import RichText from './RichText.svelte';
import RichTextInspector from './RichTextInspector.svelte';
import { RichTextSchema, RICH_TEXT_DEFAULTS, type RichTextProps } from './schema';
export const richTextBlockSpec: BlockSpec<RichTextProps> = {
type: 'richText',
label: 'Text',
icon: 'text',
category: 'content',
schema: RichTextSchema,
schemaVersion: 1,
defaults: RICH_TEXT_DEFAULTS,
Component: RichText,
Inspector: RichTextInspector,
};
export type { RichTextProps };
export { RichTextSchema, RICH_TEXT_DEFAULTS };

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
export const RichTextSchema = z.object({
content: z.string().max(10_000).default(''),
align: z.enum(['left', 'center']).default('left'),
size: z.enum(['sm', 'md', 'lg']).default('md'),
});
export type RichTextProps = z.infer<typeof RichTextSchema>;
export const RICH_TEXT_DEFAULTS: RichTextProps = {
content: '',
align: 'left',
size: 'md',
};

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { SpacerProps } from './schema';
let { block, mode }: BlockRenderProps<SpacerProps> = $props();
const isEdit = $derived(mode === 'edit');
</script>
<div
class="wb-spacer"
class:wb-spacer--sm={block.props.size === 'sm'}
class:wb-spacer--md={block.props.size === 'md'}
class:wb-spacer--lg={block.props.size === 'lg'}
class:wb-spacer--xl={block.props.size === 'xl'}
data-mode={mode}
>
{#if isEdit}
<span class="wb-spacer__label">Spacer ({block.props.size})</span>
{/if}
</div>
<style>
.wb-spacer {
display: flex;
align-items: center;
justify-content: center;
}
.wb-spacer--sm {
height: 1.5rem;
}
.wb-spacer--md {
height: 3rem;
}
.wb-spacer--lg {
height: 6rem;
}
.wb-spacer--xl {
height: 9rem;
}
.wb-spacer[data-mode='edit'] {
border: 1px dashed rgba(255, 255, 255, 0.1);
}
.wb-spacer__label {
font-size: 0.75rem;
opacity: 0.35;
letter-spacing: 0.05em;
text-transform: uppercase;
}
</style>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { SpacerProps } from './schema';
let { block, onChange }: BlockInspectorProps<SpacerProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Größe</span>
<select
value={block.props.size}
onchange={(e) => onChange({ size: e.currentTarget.value as SpacerProps['size'] })}
>
<option value="sm">Klein (1.5rem)</option>
<option value="md">Mittel (3rem)</option>
<option value="lg">Groß (6rem)</option>
<option value="xl">Sehr groß (9rem)</option>
</select>
</label>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field select {
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;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import Spacer from './Spacer.svelte';
import SpacerInspector from './SpacerInspector.svelte';
import { SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './schema';
export const spacerBlockSpec: BlockSpec<SpacerProps> = {
type: 'spacer',
label: 'Abstand',
icon: 'separator',
category: 'layout',
schema: SpacerSchema,
schemaVersion: 1,
defaults: SPACER_DEFAULTS,
Component: Spacer,
Inspector: SpacerInspector,
};
export type { SpacerProps };
export { SpacerSchema, SPACER_DEFAULTS };

View file

@ -0,0 +1,11 @@
import { z } from 'zod';
export const SpacerSchema = z.object({
size: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
});
export type SpacerProps = z.infer<typeof SpacerSchema>;
export const SPACER_DEFAULTS: SpacerProps = {
size: 'md',
};

View file

@ -0,0 +1,92 @@
import type { Component } from 'svelte';
import type { ZodTypeAny, z } from 'zod';
/**
* Render modes for every block component.
*
* - `edit` Inside the editor. Shows inline-edit affordances (e.g. click
* a Hero title to edit it), may render placeholder copy for
* empty required fields.
* - `preview` Editor preview pane. Same rendering as `public` but inside
* the editor chrome (responsive preview, breakpoint switcher).
* - `public` Served to real visitors via SvelteKit SSR. No edit chrome,
* no placeholders only real data. This is the mode the
* published_snapshots blob is serialized for.
*/
export type BlockMode = 'edit' | 'preview' | 'public';
/**
* A single block in the tree. Props are block-type-specific and validated
* against the registered Zod schema at write time (in stores) and at
* publish time (in the snapshot builder).
*/
export interface Block<Props = unknown> {
id: string;
type: string;
props: Props;
schemaVersion: number;
order: number;
parentBlockId: string | null;
slotKey: string | null;
}
/**
* Category for grouping blocks in the insert palette.
*/
export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed';
/**
* Props passed to every block renderer. `onEdit` is only present in
* `edit` mode consumers must guard with `if (mode === 'edit' && onEdit)`.
*/
export interface BlockRenderProps<Props = unknown> {
block: Block<Props>;
mode: BlockMode;
children?: Block[];
onEdit?: (patch: Partial<Props>) => void;
}
/**
* Props passed to every block inspector (right pane of the editor).
*/
export interface BlockInspectorProps<Props = unknown> {
block: Block<Props>;
onChange: (patch: Partial<Props>) => void;
}
/**
* Registered spec for one block type. The schema, renderer, inspector,
* and metadata are bundled the editor and public renderer consume the
* same spec, so drift is structurally impossible.
*/
export interface BlockSpec<Props = unknown> {
/** Stable type id, used in DB (`blocks.type`) and in code. */
type: string;
/** Human label shown in the insert palette. */
label: string;
/** Lucide icon name (or any icon id the editor knows how to render). */
icon: string;
/** Category for palette grouping. */
category: BlockCategory;
/** Zod schema defining valid props. Enforced at write + publish time. */
schema: ZodTypeAny;
/** Current schema version. Bump when the schema shape changes. */
schemaVersion: number;
/** Default prop values when a new block of this type is inserted. */
defaults: Props;
/** Svelte 5 component rendering the block in all three modes. */
Component: Component<BlockRenderProps<Props>>;
/** Svelte 5 component rendering the inspector form for this block. */
Inspector: Component<BlockInspectorProps<Props>>;
/**
* Optional upgraders: version N version N+1 prop transformer.
* Keyed by the SOURCE version (v1 v2 upgrader lives under key `1`).
*/
upgraders?: Record<number, (oldProps: unknown) => Props>;
}
/** Helper to infer props type from a spec's schema. */
export type PropsOf<Spec extends BlockSpec<unknown>> = Spec extends BlockSpec<infer P> ? P : never;
/** Helper to infer props type from a Zod schema. */
export type InferProps<S extends ZodTypeAny> = z.infer<S>;

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

713
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff