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:
Till JS 2026-04-23 14:36:52 +02:00
parent 79d112657c
commit 57be0f61b1
20 changed files with 1817 additions and 2 deletions

View file

@ -0,0 +1,23 @@
-- Website module — M4 form submissions.
-- See docs/plans/website-builder.md §M4 + §D8.
--
-- Apply with:
-- psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql
CREATE TABLE IF NOT EXISTS "website"."submissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"site_id" uuid NOT NULL,
"block_id" uuid NOT NULL,
"payload" jsonb NOT NULL,
"target_module" text NOT NULL,
"target_action" text NOT NULL,
"target_record_id" uuid,
"status" text NOT NULL,
"error_message" text,
"ip" text,
"user_agent" text,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "submissions_site_created_idx"
ON "website"."submissions" ("site_id", "created_at" DESC);

View file

@ -10,8 +10,10 @@ import { Hono } from 'hono';
import { and, eq } from 'drizzle-orm';
import { db, publishedSnapshots } from './schema';
import { errorResponse } from '../../lib/responses';
import { websiteSubmitRoutes } from './submit';
const routes = new Hono();
routes.route('/', websiteSubmitRoutes);
/**
* GET /api/v1/website/public/sites/:slug

View file

@ -11,7 +11,7 @@ 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 { db, publishedSnapshots, submissions } from './schema';
import { isValidSlug } from './reserved-slugs';
const routes = new Hono<{ Variables: AuthVariables }>();
@ -243,4 +243,61 @@ routes.post('/sites/:id/rollback/:snapshotId', async (c) => {
return c.json({ rolledBack: true, slug: target[0].slug });
});
// ─── GET /sites/:id/submissions ─────────────────────────
// Owner-only list of inbox submissions for a site.
routes.get('/sites/:id/submissions', async (c) => {
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const rows = await db
.select({
id: submissions.id,
siteId: submissions.siteId,
blockId: submissions.blockId,
payload: submissions.payload,
targetModule: submissions.targetModule,
status: submissions.status,
errorMessage: submissions.errorMessage,
createdAt: submissions.createdAt,
})
.from(submissions)
.where(eq(submissions.siteId, siteId))
.orderBy(desc(submissions.createdAt))
.limit(200);
return c.json({
submissions: rows.map((r) => ({
id: r.id,
blockId: r.blockId,
payload: r.payload,
targetModule: r.targetModule,
status: r.status,
errorMessage: r.errorMessage,
createdAt: r.createdAt.toISOString(),
})),
});
});
// ─── DELETE /sites/:id/submissions/:submissionId ───────
routes.delete('/sites/:id/submissions/:submissionId', async (c) => {
const siteId = c.req.param('id');
const submissionId = c.req.param('submissionId');
if (!siteId || !submissionId) {
return errorResponse(c, 'siteId + submissionId required', 400);
}
const deleted = await db
.delete(submissions)
.where(and(eq(submissions.id, submissionId), eq(submissions.siteId, siteId)))
.returning({ id: submissions.id });
if (deleted.length === 0) {
return errorResponse(c, 'Submission not found', 404, { code: 'NOT_FOUND' });
}
return c.json({ deleted: true });
});
export const websitePublishRoutes = routes;

View file

@ -61,9 +61,42 @@ export const publishedSnapshots = websiteSchema.table(
]
);
/**
* Form submissions inbox. Every POST to /public/submit/:siteId/:blockId
* lands here. `payload` is nulled after successful delivery to the
* target module (M4.x); M4 first-pass keeps it indefinitely so the
* owner sees it in the submissions UI.
*/
export const submissions = websiteSchema.table(
'submissions',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id').notNull(),
/** No FK — blocks live in Dexie + the sync pool, not here. */
blockId: uuid('block_id').notNull(),
/** JSON object — keys match the block's declared field names. */
payload: jsonb('payload').notNull(),
/** Denormalized at submit time so target routing survives block edits. */
targetModule: text('target_module').notNull(),
/** `'inbox'` in M4; expand when we wire contacts/notify handlers. */
targetAction: text('target_action').notNull(),
/** FK into the target module's record once delivered (M4.x). */
targetRecordId: uuid('target_record_id'),
/** 'received' | 'delivered' | 'failed'. */
status: text('status').notNull(),
errorMessage: text('error_message'),
ip: text('ip'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('submissions_site_created_idx').on(table.siteId, table.createdAt)]
);
export const db = drizzle(getConnection(), {
schema: { publishedSnapshots },
schema: { publishedSnapshots, submissions },
});
export type PublishedSnapshotRow = typeof publishedSnapshots.$inferSelect;
export type NewPublishedSnapshot = typeof publishedSnapshots.$inferInsert;
export type SubmissionRow = typeof submissions.$inferSelect;
export type NewSubmission = typeof submissions.$inferInsert;

View file

@ -0,0 +1,215 @@
/**
* Public form-submit endpoint UNAUTHENTICATED.
*
* Flow:
* 1. Look up the current published snapshot for this site
* 2. Walk the snapshot blob to find the block, extract its form schema
* 3. Validate incoming payload against declared fields (type + length
* + required)
* 4. Honeypot check (the block renderer adds a hidden `_trap` field
* if present and non-empty, silently 202 without recording)
* 5. Rate-limit per IP (10 submissions / 5 minutes / site)
* 6. Insert into website.submissions with status='received'
*
* M4 first-pass: target delivery is not wired. The owner sees
* submissions in /website/[id]/submissions. Contacts / notify
* forwarding is M4.x once server-side tool handlers exist.
*
* Security: this route runs BEFORE authMiddleware so anonymous visitors
* can submit. Every bit of user-controlled input (site slug, block id,
* payload) is treated as untrusted.
*/
import { Hono } from 'hono';
import { and, eq } from 'drizzle-orm';
import { db, publishedSnapshots, submissions } from './schema';
const routes = new Hono();
// Simple in-memory rate limit (per-IP, per-site, sliding window).
// Replace with Redis when the service scales horizontally (M7).
const RATE_WINDOW_MS = 5 * 60 * 1000;
const RATE_MAX = 10;
const rateLimits = new Map<string, { count: number; windowStart: number }>();
function rateLimitHit(key: string): boolean {
const now = Date.now();
const bucket = rateLimits.get(key);
if (!bucket || now - bucket.windowStart > RATE_WINDOW_MS) {
rateLimits.set(key, { count: 1, windowStart: now });
return false;
}
bucket.count += 1;
return bucket.count > RATE_MAX;
}
interface SnapshotBlob {
version: string;
pages?: Array<{
blocks?: SnapshotBlock[];
}>;
}
interface SnapshotBlock {
id: string;
type: string;
props: unknown;
children?: SnapshotBlock[];
}
interface FormBlockProps {
fields: Array<{
name: string;
label: string;
type: 'text' | 'email' | 'tel' | 'url' | 'textarea' | 'number';
required: boolean;
maxLength: number;
}>;
target: 'inbox';
}
function findFormBlock(blob: SnapshotBlob, blockId: string): SnapshotBlock | null {
const pages = blob.pages ?? [];
for (const page of pages) {
const hit = walk(page.blocks ?? [], blockId);
if (hit) return hit;
}
return null;
}
function walk(blocks: SnapshotBlock[], blockId: string): SnapshotBlock | null {
for (const block of blocks) {
if (block.id === blockId) return block;
if (block.children) {
const hit = walk(block.children, blockId);
if (hit) return hit;
}
}
return null;
}
function isFormBlock(block: SnapshotBlock): block is SnapshotBlock & { props: FormBlockProps } {
if (block.type !== 'form') return false;
const props = block.props as Partial<FormBlockProps>;
return Array.isArray(props?.fields);
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateField(
field: FormBlockProps['fields'][number],
raw: unknown
): { ok: true; value: string } | { ok: false; error: string } {
if (typeof raw !== 'string') {
return { ok: false, error: `Feld "${field.name}" muss Text sein` };
}
const trimmed = raw.trim();
if (field.required && trimmed.length === 0) {
return { ok: false, error: `Pflichtfeld "${field.label}" fehlt` };
}
if (trimmed.length > field.maxLength) {
return { ok: false, error: `Feld "${field.label}" ist zu lang` };
}
if (trimmed.length === 0) return { ok: true, value: '' };
if (field.type === 'email' && !EMAIL_RE.test(trimmed)) {
return { ok: false, error: `"${field.label}" ist keine gültige E-Mail` };
}
if (field.type === 'url') {
try {
new URL(trimmed);
} catch {
return { ok: false, error: `"${field.label}" ist keine gültige URL` };
}
}
if (field.type === 'number' && !/^-?\d+(\.\d+)?$/.test(trimmed)) {
return { ok: false, error: `"${field.label}" muss eine Zahl sein` };
}
return { ok: true, value: trimmed };
}
routes.post('/submit/:siteSlug/:blockId', async (c) => {
const { siteSlug, blockId } = c.req.param();
if (!siteSlug || !blockId) {
return c.json({ error: 'siteSlug + blockId required' }, 400);
}
// Rate-limit. `cf-connecting-ip` + `x-forwarded-for` for proxied
// deployments, fall back to a generic bucket if we can't find
// anything (better than letting the bucket be "nothing").
const ip =
c.req.header('cf-connecting-ip') ??
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
'unknown';
if (rateLimitHit(`${siteSlug}:${ip}`)) {
return c.json({ error: 'Rate limit überschritten — bitte später erneut versuchen' }, 429);
}
// Load current published snapshot to find the block schema. The
// block definition lives IN the snapshot blob, not in a separate
// table — this keeps the source of truth in one place and prevents
// a block-edit race (site gets unpublished while a form is in the
// middle of being submitted).
const snapshotRow = await db
.select({ blob: publishedSnapshots.blob, siteId: publishedSnapshots.siteId })
.from(publishedSnapshots)
.where(and(eq(publishedSnapshots.slug, siteSlug), eq(publishedSnapshots.isCurrent, true)))
.limit(1);
if (!snapshotRow[0]) {
return c.json({ error: 'Website nicht gefunden oder offline' }, 404);
}
const block = findFormBlock(snapshotRow[0].blob as SnapshotBlob, blockId);
if (!block) {
return c.json({ error: 'Block nicht gefunden' }, 404);
}
if (!isFormBlock(block)) {
return c.json({ error: 'Block ist kein Formular' }, 400);
}
const rawBody = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!rawBody || typeof rawBody !== 'object') {
return c.json({ error: 'Payload fehlt oder ungültig' }, 400);
}
// Honeypot — the renderer names the trap input whatever value is in
// `_trap`. For M4 it's the public `honeypot` field bound in the
// Form renderer. We also look for a generic "_trap" key.
const trap = rawBody.honeypot ?? rawBody._trap;
if (typeof trap === 'string' && trap.trim().length > 0) {
// Act as success to the bot, store nothing.
return c.json({ ok: true, spam: true }, 202);
}
// Validate every declared field. Ignore unknown keys the client
// tried to sneak in.
const cleaned: Record<string, string> = {};
for (const field of block.props.fields) {
const result = validateField(field, rawBody[field.name]);
if (!result.ok) {
return c.json({ error: result.error, field: field.name }, 400);
}
cleaned[field.name] = result.value;
}
const userAgent = c.req.header('user-agent') ?? null;
const [row] = await db
.insert(submissions)
.values({
siteId: snapshotRow[0].siteId,
blockId,
payload: cleaned,
targetModule: 'inbox',
targetAction: 'inbox',
status: 'received',
ip: ip === 'unknown' ? null : ip,
userAgent: userAgent && userAgent.length > 500 ? userAgent.slice(0, 500) : userAgent,
})
.returning({ id: submissions.id });
return c.json({ ok: true, submissionId: row?.id ?? null }, 201);
});
export const websiteSubmitRoutes = routes;

View file

@ -0,0 +1,150 @@
/**
* Module-embed resolvers client-side functions that walk Dexie to
* pre-fetch data for `moduleEmbed` blocks at publish time.
*
* These run exactly once per publish and inline the result into the
* snapshot's `block.props.resolved` field, so the public renderer
* reads the blob and never needs Dexie or the network to show embedded
* content. Trade-off: publishes are slightly slower, public visits are
* much faster.
*
* Every resolver MUST enforce the source's public-visibility rules
* e.g. `picture.board.isPublic === true`. An owner who embeds a
* private board gets an empty result with a clear error message in the
* resolved.error field.
*/
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { mediaFileUrl } from './upload';
import type { EmbedItem, EmbedSource, ModuleEmbedProps } from '@mana/website-blocks';
import type { LocalBoard, LocalBoardItem, LocalImage } from '$lib/modules/picture/types';
import type { LocalLibraryEntry } from '$lib/modules/library/types';
export interface ResolvedEmbed {
items: EmbedItem[];
error?: string;
resolvedAt: string;
}
/** Resolve a single moduleEmbed block's props. */
export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmbed> {
const now = new Date().toISOString();
try {
let items: EmbedItem[];
switch (props.source as EmbedSource) {
case 'picture.board':
items = await resolvePictureBoard(props);
break;
case 'library.entries':
items = await resolveLibraryEntries(props);
break;
default:
return {
items: [],
error: `Unbekannte Quelle: ${props.source}`,
resolvedAt: now,
};
}
return { items: items.slice(0, props.maxItems), resolvedAt: now };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { items: [], error: message, resolvedAt: now };
}
}
/**
* Picture-board: returns image items for a board that the owner marked
* `isPublic=true`. Private boards return an error.
*/
async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]> {
if (!props.sourceId) {
throw new Error('Bitte wähle ein Board aus');
}
const [rawBoard] = await db
.table<LocalBoard>('boards')
.where('id')
.equals(props.sourceId)
.toArray();
if (!rawBoard || rawBoard.deletedAt) {
throw new Error('Board nicht gefunden');
}
if (!rawBoard.isPublic) {
throw new Error('Board ist nicht öffentlich — setze "Öffentlich" im Picture-Modul');
}
const items = await db
.table<LocalBoardItem>('boardItems')
.where('boardId')
.equals(props.sourceId)
.toArray();
const imageItems = items
.filter((i) => !i.deletedAt && i.itemType === 'image' && i.imageId)
.sort((a, b) => a.zIndex - b.zIndex);
if (imageItems.length === 0) return [];
const imageIds = imageItems.map((i) => i.imageId as string);
const images = await db.table<LocalImage>('images').where('id').anyOf(imageIds).toArray();
const decrypted = (await decryptRecords('images', images)) as LocalImage[];
const imageById = new Map<string, LocalImage>();
for (const img of decrypted) imageById.set(img.id, img);
const out: EmbedItem[] = [];
for (const item of imageItems) {
const img = imageById.get(item.imageId as string);
if (!img) continue;
const url = img.publicUrl ?? mediaFileUrl(img.id, 'medium');
out.push({
title: img.prompt?.slice(0, 120) || 'Bild',
imageUrl: url,
});
}
return out;
}
/**
* Library-entries: returns book/movie/series/comic entries. Owner-only
* data by default M4 first-pass exposes it if the owner opts in via
* `filter.isFavorite` (favorites are the typical "show-on-my-site"
* subset). Future: per-entry `showOnWebsite` flag.
*/
async function resolveLibraryEntries(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let locals = await db.table<LocalLibraryEntry>('libraryEntries').toArray();
locals = locals.filter((e) => !e.deletedAt);
if (props.filter?.kind) {
locals = locals.filter((e) => e.kind === props.filter?.kind);
}
if (props.filter?.status) {
locals = locals.filter((e) => e.status === props.filter?.status);
}
if (props.filter?.isFavorite === true) {
locals = locals.filter((e) => e.isFavorite === true);
}
// Newest completions first.
locals.sort((a, b) => {
const aKey = a.completedAt ?? a.updatedAt ?? '';
const bKey = b.completedAt ?? b.updatedAt ?? '';
return bKey.localeCompare(aKey);
});
const decrypted = (await decryptRecords('libraryEntries', locals)) as LocalLibraryEntry[];
return decrypted.map((entry) => {
const creators = (entry.creators ?? []).slice(0, 2).join(', ');
const year = entry.year ? ` · ${entry.year}` : '';
const subtitle = creators ? `${creators}${year}` : year.trim() || undefined;
return {
title: entry.title,
subtitle,
imageUrl:
entry.coverUrl ??
(entry.coverMediaId ? mediaFileUrl(entry.coverMediaId, 'medium') : undefined),
};
});
}

View file

@ -16,6 +16,7 @@
import { getManaApiUrl } from '$lib/api/config';
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import { resolveEmbed } from './embeds';
import type {
LocalWebsite,
LocalWebsiteBlock,
@ -25,6 +26,7 @@ import type {
SiteSettings,
PageSeo,
} from './types';
import type { ModuleEmbedProps } from '@mana/website-blocks';
// ─── Snapshot shape ──────────────────────────────────────
@ -127,6 +129,13 @@ export async function buildSnapshot(siteId: string): Promise<DraftSnapshot> {
blocks: buildBlockTree(blocksByPage.get(p.id) ?? []),
}));
// Pre-resolve moduleEmbed blocks: walk the tree, fetch source data
// from Dexie, inline into block.props. The public renderer serves
// the snapshot without further lookups. See plan §M4.
for (const page of pages) {
await resolveEmbedsInTree(page.blocks);
}
return {
version: SNAPSHOT_VERSION,
site: toSnapshotSite(site),
@ -134,6 +143,19 @@ export async function buildSnapshot(siteId: string): Promise<DraftSnapshot> {
};
}
async function resolveEmbedsInTree(nodes: SnapshotBlockNode[]): Promise<void> {
for (const node of nodes) {
if (node.type === 'moduleEmbed') {
const props = node.props as ModuleEmbedProps;
const resolved = await resolveEmbed(props);
node.props = { ...props, resolved };
}
if (node.children.length > 0) {
await resolveEmbedsInTree(node.children);
}
}
}
function toSnapshotSite(site: LocalWebsite): SnapshotSite {
return {
id: site.id,
@ -282,6 +304,44 @@ export async function fetchSnapshotHistory(
return body.snapshots;
}
export interface SubmissionEntry {
id: string;
blockId: string;
payload: Record<string, unknown>;
targetModule: string;
status: string;
errorMessage: string | null;
createdAt: string;
}
export async function fetchSubmissions(siteId: string, jwt: string): Promise<SubmissionEntry[]> {
const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/submissions`, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok) {
throw new PublishError(`Submissions fetch failed (${res.status})`, 'UNKNOWN', res.status);
}
const body = (await res.json()) as { submissions: SubmissionEntry[] };
return body.submissions;
}
export async function deleteSubmission(
siteId: string,
submissionId: string,
jwt: string
): Promise<void> {
const res = await fetch(
`${getManaApiUrl()}/api/v1/website/sites/${siteId}/submissions/${submissionId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${jwt}` },
}
);
if (!res.ok && res.status !== 404) {
throw new PublishError(`Delete submission failed (${res.status})`, 'UNKNOWN', res.status);
}
}
export async function rollbackSnapshot(
siteId: string,
snapshotId: string,

View file

@ -0,0 +1,231 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { fetchSubmissions, deleteSubmission, type SubmissionEntry } from '../publish';
interface Props {
siteId: string;
}
let { siteId }: Props = $props();
let entries = $state<SubmissionEntry[] | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(false);
async function load() {
loading = true;
loadError = null;
try {
const token = await authStore.getValidToken();
if (!token) throw new Error('Nicht angemeldet');
entries = await fetchSubmissions(siteId, token);
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
siteId;
void load();
});
async function remove(submissionId: string) {
if (!confirm('Submission wirklich löschen?')) return;
const token = await authStore.getValidToken();
if (!token) return;
await deleteSubmission(siteId, submissionId, token);
entries = entries?.filter((e) => e.id !== submissionId) ?? [];
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE');
}
</script>
<div class="wb-submissions">
<header class="wb-submissions__head">
<div>
<h2>Eingegangen</h2>
<p>Formular-Einsendungen von deiner Website.</p>
</div>
<button class="wb-btn wb-btn--ghost" onclick={load} disabled={loading}>
{loading ? 'Lade…' : 'Aktualisieren'}
</button>
</header>
{#if loadError}
<p class="wb-error">{loadError}</p>
{:else if entries === null}
<div class="wb-submissions__empty">Lade…</div>
{:else if entries.length === 0}
<div class="wb-submissions__empty">
<p>Noch keine Einsendungen.</p>
<small>Sobald jemand ein Formular auf deiner Website ausfüllt, landet es hier.</small>
</div>
{:else}
<ul class="wb-submissions__list">
{#each entries as entry (entry.id)}
<li class="wb-submission">
<div class="wb-submission__head">
<span class="wb-submission__time">{formatDate(entry.createdAt)}</span>
<div class="wb-submission__actions">
<span class="wb-pill wb-pill--{entry.status}">{entry.status}</span>
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => remove(entry.id)}>
×
</button>
</div>
</div>
<dl class="wb-submission__payload">
{#each Object.entries(entry.payload) as [key, value] (key)}
<dt>{key}</dt>
<dd>{value}</dd>
{/each}
</dl>
{#if entry.errorMessage}
<p class="wb-submission__error">{entry.errorMessage}</p>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style>
.wb-submissions {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 60rem;
margin: 0 auto;
}
.wb-submissions__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.wb-submissions__head h2 {
margin: 0;
font-size: 1.25rem;
}
.wb-submissions__head p {
margin: 0.25rem 0 0;
font-size: 0.875rem;
opacity: 0.6;
}
.wb-submissions__empty {
padding: 3rem 1.5rem;
text-align: center;
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
opacity: 0.65;
}
.wb-submissions__empty small {
display: block;
margin-top: 0.25rem;
opacity: 0.7;
}
.wb-submissions__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-submission {
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
}
.wb-submission__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.wb-submission__time {
font-size: 0.8125rem;
opacity: 0.6;
}
.wb-submission__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.wb-submission__payload {
display: grid;
grid-template-columns: 7rem 1fr;
gap: 0.2rem 0.75rem;
margin: 0;
font-size: 0.875rem;
}
.wb-submission__payload dt {
font-weight: 500;
opacity: 0.6;
}
.wb-submission__payload dd {
margin: 0;
word-break: break-word;
}
.wb-submission__error {
margin: 0.5rem 0 0;
color: rgb(248, 113, 113);
font-size: 0.8125rem;
}
.wb-pill {
font-size: 0.7rem;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
}
.wb-pill--received {
background: rgba(16, 185, 129, 0.15);
color: rgb(110, 231, 183);
}
.wb-pill--delivered {
background: rgba(59, 130, 246, 0.15);
color: rgb(147, 197, 253);
}
.wb-pill--failed {
background: rgba(248, 113, 113, 0.15);
color: rgb(252, 165, 165);
}
.wb-btn {
padding: 0.35rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.wb-btn--ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
}
.wb-btn--icon {
width: 1.75rem;
padding: 0;
line-height: 1;
}
.wb-btn--danger:hover {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: rgb(248, 113, 113);
}
.wb-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.wb-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;
color: rgb(248, 113, 113);
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { page } from '$app/stores';
import { RoutePage } from '$lib/components/shell';
import SubmissionsView from '$lib/modules/website/views/SubmissionsView.svelte';
import { useAllSites, findSite } from '$lib/modules/website/queries';
const siteId = $derived($page.params.siteId ?? '');
const sites = useAllSites();
const site = $derived(findSite(sites.value, siteId));
</script>
<svelte:head>
<title>{site?.name ?? 'Website'} Submissions</title>
</svelte:head>
<RoutePage
appId="website"
backHref={`/website/${siteId}`}
title={site ? `${site.name} Submissions` : 'Submissions'}
>
<SubmissionsView {siteId} />
</RoutePage>

View file

@ -0,0 +1,41 @@
/**
* Form-submit proxy forwards the visitor's POST to the mana-api
* public submit endpoint. Keeps the submission on the same origin as
* the public page so no CORS preflight is needed and the visitor's
* session state (if any) isn't fiddled with.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getManaApiUrl } from '$lib/api/config';
export const POST: RequestHandler = async ({ params, request, getClientAddress }) => {
const { siteSlug, blockId } = params;
if (!siteSlug || !blockId) {
return json({ error: 'siteSlug + blockId required' }, { status: 400 });
}
const body = await request.text();
// Forward client IP + user-agent so the api can rate-limit + store
// them. SvelteKit's `getClientAddress()` respects the usual proxy
// headers (X-Forwarded-For, Cloudflare's CF-Connecting-IP).
const forwardedIp = getClientAddress();
const userAgent = request.headers.get('user-agent') ?? '';
const upstream = await fetch(
`${getManaApiUrl()}/api/v1/website/public/submit/${encodeURIComponent(siteSlug)}/${encodeURIComponent(blockId)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Forwarded-For': forwardedIp,
'User-Agent': userAgent,
},
body,
}
);
const data = (await upstream.json().catch(() => ({}))) as Record<string, unknown>;
return json(data, { status: upstream.status });
};