mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
a64a7e39cf
commit
54a12ffd5c
59 changed files with 5629 additions and 218 deletions
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
38
apps/api/drizzle/website/0000_init.sql
Normal file
38
apps/api/drizzle/website/0000_init.sql
Normal 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;
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
55
apps/api/src/modules/website/public-routes.ts
Normal file
55
apps/api/src/modules/website/public-routes.ts
Normal 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;
|
||||
246
apps/api/src/modules/website/publish.ts
Normal file
246
apps/api/src/modules/website/publish.ts
Normal 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;
|
||||
42
apps/api/src/modules/website/reserved-slugs.ts
Normal file
42
apps/api/src/modules/website/reserved-slugs.ts
Normal 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;
|
||||
}
|
||||
53
apps/api/src/modules/website/routes.ts
Normal file
53
apps/api/src/modules/website/routes.ts
Normal 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;
|
||||
69
apps/api/src/modules/website/schema.ts
Normal file
69
apps/api/src/modules/website/schema.ts
Normal 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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
393
apps/mana/apps/web/src/lib/modules/website/ListView.svelte
Normal file
393
apps/mana/apps/web/src/lib/modules/website/ListView.svelte
Normal 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"
|
||||
>2–40 Kleinbuchstaben/Zahlen/Bindestrich. Reservierte Slugs wie "api", "app" sind gesperrt.</small
|
||||
>
|
||||
</label>
|
||||
|
||||
{#if createError}
|
||||
<p class="wb-error">{createError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="wb-modal__actions">
|
||||
<button class="wb-btn wb-btn--ghost" onclick={closeCreate} disabled={creating}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button class="wb-btn wb-btn--primary" onclick={submit} disabled={creating}>
|
||||
{creating ? 'Wird erstellt…' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wb-list {
|
||||
padding: 1.5rem;
|
||||
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>
|
||||
|
|
@ -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');
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
64
apps/mana/apps/web/src/lib/modules/website/constants.ts
Normal file
64
apps/mana/apps/web/src/lib/modules/website/constants.ts
Normal 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;
|
||||
53
apps/mana/apps/web/src/lib/modules/website/index.ts
Normal file
53
apps/mana/apps/web/src/lib/modules/website/index.ts
Normal 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';
|
||||
|
|
@ -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' }],
|
||||
};
|
||||
305
apps/mana/apps/web/src/lib/modules/website/publish.ts
Normal file
305
apps/mana/apps/web/src/lib/modules/website/publish.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
140
apps/mana/apps/web/src/lib/modules/website/queries.ts
Normal file
140
apps/mana/apps/web/src/lib/modules/website/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
145
apps/mana/apps/web/src/lib/modules/website/types.ts
Normal file
145
apps/mana/apps/web/src/lib/modules/website/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
12
apps/mana/apps/web/src/routes/(app)/website/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/website/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
53
apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.server.ts
Normal file
53
apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
156
apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.svelte
Normal file
156
apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.svelte
Normal 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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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}
|
||||
776
docs/plans/website-builder.md
Normal file
776
docs/plans/website-builder.md
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
# Website Builder — Block-Tree CMS für Privat + Firma
|
||||
|
||||
_Started 2026-04-23._
|
||||
|
||||
Ein Modul `website`, mit dem Nutzer (privat) und Firmen (Space mit mehreren Mitgliedern) mehrseitige Websites bauen, live bearbeiten und unter `mana.how`-Domains veröffentlichen. Kein Drag-Drop-Canvas wie Framer/Webflow, sondern ein **Block-Baum-Editor** mit Zod-validierten Block-Typen — dieselben Svelte-Komponenten rendern im Editor, in der Live-Preview und im öffentlichen Seitenaufruf. Content aus anderen Mana-Modulen (picture, library, news, …) wird per `moduleEmbed`-Block direkt eingebettet.
|
||||
|
||||
Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Zielzustand direkt, keine Legacy-Reste.
|
||||
|
||||
## Ziel in einem Satz
|
||||
|
||||
Jeder Mana-Nutzer (oder jede Firma via Space) kann eine vollständige Website mit beliebig vielen Seiten über einen Block-Baum-Editor bauen, Daten aus seinen Mana-Modulen einbetten, und unter `/s/{slug}`, später `{slug}.mana.how` oder einer Custom-Domain veröffentlichen — mit SSR-Rendering aus denselben Svelte-Komponenten, ohne separaten Astro-Build-Pfad.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
- **Kein Free-Form Canvas.** Keine absolute Positionierung, keine Pixel-CSS. Layout über Block-Typen, Theme-Variablen und wenige Container (`columns`, `rows`, `spacer`).
|
||||
- **Kein dualer Renderer.** Keine Svelte-Komponenten **und** Astro-Komponenten für dieselben Blöcke. Der öffentliche Renderer ist SvelteKit-SSR, der Editor rendert dieselben Components.
|
||||
- **Keine Admin-UI-Nutzung des bestehenden `mana-landing-builder` Services** für User-Sites. Der Service bleibt für Org-Landing-Pages (andere Code-Pfade, andere Zielgruppe). Wir beschreiben in M6 optional eine Konsolidierung.
|
||||
- **Kein Plugin-System.** Block-Typen sind intern und in `packages/website-blocks` versioniert. Dritt-Blöcke erst wenn Bedarf real wird.
|
||||
- **Kein Markdown-Editor-Ersatz.** RichText-Blöcke nutzen einen kuratierten Satz Tiptap-Extensions, nicht Markdown. Ein Export-zu-Markdown ist möglich, aber nicht Teil des Write-Pfades.
|
||||
- **Keine E-Commerce-Primitive.** Shop, Warenkorb, Checkout: nicht Scope. Pricing-Blöcke sind Display-only.
|
||||
- **Keine Versionierung auf Block-Ebene.** Sites haben `draft` und `published` als zwei konsistente Snapshots; kein per-Block-History-Browser.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Editor (auth-gated, local-first) │
|
||||
│ apps/mana/apps/web/src/routes/(app)/website/… │
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ Seitenbaum + │ │ Block-Baum + │ │ Inspector │ │
|
||||
│ │ Seite-Settings│ │ Live Preview │ │ (Zod → Form) │ │
|
||||
│ └───────┬───────┘ └───────┬───────┘ └────────┬─────────┘ │
|
||||
│ └──── Dexie (websites/pages/blocks) ───┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ encryptRecord(plaintext) → table.add() │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ _pendingChanges (appId='website') │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│ (sync engine, same pipe as every module)
|
||||
▼
|
||||
mana-sync → Postgres (website.* schema)
|
||||
│
|
||||
│ read path for public visitors
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Public renderer (no auth, SSR) │
|
||||
│ apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/… │
|
||||
│ │
|
||||
│ +page.server.ts │
|
||||
│ └─ resolveSite(siteSlug, path) │
|
||||
│ └─ reads published snapshot from Postgres (no Dexie) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ +page.svelte renders <BlockRenderer mode="public" /> │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Form submissions (no auth) │
|
||||
│ POST /api/v1/website/sites/:id/submit/:blockId │
|
||||
│ → validate against stored block schema (Zod) │
|
||||
│ → write to target module via mana-tool-registry handler │
|
||||
│ → record in websiteSubmissions for audit │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
Explizit, begründet, und als Ankerpunkt für spätere Zweifel.
|
||||
|
||||
### D1 — Ein Block-Typ, eine Svelte-Komponente, drei Render-Modi
|
||||
|
||||
Jeder Block-Typ (`hero`, `richText`, `image`, `gallery`, `form`, …) lebt als **genau eine** Svelte-Komponente in `packages/website-blocks/src/{type}/{Type}.svelte`. Die Komponente bekommt `{ block, mode }`, wobei `mode ∈ 'edit' | 'preview' | 'public'`. Im `edit`-Mode werden Inline-Editing-Controls sichtbar; im `public`-Mode reine Anzeige.
|
||||
|
||||
**Warum:** Ein Codepfad bedeutet: wenn der Editor eine Change rendert, sieht der Besucher später exakt dasselbe. Dual-Rendering (Svelte-Editor + Astro-Public) wie in `shared-landing-ui` heute erzeugt garantiert Drift.
|
||||
|
||||
**Konsequenz:** Der SvelteKit-Public-Renderer ist SSR, nicht statisch. Für Performance siehe D9 (Caching).
|
||||
|
||||
### D2 — Block-Schema ist SSOT für Rendering, Validierung, UI, AI-Tools
|
||||
|
||||
Pro Block-Typ ein Zod-Schema in `packages/website-blocks/src/{type}/schema.ts`. Das Schema ist gleichzeitig:
|
||||
|
||||
1. **Datenbank-Validierung** (Store schreibt, Server validiert)
|
||||
2. **Inspector-Formular** (Auto-Generierung via `zod-to-form`-Utility)
|
||||
3. **AI-Tool-Input** (über `mana-tool-registry`, siehe D7)
|
||||
4. **Persistenz-Schema-Migrationen** (jedes Block-Schema hat `version`, Upgrader)
|
||||
|
||||
**Warum:** Schema und Renderer werden immer zusammen geändert. Wenn sie getrennt leben, bekommen wir stille UI-Abweichungen vom Datenmodell. Zod als eine Quelle schließt das aus.
|
||||
|
||||
**Block-Paket-Skizze:**
|
||||
```
|
||||
packages/website-blocks/src/
|
||||
├── hero/
|
||||
│ ├── schema.ts # HeroBlockSchema (Zod, v1)
|
||||
│ ├── Hero.svelte # Renderer (mode-aware)
|
||||
│ ├── Hero.inspector.ts # optional: custom inspector (sonst auto)
|
||||
│ └── index.ts
|
||||
├── richText/…
|
||||
├── image/…
|
||||
├── gallery/…
|
||||
├── form/…
|
||||
├── moduleEmbed/…
|
||||
├── columns/…
|
||||
├── spacer/…
|
||||
├── cta/…
|
||||
├── faq/…
|
||||
├── registry.ts # { type → { schema, Component, icon, category } }
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### D3 — Block-Baum über `parentBlockId`, Reihenfolge über `order`
|
||||
|
||||
Blöcke speichern `parentBlockId` (nullable — Top-Level auf einer Seite) und `order` (double-linked via fractional indexing, kein Reindex bei Insert). Container-Blöcke (`columns`, `rows`) haben mehrere Slots; `slotKey` ist optional auf dem Child.
|
||||
|
||||
**Warum:** Flache Tabelle mit `parentBlockId` ist die konventionelle, gut-getestete Repräsentation eines Baums in einem CRDT-fähigen System (wir haben field-level LWW via mana-sync). Alternativen:
|
||||
|
||||
- *JSON-Blob für den ganzen Baum*: einfach, aber jedes Move eines Blocks schreibt den gesamten Baum. Konfliktverlust garantiert bei Co-Editing.
|
||||
- *Nested Set / Path-Enumeration*: schnell für Lese-Queries, aber Writes sind teuer und Konflikte weh tun.
|
||||
|
||||
Flach + `parentBlockId` + fractional index = feld-weise LWW ist pro Block funktionabel, Co-Editing zweier Member am selben Block ist sicher (Timestamp entscheidet pro Feld).
|
||||
|
||||
### D4 — Drei Tabellen plus optional Submissions, alle space-scoped, alle plaintext
|
||||
|
||||
```
|
||||
websites { id, spaceId, slug, name, theme, navConfig, footerConfig,
|
||||
publishedVersion, draftUpdatedAt, settings }
|
||||
websitePages { id, siteId, path, title, seo, order }
|
||||
websiteBlocks { id, pageId, parentBlockId, type, slotKey, props, order, schemaVersion }
|
||||
websiteSubmissions { id, siteId, blockId, payload, targetModule, targetRecordId,
|
||||
status, createdAt, ip, userAgent }
|
||||
```
|
||||
|
||||
Alle Felder plaintext. Begründung: Site-Content ist öffentlich — es für den Autor zu verschlüsseln wäre sinnfrei, und macht SSR im Public-Path unmöglich (der Server hat keinen MK). Form-Submissions können sensible Daten enthalten; die landen nach Validierung in den Zielmodulen (z.B. `contacts`), dort sind die existenten Encryption-Regeln gültig. Der Submission-Audit-Row (`payload`) wird nach erfolgreicher Weitergabe geleert (siehe M2).
|
||||
|
||||
### D5 — Publish-Modell: `draft` + `published` als zwei separate Snapshots
|
||||
|
||||
Jedes `website` hat einen `publishedVersion` (UUID). Editor schreibt immer gegen den Draft (= die Live-Tabellenzeilen). Auf "Publish" wird ein Snapshot erzeugt: `websitePublishedSnapshots { siteId, version, blob }` — das `blob` ist ein vollständig aufgelöster, deterministisch serialisierter Baum (JSON). Der Public-Renderer liest nur dieses Blob.
|
||||
|
||||
**Warum:**
|
||||
- Editor kann beliebig herumspielen, ohne dass Besucher halbfertige Seiten sehen.
|
||||
- Rollback ist trivial: `publishedVersion` zeigt auf älteren Snapshot.
|
||||
- Public-Read ist **ein** Query (`SELECT blob WHERE siteId AND version`), kein JOIN über drei Tabellen.
|
||||
- Snapshots sind unveränderlich — gut cachebar (D9).
|
||||
|
||||
**Alternative verworfen:** "Live-Edit = sofort live". Katastrophal für Firmen-Nutzung, wo mehrere Member über Stunden editieren. Draft/Publish ist der nicht-verhandelbare Standard.
|
||||
|
||||
### D6 — Public-Serving über SvelteKit-Route, nicht via `mana-landing-builder`
|
||||
|
||||
`apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts` lädt Site + Page + BlockTree aus Postgres und rendert SSR. `mana-landing-builder` wird **nicht** erweitert — der Service bleibt für den separaten Org-Landing-Pages-Use-Case (admin-only), in M6 wird entschieden, ob er fusioniert oder abgelöst wird.
|
||||
|
||||
**Warum:**
|
||||
- Astro-Static-Export in `mana-landing-builder` zwingt zu dualem Rendering (D1 verletzt).
|
||||
- SvelteKit-SSR mit Caching (D9) ist für Hunderttausende User-Sites schnell genug.
|
||||
- Statischer Export lohnt erst bei hohem Traffic pro Site — dann pro Site opt-in, nicht default.
|
||||
|
||||
**Subdomain-Handling (Phase 3):** SvelteKit-Host-Handler im Hook `hooks.server.ts` erkennt `{slug}.mana.how` und rewritet intern auf `/s/{slug}/…`. Wildcard-Cert existiert bereits.
|
||||
|
||||
### D7 — AI-Tools via `mana-tool-registry`, nicht separat
|
||||
|
||||
Sobald `mana-tool-registry` (siehe `docs/plans/mana-mcp-and-personas.md` M1) steht, registriert `website` seine Tools dort: `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`. Policy-Hint pro Tool: `write` für CRUD, `destructive` für `delete_page`/`delete_site` (nicht MCP-exponiert).
|
||||
|
||||
**Warum:** Alle AI-Writes laufen zwingend durch denselben Tool-Layer. Kein paralleles "AI-kann-Websites-bauen"-Subsystem.
|
||||
|
||||
**Reihenfolge:** `mana-tool-registry` M1 muss stehen, bevor Website-AI-Tools registriert werden. Bis dahin: Editor ohne AI. Website-AI-Tools landen als Teil von M5.
|
||||
|
||||
### D8 — Form-Submissions schreiben über Tool-Registry-Handler, nicht direkt in Ziel-Tabellen
|
||||
|
||||
Ein `form`-Block hat `targetModule` (z.B. `'contacts'`) und `targetAction` (z.B. `'create_contact'`). Der Submit-Endpoint:
|
||||
1. Validiert Payload gegen das im Block gespeicherte Zod-Schema
|
||||
2. Speichert Audit-Row in `websiteSubmissions` (Status: `'received'`)
|
||||
3. Ruft den entsprechenden Tool-Handler aus `mana-tool-registry` auf (`ctx`: site-owner user/space)
|
||||
4. Updated Audit-Row mit `targetRecordId` und `'delivered'`
|
||||
|
||||
**Warum:** Der Tool-Registry-Handler kennt bereits Encryption, RLS, Validation des Zielmoduls. Duplizieren wäre erzwungener Legacy-Einstiegspunkt.
|
||||
|
||||
**Abgrenzung:** Unauthentifizierter Submit-Endpoint → Rate-Limiting via Edge (Cloudflare) und Captcha-Block-Typ (in M6, nicht M1).
|
||||
|
||||
### D9 — Caching: Published-Snapshot mit Cache-Tag, Invalidation bei Publish
|
||||
|
||||
Published-Blob wird mit `Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400` geliefert, plus `Cache-Tag: site-{siteId}`. Bei `website.publish` → Cloudflare Purge der Tag-Gruppe.
|
||||
|
||||
**Warum:** Keine statische Build-Stufe nötig; Edge-Cache bei Cloudflare liefert millisekunden-Responses für populäre Sites. Bei Edits ist die neue Version nach Publish binnen weniger Sekunden live.
|
||||
|
||||
**Alternative verworfen:** Redis-Cache in der App. Doppelter Infrastruktur-Aufwand, CF macht es gratis.
|
||||
|
||||
### D10 — Multi-Tenant über Spaces, Editing-Permission über Membership
|
||||
|
||||
Ein `website` gehört zu einer `spaceId`. Jedes Mitglied des Spaces kann editieren + publishen. Rollen (editor-only, viewer-only) kommen später, wenn `space_members.role` nicht-trivial wird.
|
||||
|
||||
**Warum:** Wir verwenden, was es gibt. Spaces-RLS ist getestet. Ein eigenes `website_members` wäre parallele Permission-Ebene → Drift garantiert.
|
||||
|
||||
**Privat vs. Firma-Distinction:** Kein eigenes "ist eine Firma"-Flag. Ein Space mit einem Member = Privat, ein Space mit 2+ Membern = Firma. Die UI kann in Phase 2 auf `spaceMemberCount > 1` reagieren, um Team-Workflows zu zeigen.
|
||||
|
||||
### D11 — Slugs: space-scoped unique, reserved-Liste hart
|
||||
|
||||
`websites.slug` ist unique pro `spaceId`. Öffentliche URL in Phase 1 ist `/s/{siteSlug}` global unique (nicht space-scoped) — und deswegen gibt es *auch* eine globale Unique-Constraint auf `slug` wenn `isPublished=true`.
|
||||
|
||||
**Reserved slugs:** `app`, `api`, `auth`, `admin`, `settings`, `docs`, `blog`, `www`, `mail`, `dashboard`, plus alle existierenden Modulnamen. Liste in `apps/api/src/modules/website/reserved-slugs.ts`, erzwungen bei Write und in Migration gecheckt.
|
||||
|
||||
**Warum:** Eine Site mit `slug=api` würde die API-Route verschatten. Lieber strikt + reserviert Namen.
|
||||
|
||||
### D12 — Media-Assets über bestehendes `shared-uload`, kein eigenes `websiteAssets`
|
||||
|
||||
Bilder, Dateien, Cover: Upload über `shared-uload` → MinIO, Rückgabe der URL. Der `image`-Block speichert `{ url, altText, focalPoint }`. Cleanup: wir führen keine Reference-Counting-Tabelle in Phase 1. Bei `delete site` bleiben Assets liegen (ok, sie sind im Space-Bucket, Storage ist billig). GC-Job in M7.
|
||||
|
||||
**Warum:** Ein eigenes `websiteAssets` mit Reference-Counting wäre sauberer, aber ein GC-Job reicht als Aufräumer und verzögert erst mal Komplexität.
|
||||
|
||||
### D13 — Kein Legacy-Fork der `shared-landing-ui` Astro-Sections
|
||||
|
||||
Die 13 existierenden Astro-Sections (`HeroSection`, `FeatureSection`, …) werden **nicht** nach Svelte portiert oder geteilt. Wir schreiben die Block-Renderer neu. Die visuellen Patterns darf man inspirieren, aber Code teilen = duales Rendering (D1 verletzt).
|
||||
|
||||
**Warum:** Die Astro-Sections haben andere Constraints (Astro-Islands, build-time-data). Teilen würde beide Seiten einschränken.
|
||||
|
||||
**Konsequenz:** `shared-landing-ui` bleibt für Org-Landing-Pages. In M6 diskutieren wir die Konsolidierung ehrlich.
|
||||
|
||||
## Komponenten
|
||||
|
||||
### Komponente 1 — `packages/website-blocks`
|
||||
|
||||
Neues Workspace-Paket. Reine Svelte-Components + Zod-Schemata, keine Dexie/Netzwerk-Abhängigkeiten. Nutzbar vom Editor (Dexie-Kontext) **und** vom Public-Renderer (Postgres-Snapshot-Kontext) — beide Seiten geben `{ block, mode, children }`, der Renderer kümmert sich nicht um Datenquelle.
|
||||
|
||||
**Public API (Skizze):**
|
||||
|
||||
```ts
|
||||
// packages/website-blocks/src/registry.ts
|
||||
export interface BlockSpec<Props = unknown> {
|
||||
type: string; // 'hero', 'richText', …
|
||||
schema: ZodSchema<Props>;
|
||||
schemaVersion: number;
|
||||
Component: SvelteComponent<{
|
||||
block: Block<Props>;
|
||||
mode: 'edit' | 'preview' | 'public';
|
||||
children?: Block[]; // nur bei Containern
|
||||
onEdit?: (patch: Partial<Props>) => void; // im edit-Mode
|
||||
}>;
|
||||
icon: string; // Lucide-Name
|
||||
category: 'content' | 'media' | 'layout' | 'form' | 'embed';
|
||||
defaults: Props; // Initialwerte beim Einfügen
|
||||
upgraders?: Record<number, (old: unknown) => Props>; // v1→v2 migrations
|
||||
}
|
||||
|
||||
export const blockRegistry: Record<string, BlockSpec>;
|
||||
```
|
||||
|
||||
**Block-Coverage M1:** `hero`, `richText`, `image`, `spacer`, `cta`, `columns` (2/3-spalt), `gallery`. Sieben Typen reichen für brauchbare One-Pager.
|
||||
|
||||
**Block-Coverage M4 expand:** `form`, `moduleEmbed`, `pricing`, `faq`, `testimonials`, `team`, `contact`, `footer`. Fünfzehn Typen decken alle 13 `shared-landing-ui`-Sections plus neuen Bedarf.
|
||||
|
||||
### Komponente 2 — `apps/mana/apps/web/src/lib/modules/website`
|
||||
|
||||
Standard-Modul-Struktur, wie jedes andere Modul im Repo:
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/modules/website/
|
||||
├── types.ts # LocalWebsite, LocalWebsitePage, LocalWebsiteBlock
|
||||
├── collections.ts # websitesTable, websitePagesTable, websiteBlocksTable
|
||||
├── queries.ts # useSite(id), usePage(id), useBlocks(pageId), useBlockTree(pageId)
|
||||
├── stores/
|
||||
│ ├── sites.svelte.ts # createSite, updateSite, deleteSite, publishSite
|
||||
│ ├── pages.svelte.ts # createPage, updatePage, deletePage, reorderPages
|
||||
│ └── blocks.svelte.ts # addBlock, updateBlock, deleteBlock, moveBlock
|
||||
├── components/
|
||||
│ ├── BlockRenderer.svelte # rekursiv, nutzt blockRegistry
|
||||
│ ├── BlockTreeEditor.svelte # Seitenleiste: Baum + Insert-Palette
|
||||
│ ├── BlockInspector.svelte # rechts: Zod-schema → Formular
|
||||
│ ├── InsertPalette.svelte # "+" zwischen Blöcken
|
||||
│ ├── PagePicker.svelte
|
||||
│ ├── SiteSettingsDialog.svelte # Theme, Nav, Footer, SEO-Defaults
|
||||
│ ├── PublishBar.svelte # "Unveröffentlichte Änderungen" + Publish-Button
|
||||
│ └── TemplatePicker.svelte # Starter-Templates
|
||||
├── views/
|
||||
│ ├── SitesListView.svelte # alle Sites des Spaces
|
||||
│ ├── SiteEditorView.svelte # drei-Pane Editor
|
||||
│ └── SiteSettingsView.svelte
|
||||
├── tools.ts # AI-Tool-Registrierungen (aktiviert erst in M5)
|
||||
├── constants.ts # THEME_PRESETS, RESERVED_SLUGS (client-copy)
|
||||
├── module.config.ts # { appId: 'website', tables: [...] }
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
**Routes:**
|
||||
```
|
||||
apps/mana/apps/web/src/routes/(app)/website/
|
||||
├── +page.svelte # SitesListView
|
||||
├── new/+page.svelte # Template-Picker oder Blank
|
||||
└── [siteId]/
|
||||
├── +layout.svelte # lädt site, stellt Context
|
||||
├── +page.svelte # redirect auf /edit
|
||||
├── edit/
|
||||
│ └── [pageId]/+page.svelte # SiteEditorView
|
||||
├── settings/+page.svelte # SiteSettingsView
|
||||
└── submissions/+page.svelte # Eingegangene Form-Submissions
|
||||
```
|
||||
|
||||
### Komponente 3 — Public-Renderer-Routes
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/routes/s/
|
||||
└── [siteSlug]/
|
||||
├── +layout.server.ts # resolve site, throw 404 if unpublished
|
||||
├── +layout.svelte # theme vars, nav, footer
|
||||
└── [[...path]]/
|
||||
├── +page.server.ts # resolve page by path, 404 if missing
|
||||
└── +page.svelte # <BlockRenderer mode="public" />
|
||||
```
|
||||
|
||||
**Resolver-Logik (+layout.server.ts):**
|
||||
```ts
|
||||
export const load = async ({ params, setHeaders }) => {
|
||||
const snapshot = await db
|
||||
.select()
|
||||
.from(publishedSnapshotsTable)
|
||||
.where(and(
|
||||
eq(publishedSnapshotsTable.slug, params.siteSlug),
|
||||
eq(publishedSnapshotsTable.isCurrent, true)
|
||||
))
|
||||
.limit(1);
|
||||
if (!snapshot[0]) error(404);
|
||||
setHeaders({
|
||||
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
|
||||
'cache-tag': `site-${snapshot[0].siteId}`,
|
||||
});
|
||||
return { site: snapshot[0].blob };
|
||||
};
|
||||
```
|
||||
|
||||
Snapshot-Blob-Format:
|
||||
```ts
|
||||
interface PublishedSnapshot {
|
||||
version: string;
|
||||
site: { id, slug, name, theme, navConfig, footerConfig, settings };
|
||||
pages: Array<{
|
||||
id, path, title, seo,
|
||||
blocks: BlockTreeNode[]; // rekursiver Baum, bereits auflösend
|
||||
}>;
|
||||
publishedAt: string;
|
||||
publishedBy: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Komponente 4 — `apps/api/src/modules/website`
|
||||
|
||||
Backend-Routes im unified `@mana/api`:
|
||||
|
||||
```
|
||||
apps/api/src/modules/website/
|
||||
├── routes.ts # Hono router
|
||||
├── publish.ts # POST /sites/:id/publish
|
||||
├── submit.ts # POST /sites/:id/submit/:blockId (unauth)
|
||||
├── snapshots.ts # query helpers for published snapshots
|
||||
├── reserved-slugs.ts # SSOT
|
||||
└── tools.ts # Tool-Registry registrations (M5)
|
||||
```
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| `POST` | `/api/v1/website/sites/:id/publish` | JWT (space-member) | Snapshot erzeugen, `publishedVersion` setzen, CF-Cache purgen |
|
||||
| `POST` | `/api/v1/website/sites/:id/submit/:blockId` | None | Form-Submission annehmen, validieren, weitergeben |
|
||||
| `GET` | `/api/v1/website/sites/:id/submissions` | JWT (space-member) | Submissions listen |
|
||||
| `DELETE` | `/api/v1/website/sites/:id/submissions/:subId` | JWT (space-member) | Submission löschen |
|
||||
|
||||
Keine CRUD-Endpoints für Pages/Blocks — das läuft über den normalen Sync-Pfad (Dexie → mana-sync → Postgres) wie bei allen anderen Modulen.
|
||||
|
||||
### Komponente 5 — Starter-Templates
|
||||
|
||||
Sechs handkuratierte Templates in `apps/mana/apps/web/src/lib/modules/website/templates/`:
|
||||
|
||||
| Template | Zielgruppe | Seiten | Blöcke |
|
||||
|----------|-----------|--------|--------|
|
||||
| `portfolio` | Kreative, Freelancer | Start, Über mich, Arbeiten, Kontakt | hero + gallery + richText + form |
|
||||
| `personal-linktree` | Privatnutzer, Creator | Start (Single-Page) | hero + 8× cta |
|
||||
| `event` | Hochzeit, Geburtstag, Konferenz | Start, Programm, Anreise, RSVP | hero + richText + form |
|
||||
| `smb-corporate` | Kleinbetrieb | Start, Leistungen, Team, Kontakt | hero + 3×columns + team + contact |
|
||||
| `product-landing` | Firmen-Produktseite | Start (Single-Page, lang) | hero + features + testimonials + pricing + faq + cta |
|
||||
| `blank` | Fortgeschritten | 1 leere Seite | — |
|
||||
|
||||
Templates sind JSON in `templates/{name}.json`: `{ site, pages[], blocks[] }`. Apply-Funktion klont mit neuen UUIDs in den Ziel-Space. Templates sind statisch im Build, nicht in DB — kein Admin-Flow zum Editieren im MVP (M6 evtl.).
|
||||
|
||||
### Komponente 6 — Inspector-Autoform
|
||||
|
||||
`components/BlockInspector.svelte` rendert Formulare aus Zod-Schemas via kleiner Utility `zodToForm(schema)` in `packages/website-blocks/src/inspector/`. Mapping:
|
||||
|
||||
| Zod | UI |
|
||||
|-----|----|
|
||||
| `z.string()` | `<input type="text">` |
|
||||
| `z.string().long()` (custom brand) | `<textarea>` |
|
||||
| `z.string().url()` | `<input type="url">` |
|
||||
| `z.enum([...])` | `<select>` |
|
||||
| `z.boolean()` | `<input type="checkbox">` |
|
||||
| `z.number()` | `<input type="number">` |
|
||||
| `z.object({ ... })` | Gruppen-Fieldset (rekursiv) |
|
||||
| `z.array(z.object({...}))` | Liste mit Add/Remove + Drag-Reorder |
|
||||
| Block-spezifisches Custom | Override via `Block.inspector.ts` |
|
||||
|
||||
**Warum Auto-Formular, nicht pro-Block-Handschrift:** 15 Blöcke × 8 Felder = 120 Formfelder handgeschrieben wäre Copy-Paste. Auto-Gen deckt 80%, Override-Mechanismus für Spezialfälle (Farbwähler, Icon-Picker, Module-Source-Picker).
|
||||
|
||||
### Komponente 7 — `moduleEmbed`-Block
|
||||
|
||||
Spezialblock, der Daten aus anderen Modulen zieht. Props:
|
||||
```ts
|
||||
{
|
||||
source: 'picture.board' | 'library.entries' | 'news.feed' | 'cards.deck' | 'library.kind';
|
||||
sourceId: string; // board-id, deck-id, feed-id
|
||||
filter?: { kind?, tag?, limit?, order? };
|
||||
layout: 'grid' | 'list' | 'carousel';
|
||||
}
|
||||
```
|
||||
|
||||
**Source-Provider-Pattern:** Jedes Modul, das einbettbare Daten liefert, registriert einen Provider:
|
||||
```ts
|
||||
// apps/api/src/modules/website/embed-providers.ts
|
||||
export interface EmbedProvider {
|
||||
source: string; // 'picture.board'
|
||||
resolve: (sourceId: string, filter: unknown, ctx: EmbedContext)
|
||||
=> Promise<EmbedResult>;
|
||||
}
|
||||
```
|
||||
|
||||
Public-Renderer ruft bei Publish die Provider und inlined das Ergebnis in den Snapshot (statischer Ansatz) **oder** ruft sie pro Request (dynamischer Ansatz). **Default: statisch beim Publish**, weil einfacher zu cachen. Opt-in dynamisch pro Block (Use-Case: "Letzte 5 Artikel", tagesaktuell). Dynamisch = Cache-Tag zusätzlich `embed-{source}-{sourceId}`, Purge bei Source-Änderung.
|
||||
|
||||
**Permission-Check im Provider:** Provider bekommt `EmbedContext { ownerUserId, siteId, isPublic: true }`. Source-Daten werden nur inlined, wenn das Original als "public" markiert ist (pro Modul unterschiedlich: picture `board.isPublic`, library `entry.visibility='public'`, …). Ohne Public-Flag → Provider returnt leer oder wirft "needs_public".
|
||||
|
||||
### Komponente 8 — Policies: Tier-Gating
|
||||
|
||||
`packages/shared-branding/src/mana-apps.ts` bekommt einen neuen Entry:
|
||||
|
||||
```ts
|
||||
website: {
|
||||
id: 'website',
|
||||
name: 'Website',
|
||||
description: 'Baukasten für deine Website',
|
||||
requiredTier: 'public', // alle angemeldeten User
|
||||
url: '/website',
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
**Limits pro Tier (konfiguriert in `website.limits.ts`):**
|
||||
|
||||
| Tier | Max Sites | Max Pages/Site | Max Blocks/Page | Custom Domain |
|
||||
|------|-----------|----------------|-----------------|---------------|
|
||||
| `public` | 1 | 1 | 20 | ❌ |
|
||||
| `beta` | 3 | 5 | 50 | ❌ |
|
||||
| `alpha` | 10 | unlimited | unlimited | ❌ |
|
||||
| `founder` | unlimited | unlimited | unlimited | ✅ |
|
||||
|
||||
Enforcement in den Stores (pre-create check via JWT-Claim). Feature-Flag-Banner im Editor, der höheres Tier bewirbt.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Dexie-Tabellen (IndexedDB, client-side)
|
||||
|
||||
```ts
|
||||
// database.ts — neue Version block
|
||||
db.version(NEW).stores({
|
||||
// …existing…
|
||||
websites: 'id, spaceId, slug, publishedVersion, updatedAt, deletedAt',
|
||||
websitePages: 'id, siteId, path, order, updatedAt, deletedAt',
|
||||
websiteBlocks: 'id, pageId, parentBlockId, order, type, updatedAt, deletedAt',
|
||||
websiteSubmissions: 'id, siteId, blockId, createdAt, status',
|
||||
});
|
||||
```
|
||||
|
||||
### Postgres-Schemas (mana_platform, neu)
|
||||
|
||||
```sql
|
||||
-- Schema: website.* (isoliert via pgSchema)
|
||||
|
||||
CREATE TABLE website.sites (
|
||||
id UUID PRIMARY KEY,
|
||||
space_id UUID NOT NULL REFERENCES space.spaces(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
theme JSONB NOT NULL, -- { preset, overrides }
|
||||
nav_config JSONB NOT NULL, -- { items: [{label,pagePath}] }
|
||||
footer_config JSONB NOT NULL,
|
||||
settings JSONB NOT NULL, -- { favicon, defaultSeo, analytics? }
|
||||
published_version UUID, -- FK → website.published_snapshots(id)
|
||||
draft_updated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE (space_id, slug)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX sites_published_slug_idx
|
||||
ON website.sites (slug)
|
||||
WHERE published_version IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE website.pages (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL, -- '/' for home, '/about' for subpage
|
||||
title TEXT NOT NULL,
|
||||
seo JSONB NOT NULL, -- { title, description, ogImage, noindex }
|
||||
"order" DOUBLE PRECISION NOT NULL, -- fractional index
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE (site_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE website.blocks (
|
||||
id UUID PRIMARY KEY,
|
||||
page_id UUID NOT NULL REFERENCES website.pages(id) ON DELETE CASCADE,
|
||||
parent_block_id UUID REFERENCES website.blocks(id) ON DELETE CASCADE,
|
||||
slot_key TEXT, -- for container-blocks
|
||||
type TEXT NOT NULL, -- registry type id
|
||||
props JSONB NOT NULL,
|
||||
schema_version INT NOT NULL DEFAULT 1,
|
||||
"order" DOUBLE PRECISION NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX blocks_page_parent_idx ON website.blocks (page_id, parent_block_id, "order");
|
||||
|
||||
CREATE TABLE website.published_snapshots (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL, -- duplicated for fast public lookup
|
||||
blob JSONB NOT NULL, -- full snapshot
|
||||
is_current BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
published_by UUID NOT NULL REFERENCES platform.users(id)
|
||||
);
|
||||
CREATE UNIQUE INDEX snapshots_current_slug_idx
|
||||
ON website.published_snapshots (slug)
|
||||
WHERE is_current = TRUE;
|
||||
|
||||
CREATE TABLE website.submissions (
|
||||
id UUID PRIMARY KEY,
|
||||
site_id UUID NOT NULL REFERENCES website.sites(id) ON DELETE CASCADE,
|
||||
block_id UUID NOT NULL, -- no FK: block could be deleted, submission stays for audit
|
||||
payload JSONB NOT NULL, -- cleared after target-module write
|
||||
target_module TEXT NOT NULL,
|
||||
target_action TEXT NOT NULL,
|
||||
target_record_id UUID,
|
||||
status TEXT NOT NULL, -- 'received' | 'delivered' | 'failed'
|
||||
error_message TEXT,
|
||||
ip INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX submissions_site_created_idx ON website.submissions (site_id, created_at DESC);
|
||||
```
|
||||
|
||||
### RLS
|
||||
|
||||
```sql
|
||||
-- sites, pages, blocks: nur Space-Member lesen/schreiben
|
||||
ALTER TABLE website.sites ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY sites_space_member ON website.sites
|
||||
USING (space_id IN (SELECT space_id FROM space.memberships WHERE user_id = current_user_id()));
|
||||
|
||||
-- Analog für pages/blocks (JOIN über site)
|
||||
|
||||
-- published_snapshots: öffentlicher Read (kein Auth), Write nur durch publish-Endpoint (service-role)
|
||||
ALTER TABLE website.published_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY snapshots_public_read ON website.published_snapshots
|
||||
FOR SELECT USING (is_current = TRUE);
|
||||
CREATE POLICY snapshots_service_write ON website.published_snapshots
|
||||
FOR ALL TO service_role USING (TRUE);
|
||||
|
||||
-- submissions: nur Space-Member lesen; Write via service-role aus Submit-Endpoint
|
||||
ALTER TABLE website.submissions ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY submissions_space_member_read ON website.submissions
|
||||
FOR SELECT USING (site_id IN (
|
||||
SELECT id FROM website.sites WHERE space_id IN
|
||||
(SELECT space_id FROM space.memberships WHERE user_id = current_user_id())
|
||||
));
|
||||
```
|
||||
|
||||
### Encryption-Registry
|
||||
|
||||
```ts
|
||||
// apps/mana/apps/web/src/lib/data/crypto/registry.ts
|
||||
|
||||
websites: plaintext(['id', 'spaceId', 'slug', 'name', 'theme', 'navConfig',
|
||||
'footerConfig', 'settings', 'publishedVersion',
|
||||
'draftUpdatedAt', 'createdAt', 'updatedAt', 'deletedAt',
|
||||
'userId']),
|
||||
websitePages: plaintext(['id', 'siteId', 'path', 'title', 'seo', 'order',
|
||||
'createdAt', 'updatedAt', 'deletedAt', 'userId']),
|
||||
websiteBlocks: plaintext(['id', 'pageId', 'parentBlockId', 'slotKey', 'type',
|
||||
'props', 'schemaVersion', 'order', 'createdAt',
|
||||
'updatedAt', 'deletedAt', 'userId']),
|
||||
websiteSubmissions: plaintext(['id', 'siteId', 'blockId', 'payload',
|
||||
'targetModule', 'targetAction', 'targetRecordId',
|
||||
'status', 'errorMessage', 'ip', 'userAgent',
|
||||
'createdAt', 'userId']),
|
||||
```
|
||||
|
||||
**Explizit plaintext** (nicht implizit leer), damit `pnpm run check:crypto` (die CI-Gate aus dem spaces-foundation-Sprint) den Ausschluss aktiv bestätigt.
|
||||
|
||||
## No-Legacy-Residues
|
||||
|
||||
Explizite Anti-Patterns, gegen die wir uns committen:
|
||||
|
||||
1. **Kein duales Rendering.** Es gibt **eine** Svelte-Komponente pro Block-Typ. Kein paralleles Astro oder React, keine "nur für Public"-Version. Wenn jemals statischer Build gewünscht ist, wird SvelteKit im prerender-Mode genutzt, nicht ein separates Build-Tool.
|
||||
|
||||
2. **Kein JSON-Blob-im-JSON-Blob.** Der Block-Baum ist als Zeilen in `website.blocks` gespeichert, nicht als `site.tree = {...massive nested JSON...}`. Das Published-Snapshot-Blob ist die einzige Denormalisierung und existiert nur lesbar.
|
||||
|
||||
3. **Keine eigene Tool-Registry für Website-AI.** Sobald `mana-tool-registry` steht, läuft alles darüber. Kein `packages/website-ai-tools` parallel zu `packages/mana-tool-registry`.
|
||||
|
||||
4. **Keine Direct-Writes in Zielmodule bei Form-Submissions.** Submit-Endpoint ruft Tool-Handler, niemals direkt `contactsTable.insert()`. Ein Code-Pfad.
|
||||
|
||||
5. **Kein shared-landing-ui-Fork.** Auch nicht "nur die Hero-Section kopieren, weil sie schon gut ist". Wir schreiben neu oder abstrahieren später bei M6-Konsolidierung.
|
||||
|
||||
6. **Keine Legacy-slug-Konflikte.** `reserved-slugs.ts` ist SSOT und wird in Migration-Script validiert: bei Migration werden alle existierenden `slug` gegen die Liste geprüft; fehlschlag → harter Exit. Wir haben noch nicht live, also null Konflikt erwartet.
|
||||
|
||||
7. **Kein "website_user_role" als Parallel-Permission-System.** Space-Membership ist die einzige Permission. Wenn feinere Rollen kommen, dann auf `space.memberships` — nicht als Duplikat in website.
|
||||
|
||||
8. **Kein impliziter "publish on save".** Draft und Published sind ausdrücklich getrennt. Publish ist ein expliziter User-Trigger. Kein Auto-Publish, keine "automatisch nach 5 Minuten publish"-Heuristik.
|
||||
|
||||
9. **Kein lokaler LiveQuery im Public-Renderer.** Der Public-Renderer nutzt ausschließlich Postgres. Dexie ist für den Editor, Ende.
|
||||
|
||||
10. **Keine `window.ANALYTICS.track(...)`-Einbau in Block-Renderer.** Analytics-Einbau (später M7) läuft über einen dedizierten `analytics`-Block oder via `settings.analytics` → `<script>`-Injection im Layout. Kein Pepper streuen.
|
||||
|
||||
11. **Keine Inline-CSS-Properties als Block-Props.** Kein `block.props.style = 'margin-top: 42px'`. Styling nur über Theme-Variables und ausgewählte Design-Tokens (`spacing: 'tight'|'normal'|'loose'`). Wenn Nutzer freies CSS braucht, ist das ein Pro-Feature in M7+.
|
||||
|
||||
12. **Keine Parallel-Slug-Resolution.** Eine einzige Funktion in `apps/api/src/modules/website/snapshots.ts` resolved `siteSlug → snapshot`. Nicht in SvelteKit-Hook plus Server-Route plus Cache-Layer. Ein Ort.
|
||||
|
||||
## Milestones
|
||||
|
||||
Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich, typechecked, `pnpm run validate:all` grün.
|
||||
|
||||
### M1 — Foundation (Schema + Editor-Skelett + 3 Blöcke)
|
||||
|
||||
- [ ] `packages/website-blocks/` scaffold: `registry.ts`, `BlockSpec`-Typ, Inspector-Autoform-Utility
|
||||
- [ ] Drei Block-Typen: `hero`, `richText`, `spacer` — Schema + Component + Tests
|
||||
- [ ] Dexie-Schema-Bump: `websites`, `websitePages`, `websiteBlocks`
|
||||
- [ ] Drizzle-Schema: `website.sites`, `website.pages`, `website.blocks` (ohne snapshots, submissions)
|
||||
- [ ] Encryption-Registry-Einträge (plaintext) + check:crypto grün
|
||||
- [ ] `apps/mana/apps/web/src/lib/modules/website/` — module.config.ts, collections, queries, stores
|
||||
- [ ] Route `/(app)/website`: Sites-Liste, Create-Site-Flow, Single-Page-Editor
|
||||
- [ ] Editor-UI (drei-Pane): Seiten-Liste links, Preview Mitte, Inspector rechts
|
||||
- [ ] Speichern zur Dexie → mana-sync → Postgres (kein Publish, kein Public-Route)
|
||||
- [ ] `apps/api/src/modules/website/routes.ts` — Health-Endpoint + Validation-Stub
|
||||
- [ ] App-Registry-Eintrag in `packages/shared-branding/src/mana-apps.ts`, Tier: `public`, Limits: 1 Site, 1 Page, 20 Blöcke
|
||||
|
||||
**Exit criteria:** Ein Nutzer legt eine Site an, fügt Hero + RichText + Spacer ein, Änderungen synchronisieren zu Postgres, beim Reload ist alles wieder da. Kein Public-Rendering.
|
||||
|
||||
### M2 — Publish + Public-Renderer + Mehrseitigkeit
|
||||
|
||||
- [ ] Drizzle-Schema: `website.published_snapshots`
|
||||
- [ ] `apps/api/src/modules/website/publish.ts` — Publish-Endpoint:
|
||||
- [ ] Baue Snapshot-Blob aus current draft
|
||||
- [ ] Insert in `published_snapshots`, setze `is_current=TRUE` (old: FALSE), setze `sites.published_version`
|
||||
- [ ] Cloudflare-Cache-Purge via API (Tag: `site-{id}`)
|
||||
- [ ] `apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/` — Public-Render-Routen
|
||||
- [ ] Cache-Header + Cache-Tag setzen
|
||||
- [ ] Mehrseitigkeit: Page-Management im Editor (Add/Rename/Delete/Reorder), Nav-Config
|
||||
- [ ] Limits-Enforcement in Stores (Pre-Check gegen Tier)
|
||||
- [ ] `PublishBar.svelte` mit "Unveröffentlichte Änderungen"-Indikator
|
||||
- [ ] Rollback-UI: letzte 10 Snapshots listen, ein Klick "auf diese Version zurück"
|
||||
- [ ] Tests: Snapshot-Deterministik (zweimal publish → identisches Blob bei unverändertem Draft)
|
||||
|
||||
**Exit criteria:** Nutzer publisht. `https://<dev>/s/<slug>/` serviert die Site öffentlich, nicht authentifiziert. Rollback funktioniert.
|
||||
|
||||
### M3 — Block-Coverage-Expand (Layout + Media)
|
||||
|
||||
- [ ] Blöcke ergänzen: `image`, `gallery`, `cta`, `columns` (container), `faq`
|
||||
- [ ] `columns`-Container: rendert Slots, Drop-Zone pro Slot im Edit-Mode
|
||||
- [ ] Image-Upload via shared-uload-Integration im Inspector
|
||||
- [ ] Gallery-Masonry mit Lightbox im Public-Mode
|
||||
- [ ] Theme-System: `packages/website-blocks/src/themes/` — 3 Presets (classic, modern, warm)
|
||||
- [ ] Theme-Preview + Farb-Customization im SiteSettingsDialog
|
||||
- [ ] Tests pro Block: Schema-Validation, Component-Snapshot in allen drei Modes
|
||||
|
||||
**Exit criteria:** Ein Nutzer baut aus den jetzt 8 Block-Typen eine echte Landingpage, wählt ein Theme, publisht.
|
||||
|
||||
### M4 — Forms + moduleEmbed
|
||||
|
||||
- [ ] Drizzle-Schema: `website.submissions`
|
||||
- [ ] `form`-Block: Field-Editor im Inspector (Name/Label/Type/Required), Target-Module-Picker
|
||||
- [ ] Submit-Endpoint `POST /api/v1/website/sites/:id/submit/:blockId`:
|
||||
- [ ] Rate-Limit via Hono-Middleware (10 req/min per IP)
|
||||
- [ ] Schema-Validation gegen gespeicherten Block
|
||||
- [ ] Call zu mana-tool-registry-Handler (Start-Implementation noch vor M5)
|
||||
- [ ] Submission-Record + optionale E-Mail-Notification via mana-notify
|
||||
- [ ] `moduleEmbed`-Block:
|
||||
- [ ] Source-Provider-Interface
|
||||
- [ ] Provider für `picture.board`, `library.entries`, `news.feed`
|
||||
- [ ] Im Publish-Snapshot werden Provider aufgerufen und Ergebnis inlined (statischer Ansatz)
|
||||
- [ ] Permission-Check: nur `isPublic` Content wird inlined
|
||||
- [ ] Submissions-Liste im Editor (`/website/[id]/submissions`)
|
||||
- [ ] Weitere Blöcke: `pricing`, `testimonials`, `team`, `contact` (konzeptuell: `form` + layout)
|
||||
|
||||
**Exit criteria:** Kontaktformular auf einer Site schreibt neuen `contacts`-Record beim Site-Owner. Picture-Board wird als Gallery auf der öffentlichen Seite angezeigt.
|
||||
|
||||
### M5 — AI-Tools + Templates
|
||||
|
||||
**Voraussetzung:** `mana-tool-registry` M1 aus mana-mcp-Plan hat gelandet.
|
||||
|
||||
- [ ] Website-Tools registrieren: `website.create_site`, `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`
|
||||
- [ ] Policy-Hints: `create_site` propose, `add_block`/`update_block` propose, `publish` propose, `delete_*` destructive (nicht MCP-exponiert)
|
||||
- [ ] Starter-Templates (6 Stück, siehe Komponente 5) als JSON in `templates/`
|
||||
- [ ] TemplatePicker im `/website/new`-Flow
|
||||
- [ ] AI-Proposal-Inbox für Website-Änderungen (`<AiProposalInbox module="website" />`)
|
||||
- [ ] Integrations-Test: AI-Mission "Baue mir eine Portfolio-Seite aus meinen 3 besten Boards" produziert Proposals, User approves, Site landet in Editor
|
||||
|
||||
**Exit criteria:** Template-Flow + AI-Mission-Flow produziert brauchbare Sites.
|
||||
|
||||
### M6 — Subdomain-Publishing + Custom-Domain-Foundation
|
||||
|
||||
- [ ] SvelteKit-Hook `hooks.server.ts`: Host-Header → rewrite `{slug}.mana.how` → `/s/{slug}/…`
|
||||
- [ ] Wildcard-DNS + TLS-Check im Staging
|
||||
- [ ] Custom-Domain-Schema: `website.custom_domains { site_id, hostname, status, tls_status, verified_at }`
|
||||
- [ ] DNS-Verify-Flow: CNAME-Record auf `custom.mana.how`, TXT-Record mit Challenge
|
||||
- [ ] Cloudflare-SaaS-Hostname-Integration (API-Call bei Verify-Success)
|
||||
- [ ] Tier-Gate: Custom-Domain nur für `founder`
|
||||
- [ ] `mana-landing-builder` Konsolidierungs-Entscheidung:
|
||||
- [ ] Untersuchen: kann Org-Landing-Page als spezial `spaceKind='organization'`-Site im neuen System leben?
|
||||
- [ ] Wenn ja: Org-Landing-Pages migrieren, `mana-landing-builder` → deprecation note, löschen nach Datenmigration
|
||||
- [ ] Wenn nein: Gründe dokumentieren, beide Systeme parallel halten
|
||||
|
||||
**Exit criteria:** `{slug}.mana.how` funktioniert. Founder-User kann eigene Domain verbinden.
|
||||
|
||||
### M7 — Observability, GC, Analytics
|
||||
|
||||
- [ ] Prometheus-Metrics: `website_publish_total`, `website_submissions_total`, `website_render_duration_seconds`, `website_cache_hit_ratio`
|
||||
- [ ] Orphan-Asset-GC: Job findet uload-Assets, die in keinem Block mehr referenziert sind, löscht nach 30d Grace-Period
|
||||
- [ ] `analytics`-Block-Typ: Plausible/Simple Analytics Snippet als Opt-In
|
||||
- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten)
|
||||
- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen (behält nur IDs + Status für Audit)
|
||||
- [ ] Dashboards in `docs/observability/website.md`
|
||||
|
||||
**Exit criteria:** Betrieb ist beobachtbar, Storage wächst nicht unbegrenzt.
|
||||
|
||||
## Risiken + Mitigation
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|
||||
|--------|-----|--------|------------|
|
||||
| Block-Schema-Migration bei Version-Bump ist schmerzhaft | Mittel | Mittel | `schemaVersion` + `upgraders`-Map pro Block, Migrations-Utility im Build zwingt forward-migration; Tests pro Upgrade |
|
||||
| Published-Snapshot-Blob wird zu groß (Performance) | Mittel | Mittel | Hart-Limit auf Gesamtgröße (5MB), UI warnt bei 80%; `moduleEmbed` dynamisch als Fallback bei großen Galleries |
|
||||
| Fractional Index läuft in Precision-Probleme bei häufigem Reorder | Niedrig | Mittel | Nach 1000 Reorders pro Page automatischer Rebalance-Job; Library wie `fractional-indexing` mit `jitter` |
|
||||
| CF-Cache-Purge-API down → stale Inhalte | Niedrig | Niedrig | Bei Purge-Fehler: Fallback auf `max-age=60`, Nutzer sieht neue Version in ≤1min; Alert bei wiederholtem Fehler |
|
||||
| Form-Spam auf Submit-Endpoint | Hoch | Mittel | Rate-Limit per IP, Honeypot-Feld pro Form, Captcha-Block-Typ in M7 |
|
||||
| Reserved-Slug-Liste wird erweitert, alte Sites brechen | Niedrig (pre-launch) | Hoch (post-launch) | Pre-launch: wir haben null Konflikt. Post-launch: neue Reserved-Slugs nur mit Migration + Slug-Rewrite für Betroffene |
|
||||
| moduleEmbed-Provider sharen versehentlich private Daten | Mittel | Hoch | Hart kodierter `isPublic`-Check pro Provider, Integrations-Test: Embed-Provider mit `isPublic=false` returnt null |
|
||||
| Editor-Co-Editing zwei Member → Konflikte | Mittel | Niedrig | mana-sync field-level LWW löst es pro Feld; bei Baum-Konflikten (beide adden Block an gleicher Position) → fractional index bricht Tie |
|
||||
| Svelte 5 Block-Components leaken Renderer-State zwischen `mode`-Wechsel | Niedrig | Niedrig | Unit-Tests pro Block mit allen drei Modes; `$effect` cleanup disziplinieren |
|
||||
| AI generiert Block-Props, die Schema-Validation brechen | Mittel | Niedrig | Tool-Handler validiert vor Apply; fehlende Required-Fields → Proposal mit Warnung, User kann ergänzen |
|
||||
|
||||
## Offene Entscheidungen (später)
|
||||
|
||||
- **I18n pro Site:** Mehrsprachige Sites (DE/EN/IT)? Könnte über `pages.path` mit Locale-Prefix gemacht werden (`/de/about` vs. `/en/about`). Nicht in Scope M1–M5; Entscheidung in M6-Zeitraum mit realer Nutzer-Nachfrage.
|
||||
- **Custom-Code-Blöcke:** `<script>`-Injection oder `<iframe>`-Embed wäre mächtig, aber security-relevant (XSS). Wenn, dann nur für `founder` und sandboxed. Frühestens M7.
|
||||
- **Versionierung über Publish hinaus:** Full Version-History (wie Git) pro Site? Derzeit nur "letzte 10 Snapshots". Wenn Kunden fragen, ausbauen.
|
||||
- **A/B-Testing:** Zwei Varianten einer Page gegen ein Traffic-Split testen. Nice-to-have, aber erst wenn Analytics steht.
|
||||
- **Staging-Deploy pro Site:** Eine Staging-URL (`/s/{slug}/preview?token=…`) zum Teilen vor Publish. Könnte Hand-in-Hand mit Collaboration (Member commentiert "das Hero sieht schief aus") kommen.
|
||||
- **Export:** "Export als statische HTML/CSS/JS"-Download — sinnvoll als Lock-In-Gegenmittel, nicht trivial (alle Assets + Routen + Forms). Eher nein im MVP.
|
||||
- **Konsolidierung mit `mana-landing-builder`:** Entscheidung in M6 wie oben. Solange parallel, beide als bewusst getrennte Systeme dokumentieren.
|
||||
|
||||
## Referenzen
|
||||
|
||||
- [`docs/plans/spaces-foundation.md`](spaces-foundation.md) — Space-Scoping, Membership-Model
|
||||
- [`docs/plans/space-scoped-data-model.md`](space-scoped-data-model.md) — space-scoped RLS-Pattern
|
||||
- [`docs/plans/mana-mcp-and-personas.md`](mana-mcp-and-personas.md) — `packages/mana-tool-registry` als SSOT für AI/MCP-Tools
|
||||
- [`docs/plans/library-module.md`](library-module.md) — Module-Pattern-Beispiel für diskriminierte Typen
|
||||
- [`apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`](../../apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md) — Sync-Engine-Deep-Dive
|
||||
- [`.claude/guidelines/sveltekit-web.md`](../../.claude/guidelines/sveltekit-web.md) — Svelte 5 Runes Conventions
|
||||
- [`.claude/guidelines/database.md`](../../.claude/guidelines/database.md) — pgSchema-Pattern, Drizzle
|
||||
|
||||
## Shipping Log
|
||||
|
||||
(Leer — wird befüllt, während M1 → M7 gehen.)
|
||||
|
||||
| Phase | Purpose | Commit |
|
||||
| --- | --- | --- |
|
||||
| — | — | — |
|
||||
|
|
@ -260,6 +260,12 @@ export const APP_ICONS = {
|
|||
// while still reading as "chronological" in the AI Workbench family.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="tl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#ea580c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#tl)"/><line x1="34" y1="22" x2="34" y2="78" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.55"/><circle cx="34" cy="30" r="5" fill="white"/><circle cx="34" cy="50" r="5" fill="white"/><circle cx="34" cy="70" r="5" fill="white"/><rect x="44" y="26" width="32" height="8" rx="2" fill="white" fill-opacity="0.95"/><rect x="44" y="46" width="26" height="8" rx="2" fill="white" fill-opacity="0.8"/><rect x="44" y="66" width="30" height="8" rx="2" fill="white" fill-opacity="0.9"/></svg>`
|
||||
),
|
||||
website: svgToDataUrl(
|
||||
// Browser window with three stacked content blocks — the "Baukasten"
|
||||
// theme. Indigo→violet gradient, sibling to broadcast (communication
|
||||
// family) but leaning more towards the creative-publishing cluster.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#8b5cf6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wb)"/><rect x="18" y="22" width="64" height="56" rx="5" fill="white"/><rect x="18" y="22" width="64" height="10" rx="5" fill="white" fill-opacity="0.75"/><circle cx="25" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><circle cx="31" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><circle cx="37" cy="27" r="1.8" fill="#6366f1" fill-opacity="0.5"/><rect x="24" y="38" width="52" height="12" rx="2" fill="#6366f1" fill-opacity="0.85"/><rect x="24" y="54" width="24" height="18" rx="2" fill="#6366f1" fill-opacity="0.35"/><rect x="52" y="54" width="24" height="8" rx="2" fill="#6366f1" fill-opacity="0.55"/><rect x="52" y="64" width="24" height="8" rx="2" fill="#6366f1" fill-opacity="0.45"/></svg>`
|
||||
),
|
||||
spaces: svgToDataUrl(
|
||||
// Three people-silhouettes clustered in the tile — the Spaces primitive
|
||||
// is about shared workspaces, so the icon emphasises "group". Teal→indigo
|
||||
|
|
|
|||
|
|
@ -1105,6 +1105,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'beta',
|
||||
requiredTier: 'beta',
|
||||
},
|
||||
{
|
||||
id: 'website',
|
||||
name: 'Website',
|
||||
description: {
|
||||
de: 'Baukasten für deine Website',
|
||||
en: 'Website builder',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Mehrseitige Websites mit einem Block-Editor bauen — Hero, Text, Galerien, Formulare — und Inhalte aus anderen Mana-Modulen (Picture-Boards, Bibliothek, News) direkt einbetten. Für Privat (Portfolio, Event-Seite) und für Firmen (Space mit mehreren Membern).',
|
||||
en: 'Build multi-page websites with a block editor — hero, text, galleries, forms — and embed content from your other Mana modules (picture boards, library, news) directly. For personal use (portfolio, event page) and for organisations (shared space with multiple members).',
|
||||
},
|
||||
icon: APP_ICONS.website,
|
||||
color: '#6366f1',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'public',
|
||||
},
|
||||
{
|
||||
id: 'spaces',
|
||||
name: 'Spaces',
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'storage',
|
||||
'uload',
|
||||
'landing', // future
|
||||
'website',
|
||||
'presi',
|
||||
'cards',
|
||||
'picture',
|
||||
|
|
@ -104,6 +105,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'invoices',
|
||||
'finance',
|
||||
'landing', // future — Paket C (Vereinswebsite)
|
||||
'website',
|
||||
'presi',
|
||||
'cards',
|
||||
'quotes',
|
||||
|
|
@ -123,6 +125,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'mail',
|
||||
'storage',
|
||||
'uload',
|
||||
'website',
|
||||
'recipes',
|
||||
'food',
|
||||
'places',
|
||||
|
|
@ -146,6 +149,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'storage',
|
||||
'mail',
|
||||
'uload',
|
||||
'website',
|
||||
'news',
|
||||
'news-research',
|
||||
'research-lab',
|
||||
|
|
@ -169,6 +173,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'storage',
|
||||
'mail',
|
||||
'uload',
|
||||
'website',
|
||||
'invoices',
|
||||
'finance',
|
||||
'times',
|
||||
|
|
|
|||
44
packages/website-blocks/package.json
Normal file
44
packages/website-blocks/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@mana/website-blocks",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Block-tree primitives for the Mana website builder — Svelte components + Zod schemas, mode-aware rendering for editor/preview/public.",
|
||||
"type": "module",
|
||||
"svelte": "./src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"svelte": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./registry": {
|
||||
"svelte": "./src/registry.ts",
|
||||
"types": "./src/registry.ts",
|
||||
"default": "./src/registry.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.16.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
105
packages/website-blocks/src/hero/Hero.svelte
Normal file
105
packages/website-blocks/src/hero/Hero.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { HeroProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<HeroProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-hero"
|
||||
class:wb-hero--left={block.props.align === 'left'}
|
||||
class:wb-hero--center={block.props.align === 'center'}
|
||||
class:wb-hero--bg-subtle={block.props.background === 'subtle'}
|
||||
class:wb-hero--bg-gradient={block.props.background === 'gradient'}
|
||||
data-mode={mode}
|
||||
>
|
||||
<div class="wb-hero__inner">
|
||||
{#if block.props.eyebrow}
|
||||
<p class="wb-hero__eyebrow">{block.props.eyebrow}</p>
|
||||
{:else if isEdit}
|
||||
<p class="wb-hero__eyebrow wb-placeholder">Eyebrow (optional)</p>
|
||||
{/if}
|
||||
|
||||
<h1 class="wb-hero__title">
|
||||
{block.props.title || (isEdit ? 'Klick in den Inspector, um den Titel zu setzen' : '')}
|
||||
</h1>
|
||||
|
||||
{#if block.props.subtitle}
|
||||
<p class="wb-hero__subtitle">{block.props.subtitle}</p>
|
||||
{:else if isEdit}
|
||||
<p class="wb-hero__subtitle wb-placeholder">Untertitel (optional)</p>
|
||||
{/if}
|
||||
|
||||
{#if block.props.ctaLabel && block.props.ctaHref}
|
||||
<a class="wb-hero__cta" href={block.props.ctaHref}>{block.props.ctaLabel}</a>
|
||||
{:else if isEdit && (block.props.ctaLabel || block.props.ctaHref)}
|
||||
<span class="wb-hero__cta wb-placeholder" aria-disabled="true">
|
||||
{block.props.ctaLabel || 'Call-to-Action Label fehlt'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-hero {
|
||||
padding: 4rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-hero--bg-subtle {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.wb-hero--bg-gradient {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.18), rgba(168, 85, 247, 0.18));
|
||||
}
|
||||
.wb-hero__inner {
|
||||
max-width: 64rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-hero--center .wb-hero__inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.wb-hero--left .wb-hero__inner {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.wb-hero__eyebrow {
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-hero__title {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-hero__subtitle {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.8;
|
||||
max-width: 48rem;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-hero__cta {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.wb-placeholder {
|
||||
opacity: 0.35;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
123
packages/website-blocks/src/hero/HeroInspector.svelte
Normal file
123
packages/website-blocks/src/hero/HeroInspector.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { HeroProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<HeroProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Eyebrow</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.eyebrow}
|
||||
oninput={(e) => onChange({ eyebrow: e.currentTarget.value })}
|
||||
placeholder="Optional — kleiner Text über dem Titel"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Titel *</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.title}
|
||||
oninput={(e) => onChange({ title: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Untertitel</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={block.props.subtitle}
|
||||
oninput={(e) => onChange({ subtitle: e.currentTarget.value })}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>CTA Label</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.ctaLabel}
|
||||
oninput={(e) => onChange({ ctaLabel: e.currentTarget.value })}
|
||||
placeholder="z.B. Los geht's"
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-field">
|
||||
<span>CTA Link</span>
|
||||
<input
|
||||
type="url"
|
||||
value={block.props.ctaHref}
|
||||
oninput={(e) => onChange({ ctaHref: e.currentTarget.value })}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Ausrichtung</span>
|
||||
<select
|
||||
value={block.props.align}
|
||||
onchange={(e) => onChange({ align: e.currentTarget.value as HeroProps['align'] })}
|
||||
>
|
||||
<option value="center">Zentriert</option>
|
||||
<option value="left">Linksbündig</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Hintergrund</span>
|
||||
<select
|
||||
value={block.props.background}
|
||||
onchange={(e) => onChange({ background: e.currentTarget.value as HeroProps['background'] })}
|
||||
>
|
||||
<option value="none">Kein</option>
|
||||
<option value="subtle">Dezent</option>
|
||||
<option value="gradient">Gradient</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field textarea,
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/hero/index.ts
Normal file
19
packages/website-blocks/src/hero/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Hero from './Hero.svelte';
|
||||
import HeroInspector from './HeroInspector.svelte';
|
||||
import { HeroSchema, HERO_DEFAULTS, type HeroProps } from './schema';
|
||||
|
||||
export const heroBlockSpec: BlockSpec<HeroProps> = {
|
||||
type: 'hero',
|
||||
label: 'Hero',
|
||||
icon: 'heading',
|
||||
category: 'content',
|
||||
schema: HeroSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: HERO_DEFAULTS,
|
||||
Component: Hero,
|
||||
Inspector: HeroInspector,
|
||||
};
|
||||
|
||||
export type { HeroProps };
|
||||
export { HeroSchema, HERO_DEFAULTS };
|
||||
23
packages/website-blocks/src/hero/schema.ts
Normal file
23
packages/website-blocks/src/hero/schema.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const HeroSchema = z.object({
|
||||
eyebrow: z.string().max(120).default(''),
|
||||
title: z.string().min(1).max(240),
|
||||
subtitle: z.string().max(480).default(''),
|
||||
ctaLabel: z.string().max(60).default(''),
|
||||
ctaHref: z.string().max(512).default(''),
|
||||
align: z.enum(['left', 'center']).default('center'),
|
||||
background: z.enum(['none', 'subtle', 'gradient']).default('subtle'),
|
||||
});
|
||||
|
||||
export type HeroProps = z.infer<typeof HeroSchema>;
|
||||
|
||||
export const HERO_DEFAULTS: HeroProps = {
|
||||
eyebrow: '',
|
||||
title: 'Dein Titel',
|
||||
subtitle: 'Eine kurze Beschreibung — was macht diese Seite relevant?',
|
||||
ctaLabel: '',
|
||||
ctaHref: '',
|
||||
align: 'center',
|
||||
background: 'subtle',
|
||||
};
|
||||
28
packages/website-blocks/src/index.ts
Normal file
28
packages/website-blocks/src/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type {
|
||||
Block,
|
||||
BlockMode,
|
||||
BlockCategory,
|
||||
BlockRenderProps,
|
||||
BlockInspectorProps,
|
||||
BlockSpec,
|
||||
PropsOf,
|
||||
InferProps,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
BLOCK_SPECS,
|
||||
getBlockSpec,
|
||||
requireBlockSpec,
|
||||
getAllBlockSpecs,
|
||||
validateBlockProps,
|
||||
safeValidateBlockProps,
|
||||
} from './registry';
|
||||
|
||||
export { heroBlockSpec, HeroSchema, HERO_DEFAULTS, type HeroProps } from './hero';
|
||||
export {
|
||||
richTextBlockSpec,
|
||||
RichTextSchema,
|
||||
RICH_TEXT_DEFAULTS,
|
||||
type RichTextProps,
|
||||
} from './richText';
|
||||
export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer';
|
||||
68
packages/website-blocks/src/registry.ts
Normal file
68
packages/website-blocks/src/registry.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { BlockSpec } from './types';
|
||||
import { heroBlockSpec } from './hero';
|
||||
import { richTextBlockSpec } from './richText';
|
||||
import { spacerBlockSpec } from './spacer';
|
||||
|
||||
/**
|
||||
* The block registry — single source of truth for every block type the
|
||||
* website builder knows about. Editor insert palette, renderer, inspector,
|
||||
* schema validation, and future AI tools all consume this map.
|
||||
*
|
||||
* Adding a new block = create a folder under `src/{type}/`, export a
|
||||
* `BlockSpec` from its index, and list it here.
|
||||
*/
|
||||
export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
|
||||
heroBlockSpec,
|
||||
richTextBlockSpec,
|
||||
spacerBlockSpec,
|
||||
] as unknown as readonly BlockSpec<unknown>[];
|
||||
|
||||
const BY_TYPE: Record<string, BlockSpec<unknown>> = (() => {
|
||||
const map: Record<string, BlockSpec<unknown>> = {};
|
||||
for (const spec of BLOCK_SPECS) {
|
||||
if (map[spec.type]) {
|
||||
throw new Error(`[website-blocks] duplicate block type "${spec.type}"`);
|
||||
}
|
||||
map[spec.type] = spec as BlockSpec<unknown>;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
export function getBlockSpec(type: string): BlockSpec<unknown> | undefined {
|
||||
return BY_TYPE[type];
|
||||
}
|
||||
|
||||
export function requireBlockSpec(type: string): BlockSpec<unknown> {
|
||||
const spec = BY_TYPE[type];
|
||||
if (!spec) throw new Error(`[website-blocks] unknown block type "${type}"`);
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function getAllBlockSpecs(): readonly BlockSpec<unknown>[] {
|
||||
return BLOCK_SPECS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate props against a block type's schema. Returns the parsed props
|
||||
* (with defaults applied) on success, or throws with the Zod error.
|
||||
*/
|
||||
export function validateBlockProps(type: string, props: unknown): unknown {
|
||||
const spec = requireBlockSpec(type);
|
||||
return spec.schema.parse(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe-validate: returns `{ success, data, error }` without throwing.
|
||||
* Used at boundaries (submit endpoint, snapshot builder) where we want
|
||||
* to collect all errors rather than fail on the first one.
|
||||
*/
|
||||
export function safeValidateBlockProps(
|
||||
type: string,
|
||||
props: unknown
|
||||
): { success: true; data: unknown } | { success: false; error: unknown } {
|
||||
const spec = getBlockSpec(type);
|
||||
if (!spec) return { success: false, error: new Error(`Unknown block type "${type}"`) };
|
||||
const parsed = spec.schema.safeParse(props);
|
||||
if (parsed.success) return { success: true, data: parsed.data };
|
||||
return { success: false, error: parsed.error };
|
||||
}
|
||||
73
packages/website-blocks/src/richText/RichText.svelte
Normal file
73
packages/website-blocks/src/richText/RichText.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { RichTextProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<RichTextProps> = $props();
|
||||
|
||||
const paragraphs = $derived(
|
||||
block.props.content
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
);
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-richtext"
|
||||
class:wb-richtext--left={block.props.align === 'left'}
|
||||
class:wb-richtext--center={block.props.align === 'center'}
|
||||
class:wb-richtext--sm={block.props.size === 'sm'}
|
||||
class:wb-richtext--md={block.props.size === 'md'}
|
||||
class:wb-richtext--lg={block.props.size === 'lg'}
|
||||
data-mode={mode}
|
||||
>
|
||||
<div class="wb-richtext__inner">
|
||||
{#if paragraphs.length === 0 && isEdit}
|
||||
<p class="wb-placeholder">Leerer Text — öffne den Inspector und fang an zu schreiben.</p>
|
||||
{:else}
|
||||
{#each paragraphs as paragraph, i (i)}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-richtext {
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-richtext__inner {
|
||||
max-width: 48rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-richtext--left .wb-richtext__inner {
|
||||
text-align: left;
|
||||
}
|
||||
.wb-richtext--center .wb-richtext__inner {
|
||||
text-align: center;
|
||||
}
|
||||
.wb-richtext p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.wb-richtext--sm p {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.wb-richtext--md p {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.wb-richtext--lg p {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.wb-placeholder {
|
||||
opacity: 0.35;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { RichTextProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<RichTextProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Text</span>
|
||||
<textarea
|
||||
rows="10"
|
||||
value={block.props.content}
|
||||
oninput={(e) => onChange({ content: e.currentTarget.value })}
|
||||
placeholder="Leere Zeile = neuer Absatz. Markdown folgt in M3."
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Ausrichtung</span>
|
||||
<select
|
||||
value={block.props.align}
|
||||
onchange={(e) => onChange({ align: e.currentTarget.value as RichTextProps['align'] })}
|
||||
>
|
||||
<option value="left">Linksbündig</option>
|
||||
<option value="center">Zentriert</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Schriftgröße</span>
|
||||
<select
|
||||
value={block.props.size}
|
||||
onchange={(e) => onChange({ size: e.currentTarget.value as RichTextProps['size'] })}
|
||||
>
|
||||
<option value="sm">Klein</option>
|
||||
<option value="md">Normal</option>
|
||||
<option value="lg">Groß</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field textarea,
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 6rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/richText/index.ts
Normal file
19
packages/website-blocks/src/richText/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import RichText from './RichText.svelte';
|
||||
import RichTextInspector from './RichTextInspector.svelte';
|
||||
import { RichTextSchema, RICH_TEXT_DEFAULTS, type RichTextProps } from './schema';
|
||||
|
||||
export const richTextBlockSpec: BlockSpec<RichTextProps> = {
|
||||
type: 'richText',
|
||||
label: 'Text',
|
||||
icon: 'text',
|
||||
category: 'content',
|
||||
schema: RichTextSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: RICH_TEXT_DEFAULTS,
|
||||
Component: RichText,
|
||||
Inspector: RichTextInspector,
|
||||
};
|
||||
|
||||
export type { RichTextProps };
|
||||
export { RichTextSchema, RICH_TEXT_DEFAULTS };
|
||||
15
packages/website-blocks/src/richText/schema.ts
Normal file
15
packages/website-blocks/src/richText/schema.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const RichTextSchema = z.object({
|
||||
content: z.string().max(10_000).default(''),
|
||||
align: z.enum(['left', 'center']).default('left'),
|
||||
size: z.enum(['sm', 'md', 'lg']).default('md'),
|
||||
});
|
||||
|
||||
export type RichTextProps = z.infer<typeof RichTextSchema>;
|
||||
|
||||
export const RICH_TEXT_DEFAULTS: RichTextProps = {
|
||||
content: '',
|
||||
align: 'left',
|
||||
size: 'md',
|
||||
};
|
||||
50
packages/website-blocks/src/spacer/Spacer.svelte
Normal file
50
packages/website-blocks/src/spacer/Spacer.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { SpacerProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<SpacerProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="wb-spacer"
|
||||
class:wb-spacer--sm={block.props.size === 'sm'}
|
||||
class:wb-spacer--md={block.props.size === 'md'}
|
||||
class:wb-spacer--lg={block.props.size === 'lg'}
|
||||
class:wb-spacer--xl={block.props.size === 'xl'}
|
||||
data-mode={mode}
|
||||
>
|
||||
{#if isEdit}
|
||||
<span class="wb-spacer__label">Spacer ({block.props.size})</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-spacer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-spacer--sm {
|
||||
height: 1.5rem;
|
||||
}
|
||||
.wb-spacer--md {
|
||||
height: 3rem;
|
||||
}
|
||||
.wb-spacer--lg {
|
||||
height: 6rem;
|
||||
}
|
||||
.wb-spacer--xl {
|
||||
height: 9rem;
|
||||
}
|
||||
.wb-spacer[data-mode='edit'] {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.wb-spacer__label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.35;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
50
packages/website-blocks/src/spacer/SpacerInspector.svelte
Normal file
50
packages/website-blocks/src/spacer/SpacerInspector.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { SpacerProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<SpacerProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Größe</span>
|
||||
<select
|
||||
value={block.props.size}
|
||||
onchange={(e) => onChange({ size: e.currentTarget.value as SpacerProps['size'] })}
|
||||
>
|
||||
<option value="sm">Klein (1.5rem)</option>
|
||||
<option value="md">Mittel (3rem)</option>
|
||||
<option value="lg">Groß (6rem)</option>
|
||||
<option value="xl">Sehr groß (9rem)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/spacer/index.ts
Normal file
19
packages/website-blocks/src/spacer/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Spacer from './Spacer.svelte';
|
||||
import SpacerInspector from './SpacerInspector.svelte';
|
||||
import { SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './schema';
|
||||
|
||||
export const spacerBlockSpec: BlockSpec<SpacerProps> = {
|
||||
type: 'spacer',
|
||||
label: 'Abstand',
|
||||
icon: 'separator',
|
||||
category: 'layout',
|
||||
schema: SpacerSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: SPACER_DEFAULTS,
|
||||
Component: Spacer,
|
||||
Inspector: SpacerInspector,
|
||||
};
|
||||
|
||||
export type { SpacerProps };
|
||||
export { SpacerSchema, SPACER_DEFAULTS };
|
||||
11
packages/website-blocks/src/spacer/schema.ts
Normal file
11
packages/website-blocks/src/spacer/schema.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const SpacerSchema = z.object({
|
||||
size: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
|
||||
});
|
||||
|
||||
export type SpacerProps = z.infer<typeof SpacerSchema>;
|
||||
|
||||
export const SPACER_DEFAULTS: SpacerProps = {
|
||||
size: 'md',
|
||||
};
|
||||
92
packages/website-blocks/src/types.ts
Normal file
92
packages/website-blocks/src/types.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { Component } from 'svelte';
|
||||
import type { ZodTypeAny, z } from 'zod';
|
||||
|
||||
/**
|
||||
* Render modes for every block component.
|
||||
*
|
||||
* - `edit` — Inside the editor. Shows inline-edit affordances (e.g. click
|
||||
* a Hero title to edit it), may render placeholder copy for
|
||||
* empty required fields.
|
||||
* - `preview` — Editor preview pane. Same rendering as `public` but inside
|
||||
* the editor chrome (responsive preview, breakpoint switcher).
|
||||
* - `public` — Served to real visitors via SvelteKit SSR. No edit chrome,
|
||||
* no placeholders — only real data. This is the mode the
|
||||
* published_snapshots blob is serialized for.
|
||||
*/
|
||||
export type BlockMode = 'edit' | 'preview' | 'public';
|
||||
|
||||
/**
|
||||
* A single block in the tree. Props are block-type-specific and validated
|
||||
* against the registered Zod schema at write time (in stores) and at
|
||||
* publish time (in the snapshot builder).
|
||||
*/
|
||||
export interface Block<Props = unknown> {
|
||||
id: string;
|
||||
type: string;
|
||||
props: Props;
|
||||
schemaVersion: number;
|
||||
order: number;
|
||||
parentBlockId: string | null;
|
||||
slotKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category for grouping blocks in the insert palette.
|
||||
*/
|
||||
export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed';
|
||||
|
||||
/**
|
||||
* Props passed to every block renderer. `onEdit` is only present in
|
||||
* `edit` mode — consumers must guard with `if (mode === 'edit' && onEdit)`.
|
||||
*/
|
||||
export interface BlockRenderProps<Props = unknown> {
|
||||
block: Block<Props>;
|
||||
mode: BlockMode;
|
||||
children?: Block[];
|
||||
onEdit?: (patch: Partial<Props>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to every block inspector (right pane of the editor).
|
||||
*/
|
||||
export interface BlockInspectorProps<Props = unknown> {
|
||||
block: Block<Props>;
|
||||
onChange: (patch: Partial<Props>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered spec for one block type. The schema, renderer, inspector,
|
||||
* and metadata are bundled — the editor and public renderer consume the
|
||||
* same spec, so drift is structurally impossible.
|
||||
*/
|
||||
export interface BlockSpec<Props = unknown> {
|
||||
/** Stable type id, used in DB (`blocks.type`) and in code. */
|
||||
type: string;
|
||||
/** Human label shown in the insert palette. */
|
||||
label: string;
|
||||
/** Lucide icon name (or any icon id the editor knows how to render). */
|
||||
icon: string;
|
||||
/** Category for palette grouping. */
|
||||
category: BlockCategory;
|
||||
/** Zod schema defining valid props. Enforced at write + publish time. */
|
||||
schema: ZodTypeAny;
|
||||
/** Current schema version. Bump when the schema shape changes. */
|
||||
schemaVersion: number;
|
||||
/** Default prop values when a new block of this type is inserted. */
|
||||
defaults: Props;
|
||||
/** Svelte 5 component rendering the block in all three modes. */
|
||||
Component: Component<BlockRenderProps<Props>>;
|
||||
/** Svelte 5 component rendering the inspector form for this block. */
|
||||
Inspector: Component<BlockInspectorProps<Props>>;
|
||||
/**
|
||||
* Optional upgraders: version N → version N+1 prop transformer.
|
||||
* Keyed by the SOURCE version (v1 → v2 upgrader lives under key `1`).
|
||||
*/
|
||||
upgraders?: Record<number, (oldProps: unknown) => Props>;
|
||||
}
|
||||
|
||||
/** Helper to infer props type from a spec's schema. */
|
||||
export type PropsOf<Spec extends BlockSpec<unknown>> = Spec extends BlockSpec<infer P> ? P : never;
|
||||
|
||||
/** Helper to infer props type from a Zod schema. */
|
||||
export type InferProps<S extends ZodTypeAny> = z.infer<S>;
|
||||
19
packages/website-blocks/tsconfig.json
Normal file
19
packages/website-blocks/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"types": ["svelte"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
713
pnpm-lock.yaml
generated
713
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue