mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 15:46:41 +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
100
packages/website-blocks/src/faq/Faq.svelte
Normal file
100
packages/website-blocks/src/faq/Faq.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { FaqProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<FaqProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<section class="wb-faq" data-mode={mode}>
|
||||
<div class="wb-faq__inner">
|
||||
{#if block.props.title}
|
||||
<h2>{block.props.title}</h2>
|
||||
{/if}
|
||||
|
||||
{#if block.props.items.length === 0 && isEdit}
|
||||
<p class="wb-placeholder">Füge Fragen im Inspector hinzu.</p>
|
||||
{:else}
|
||||
<div class="wb-faq__list">
|
||||
{#each block.props.items as item, i (i)}
|
||||
<details open={block.props.defaultOpen}>
|
||||
<summary>{item.question}</summary>
|
||||
<div class="wb-faq__answer">
|
||||
{#each item.answer.split(/\n{2,}/) as paragraph, j (j)}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-faq {
|
||||
padding: 3rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-faq__inner {
|
||||
max-width: 48rem;
|
||||
width: 100%;
|
||||
}
|
||||
.wb-faq h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wb-faq__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
details {
|
||||
border: 1px solid rgba(127, 127, 127, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
summary::after {
|
||||
content: '+';
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
details[open] summary::after {
|
||||
content: '–';
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.wb-faq__answer {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(127, 127, 127, 0.1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.wb-faq__answer p {
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wb-faq__answer p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.wb-placeholder {
|
||||
opacity: 0.4;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
201
packages/website-blocks/src/faq/FaqInspector.svelte
Normal file
201
packages/website-blocks/src/faq/FaqInspector.svelte
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { FaqProps, FaqItem } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<FaqProps> = $props();
|
||||
|
||||
function updateItem(index: number, patch: Partial<FaqItem>) {
|
||||
const next = block.props.items.map((item, i) => (i === index ? { ...item, ...patch } : item));
|
||||
onChange({ items: next });
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
onChange({ items: [...block.props.items, { question: 'Neue Frage', answer: 'Die Antwort.' }] });
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
onChange({ items: block.props.items.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
function moveItem(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= block.props.items.length) return;
|
||||
const next = [...block.props.items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange({ items: next });
|
||||
}
|
||||
</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>
|
||||
|
||||
<label class="wb-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.props.defaultOpen}
|
||||
onchange={(e) => onChange({ defaultOpen: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>Alle standardmäßig ausgeklappt</span>
|
||||
</label>
|
||||
|
||||
<div class="wb-items">
|
||||
<div class="wb-items__header">
|
||||
<span>Fragen ({block.props.items.length})</span>
|
||||
<button class="wb-btn wb-btn--primary" onclick={addItem}>+ Frage</button>
|
||||
</div>
|
||||
|
||||
{#each block.props.items as item, i (i)}
|
||||
<div class="wb-item">
|
||||
<div class="wb-item__head">
|
||||
<span class="wb-item__index">#{i + 1}</span>
|
||||
<div class="wb-item__actions">
|
||||
<button
|
||||
class="wb-btn wb-btn--icon"
|
||||
onclick={() => moveItem(i, -1)}
|
||||
disabled={i === 0}
|
||||
title="Nach oben">↑</button
|
||||
>
|
||||
<button
|
||||
class="wb-btn wb-btn--icon"
|
||||
onclick={() => moveItem(i, 1)}
|
||||
disabled={i === block.props.items.length - 1}
|
||||
title="Nach unten">↓</button
|
||||
>
|
||||
<button
|
||||
class="wb-btn wb-btn--icon wb-btn--danger"
|
||||
onclick={() => removeItem(i)}
|
||||
title="Löschen">×</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={item.question}
|
||||
oninput={(e) => updateItem(i, { question: e.currentTarget.value })}
|
||||
placeholder="Frage"
|
||||
/>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={item.answer}
|
||||
oninput={(e) => updateItem(i, { answer: e.currentTarget.value })}
|
||||
placeholder="Antwort"
|
||||
></textarea>
|
||||
</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-item input,
|
||||
.wb-item 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-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wb-item textarea {
|
||||
resize: vertical;
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
.wb-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-items__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.wb-item__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.wb-item__index {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.wb-item__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/faq/index.ts
Normal file
19
packages/website-blocks/src/faq/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Faq from './Faq.svelte';
|
||||
import FaqInspector from './FaqInspector.svelte';
|
||||
import { FaqSchema, FAQ_DEFAULTS, type FaqProps, type FaqItem } from './schema';
|
||||
|
||||
export const faqBlockSpec: BlockSpec<FaqProps> = {
|
||||
type: 'faq',
|
||||
label: 'FAQ',
|
||||
icon: 'question',
|
||||
category: 'content',
|
||||
schema: FaqSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: FAQ_DEFAULTS,
|
||||
Component: Faq,
|
||||
Inspector: FaqInspector,
|
||||
};
|
||||
|
||||
export type { FaqProps, FaqItem };
|
||||
export { FaqSchema, FAQ_DEFAULTS };
|
||||
24
packages/website-blocks/src/faq/schema.ts
Normal file
24
packages/website-blocks/src/faq/schema.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const FaqItemSchema = z.object({
|
||||
question: z.string().min(1).max(280),
|
||||
answer: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export type FaqItem = z.infer<typeof FaqItemSchema>;
|
||||
|
||||
export const FaqSchema = z.object({
|
||||
title: z.string().max(160).default('FAQ'),
|
||||
items: z.array(FaqItemSchema).max(50).default([]),
|
||||
defaultOpen: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type FaqProps = z.infer<typeof FaqSchema>;
|
||||
|
||||
export const FAQ_DEFAULTS: FaqProps = {
|
||||
title: 'Häufige Fragen',
|
||||
items: [
|
||||
{ question: 'Beispielfrage?', answer: 'Die Antwort. Klick in den Inspector zum Bearbeiten.' },
|
||||
],
|
||||
defaultOpen: false,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue