mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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
23
apps/api/drizzle/website/0001_submissions.sql
Normal file
23
apps/api/drizzle/website/0001_submissions.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
215
apps/api/src/modules/website/submit.ts
Normal file
215
apps/api/src/modules/website/submit.ts
Normal 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;
|
||||
150
apps/mana/apps/web/src/lib/modules/website/embeds.ts
Normal file
150
apps/mana/apps/web/src/lib/modules/website/embeds.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
};
|
||||
221
packages/website-blocks/src/form/Form.svelte
Normal file
221
packages/website-blocks/src/form/Form.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { FormProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<FormProps> = $props();
|
||||
|
||||
const isPublic = $derived(mode === 'public');
|
||||
|
||||
// Submission state — only active in public mode. In edit/preview the
|
||||
// form is a static preview; clicking submit does nothing.
|
||||
let values = $state<Record<string, string>>({});
|
||||
let submitting = $state(false);
|
||||
let submitted = $state(false);
|
||||
let errorText = $state<string | null>(null);
|
||||
// Honeypot — real users never fill this. Bots usually do.
|
||||
let honeypot = $state('');
|
||||
|
||||
async function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!isPublic) return; // edit/preview — no network call
|
||||
|
||||
if (honeypot.trim()) {
|
||||
// Silent fail — behave like success to the bot.
|
||||
submitted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
errorText = null;
|
||||
try {
|
||||
// Build payload from declared fields only (ignores any extra
|
||||
// input) so clients can't inject surprise keys.
|
||||
const payload: Record<string, string> = {};
|
||||
for (const field of block.props.fields) {
|
||||
payload[field.name] = values[field.name] ?? '';
|
||||
}
|
||||
|
||||
// The site slug lives in the URL path: /s/<slug>/... The
|
||||
// proxy route at /s/[slug]/__submit/[blockId] forwards to the
|
||||
// mana-api public endpoint with CORS/rate-limit handled
|
||||
// there. Works identically for the home page (`/s/slug`) and
|
||||
// nested pages (`/s/slug/about`).
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const slug = pathParts[2] ?? '';
|
||||
if (!slug) {
|
||||
throw new Error('Konnte Website-Slug aus URL nicht ableiten');
|
||||
}
|
||||
const res = await fetch(`/s/${slug}/__submit/${block.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `Fehler (${res.status})`);
|
||||
}
|
||||
submitted = true;
|
||||
} catch (err) {
|
||||
errorText = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="wb-form" data-mode={mode}>
|
||||
<div class="wb-form__inner">
|
||||
{#if block.props.title}
|
||||
<h2>{block.props.title}</h2>
|
||||
{/if}
|
||||
{#if block.props.description}
|
||||
<p class="wb-form__description">{block.props.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if submitted}
|
||||
<div class="wb-form__success">{block.props.successMessage}</div>
|
||||
{:else}
|
||||
<form onsubmit={onSubmit} novalidate>
|
||||
{#each block.props.fields as field (field.name)}
|
||||
<label class="wb-form__field">
|
||||
<span>
|
||||
{field.label}
|
||||
{#if field.required}<span class="wb-form__required">*</span>{/if}
|
||||
</span>
|
||||
{#if field.type === 'textarea'}
|
||||
<textarea
|
||||
name={field.name}
|
||||
rows="4"
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
maxlength={field.maxLength}
|
||||
bind:value={values[field.name]}
|
||||
></textarea>
|
||||
{:else}
|
||||
<input
|
||||
name={field.name}
|
||||
type={field.type}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
maxlength={field.maxLength}
|
||||
bind:value={values[field.name]}
|
||||
/>
|
||||
{/if}
|
||||
{#if field.helpText}
|
||||
<small>{field.helpText}</small>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
|
||||
<!-- Honeypot: hidden via CSS (not `type=hidden`), bots see + fill. -->
|
||||
<label class="wb-form__honeypot" aria-hidden="true">
|
||||
<span>Nicht ausfüllen</span>
|
||||
<input type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||
</label>
|
||||
|
||||
{#if errorText}
|
||||
<p class="wb-form__error">{errorText}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="wb-form__submit" disabled={submitting}>
|
||||
{submitting ? 'Sende…' : block.props.submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-form {
|
||||
padding: 3rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-form__inner {
|
||||
max-width: 36rem;
|
||||
width: 100%;
|
||||
}
|
||||
.wb-form h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.wb-form__description {
|
||||
margin: 0 0 1.5rem;
|
||||
opacity: 0.7;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wb-form__success {
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(16, 185, 129);
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-form__field > span {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wb-form__required {
|
||||
color: rgb(220, 38, 38);
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
.wb-form__field input,
|
||||
.wb-form__field textarea {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--wb-radius, 0.375rem);
|
||||
border: 1px solid var(--wb-border, rgba(127, 127, 127, 0.25));
|
||||
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.wb-form__field textarea {
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
}
|
||||
.wb-form__field small {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wb-form__honeypot {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wb-form__error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
border: 1px solid rgba(220, 38, 38, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(220, 38, 38);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-form__submit {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
|
||||
color: var(--wb-primary-fg, white);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.wb-form__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
287
packages/website-blocks/src/form/FormInspector.svelte
Normal file
287
packages/website-blocks/src/form/FormInspector.svelte
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { FormProps, FormField } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<FormProps> = $props();
|
||||
|
||||
const TYPE_LABELS: Record<FormField['type'], string> = {
|
||||
text: 'Text',
|
||||
email: 'E-Mail',
|
||||
tel: 'Telefon',
|
||||
url: 'URL',
|
||||
number: 'Zahl',
|
||||
textarea: 'Mehrzeilig',
|
||||
};
|
||||
|
||||
function updateField(index: number, patch: Partial<FormField>) {
|
||||
const next = block.props.fields.map((f, i) => (i === index ? { ...f, ...patch } : f));
|
||||
onChange({ fields: next });
|
||||
}
|
||||
|
||||
function addField() {
|
||||
const existingNames = new Set(block.props.fields.map((f) => f.name));
|
||||
let counter = 1;
|
||||
let name = `feld_${counter}`;
|
||||
while (existingNames.has(name)) name = `feld_${++counter}`;
|
||||
const newField: FormField = {
|
||||
name,
|
||||
label: 'Neues Feld',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
maxLength: 500,
|
||||
};
|
||||
onChange({ fields: [...block.props.fields, newField] });
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
onChange({ fields: block.props.fields.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
function moveField(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= block.props.fields.length) return;
|
||||
const next = [...block.props.fields];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange({ fields: next });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Titel</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.title}
|
||||
oninput={(e) => onChange({ title: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Beschreibung</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={block.props.description}
|
||||
oninput={(e) => onChange({ description: e.currentTarget.value })}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Submit-Button</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.submitLabel}
|
||||
oninput={(e) => onChange({ submitLabel: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-field">
|
||||
<span>Ziel</span>
|
||||
<select disabled>
|
||||
<option>Inbox (Owner)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Erfolgs-Nachricht</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.successMessage}
|
||||
oninput={(e) => onChange({ successMessage: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="wb-fields">
|
||||
<div class="wb-fields__head">
|
||||
<span>Felder ({block.props.fields.length})</span>
|
||||
<button class="wb-btn wb-btn--primary" onclick={addField}>+ Feld</button>
|
||||
</div>
|
||||
|
||||
{#each block.props.fields as field, i (i)}
|
||||
<div class="wb-form-field">
|
||||
<div class="wb-form-field__head">
|
||||
<span class="wb-form-field__idx">#{i + 1}</span>
|
||||
<div class="wb-form-field__actions">
|
||||
<button class="wb-btn wb-btn--icon" onclick={() => moveField(i, -1)} disabled={i === 0}
|
||||
>↑</button
|
||||
>
|
||||
<button
|
||||
class="wb-btn wb-btn--icon"
|
||||
onclick={() => moveField(i, 1)}
|
||||
disabled={i === block.props.fields.length - 1}>↓</button
|
||||
>
|
||||
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => removeField(i)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Label</span>
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
oninput={(e) => updateField(i, { label: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-field">
|
||||
<span>Typ</span>
|
||||
<select
|
||||
value={field.type}
|
||||
onchange={(e) => updateField(i, { type: e.currentTarget.value as FormField['type'] })}
|
||||
>
|
||||
{#each Object.entries(TYPE_LABELS) as [value, label] (value)}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Feld-Name (intern)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={field.name}
|
||||
oninput={(e) => updateField(i, { name: e.currentTarget.value })}
|
||||
pattern="^[a-z][a-z0-9_]*$"
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onchange={(e) => updateField(i, { required: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>Pflicht</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Placeholder</span>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder}
|
||||
oninput={(e) => updateField(i, { placeholder: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field,
|
||||
.wb-checkbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field select,
|
||||
.wb-field textarea {
|
||||
width: 100%;
|
||||
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;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wb-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-fields__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.wb-form-field__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.wb-form-field__idx {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.wb-form-field__actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-btn {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wb-btn--primary {
|
||||
background: rgba(99, 102, 241, 0.85);
|
||||
color: white;
|
||||
}
|
||||
.wb-btn--icon {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: inherit;
|
||||
width: 1.75rem;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wb-btn--icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.wb-btn--danger:hover:not(:disabled) {
|
||||
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.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/form/index.ts
Normal file
19
packages/website-blocks/src/form/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Form from './Form.svelte';
|
||||
import FormInspector from './FormInspector.svelte';
|
||||
import { FormSchema, FORM_DEFAULTS, type FormProps, type FormField } from './schema';
|
||||
|
||||
export const formBlockSpec: BlockSpec<FormProps> = {
|
||||
type: 'form',
|
||||
label: 'Formular',
|
||||
icon: 'clipboard',
|
||||
category: 'form',
|
||||
schema: FormSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: FORM_DEFAULTS,
|
||||
Component: Form,
|
||||
Inspector: FormInspector,
|
||||
};
|
||||
|
||||
export type { FormProps, FormField };
|
||||
export { FormSchema, FORM_DEFAULTS };
|
||||
76
packages/website-blocks/src/form/schema.ts
Normal file
76
packages/website-blocks/src/form/schema.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const FormFieldSchema = z.object({
|
||||
/** Internal name — key in the submission payload. */
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.regex(/^[a-z][a-z0-9_]*$/i),
|
||||
/** Visible label above the input. */
|
||||
label: z.string().min(1).max(120),
|
||||
/** HTML input type. `textarea` renders `<textarea>`. */
|
||||
type: z.enum(['text', 'email', 'tel', 'url', 'textarea', 'number']).default('text'),
|
||||
required: z.boolean().default(false),
|
||||
placeholder: z.string().max(120).default(''),
|
||||
/** Optional helper line under the input. */
|
||||
helpText: z.string().max(280).default(''),
|
||||
/** Max length for text/textarea (default 1000). */
|
||||
maxLength: z.number().int().positive().max(10_000).default(1000),
|
||||
});
|
||||
|
||||
export type FormField = z.infer<typeof FormFieldSchema>;
|
||||
|
||||
export const FormSchema = z.object({
|
||||
title: z.string().max(160).default(''),
|
||||
description: z.string().max(480).default(''),
|
||||
fields: z.array(FormFieldSchema).min(1).max(20).default([]),
|
||||
submitLabel: z.string().min(1).max(40).default('Absenden'),
|
||||
successMessage: z.string().min(1).max(400).default('Danke! Wir melden uns bald.'),
|
||||
/**
|
||||
* Where the submission goes. `inbox` — stored server-side, owner sees
|
||||
* it in /website/[id]/submissions (M4 default). Future targets
|
||||
* (contacts, notify) land in M4.x when server-side tool handlers
|
||||
* exist.
|
||||
*/
|
||||
target: z.enum(['inbox']).default('inbox'),
|
||||
});
|
||||
|
||||
export type FormProps = z.infer<typeof FormSchema>;
|
||||
|
||||
export const FORM_DEFAULTS: FormProps = {
|
||||
title: 'Kontakt',
|
||||
description: '',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'E-Mail',
|
||||
type: 'email',
|
||||
required: true,
|
||||
placeholder: 'du@beispiel.de',
|
||||
helpText: '',
|
||||
maxLength: 200,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
label: 'Nachricht',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
maxLength: 2000,
|
||||
},
|
||||
],
|
||||
submitLabel: 'Absenden',
|
||||
successMessage: 'Danke! Wir melden uns bald.',
|
||||
target: 'inbox',
|
||||
};
|
||||
|
|
@ -43,6 +43,15 @@ export {
|
|||
type GalleryProps,
|
||||
type GalleryImage,
|
||||
} from './gallery';
|
||||
export { formBlockSpec, FormSchema, FORM_DEFAULTS, type FormProps, type FormField } from './form';
|
||||
export {
|
||||
moduleEmbedBlockSpec,
|
||||
ModuleEmbedSchema,
|
||||
MODULE_EMBED_DEFAULTS,
|
||||
type ModuleEmbedProps,
|
||||
type EmbedItem,
|
||||
type EmbedSource,
|
||||
} from './moduleEmbed';
|
||||
|
||||
export {
|
||||
THEME_PRESETS,
|
||||
|
|
|
|||
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: {},
|
||||
};
|
||||
|
|
@ -7,6 +7,8 @@ import { ctaBlockSpec } from './cta';
|
|||
import { faqBlockSpec } from './faq';
|
||||
import { columnsBlockSpec } from './columns';
|
||||
import { galleryBlockSpec } from './gallery';
|
||||
import { formBlockSpec } from './form';
|
||||
import { moduleEmbedBlockSpec } from './moduleEmbed';
|
||||
|
||||
/**
|
||||
* The block registry — single source of truth for every block type the
|
||||
|
|
@ -23,6 +25,8 @@ export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
|
|||
imageBlockSpec,
|
||||
galleryBlockSpec,
|
||||
faqBlockSpec,
|
||||
formBlockSpec,
|
||||
moduleEmbedBlockSpec,
|
||||
columnsBlockSpec,
|
||||
spacerBlockSpec,
|
||||
] as unknown as readonly BlockSpec<unknown>[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue