mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 04:53:38 +02:00
feat(website): M4 — forms + moduleEmbed
Adds two new block types and the server-side infrastructure for untrusted input + cross-module data embedding. Forms: - packages/website-blocks/src/form: declarative fields (text, email, tel, url, textarea, number) with required / maxLength / placeholder per field. Honeypot hidden input in the renderer; public-mode POST to a same-origin SvelteKit proxy that forwards to mana-api. - apps/api: website.submissions table (schema.ts + 0001_submissions.sql) + POST /public/submit/:siteSlug/:blockId. Loads the current published snapshot, finds the form block, validates payload against its declared fields (trim, type check, length cap), rejects honeypot submissions silently, rate-limits per IP (10 / 5 min) in-memory. Unknown keys are dropped — clients can only submit declared fields. - Owner-facing: GET/DELETE /sites/:id/submissions + SubmissionsView component + /(app)/website/[siteId]/submissions route. Shows incoming submissions with status pill + payload preview + delete. - apps/mana/.../routes/s/[siteSlug]/__submit/[blockId]/+server.ts: same-origin proxy so form posts don't trigger CORS and IP / user- agent headers are forwarded via SvelteKit's trusted getClientAddress. M4 first-pass does NOT wire target-module delivery (contacts / notify). Submissions stay in the inbox until owner-side tool handlers land (M4.x). `target` enum is intentionally `['inbox']` only for now. moduleEmbed: - packages/website-blocks/src/moduleEmbed: source dropdown (picture.board | library.entries), max-items, layout (grid | list), optional filter object. The `resolved` field on props is populated at publish time by the editor-side resolver — public renderer reads it directly, no Dexie / API round-trip needed. - apps/mana/.../website/embeds.ts: per-source resolvers. picture.board enforces `isPublic=true`; library.entries respects filter.isFavorite / kind / status so owners can expose a subset (e.g. "my favorites"). - buildSnapshot() walks the tree after assembly and fills in block.props.resolved for every moduleEmbed. Publish slower, public visits fast. No cross-service call at render time. Validation: - pnpm run validate:all: 6/6 gates green - pnpm run check (web): 0 errors, 0 warnings - apps/api type-check: green Apply Postgres with: psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql Plan: docs/plans/website-builder.md (M4 shipped) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79d112657c
commit
57be0f61b1
20 changed files with 1817 additions and 2 deletions
174
packages/website-blocks/src/moduleEmbed/ModuleEmbed.svelte
Normal file
174
packages/website-blocks/src/moduleEmbed/ModuleEmbed.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { ModuleEmbedProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<ModuleEmbedProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
const resolved = $derived(block.props.resolved);
|
||||
const items = $derived(resolved?.items ?? []);
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-embed wb-embed--{block.props.layout}"
|
||||
data-source={block.props.source}
|
||||
data-mode={mode}
|
||||
>
|
||||
<div class="wb-embed__inner">
|
||||
{#if block.props.title}
|
||||
<h2>{block.props.title}</h2>
|
||||
{/if}
|
||||
|
||||
{#if !resolved && isEdit}
|
||||
<div class="wb-embed__placeholder">
|
||||
Nicht aufgelöst. Quelle: {block.props.source}
|
||||
{#if block.props.sourceId}
|
||||
({block.props.sourceId})
|
||||
{/if}. Beim Veröffentlichen werden die Inhalte gezogen.
|
||||
</div>
|
||||
{:else if resolved?.error}
|
||||
<div class="wb-embed__error">Einbettung fehlgeschlagen: {resolved.error}</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="wb-embed__empty">Keine Inhalte gefunden.</div>
|
||||
{:else if block.props.layout === 'grid'}
|
||||
<div class="wb-embed__grid">
|
||||
{#each items as item, i (i)}
|
||||
<a class="wb-embed-card" href={item.href ?? '#'} class:is-static={!item.href}>
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt={item.title} loading="lazy" />
|
||||
{/if}
|
||||
<div class="wb-embed-card__body">
|
||||
<p class="wb-embed-card__title">{item.title}</p>
|
||||
{#if item.subtitle}
|
||||
<p class="wb-embed-card__subtitle">{item.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="wb-embed__list">
|
||||
{#each items as item, i (i)}
|
||||
<li class="wb-embed-row">
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt={item.title} loading="lazy" />
|
||||
{/if}
|
||||
<div>
|
||||
<a class="wb-embed-row__title" href={item.href ?? '#'}>{item.title}</a>
|
||||
{#if item.subtitle}
|
||||
<p>{item.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-embed {
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wb-embed__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-embed h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.wb-embed__placeholder,
|
||||
.wb-embed__error,
|
||||
.wb-embed__empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(127, 127, 127, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.wb-embed__placeholder {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
.wb-embed__error {
|
||||
border-color: rgba(248, 113, 113, 0.3);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.wb-embed__empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.wb-embed__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-embed-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--wb-border, rgba(127, 127, 127, 0.15));
|
||||
border-radius: var(--wb-radius, 0.5rem);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wb-embed-card.is-static {
|
||||
pointer-events: none;
|
||||
}
|
||||
.wb-embed-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
}
|
||||
.wb-embed-card__body {
|
||||
padding: 0.5rem 0.75rem 0.875rem;
|
||||
}
|
||||
.wb-embed-card__title {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wb-embed-card__subtitle {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wb-embed__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-embed-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem;
|
||||
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
|
||||
border-radius: var(--wb-radius, 0.5rem);
|
||||
}
|
||||
.wb-embed-row img {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.wb-embed-row__title {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wb-embed-row p {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* URL-style fallback for the pure-package inspector — the app-side
|
||||
* override adds a board picker that queries Dexie.
|
||||
*/
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { ModuleEmbedProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<ModuleEmbedProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Quelle</span>
|
||||
<select
|
||||
value={block.props.source}
|
||||
onchange={(e) => onChange({ source: e.currentTarget.value as ModuleEmbedProps['source'] })}
|
||||
>
|
||||
<option value="picture.board">Picture-Board</option>
|
||||
<option value="library.entries">Bibliothek</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Quellen-ID</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.sourceId}
|
||||
oninput={(e) => onChange({ sourceId: e.currentTarget.value })}
|
||||
placeholder="Board-ID oder leer für 'alle'"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Titel (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.title}
|
||||
oninput={(e) => onChange({ title: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Layout</span>
|
||||
<select
|
||||
value={block.props.layout}
|
||||
onchange={(e) => onChange({ layout: e.currentTarget.value as ModuleEmbedProps['layout'] })}
|
||||
>
|
||||
<option value="grid">Grid</option>
|
||||
<option value="list">Liste</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Max. Einträge</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="48"
|
||||
value={block.props.maxItems}
|
||||
oninput={(e) => onChange({ maxItems: parseInt(e.currentTarget.value, 10) || 12 })}
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
25
packages/website-blocks/src/moduleEmbed/index.ts
Normal file
25
packages/website-blocks/src/moduleEmbed/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import ModuleEmbed from './ModuleEmbed.svelte';
|
||||
import ModuleEmbedInspectorFallback from './ModuleEmbedInspectorFallback.svelte';
|
||||
import {
|
||||
ModuleEmbedSchema,
|
||||
MODULE_EMBED_DEFAULTS,
|
||||
type ModuleEmbedProps,
|
||||
type EmbedItem,
|
||||
type EmbedSource,
|
||||
} from './schema';
|
||||
|
||||
export const moduleEmbedBlockSpec: BlockSpec<ModuleEmbedProps> = {
|
||||
type: 'moduleEmbed',
|
||||
label: 'Modul einbetten',
|
||||
icon: 'link',
|
||||
category: 'embed',
|
||||
schema: ModuleEmbedSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: MODULE_EMBED_DEFAULTS,
|
||||
Component: ModuleEmbed,
|
||||
Inspector: ModuleEmbedInspectorFallback,
|
||||
};
|
||||
|
||||
export type { ModuleEmbedProps, EmbedItem, EmbedSource };
|
||||
export { ModuleEmbedSchema, MODULE_EMBED_DEFAULTS };
|
||||
68
packages/website-blocks/src/moduleEmbed/schema.ts
Normal file
68
packages/website-blocks/src/moduleEmbed/schema.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Resolved item shape — every embed provider returns items in this
|
||||
* normalized form so the renderer doesn't care about the source.
|
||||
*/
|
||||
export const EmbedItemSchema = z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
/** External link — for library entries, a page URL. */
|
||||
href: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EmbedItem = z.infer<typeof EmbedItemSchema>;
|
||||
|
||||
export const EmbedResolvedSchema = z.object({
|
||||
items: z.array(EmbedItemSchema),
|
||||
/** If resolution failed, the error message surfaces in public mode. */
|
||||
error: z.string().optional(),
|
||||
/** ISO timestamp of when resolution happened. */
|
||||
resolvedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Supported embed sources. Add new sources here + a matching provider
|
||||
* in the editor's publish resolver.
|
||||
*/
|
||||
export const EmbedSourceSchema = z.enum(['picture.board', 'library.entries']);
|
||||
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
|
||||
|
||||
export const ModuleEmbedSchema = z.object({
|
||||
source: EmbedSourceSchema.default('picture.board'),
|
||||
/** Target id — board id for picture, empty for "all entries" in library. */
|
||||
sourceId: z.string().max(64).default(''),
|
||||
/** Display title. Optional; renderer falls back to source default. */
|
||||
title: z.string().max(160).default(''),
|
||||
layout: z.enum(['grid', 'list']).default('grid'),
|
||||
maxItems: z.number().int().min(1).max(48).default(12),
|
||||
/**
|
||||
* Optional filters depending on source. Library uses { isFavorite?,
|
||||
* status?, kind? }; picture ignores them in M4.
|
||||
*/
|
||||
filter: z
|
||||
.object({
|
||||
isFavorite: z.boolean().optional(),
|
||||
status: z.string().max(32).optional(),
|
||||
kind: z.string().max(32).optional(),
|
||||
})
|
||||
.default({}),
|
||||
/**
|
||||
* Filled at publish time. The public renderer reads this directly —
|
||||
* no Dexie, no API round-trip. The editor shows a "nicht aufgelöst"
|
||||
* placeholder when missing.
|
||||
*/
|
||||
resolved: EmbedResolvedSchema.optional(),
|
||||
});
|
||||
|
||||
export type ModuleEmbedProps = z.infer<typeof ModuleEmbedSchema>;
|
||||
|
||||
export const MODULE_EMBED_DEFAULTS: ModuleEmbedProps = {
|
||||
source: 'picture.board',
|
||||
sourceId: '',
|
||||
title: '',
|
||||
layout: 'grid',
|
||||
maxItems: 12,
|
||||
filter: {},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue