mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 10:53:40 +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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue