mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 12:06:42 +02:00
feat(website): M3 — 5 more blocks, containers, upload, themes
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) <noreply@anthropic.com>
This commit is contained in:
parent
25c3bb6cdf
commit
7a4f8894e1
36 changed files with 2899 additions and 40 deletions
98
packages/website-blocks/src/image/Image.svelte
Normal file
98
packages/website-blocks/src/image/Image.svelte
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { ImageProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<ImageProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
|
||||
const aspectStyle = $derived.by(() => {
|
||||
const a = block.props.aspectRatio;
|
||||
if (a === 'auto') return '';
|
||||
const [w, h] = a.split(':').map(Number);
|
||||
return `aspect-ratio: ${w} / ${h};`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<figure
|
||||
class="wb-image"
|
||||
class:wb-image--narrow={block.props.width === 'narrow'}
|
||||
class:wb-image--container={block.props.width === 'container'}
|
||||
class:wb-image--full={block.props.width === 'full'}
|
||||
data-mode={mode}
|
||||
>
|
||||
{#if block.props.url}
|
||||
<div
|
||||
class="wb-image__frame"
|
||||
class:wb-image__frame--cover={block.props.fit === 'cover'}
|
||||
class:wb-image__frame--contain={block.props.fit === 'contain'}
|
||||
style={aspectStyle}
|
||||
>
|
||||
<img src={block.props.url} alt={block.props.altText} loading="lazy" />
|
||||
</div>
|
||||
{:else if isEdit}
|
||||
<div class="wb-image__placeholder">
|
||||
<span>Lade im Inspector ein Bild hoch oder setze eine URL.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.props.caption}
|
||||
<figcaption>{block.props.caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.wb-image {
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.wb-image--narrow .wb-image__frame {
|
||||
max-width: 32rem;
|
||||
}
|
||||
.wb-image--container .wb-image__frame {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.wb-image--full {
|
||||
padding: 0;
|
||||
}
|
||||
.wb-image--full .wb-image__frame {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.wb-image__frame {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.wb-image__frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.wb-image__frame--cover img {
|
||||
object-fit: cover;
|
||||
}
|
||||
.wb-image__frame--contain img {
|
||||
object-fit: contain;
|
||||
}
|
||||
.wb-image__placeholder {
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
color: inherit;
|
||||
opacity: 0.45;
|
||||
font-style: italic;
|
||||
}
|
||||
figcaption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
118
packages/website-blocks/src/image/ImageInspectorFallback.svelte
Normal file
118
packages/website-blocks/src/image/ImageInspectorFallback.svelte
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* URL-only fallback inspector — shipped with the block so it works
|
||||
* when consumers import the registry without wiring upload. The
|
||||
* main Mana editor replaces this via registry override with an
|
||||
* upload-enabled version.
|
||||
*/
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { ImageProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<ImageProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Bild-URL</span>
|
||||
<input
|
||||
type="url"
|
||||
value={block.props.url}
|
||||
oninput={(e) => onChange({ url: e.currentTarget.value })}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Alt-Text</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.altText}
|
||||
oninput={(e) => onChange({ altText: e.currentTarget.value })}
|
||||
placeholder="Beschreibung für Screenreader"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Bildunterschrift</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.caption}
|
||||
oninput={(e) => onChange({ caption: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Seitenverhältnis</span>
|
||||
<select
|
||||
value={block.props.aspectRatio}
|
||||
onchange={(e) =>
|
||||
onChange({ aspectRatio: e.currentTarget.value as ImageProps['aspectRatio'] })}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="21:9">21:9</option>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="1:1">1:1</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Breite</span>
|
||||
<select
|
||||
value={block.props.width}
|
||||
onchange={(e) => onChange({ width: e.currentTarget.value as ImageProps['width'] })}
|
||||
>
|
||||
<option value="narrow">Schmal</option>
|
||||
<option value="container">Container</option>
|
||||
<option value="full">Vollbreit</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Füllung</span>
|
||||
<select
|
||||
value={block.props.fit}
|
||||
onchange={(e) => onChange({ fit: e.currentTarget.value as ImageProps['fit'] })}
|
||||
>
|
||||
<option value="cover">Zuschneiden</option>
|
||||
<option value="contain">Einpassen</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
28
packages/website-blocks/src/image/index.ts
Normal file
28
packages/website-blocks/src/image/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Image from './Image.svelte';
|
||||
import { ImageSchema, IMAGE_DEFAULTS, type ImageProps } from './schema';
|
||||
|
||||
/**
|
||||
* Image block. The inspector is provided by the consuming app (the
|
||||
* editor), not by this package — uploads depend on mana-media which
|
||||
* lives outside @mana/website-blocks. The registry gets a
|
||||
* placeholder inspector that just edits URL + alt text; the real
|
||||
* upload-enabled inspector is injected via `overrideInspector()`
|
||||
* once the editor boots.
|
||||
*/
|
||||
import ImageInspectorFallback from './ImageInspectorFallback.svelte';
|
||||
|
||||
export const imageBlockSpec: BlockSpec<ImageProps> = {
|
||||
type: 'image',
|
||||
label: 'Bild',
|
||||
icon: 'image',
|
||||
category: 'media',
|
||||
schema: ImageSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: IMAGE_DEFAULTS,
|
||||
Component: Image,
|
||||
Inspector: ImageInspectorFallback,
|
||||
};
|
||||
|
||||
export type { ImageProps };
|
||||
export { ImageSchema, IMAGE_DEFAULTS };
|
||||
24
packages/website-blocks/src/image/schema.ts
Normal file
24
packages/website-blocks/src/image/schema.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const ImageSchema = z.object({
|
||||
url: z.string().max(1024).default(''),
|
||||
altText: z.string().max(280).default(''),
|
||||
caption: z.string().max(280).default(''),
|
||||
/** 16:9 / 4:3 / 1:1 / auto — 'auto' uses the image's natural ratio. */
|
||||
aspectRatio: z.enum(['auto', '16:9', '4:3', '1:1', '21:9']).default('auto'),
|
||||
/** Max width constraint. 'container' = 48rem, 'full' = full bleed. */
|
||||
width: z.enum(['narrow', 'container', 'full']).default('container'),
|
||||
/** How to crop when aspect is fixed. */
|
||||
fit: z.enum(['cover', 'contain']).default('cover'),
|
||||
});
|
||||
|
||||
export type ImageProps = z.infer<typeof ImageSchema>;
|
||||
|
||||
export const IMAGE_DEFAULTS: ImageProps = {
|
||||
url: '',
|
||||
altText: '',
|
||||
caption: '',
|
||||
aspectRatio: 'auto',
|
||||
width: 'container',
|
||||
fit: 'cover',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue