From 7a4f8894e17d10c92befe0decd13eb7f9853553e Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 14:27:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(website):=20M3=20=E2=80=94=205=20more=20bl?= =?UTF-8?q?ocks,=20containers,=20upload,=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the builder from 3 M1 blocks to 8. Containers (columns) and media blocks (image, gallery) are the structural additions; cta and faq round out the content coverage. packages/website-blocks: - image, cta, faq, columns (container), gallery — each with Zod schema, renderer (mode-aware for edit/preview/public), and fallback inspector. - Block type extended with optional `children` + `renderChild` snippet so containers render their children through the same chrome the outer renderer provides (click-to-select, public-path tagging). - themes/: 3 presets (classic light, modern dark, warm) with `resolveTheme` + `themeCssVars` helpers. Public layout now emits CSS vars via `style=` on the root; block components read `var(--wb-primary)` / `var(--wb-bg)` / `var(--wb-fg)` / etc. - Registry updated; new exports + `./themes` subpath export. apps/mana/apps/web/src/lib/modules/website: - upload.ts: multipart POST to mana-media with `app=website` scope, returns { mediaId, url }. 25 MB cap, non-image rejection client-side. - components/ImageInspector + GalleryInspector: app-side overrides wired to upload. Registered via `CUSTOM_INSPECTORS` in BlockInspector so block.type → app-side inspector, fallback to registry otherwise. - components/SiteSettingsDialog: theme preset picker + color overrides for primary/bg/fg + footer text. Mounted from a ⚙ button in the editor's left pane. - components/BlockRenderer: rebuilt around a byParent map + recursive `renderBlock` snippet so container blocks can render their children through the same click-to-select wrapper as top-level blocks. - routes/s/[siteSlug]: rename `[[...path]]` → `[...path]` (SvelteKit treats rest segments as optional automatically — double-bracket form errored at sync time). +page.svelte renders snapshot trees recursively so published pages match the editor. apps/api: unchanged. Validation: - pnpm run validate:all: all 6 gates green - pnpm run check (web): 0 errors, 0 warnings - apps/api type-check: green - website-blocks tsc: green Plan: docs/plans/website-builder.md (M3 block shipped) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../website/components/BlockInspector.svelte | 29 +- .../website/components/BlockRenderer.svelte | 61 ++- .../components/GalleryInspector.svelte | 308 +++++++++++++++ .../website/components/ImageInspector.svelte | 217 +++++++++++ .../components/SiteSettingsDialog.svelte | 361 ++++++++++++++++++ .../web/src/lib/modules/website/upload.ts | 78 ++++ .../modules/website/views/EditorView.svelte | 47 ++- .../src/routes/s/[siteSlug]/+layout.svelte | 42 +- .../+page.server.ts | 0 .../{[[...path]] => [...path]}/+page.svelte | 0 packages/website-blocks/package.json | 4 + .../website-blocks/src/columns/Columns.svelte | 94 +++++ .../src/columns/ColumnsInspector.svelte | 107 ++++++ packages/website-blocks/src/columns/index.ts | 19 + packages/website-blocks/src/columns/schema.ts | 26 ++ packages/website-blocks/src/cta/Cta.svelte | 108 ++++++ .../src/cta/CtaInspector.svelte | 122 ++++++ packages/website-blocks/src/cta/index.ts | 19 + packages/website-blocks/src/cta/schema.ts | 23 ++ packages/website-blocks/src/faq/Faq.svelte | 100 +++++ .../src/faq/FaqInspector.svelte | 201 ++++++++++ packages/website-blocks/src/faq/index.ts | 19 + packages/website-blocks/src/faq/schema.ts | 24 ++ .../website-blocks/src/gallery/Gallery.svelte | 266 +++++++++++++ .../gallery/GalleryInspectorFallback.svelte | 168 ++++++++ packages/website-blocks/src/gallery/index.ts | 19 + packages/website-blocks/src/gallery/schema.ts | 29 ++ .../website-blocks/src/image/Image.svelte | 98 +++++ .../src/image/ImageInspectorFallback.svelte | 118 ++++++ packages/website-blocks/src/image/index.ts | 28 ++ packages/website-blocks/src/image/schema.ts | 24 ++ packages/website-blocks/src/index.ts | 29 ++ packages/website-blocks/src/registry.ts | 10 + packages/website-blocks/src/themes/index.ts | 118 ++++++ packages/website-blocks/src/themes/types.ts | 1 + packages/website-blocks/src/types.ts | 22 +- 36 files changed, 2899 insertions(+), 40 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/website/components/GalleryInspector.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/website/components/ImageInspector.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/website/upload.ts rename apps/mana/apps/web/src/routes/s/[siteSlug]/{[[...path]] => [...path]}/+page.server.ts (100%) rename apps/mana/apps/web/src/routes/s/[siteSlug]/{[[...path]] => [...path]}/+page.svelte (100%) create mode 100644 packages/website-blocks/src/columns/Columns.svelte create mode 100644 packages/website-blocks/src/columns/ColumnsInspector.svelte create mode 100644 packages/website-blocks/src/columns/index.ts create mode 100644 packages/website-blocks/src/columns/schema.ts create mode 100644 packages/website-blocks/src/cta/Cta.svelte create mode 100644 packages/website-blocks/src/cta/CtaInspector.svelte create mode 100644 packages/website-blocks/src/cta/index.ts create mode 100644 packages/website-blocks/src/cta/schema.ts create mode 100644 packages/website-blocks/src/faq/Faq.svelte create mode 100644 packages/website-blocks/src/faq/FaqInspector.svelte create mode 100644 packages/website-blocks/src/faq/index.ts create mode 100644 packages/website-blocks/src/faq/schema.ts create mode 100644 packages/website-blocks/src/gallery/Gallery.svelte create mode 100644 packages/website-blocks/src/gallery/GalleryInspectorFallback.svelte create mode 100644 packages/website-blocks/src/gallery/index.ts create mode 100644 packages/website-blocks/src/gallery/schema.ts create mode 100644 packages/website-blocks/src/image/Image.svelte create mode 100644 packages/website-blocks/src/image/ImageInspectorFallback.svelte create mode 100644 packages/website-blocks/src/image/index.ts create mode 100644 packages/website-blocks/src/image/schema.ts create mode 100644 packages/website-blocks/src/themes/index.ts create mode 100644 packages/website-blocks/src/themes/types.ts diff --git a/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte b/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte index 2e3af3500..f822d34fd 100644 --- a/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte +++ b/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte @@ -1,7 +1,10 @@ -{#each topLevel as block (block.id)} +{#snippet renderBlock(block: WebsiteBlock)} {@const spec = getBlockSpec(block.type)} {#if spec} + {@const children = (byParent.get(block.id) ?? []).map(asRegistryBlock)} {#if mode === 'edit'}
onSelect?.(block.id)} + onclick={(e) => { + e.stopPropagation(); + onSelect?.(block.id); + }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); + e.stopPropagation(); onSelect?.(block.id); } }} > - +
{:else}
- +
{/if} {:else if mode === 'edit'} @@ -58,6 +92,17 @@ Unbekannter Block-Typ: {block.type} {/if} +{/snippet} + +{#snippet renderInnerChild(child: BlockType)} + {@const fullBlock = blocks.find((b) => b.id === child.id)} + {#if fullBlock} + {@render renderBlock(fullBlock)} + {/if} +{/snippet} + +{#each topLevel as block (block.id)} + {@render renderBlock(block)} {/each} diff --git a/apps/mana/apps/web/src/lib/modules/website/components/ImageInspector.svelte b/apps/mana/apps/web/src/lib/modules/website/components/ImageInspector.svelte new file mode 100644 index 000000000..d5337c373 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/ImageInspector.svelte @@ -0,0 +1,217 @@ + + +
+
e.preventDefault()} + ondrop={onDrop} + onclick={() => fileInput?.click()} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInput?.click(); + } + }} + > + {#if uploading} + Lade hoch… + {:else if block.props.url} + {block.props.altText} + Klicken / ziehen, um zu ersetzen + {:else} + Bild hier hinziehen oder klicken + {/if} +
+ + + {#if uploadError} +

{uploadError}

+ {/if} + + + + + + + +
+ + + +
+ + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte b/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte new file mode 100644 index 000000000..13f53dd26 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte @@ -0,0 +1,361 @@ + + +
e.key === 'Escape' && onClose()} + role="button" + tabindex="-1" + aria-label="Schließen" +>
+ + + + diff --git a/apps/mana/apps/web/src/lib/modules/website/upload.ts b/apps/mana/apps/web/src/lib/modules/website/upload.ts new file mode 100644 index 000000000..ce539de5b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/upload.ts @@ -0,0 +1,78 @@ +/** + * Client-side upload helper for the website builder. + * + * Forwards the file to mana-media under `app=website` so uploads are + * scoped + listable in admin. Returns `{ mediaId, url }` — the block + * stores the full URL (simpler for the public renderer, which doesn't + * have auth and can't re-resolve a mediaId on every render). + * + * The `url` is the CDN-friendly `/file/large` variant by default. If a + * block needs a different size (gallery thumbnails), call + * `mediaFileUrl(mediaId, variant)` with 'small' / 'medium' / 'large' / + * 'original'. + */ + +import { browser } from '$app/environment'; + +function getMediaUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }) + .__PUBLIC_MANA_MEDIA_URL__; + if (injected) return injected; + } + return ( + (import.meta as unknown as { env?: Record }).env?.PUBLIC_MANA_MEDIA_URL ?? + process.env.PUBLIC_MANA_MEDIA_URL ?? + 'http://localhost:3015' + ); +} + +export type MediaVariant = 'small' | 'medium' | 'large' | 'original'; + +export function mediaFileUrl(mediaId: string, variant: MediaVariant = 'large'): string { + return `${getMediaUrl()}/api/v1/media/${mediaId}/file/${variant}`; +} + +export interface UploadResult { + mediaId: string; + url: string; +} + +export class UploadError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'UploadError'; + this.status = status; + } +} + +/** + * Upload an image file. Throws `UploadError` on non-2xx responses or + * non-image content. Caller is responsible for rendering the error. + */ +export async function uploadImage(file: File): Promise { + if (!file.type.startsWith('image/')) { + throw new UploadError('Bitte wähle ein Bild (PNG, JPG, WEBP, GIF).', 400); + } + if (file.size > 25 * 1024 * 1024) { + throw new UploadError('Datei zu groß (max 25 MB).', 400); + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('app', 'website'); + + const res = await fetch(`${getMediaUrl()}/api/v1/media/upload`, { + method: 'POST', + body: formData, + }); + if (!res.ok) { + throw new UploadError(`Upload fehlgeschlagen (${res.status})`, res.status); + } + + const data = (await res.json()) as { id?: string }; + if (!data.id) throw new UploadError('Upload-Antwort ohne Media-ID', 500); + + return { mediaId: data.id, url: mediaFileUrl(data.id, 'large') }; +} diff --git a/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte b/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte index 3eeca26e5..7381fa3ca 100644 --- a/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte +++ b/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte @@ -13,6 +13,7 @@ import InsertPalette from '../components/InsertPalette.svelte'; import PageList from '../components/PageList.svelte'; import PublishBar from '../components/PublishBar.svelte'; + import SiteSettingsDialog from '../components/SiteSettingsDialog.svelte'; interface Props { siteId: string; @@ -30,6 +31,7 @@ const pageBlocks = $derived(blocksForPage(blocks.value, props.pageId)); let selectedBlockId = $state(null); + let showSettings = $state(false); const selectedBlock = $derived( selectedBlockId ? (pageBlocks.find((b) => b.id === selectedBlockId) ?? null) : null @@ -57,8 +59,19 @@