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.
*
* Most modules in apps/api inline their schemas in routes.ts and create
* tables out-of-band (or piggyback on schemas owned by other services).
* This config currently only manages the `research` schema introduced for
* the deep-research feature; expand the `schema` glob and `schemaFilter`
* as more modules adopt managed migrations.
* Managed schemas accumulate as modules adopt managed migrations. Each
* schema's generated SQL lives under `drizzle/{schema}/`. Expand the
* `schema` array and `schemaFilter` when a new module joins.
*
* Currently managed: `research`, `website`.
*/
export default defineConfig({
schema: './src/modules/research/schema.ts',
out: './drizzle/research',
schema: ['./src/modules/research/schema.ts', './src/modules/website/schema.ts'],
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
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 { whoRoutes } from './modules/who/routes';
import { websiteRoutes } from './modules/website/routes';
import { websitePublicRoutes } from './modules/website/public-routes';
import { wetterRoutes } from './modules/wetter/routes';
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.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/website/public', websitePublicRoutes);
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/spiral-db": "workspace:*",
"@mana/wallpaper-generator": "workspace:*",
"@mana/website-blocks": "workspace:*",
"@quotes/content": "workspace:*",
"@tiptap/core": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",

View file

@ -77,6 +77,7 @@ import {
ArrowClockwise,
Flask,
Exam,
Globe,
} from '@mana/shared-icons';
// ── 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({
id: 'quiz',
name: 'Quiz',

View file

@ -40,6 +40,7 @@ import { getAgent } from '../agents/store';
import { DEFAULT_AGENT_NAME } from '../agents/types';
import type { Mission, MissionIteration, PlanStep } from './types';
import {
AI_TOOL_CATALOG_BY_NAME,
buildSystemPrompt,
runPlannerLoop,
runPrePlanGuardrails,
@ -266,6 +267,12 @@ async function runMissionInner(
tools: availableTools,
model: deps.model ?? 'google/gemini-2.5-flash',
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> => {
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 { broadcastModuleConfig } from '$lib/modules/broadcast/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';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -162,6 +163,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
invoicesModuleConfig,
broadcastModuleConfig,
wetterModuleConfig,
websiteModuleConfig,
aiModuleConfig,
];

View file

@ -14,6 +14,7 @@
import {
runPlannerLoop,
AI_TOOL_CATALOG,
AI_TOOL_CATALOG_BY_NAME,
type ChatMessage,
type ToolCallRequest,
type ToolResult,
@ -104,6 +105,11 @@ export async function runCompanionChat(
model: 'google/gemini-2.5-flash',
maxRounds: MAX_TOOL_ROUNDS,
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> => {
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}