mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(webapp): wire isParallelSafe in Companion chat + Mission runner
Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:
isParallelSafe: (name) =>
AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'
Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.
Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a64a7e39cf
commit
54a12ffd5c
59 changed files with 5629 additions and 218 deletions
44
packages/website-blocks/package.json
Normal file
44
packages/website-blocks/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@mana/website-blocks",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Block-tree primitives for the Mana website builder — Svelte components + Zod schemas, mode-aware rendering for editor/preview/public.",
|
||||
"type": "module",
|
||||
"svelte": "./src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"svelte": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./registry": {
|
||||
"svelte": "./src/registry.ts",
|
||||
"types": "./src/registry.ts",
|
||||
"default": "./src/registry.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.16.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
105
packages/website-blocks/src/hero/Hero.svelte
Normal file
105
packages/website-blocks/src/hero/Hero.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { HeroProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<HeroProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-hero"
|
||||
class:wb-hero--left={block.props.align === 'left'}
|
||||
class:wb-hero--center={block.props.align === 'center'}
|
||||
class:wb-hero--bg-subtle={block.props.background === 'subtle'}
|
||||
class:wb-hero--bg-gradient={block.props.background === 'gradient'}
|
||||
data-mode={mode}
|
||||
>
|
||||
<div class="wb-hero__inner">
|
||||
{#if block.props.eyebrow}
|
||||
<p class="wb-hero__eyebrow">{block.props.eyebrow}</p>
|
||||
{:else if isEdit}
|
||||
<p class="wb-hero__eyebrow wb-placeholder">Eyebrow (optional)</p>
|
||||
{/if}
|
||||
|
||||
<h1 class="wb-hero__title">
|
||||
{block.props.title || (isEdit ? 'Klick in den Inspector, um den Titel zu setzen' : '')}
|
||||
</h1>
|
||||
|
||||
{#if block.props.subtitle}
|
||||
<p class="wb-hero__subtitle">{block.props.subtitle}</p>
|
||||
{:else if isEdit}
|
||||
<p class="wb-hero__subtitle wb-placeholder">Untertitel (optional)</p>
|
||||
{/if}
|
||||
|
||||
{#if block.props.ctaLabel && block.props.ctaHref}
|
||||
<a class="wb-hero__cta" href={block.props.ctaHref}>{block.props.ctaLabel}</a>
|
||||
{:else if isEdit && (block.props.ctaLabel || block.props.ctaHref)}
|
||||
<span class="wb-hero__cta wb-placeholder" aria-disabled="true">
|
||||
{block.props.ctaLabel || 'Call-to-Action Label fehlt'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-hero {
|
||||
padding: 4rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-hero--bg-subtle {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.wb-hero--bg-gradient {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.18), rgba(168, 85, 247, 0.18));
|
||||
}
|
||||
.wb-hero__inner {
|
||||
max-width: 64rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-hero--center .wb-hero__inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.wb-hero--left .wb-hero__inner {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.wb-hero__eyebrow {
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-hero__title {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-hero__subtitle {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.8;
|
||||
max-width: 48rem;
|
||||
margin: 0;
|
||||
}
|
||||
.wb-hero__cta {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.wb-placeholder {
|
||||
opacity: 0.35;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
123
packages/website-blocks/src/hero/HeroInspector.svelte
Normal file
123
packages/website-blocks/src/hero/HeroInspector.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { HeroProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<HeroProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Eyebrow</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.eyebrow}
|
||||
oninput={(e) => onChange({ eyebrow: e.currentTarget.value })}
|
||||
placeholder="Optional — kleiner Text über dem Titel"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Titel *</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.title}
|
||||
oninput={(e) => onChange({ title: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Untertitel</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={block.props.subtitle}
|
||||
oninput={(e) => onChange({ subtitle: e.currentTarget.value })}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>CTA Label</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.ctaLabel}
|
||||
oninput={(e) => onChange({ ctaLabel: e.currentTarget.value })}
|
||||
placeholder="z.B. Los geht's"
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-field">
|
||||
<span>CTA Link</span>
|
||||
<input
|
||||
type="url"
|
||||
value={block.props.ctaHref}
|
||||
oninput={(e) => onChange({ ctaHref: e.currentTarget.value })}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Ausrichtung</span>
|
||||
<select
|
||||
value={block.props.align}
|
||||
onchange={(e) => onChange({ align: e.currentTarget.value as HeroProps['align'] })}
|
||||
>
|
||||
<option value="center">Zentriert</option>
|
||||
<option value="left">Linksbündig</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Hintergrund</span>
|
||||
<select
|
||||
value={block.props.background}
|
||||
onchange={(e) => onChange({ background: e.currentTarget.value as HeroProps['background'] })}
|
||||
>
|
||||
<option value="none">Kein</option>
|
||||
<option value="subtle">Dezent</option>
|
||||
<option value="gradient">Gradient</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field textarea,
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/hero/index.ts
Normal file
19
packages/website-blocks/src/hero/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Hero from './Hero.svelte';
|
||||
import HeroInspector from './HeroInspector.svelte';
|
||||
import { HeroSchema, HERO_DEFAULTS, type HeroProps } from './schema';
|
||||
|
||||
export const heroBlockSpec: BlockSpec<HeroProps> = {
|
||||
type: 'hero',
|
||||
label: 'Hero',
|
||||
icon: 'heading',
|
||||
category: 'content',
|
||||
schema: HeroSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: HERO_DEFAULTS,
|
||||
Component: Hero,
|
||||
Inspector: HeroInspector,
|
||||
};
|
||||
|
||||
export type { HeroProps };
|
||||
export { HeroSchema, HERO_DEFAULTS };
|
||||
23
packages/website-blocks/src/hero/schema.ts
Normal file
23
packages/website-blocks/src/hero/schema.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const HeroSchema = z.object({
|
||||
eyebrow: z.string().max(120).default(''),
|
||||
title: z.string().min(1).max(240),
|
||||
subtitle: z.string().max(480).default(''),
|
||||
ctaLabel: z.string().max(60).default(''),
|
||||
ctaHref: z.string().max(512).default(''),
|
||||
align: z.enum(['left', 'center']).default('center'),
|
||||
background: z.enum(['none', 'subtle', 'gradient']).default('subtle'),
|
||||
});
|
||||
|
||||
export type HeroProps = z.infer<typeof HeroSchema>;
|
||||
|
||||
export const HERO_DEFAULTS: HeroProps = {
|
||||
eyebrow: '',
|
||||
title: 'Dein Titel',
|
||||
subtitle: 'Eine kurze Beschreibung — was macht diese Seite relevant?',
|
||||
ctaLabel: '',
|
||||
ctaHref: '',
|
||||
align: 'center',
|
||||
background: 'subtle',
|
||||
};
|
||||
28
packages/website-blocks/src/index.ts
Normal file
28
packages/website-blocks/src/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type {
|
||||
Block,
|
||||
BlockMode,
|
||||
BlockCategory,
|
||||
BlockRenderProps,
|
||||
BlockInspectorProps,
|
||||
BlockSpec,
|
||||
PropsOf,
|
||||
InferProps,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
BLOCK_SPECS,
|
||||
getBlockSpec,
|
||||
requireBlockSpec,
|
||||
getAllBlockSpecs,
|
||||
validateBlockProps,
|
||||
safeValidateBlockProps,
|
||||
} from './registry';
|
||||
|
||||
export { heroBlockSpec, HeroSchema, HERO_DEFAULTS, type HeroProps } from './hero';
|
||||
export {
|
||||
richTextBlockSpec,
|
||||
RichTextSchema,
|
||||
RICH_TEXT_DEFAULTS,
|
||||
type RichTextProps,
|
||||
} from './richText';
|
||||
export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer';
|
||||
68
packages/website-blocks/src/registry.ts
Normal file
68
packages/website-blocks/src/registry.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { BlockSpec } from './types';
|
||||
import { heroBlockSpec } from './hero';
|
||||
import { richTextBlockSpec } from './richText';
|
||||
import { spacerBlockSpec } from './spacer';
|
||||
|
||||
/**
|
||||
* The block registry — single source of truth for every block type the
|
||||
* website builder knows about. Editor insert palette, renderer, inspector,
|
||||
* schema validation, and future AI tools all consume this map.
|
||||
*
|
||||
* Adding a new block = create a folder under `src/{type}/`, export a
|
||||
* `BlockSpec` from its index, and list it here.
|
||||
*/
|
||||
export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
|
||||
heroBlockSpec,
|
||||
richTextBlockSpec,
|
||||
spacerBlockSpec,
|
||||
] as unknown as readonly BlockSpec<unknown>[];
|
||||
|
||||
const BY_TYPE: Record<string, BlockSpec<unknown>> = (() => {
|
||||
const map: Record<string, BlockSpec<unknown>> = {};
|
||||
for (const spec of BLOCK_SPECS) {
|
||||
if (map[spec.type]) {
|
||||
throw new Error(`[website-blocks] duplicate block type "${spec.type}"`);
|
||||
}
|
||||
map[spec.type] = spec as BlockSpec<unknown>;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
export function getBlockSpec(type: string): BlockSpec<unknown> | undefined {
|
||||
return BY_TYPE[type];
|
||||
}
|
||||
|
||||
export function requireBlockSpec(type: string): BlockSpec<unknown> {
|
||||
const spec = BY_TYPE[type];
|
||||
if (!spec) throw new Error(`[website-blocks] unknown block type "${type}"`);
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function getAllBlockSpecs(): readonly BlockSpec<unknown>[] {
|
||||
return BLOCK_SPECS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate props against a block type's schema. Returns the parsed props
|
||||
* (with defaults applied) on success, or throws with the Zod error.
|
||||
*/
|
||||
export function validateBlockProps(type: string, props: unknown): unknown {
|
||||
const spec = requireBlockSpec(type);
|
||||
return spec.schema.parse(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe-validate: returns `{ success, data, error }` without throwing.
|
||||
* Used at boundaries (submit endpoint, snapshot builder) where we want
|
||||
* to collect all errors rather than fail on the first one.
|
||||
*/
|
||||
export function safeValidateBlockProps(
|
||||
type: string,
|
||||
props: unknown
|
||||
): { success: true; data: unknown } | { success: false; error: unknown } {
|
||||
const spec = getBlockSpec(type);
|
||||
if (!spec) return { success: false, error: new Error(`Unknown block type "${type}"`) };
|
||||
const parsed = spec.schema.safeParse(props);
|
||||
if (parsed.success) return { success: true, data: parsed.data };
|
||||
return { success: false, error: parsed.error };
|
||||
}
|
||||
73
packages/website-blocks/src/richText/RichText.svelte
Normal file
73
packages/website-blocks/src/richText/RichText.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { RichTextProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<RichTextProps> = $props();
|
||||
|
||||
const paragraphs = $derived(
|
||||
block.props.content
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
);
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-richtext"
|
||||
class:wb-richtext--left={block.props.align === 'left'}
|
||||
class:wb-richtext--center={block.props.align === 'center'}
|
||||
class:wb-richtext--sm={block.props.size === 'sm'}
|
||||
class:wb-richtext--md={block.props.size === 'md'}
|
||||
class:wb-richtext--lg={block.props.size === 'lg'}
|
||||
data-mode={mode}
|
||||
>
|
||||
<div class="wb-richtext__inner">
|
||||
{#if paragraphs.length === 0 && isEdit}
|
||||
<p class="wb-placeholder">Leerer Text — öffne den Inspector und fang an zu schreiben.</p>
|
||||
{:else}
|
||||
{#each paragraphs as paragraph, i (i)}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-richtext {
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-richtext__inner {
|
||||
max-width: 48rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-richtext--left .wb-richtext__inner {
|
||||
text-align: left;
|
||||
}
|
||||
.wb-richtext--center .wb-richtext__inner {
|
||||
text-align: center;
|
||||
}
|
||||
.wb-richtext p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.wb-richtext--sm p {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.wb-richtext--md p {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.wb-richtext--lg p {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.wb-placeholder {
|
||||
opacity: 0.35;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { RichTextProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<RichTextProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Text</span>
|
||||
<textarea
|
||||
rows="10"
|
||||
value={block.props.content}
|
||||
oninput={(e) => onChange({ content: e.currentTarget.value })}
|
||||
placeholder="Leere Zeile = neuer Absatz. Markdown folgt in M3."
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Ausrichtung</span>
|
||||
<select
|
||||
value={block.props.align}
|
||||
onchange={(e) => onChange({ align: e.currentTarget.value as RichTextProps['align'] })}
|
||||
>
|
||||
<option value="left">Linksbündig</option>
|
||||
<option value="center">Zentriert</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Schriftgröße</span>
|
||||
<select
|
||||
value={block.props.size}
|
||||
onchange={(e) => onChange({ size: e.currentTarget.value as RichTextProps['size'] })}
|
||||
>
|
||||
<option value="sm">Klein</option>
|
||||
<option value="md">Normal</option>
|
||||
<option value="lg">Groß</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field textarea,
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 6rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/richText/index.ts
Normal file
19
packages/website-blocks/src/richText/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import RichText from './RichText.svelte';
|
||||
import RichTextInspector from './RichTextInspector.svelte';
|
||||
import { RichTextSchema, RICH_TEXT_DEFAULTS, type RichTextProps } from './schema';
|
||||
|
||||
export const richTextBlockSpec: BlockSpec<RichTextProps> = {
|
||||
type: 'richText',
|
||||
label: 'Text',
|
||||
icon: 'text',
|
||||
category: 'content',
|
||||
schema: RichTextSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: RICH_TEXT_DEFAULTS,
|
||||
Component: RichText,
|
||||
Inspector: RichTextInspector,
|
||||
};
|
||||
|
||||
export type { RichTextProps };
|
||||
export { RichTextSchema, RICH_TEXT_DEFAULTS };
|
||||
15
packages/website-blocks/src/richText/schema.ts
Normal file
15
packages/website-blocks/src/richText/schema.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const RichTextSchema = z.object({
|
||||
content: z.string().max(10_000).default(''),
|
||||
align: z.enum(['left', 'center']).default('left'),
|
||||
size: z.enum(['sm', 'md', 'lg']).default('md'),
|
||||
});
|
||||
|
||||
export type RichTextProps = z.infer<typeof RichTextSchema>;
|
||||
|
||||
export const RICH_TEXT_DEFAULTS: RichTextProps = {
|
||||
content: '',
|
||||
align: 'left',
|
||||
size: 'md',
|
||||
};
|
||||
50
packages/website-blocks/src/spacer/Spacer.svelte
Normal file
50
packages/website-blocks/src/spacer/Spacer.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { SpacerProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<SpacerProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="wb-spacer"
|
||||
class:wb-spacer--sm={block.props.size === 'sm'}
|
||||
class:wb-spacer--md={block.props.size === 'md'}
|
||||
class:wb-spacer--lg={block.props.size === 'lg'}
|
||||
class:wb-spacer--xl={block.props.size === 'xl'}
|
||||
data-mode={mode}
|
||||
>
|
||||
{#if isEdit}
|
||||
<span class="wb-spacer__label">Spacer ({block.props.size})</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-spacer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-spacer--sm {
|
||||
height: 1.5rem;
|
||||
}
|
||||
.wb-spacer--md {
|
||||
height: 3rem;
|
||||
}
|
||||
.wb-spacer--lg {
|
||||
height: 6rem;
|
||||
}
|
||||
.wb-spacer--xl {
|
||||
height: 9rem;
|
||||
}
|
||||
.wb-spacer[data-mode='edit'] {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.wb-spacer__label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.35;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
50
packages/website-blocks/src/spacer/SpacerInspector.svelte
Normal file
50
packages/website-blocks/src/spacer/SpacerInspector.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { SpacerProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<SpacerProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Größe</span>
|
||||
<select
|
||||
value={block.props.size}
|
||||
onchange={(e) => onChange({ size: e.currentTarget.value as SpacerProps['size'] })}
|
||||
>
|
||||
<option value="sm">Klein (1.5rem)</option>
|
||||
<option value="md">Mittel (3rem)</option>
|
||||
<option value="lg">Groß (6rem)</option>
|
||||
<option value="xl">Sehr groß (9rem)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/spacer/index.ts
Normal file
19
packages/website-blocks/src/spacer/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Spacer from './Spacer.svelte';
|
||||
import SpacerInspector from './SpacerInspector.svelte';
|
||||
import { SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './schema';
|
||||
|
||||
export const spacerBlockSpec: BlockSpec<SpacerProps> = {
|
||||
type: 'spacer',
|
||||
label: 'Abstand',
|
||||
icon: 'separator',
|
||||
category: 'layout',
|
||||
schema: SpacerSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: SPACER_DEFAULTS,
|
||||
Component: Spacer,
|
||||
Inspector: SpacerInspector,
|
||||
};
|
||||
|
||||
export type { SpacerProps };
|
||||
export { SpacerSchema, SPACER_DEFAULTS };
|
||||
11
packages/website-blocks/src/spacer/schema.ts
Normal file
11
packages/website-blocks/src/spacer/schema.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const SpacerSchema = z.object({
|
||||
size: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
|
||||
});
|
||||
|
||||
export type SpacerProps = z.infer<typeof SpacerSchema>;
|
||||
|
||||
export const SPACER_DEFAULTS: SpacerProps = {
|
||||
size: 'md',
|
||||
};
|
||||
92
packages/website-blocks/src/types.ts
Normal file
92
packages/website-blocks/src/types.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { Component } from 'svelte';
|
||||
import type { ZodTypeAny, z } from 'zod';
|
||||
|
||||
/**
|
||||
* Render modes for every block component.
|
||||
*
|
||||
* - `edit` — Inside the editor. Shows inline-edit affordances (e.g. click
|
||||
* a Hero title to edit it), may render placeholder copy for
|
||||
* empty required fields.
|
||||
* - `preview` — Editor preview pane. Same rendering as `public` but inside
|
||||
* the editor chrome (responsive preview, breakpoint switcher).
|
||||
* - `public` — Served to real visitors via SvelteKit SSR. No edit chrome,
|
||||
* no placeholders — only real data. This is the mode the
|
||||
* published_snapshots blob is serialized for.
|
||||
*/
|
||||
export type BlockMode = 'edit' | 'preview' | 'public';
|
||||
|
||||
/**
|
||||
* A single block in the tree. Props are block-type-specific and validated
|
||||
* against the registered Zod schema at write time (in stores) and at
|
||||
* publish time (in the snapshot builder).
|
||||
*/
|
||||
export interface Block<Props = unknown> {
|
||||
id: string;
|
||||
type: string;
|
||||
props: Props;
|
||||
schemaVersion: number;
|
||||
order: number;
|
||||
parentBlockId: string | null;
|
||||
slotKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category for grouping blocks in the insert palette.
|
||||
*/
|
||||
export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed';
|
||||
|
||||
/**
|
||||
* Props passed to every block renderer. `onEdit` is only present in
|
||||
* `edit` mode — consumers must guard with `if (mode === 'edit' && onEdit)`.
|
||||
*/
|
||||
export interface BlockRenderProps<Props = unknown> {
|
||||
block: Block<Props>;
|
||||
mode: BlockMode;
|
||||
children?: Block[];
|
||||
onEdit?: (patch: Partial<Props>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to every block inspector (right pane of the editor).
|
||||
*/
|
||||
export interface BlockInspectorProps<Props = unknown> {
|
||||
block: Block<Props>;
|
||||
onChange: (patch: Partial<Props>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered spec for one block type. The schema, renderer, inspector,
|
||||
* and metadata are bundled — the editor and public renderer consume the
|
||||
* same spec, so drift is structurally impossible.
|
||||
*/
|
||||
export interface BlockSpec<Props = unknown> {
|
||||
/** Stable type id, used in DB (`blocks.type`) and in code. */
|
||||
type: string;
|
||||
/** Human label shown in the insert palette. */
|
||||
label: string;
|
||||
/** Lucide icon name (or any icon id the editor knows how to render). */
|
||||
icon: string;
|
||||
/** Category for palette grouping. */
|
||||
category: BlockCategory;
|
||||
/** Zod schema defining valid props. Enforced at write + publish time. */
|
||||
schema: ZodTypeAny;
|
||||
/** Current schema version. Bump when the schema shape changes. */
|
||||
schemaVersion: number;
|
||||
/** Default prop values when a new block of this type is inserted. */
|
||||
defaults: Props;
|
||||
/** Svelte 5 component rendering the block in all three modes. */
|
||||
Component: Component<BlockRenderProps<Props>>;
|
||||
/** Svelte 5 component rendering the inspector form for this block. */
|
||||
Inspector: Component<BlockInspectorProps<Props>>;
|
||||
/**
|
||||
* Optional upgraders: version N → version N+1 prop transformer.
|
||||
* Keyed by the SOURCE version (v1 → v2 upgrader lives under key `1`).
|
||||
*/
|
||||
upgraders?: Record<number, (oldProps: unknown) => Props>;
|
||||
}
|
||||
|
||||
/** Helper to infer props type from a spec's schema. */
|
||||
export type PropsOf<Spec extends BlockSpec<unknown>> = Spec extends BlockSpec<infer P> ? P : never;
|
||||
|
||||
/** Helper to infer props type from a Zod schema. */
|
||||
export type InferProps<S extends ZodTypeAny> = z.infer<S>;
|
||||
19
packages/website-blocks/tsconfig.json
Normal file
19
packages/website-blocks/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"types": ["svelte"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue