mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +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
|
|
@ -21,6 +21,10 @@
|
|||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
},
|
||||
"./themes": {
|
||||
"types": "./src/themes/index.ts",
|
||||
"default": "./src/themes/index.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
94
packages/website-blocks/src/columns/Columns.svelte
Normal file
94
packages/website-blocks/src/columns/Columns.svelte
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps, Block } from '../types';
|
||||
import type { ColumnsProps } from './schema';
|
||||
import { columnSlotKeys } from './schema';
|
||||
|
||||
let { block, mode, children = [], renderChild }: BlockRenderProps<ColumnsProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
const slots = $derived(columnSlotKeys(block.props.count));
|
||||
|
||||
function childrenForSlot(slot: string): Block[] {
|
||||
return (children ?? []).filter((c) => c.slotKey === slot);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-columns wb-columns--gap-{block.props.gap} wb-columns--align-{block.props.align}"
|
||||
class:wb-columns--2={block.props.count === 2}
|
||||
class:wb-columns--3={block.props.count === 3}
|
||||
class:wb-columns--stack={block.props.stackOnMobile}
|
||||
data-mode={mode}
|
||||
>
|
||||
{#each slots as slot, idx (slot)}
|
||||
{@const slotChildren = childrenForSlot(slot)}
|
||||
<div
|
||||
class="wb-column"
|
||||
data-slot={slot}
|
||||
data-slot-index={idx}
|
||||
class:wb-column--empty={slotChildren.length === 0}
|
||||
>
|
||||
{#if slotChildren.length === 0 && isEdit}
|
||||
<div class="wb-column__empty">Spalte {idx + 1}</div>
|
||||
{/if}
|
||||
{#each slotChildren as child (child.id)}
|
||||
{#if renderChild}
|
||||
{@render renderChild(child)}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-columns {
|
||||
display: grid;
|
||||
padding: 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wb-columns--2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.wb-columns--3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.wb-columns--gap-sm {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wb-columns--gap-md {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.wb-columns--gap-lg {
|
||||
gap: 3rem;
|
||||
}
|
||||
.wb-columns--align-start {
|
||||
align-items: start;
|
||||
}
|
||||
.wb-columns--align-center {
|
||||
align-items: center;
|
||||
}
|
||||
.wb-columns--align-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.wb-column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wb-column__empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(127, 127, 127, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.4;
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.wb-columns--stack {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
packages/website-blocks/src/columns/ColumnsInspector.svelte
Normal file
107
packages/website-blocks/src/columns/ColumnsInspector.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { ColumnsProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<ColumnsProps> = $props();
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Spalten-Anzahl</span>
|
||||
<select
|
||||
value={String(block.props.count)}
|
||||
onchange={(e) => onChange({ count: Number(e.currentTarget.value) as ColumnsProps['count'] })}
|
||||
>
|
||||
<option value="2">2 Spalten</option>
|
||||
<option value="3">3 Spalten</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Abstand</span>
|
||||
<select
|
||||
value={block.props.gap}
|
||||
onchange={(e) => onChange({ gap: e.currentTarget.value as ColumnsProps['gap'] })}
|
||||
>
|
||||
<option value="sm">Klein</option>
|
||||
<option value="md">Mittel</option>
|
||||
<option value="lg">Groß</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Vertikale Ausrichtung</span>
|
||||
<select
|
||||
value={block.props.align}
|
||||
onchange={(e) => onChange({ align: e.currentTarget.value as ColumnsProps['align'] })}
|
||||
>
|
||||
<option value="start">Oben</option>
|
||||
<option value="center">Mittig</option>
|
||||
<option value="stretch">Gestreckt</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="wb-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.props.stackOnMobile}
|
||||
onchange={(e) => onChange({ stackOnMobile: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>Auf Mobile untereinander stapeln</span>
|
||||
</label>
|
||||
|
||||
<p class="wb-hint">
|
||||
Blöcke in eine Spalte hinzufügen: wähle oben die "+" Palette und füge Block ein — neue Blöcke
|
||||
werden der zuletzt aktiven Spalte zugewiesen. (In M4 kommt pro-Spalte-Insert-UI.)
|
||||
</p>
|
||||
</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 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-hint {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/columns/index.ts
Normal file
19
packages/website-blocks/src/columns/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Columns from './Columns.svelte';
|
||||
import ColumnsInspector from './ColumnsInspector.svelte';
|
||||
import { ColumnsSchema, COLUMNS_DEFAULTS, columnSlotKeys, type ColumnsProps } from './schema';
|
||||
|
||||
export const columnsBlockSpec: BlockSpec<ColumnsProps> = {
|
||||
type: 'columns',
|
||||
label: 'Spalten',
|
||||
icon: 'columns',
|
||||
category: 'layout',
|
||||
schema: ColumnsSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: COLUMNS_DEFAULTS,
|
||||
Component: Columns,
|
||||
Inspector: ColumnsInspector,
|
||||
};
|
||||
|
||||
export type { ColumnsProps };
|
||||
export { ColumnsSchema, COLUMNS_DEFAULTS, columnSlotKeys };
|
||||
26
packages/website-blocks/src/columns/schema.ts
Normal file
26
packages/website-blocks/src/columns/schema.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const ColumnsSchema = z.object({
|
||||
count: z.union([z.literal(2), z.literal(3)]).default(2),
|
||||
gap: z.enum(['sm', 'md', 'lg']).default('md'),
|
||||
align: z.enum(['start', 'center', 'stretch']).default('stretch'),
|
||||
stackOnMobile: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type ColumnsProps = z.infer<typeof ColumnsSchema>;
|
||||
|
||||
export const COLUMNS_DEFAULTS: ColumnsProps = {
|
||||
count: 2,
|
||||
gap: 'md',
|
||||
align: 'stretch',
|
||||
stackOnMobile: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Slot keys that a `columns` container accepts. Children of this block
|
||||
* set `slotKey = 'col-0' | 'col-1' | 'col-2'` to be placed in that
|
||||
* column.
|
||||
*/
|
||||
export function columnSlotKeys(count: 2 | 3): readonly string[] {
|
||||
return count === 2 ? ['col-0', 'col-1'] : ['col-0', 'col-1', 'col-2'];
|
||||
}
|
||||
108
packages/website-blocks/src/cta/Cta.svelte
Normal file
108
packages/website-blocks/src/cta/Cta.svelte
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { CtaProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<CtaProps> = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="wb-cta"
|
||||
class:wb-cta--left={block.props.align === 'left'}
|
||||
class:wb-cta--center={block.props.align === 'center'}
|
||||
class:wb-cta--bg-subtle={block.props.background === 'subtle'}
|
||||
class:wb-cta--bg-primary={block.props.background === 'primary'}
|
||||
data-mode={mode}
|
||||
>
|
||||
<div class="wb-cta__inner">
|
||||
{#if block.props.title}
|
||||
<h2>{block.props.title}</h2>
|
||||
{:else if isEdit}
|
||||
<h2 class="wb-placeholder">Titel im Inspector setzen</h2>
|
||||
{/if}
|
||||
|
||||
{#if block.props.description}
|
||||
<p class="wb-cta__description">{block.props.description}</p>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
class="wb-cta__button wb-cta__button--{block.props.variant}"
|
||||
href={block.props.buttonHref || '#'}
|
||||
>
|
||||
{block.props.buttonLabel || (isEdit ? 'Button-Label fehlt' : '')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wb-cta {
|
||||
padding: 3rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wb-cta--bg-subtle {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.wb-cta--bg-primary {
|
||||
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
|
||||
color: white;
|
||||
}
|
||||
.wb-cta__inner {
|
||||
max-width: 42rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wb-cta--center .wb-cta__inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.wb-cta--left .wb-cta__inner {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.wb-cta h2 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wb-cta__description {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wb-cta__button {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.wb-cta__button--primary {
|
||||
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
|
||||
color: white;
|
||||
}
|
||||
.wb-cta__button--secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: inherit;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.wb-cta__button--ghost {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.wb-cta--bg-primary .wb-cta__button--primary {
|
||||
background: white;
|
||||
color: var(--wb-primary, rgb(99, 102, 241));
|
||||
}
|
||||
.wb-placeholder {
|
||||
opacity: 0.35;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
122
packages/website-blocks/src/cta/CtaInspector.svelte
Normal file
122
packages/website-blocks/src/cta/CtaInspector.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { CtaProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<CtaProps> = $props();
|
||||
</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>Button-Label *</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.buttonLabel}
|
||||
oninput={(e) => onChange({ buttonLabel: e.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-field">
|
||||
<span>Button-Link</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.buttonHref}
|
||||
oninput={(e) => onChange({ buttonHref: e.currentTarget.value })}
|
||||
placeholder="https://… oder /kontakt"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wb-row">
|
||||
<label class="wb-field">
|
||||
<span>Variante</span>
|
||||
<select
|
||||
value={block.props.variant}
|
||||
onchange={(e) => onChange({ variant: e.currentTarget.value as CtaProps['variant'] })}
|
||||
>
|
||||
<option value="primary">Primär</option>
|
||||
<option value="secondary">Sekundär</option>
|
||||
<option value="ghost">Ghost</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Ausrichtung</span>
|
||||
<select
|
||||
value={block.props.align}
|
||||
onchange={(e) => onChange({ align: e.currentTarget.value as CtaProps['align'] })}
|
||||
>
|
||||
<option value="center">Zentriert</option>
|
||||
<option value="left">Linksbündig</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Hintergrund</span>
|
||||
<select
|
||||
value={block.props.background}
|
||||
onchange={(e) => onChange({ background: e.currentTarget.value as CtaProps['background'] })}
|
||||
>
|
||||
<option value="none">Kein</option>
|
||||
<option value="subtle">Dezent</option>
|
||||
<option value="primary">Primärfarbe</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;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field select,
|
||||
.wb-field textarea {
|
||||
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-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/cta/index.ts
Normal file
19
packages/website-blocks/src/cta/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Cta from './Cta.svelte';
|
||||
import CtaInspector from './CtaInspector.svelte';
|
||||
import { CtaSchema, CTA_DEFAULTS, type CtaProps } from './schema';
|
||||
|
||||
export const ctaBlockSpec: BlockSpec<CtaProps> = {
|
||||
type: 'cta',
|
||||
label: 'Call-to-Action',
|
||||
icon: 'megaphone',
|
||||
category: 'content',
|
||||
schema: CtaSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: CTA_DEFAULTS,
|
||||
Component: Cta,
|
||||
Inspector: CtaInspector,
|
||||
};
|
||||
|
||||
export type { CtaProps };
|
||||
export { CtaSchema, CTA_DEFAULTS };
|
||||
23
packages/website-blocks/src/cta/schema.ts
Normal file
23
packages/website-blocks/src/cta/schema.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const CtaSchema = z.object({
|
||||
title: z.string().max(160).default(''),
|
||||
description: z.string().max(480).default(''),
|
||||
buttonLabel: z.string().min(1).max(60).default('Los geht’s'),
|
||||
buttonHref: z.string().max(512).default('#'),
|
||||
variant: z.enum(['primary', 'secondary', 'ghost']).default('primary'),
|
||||
align: z.enum(['left', 'center']).default('center'),
|
||||
background: z.enum(['none', 'subtle', 'primary']).default('subtle'),
|
||||
});
|
||||
|
||||
export type CtaProps = z.infer<typeof CtaSchema>;
|
||||
|
||||
export const CTA_DEFAULTS: CtaProps = {
|
||||
title: 'Bereit loszulegen?',
|
||||
description: '',
|
||||
buttonLabel: 'Jetzt starten',
|
||||
buttonHref: '#',
|
||||
variant: 'primary',
|
||||
align: 'center',
|
||||
background: 'subtle',
|
||||
};
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
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',
|
||||
};
|
||||
|
|
@ -26,3 +26,32 @@ export {
|
|||
type RichTextProps,
|
||||
} from './richText';
|
||||
export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer';
|
||||
export { imageBlockSpec, ImageSchema, IMAGE_DEFAULTS, type ImageProps } from './image';
|
||||
export { ctaBlockSpec, CtaSchema, CTA_DEFAULTS, type CtaProps } from './cta';
|
||||
export { faqBlockSpec, FaqSchema, FAQ_DEFAULTS, type FaqProps, type FaqItem } from './faq';
|
||||
export {
|
||||
columnsBlockSpec,
|
||||
ColumnsSchema,
|
||||
COLUMNS_DEFAULTS,
|
||||
columnSlotKeys,
|
||||
type ColumnsProps,
|
||||
} from './columns';
|
||||
export {
|
||||
galleryBlockSpec,
|
||||
GallerySchema,
|
||||
GALLERY_DEFAULTS,
|
||||
type GalleryProps,
|
||||
type GalleryImage,
|
||||
} from './gallery';
|
||||
|
||||
export {
|
||||
THEME_PRESETS,
|
||||
PRESET_LABELS,
|
||||
CLASSIC_LIGHT,
|
||||
MODERN_DARK,
|
||||
WARM,
|
||||
resolveTheme,
|
||||
themeCssVars,
|
||||
type ThemeTokens,
|
||||
type ThemePreset,
|
||||
} from './themes';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import type { BlockSpec } from './types';
|
|||
import { heroBlockSpec } from './hero';
|
||||
import { richTextBlockSpec } from './richText';
|
||||
import { spacerBlockSpec } from './spacer';
|
||||
import { imageBlockSpec } from './image';
|
||||
import { ctaBlockSpec } from './cta';
|
||||
import { faqBlockSpec } from './faq';
|
||||
import { columnsBlockSpec } from './columns';
|
||||
import { galleryBlockSpec } from './gallery';
|
||||
|
||||
/**
|
||||
* The block registry — single source of truth for every block type the
|
||||
|
|
@ -14,6 +19,11 @@ import { spacerBlockSpec } from './spacer';
|
|||
export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
|
||||
heroBlockSpec,
|
||||
richTextBlockSpec,
|
||||
ctaBlockSpec,
|
||||
imageBlockSpec,
|
||||
galleryBlockSpec,
|
||||
faqBlockSpec,
|
||||
columnsBlockSpec,
|
||||
spacerBlockSpec,
|
||||
] as unknown as readonly BlockSpec<unknown>[];
|
||||
|
||||
|
|
|
|||
118
packages/website-blocks/src/themes/index.ts
Normal file
118
packages/website-blocks/src/themes/index.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Theme presets for the website builder.
|
||||
*
|
||||
* A preset is a named set of CSS variables (`--wb-primary`, `--wb-bg`,
|
||||
* `--wb-fg`, plus spacing/type tokens). The public renderer injects
|
||||
* these via inline `style` on the root — block components read them
|
||||
* through `var()` so they inherit the theme without hardcoding.
|
||||
*
|
||||
* Users pick a preset and can override individual colors via
|
||||
* `site.theme.overrides`.
|
||||
*/
|
||||
|
||||
import type { ThemePreset } from './types';
|
||||
|
||||
export interface ThemeTokens {
|
||||
/** Primary brand color — buttons, links, accents. */
|
||||
primary: string;
|
||||
/** Primary color's contrast text (white on most primaries). */
|
||||
primaryFg: string;
|
||||
/** Page background. */
|
||||
background: string;
|
||||
/** Page text color. */
|
||||
foreground: string;
|
||||
/** Subtle surface (section bg, hero subtle). */
|
||||
surface: string;
|
||||
/** Border / divider color (dividers, card outlines). */
|
||||
border: string;
|
||||
/** Muted text (captions, metadata). */
|
||||
muted: string;
|
||||
/** Font family for headings + body. */
|
||||
fontFamily: string;
|
||||
/** Font family for headings if different. Empty = same as body. */
|
||||
headingFontFamily: string;
|
||||
/** Base radius for buttons + cards. */
|
||||
radius: string;
|
||||
}
|
||||
|
||||
export const CLASSIC_LIGHT: ThemeTokens = {
|
||||
primary: '#3b82f6',
|
||||
primaryFg: '#ffffff',
|
||||
background: '#ffffff',
|
||||
foreground: '#0f172a',
|
||||
surface: '#f8fafc',
|
||||
border: 'rgba(15, 23, 42, 0.08)',
|
||||
muted: 'rgba(15, 23, 42, 0.6)',
|
||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||
headingFontFamily: '',
|
||||
radius: '0.5rem',
|
||||
};
|
||||
|
||||
export const MODERN_DARK: ThemeTokens = {
|
||||
primary: '#6366f1',
|
||||
primaryFg: '#ffffff',
|
||||
background: '#0b0d12',
|
||||
foreground: '#f5f6f8',
|
||||
surface: 'rgba(255, 255, 255, 0.04)',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
muted: 'rgba(255, 255, 255, 0.6)',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
headingFontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
radius: '0.75rem',
|
||||
};
|
||||
|
||||
export const WARM: ThemeTokens = {
|
||||
primary: '#f97316',
|
||||
primaryFg: '#ffffff',
|
||||
background: '#fdf6ed',
|
||||
foreground: '#3b2d1f',
|
||||
surface: '#f7ede2',
|
||||
border: 'rgba(59, 45, 31, 0.12)',
|
||||
muted: 'rgba(59, 45, 31, 0.6)',
|
||||
fontFamily: '"Merriweather", Georgia, serif',
|
||||
headingFontFamily: '"Merriweather", Georgia, serif',
|
||||
radius: '0.375rem',
|
||||
};
|
||||
|
||||
export const THEME_PRESETS: Record<ThemePreset, ThemeTokens> = {
|
||||
classic: CLASSIC_LIGHT,
|
||||
modern: MODERN_DARK,
|
||||
warm: WARM,
|
||||
};
|
||||
|
||||
export const PRESET_LABELS: Record<ThemePreset, string> = {
|
||||
classic: 'Klassisch (hell)',
|
||||
modern: 'Modern (dunkel)',
|
||||
warm: 'Warm (Serif)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge preset tokens with per-site overrides.
|
||||
*/
|
||||
export function resolveTheme(
|
||||
preset: ThemePreset,
|
||||
overrides?: Partial<Pick<ThemeTokens, 'primary' | 'background' | 'foreground'>>
|
||||
): ThemeTokens {
|
||||
const base = THEME_PRESETS[preset];
|
||||
return { ...base, ...(overrides ?? {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise tokens as a CSS-variable string for inline `style=`.
|
||||
*/
|
||||
export function themeCssVars(tokens: ThemeTokens): string {
|
||||
return [
|
||||
`--wb-primary:${tokens.primary}`,
|
||||
`--wb-primary-fg:${tokens.primaryFg}`,
|
||||
`--wb-bg:${tokens.background}`,
|
||||
`--wb-fg:${tokens.foreground}`,
|
||||
`--wb-surface:${tokens.surface}`,
|
||||
`--wb-border:${tokens.border}`,
|
||||
`--wb-muted:${tokens.muted}`,
|
||||
`--wb-font:${tokens.fontFamily}`,
|
||||
`--wb-font-heading:${tokens.headingFontFamily || tokens.fontFamily}`,
|
||||
`--wb-radius:${tokens.radius}`,
|
||||
].join(';');
|
||||
}
|
||||
|
||||
export type { ThemePreset };
|
||||
1
packages/website-blocks/src/themes/types.ts
Normal file
1
packages/website-blocks/src/themes/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type ThemePreset = 'classic' | 'modern' | 'warm';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Component } from 'svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import type { ZodTypeAny, z } from 'zod';
|
||||
|
||||
/**
|
||||
|
|
@ -19,6 +19,11 @@ export type BlockMode = 'edit' | 'preview' | 'public';
|
|||
* A single block in the tree. Props are block-type-specific and validated
|
||||
* against the registered Zod schema at write time (in stores) and at
|
||||
* publish time (in the snapshot builder).
|
||||
*
|
||||
* When the renderer passes a block into a container component, the
|
||||
* block's own children (blocks whose `parentBlockId === this.id`) are
|
||||
* pre-arranged into `children`. Leaf blocks always have `children = []`
|
||||
* or undefined.
|
||||
*/
|
||||
export interface Block<Props = unknown> {
|
||||
id: string;
|
||||
|
|
@ -28,6 +33,7 @@ export interface Block<Props = unknown> {
|
|||
order: number;
|
||||
parentBlockId: string | null;
|
||||
slotKey: string | null;
|
||||
children?: Block[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,13 +42,23 @@ export interface Block<Props = unknown> {
|
|||
export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed';
|
||||
|
||||
/**
|
||||
* Props passed to every block renderer. `onEdit` is only present in
|
||||
* `edit` mode — consumers must guard with `if (mode === 'edit' && onEdit)`.
|
||||
* Props passed to every block renderer.
|
||||
*
|
||||
* `children` is the block's direct children (one level). For container
|
||||
* blocks (columns, future tabs/accordion), consumers render each child
|
||||
* by invoking the `renderChild` snippet — this pushes the outer chrome
|
||||
* (click-to-select in edit mode, cache tagging in public mode) back out
|
||||
* to the renderer that owns it, so the container doesn't need to know
|
||||
* about selection state or mode-specific wrappers.
|
||||
*
|
||||
* `onEdit` is only present in `edit` mode — guard with
|
||||
* `if (mode === 'edit' && onEdit)`.
|
||||
*/
|
||||
export interface BlockRenderProps<Props = unknown> {
|
||||
block: Block<Props>;
|
||||
mode: BlockMode;
|
||||
children?: Block[];
|
||||
renderChild?: Snippet<[Block]>;
|
||||
onEdit?: (patch: Partial<Props>) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue