mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 02:56:43 +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
266
packages/website-blocks/src/gallery/Gallery.svelte
Normal file
266
packages/website-blocks/src/gallery/Gallery.svelte
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { GalleryProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<GalleryProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
|
||||
// Lightbox state — public-mode only; edit mode doesn't launch the
|
||||
// modal because clicking an image is how the user selects the
|
||||
// gallery block for editing.
|
||||
let lightboxIndex = $state<number | null>(null);
|
||||
|
||||
function openLightbox(i: number) {
|
||||
if (mode !== 'public' || !block.props.lightbox) return;
|
||||
lightboxIndex = i;
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxIndex = null;
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (lightboxIndex === null) return;
|
||||
lightboxIndex = (lightboxIndex + 1) % block.props.images.length;
|
||||
}
|
||||
|
||||
function prevImage() {
|
||||
if (lightboxIndex === null) return;
|
||||
lightboxIndex = (lightboxIndex - 1 + block.props.images.length) % block.props.images.length;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (lightboxIndex === null) return;
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
}
|
||||
|
||||
const activeImage = $derived(lightboxIndex !== null ? block.props.images[lightboxIndex] : null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<section
|
||||
class="wb-gallery wb-gallery--cols-{block.props.columns} wb-gallery--gap-{block.props.gap}"
|
||||
class:wb-gallery--masonry={block.props.layout === 'masonry'}
|
||||
data-mode={mode}
|
||||
>
|
||||
{#if block.props.title}
|
||||
<h2 class="wb-gallery__title">{block.props.title}</h2>
|
||||
{/if}
|
||||
|
||||
{#if block.props.images.length === 0 && isEdit}
|
||||
<div class="wb-gallery__empty">Füge im Inspector Bilder hinzu.</div>
|
||||
{:else}
|
||||
<div class="wb-gallery__grid">
|
||||
{#each block.props.images as image, i (i)}
|
||||
<figure class="wb-gallery__item">
|
||||
{#if mode === 'public' && block.props.lightbox}
|
||||
<button
|
||||
class="wb-gallery__item-trigger"
|
||||
onclick={() => openLightbox(i)}
|
||||
aria-label={image.altText || `Bild ${i + 1} öffnen`}
|
||||
>
|
||||
<img src={image.url} alt={image.altText} loading="lazy" />
|
||||
</button>
|
||||
{:else}
|
||||
<img src={image.url} alt={image.altText} loading="lazy" />
|
||||
{/if}
|
||||
{#if image.caption}
|
||||
<figcaption>{image.caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if activeImage && mode === 'public'}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="wb-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Vollbild-Ansicht"
|
||||
tabindex="-1"
|
||||
onclick={closeLightbox}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeLightbox()}
|
||||
>
|
||||
<button class="wb-lightbox__close" onclick={closeLightbox} aria-label="Schließen">×</button>
|
||||
<button
|
||||
class="wb-lightbox__nav wb-lightbox__nav--prev"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
prevImage();
|
||||
}}
|
||||
aria-label="Vorheriges Bild">‹</button
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<figure
|
||||
class="wb-lightbox__figure"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img src={activeImage.url} alt={activeImage.altText} />
|
||||
{#if activeImage.caption}
|
||||
<figcaption>{activeImage.caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
<button
|
||||
class="wb-lightbox__nav wb-lightbox__nav--next"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
nextImage();
|
||||
}}
|
||||
aria-label="Nächstes Bild">›</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wb-gallery {
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wb-gallery__title {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.wb-gallery__empty {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(127, 127, 127, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.45;
|
||||
font-style: italic;
|
||||
}
|
||||
.wb-gallery__grid {
|
||||
display: grid;
|
||||
}
|
||||
.wb-gallery--cols-2 .wb-gallery__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.wb-gallery--cols-3 .wb-gallery__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.wb-gallery--cols-4 .wb-gallery__grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.wb-gallery--gap-sm .wb-gallery__grid {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.wb-gallery--gap-md .wb-gallery__grid {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wb-gallery--gap-lg .wb-gallery__grid {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.wb-gallery--masonry .wb-gallery__grid {
|
||||
/* CSS masonry not universal; fallback to regular grid with row
|
||||
flow. Switch to true masonry behind a feature flag later. */
|
||||
grid-auto-rows: masonry;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wb-gallery--cols-2 .wb-gallery__grid,
|
||||
.wb-gallery--cols-3 .wb-gallery__grid,
|
||||
.wb-gallery--cols-4 .wb-gallery__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
.wb-gallery__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.wb-gallery__item-trigger {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-gallery__item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 0.375rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.wb-gallery__item-trigger:hover img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.wb-gallery__item figcaption {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wb-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.wb-lightbox__figure {
|
||||
margin: 0;
|
||||
max-width: min(90vw, 72rem);
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: default;
|
||||
}
|
||||
.wb-lightbox__figure img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.wb-lightbox__figure figcaption {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
text-align: center;
|
||||
}
|
||||
.wb-lightbox__close,
|
||||
.wb-lightbox__nav {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-lightbox__close {
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
.wb-lightbox__nav--prev {
|
||||
left: 1.5rem;
|
||||
}
|
||||
.wb-lightbox__nav--next {
|
||||
right: 1.5rem;
|
||||
}
|
||||
.wb-lightbox__close:hover,
|
||||
.wb-lightbox__nav:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* URL-only fallback. The app-side inspector (with upload) overrides
|
||||
* this via the custom-inspector registry — see
|
||||
* apps/mana/apps/web/src/lib/modules/website/components/GalleryInspector.svelte
|
||||
*/
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { GalleryProps, GalleryImage } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<GalleryProps> = $props();
|
||||
|
||||
function updateImage(index: number, patch: Partial<GalleryImage>) {
|
||||
const next = block.props.images.map((img, i) => (i === index ? { ...img, ...patch } : img));
|
||||
onChange({ images: next });
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
onChange({ images: [...block.props.images, { url: '', altText: '', caption: '' }] });
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
onChange({ images: block.props.images.filter((_, i) => i !== index) });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Überschrift</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 GalleryProps['layout'] })}
|
||||
>
|
||||
<option value="grid">Grid</option>
|
||||
<option value="masonry">Masonry</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wb-field">
|
||||
<span>Spalten</span>
|
||||
<select
|
||||
value={String(block.props.columns)}
|
||||
onchange={(e) =>
|
||||
onChange({ columns: Number(e.currentTarget.value) as GalleryProps['columns'] })}
|
||||
>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wb-images">
|
||||
<div class="wb-images__head">
|
||||
<span>Bilder ({block.props.images.length})</span>
|
||||
<button class="wb-btn wb-btn--primary" onclick={addImage}>+ Bild</button>
|
||||
</div>
|
||||
|
||||
{#each block.props.images as img, i (i)}
|
||||
<div class="wb-image-row">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
value={img.url}
|
||||
oninput={(e) => updateImage(i, { url: e.currentTarget.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Alt-Text"
|
||||
value={img.altText}
|
||||
oninput={(e) => updateImage(i, { altText: e.currentTarget.value })}
|
||||
/>
|
||||
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => removeImage(i)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</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 {
|
||||
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-size: 0.875rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wb-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-images__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-image-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.wb-image-row input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
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-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;
|
||||
}
|
||||
.wb-btn--danger:hover {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/gallery/index.ts
Normal file
19
packages/website-blocks/src/gallery/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Gallery from './Gallery.svelte';
|
||||
import GalleryInspectorFallback from './GalleryInspectorFallback.svelte';
|
||||
import { GallerySchema, GALLERY_DEFAULTS, type GalleryProps, type GalleryImage } from './schema';
|
||||
|
||||
export const galleryBlockSpec: BlockSpec<GalleryProps> = {
|
||||
type: 'gallery',
|
||||
label: 'Galerie',
|
||||
icon: 'images',
|
||||
category: 'media',
|
||||
schema: GallerySchema,
|
||||
schemaVersion: 1,
|
||||
defaults: GALLERY_DEFAULTS,
|
||||
Component: Gallery,
|
||||
Inspector: GalleryInspectorFallback,
|
||||
};
|
||||
|
||||
export type { GalleryProps, GalleryImage };
|
||||
export { GallerySchema, GALLERY_DEFAULTS };
|
||||
29
packages/website-blocks/src/gallery/schema.ts
Normal file
29
packages/website-blocks/src/gallery/schema.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const GalleryImageSchema = z.object({
|
||||
url: z.string().min(1).max(1024),
|
||||
altText: z.string().max(280).default(''),
|
||||
caption: z.string().max(280).default(''),
|
||||
});
|
||||
|
||||
export type GalleryImage = z.infer<typeof GalleryImageSchema>;
|
||||
|
||||
export const GallerySchema = z.object({
|
||||
title: z.string().max(160).default(''),
|
||||
images: z.array(GalleryImageSchema).max(60).default([]),
|
||||
layout: z.enum(['masonry', 'grid']).default('grid'),
|
||||
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
||||
gap: z.enum(['sm', 'md', 'lg']).default('md'),
|
||||
lightbox: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type GalleryProps = z.infer<typeof GallerySchema>;
|
||||
|
||||
export const GALLERY_DEFAULTS: GalleryProps = {
|
||||
title: '',
|
||||
images: [],
|
||||
layout: 'grid',
|
||||
columns: 3,
|
||||
gap: 'md',
|
||||
lightbox: true,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue