mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 03:49:40 +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
|
|
@ -1,7 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { getBlockSpec, type Block } from '@mana/website-blocks';
|
||||
import type { Component } from 'svelte';
|
||||
import { getBlockSpec, type Block, type BlockInspectorProps } from '@mana/website-blocks';
|
||||
import { blocksStore, InvalidBlockPropsError } from '../stores/blocks.svelte';
|
||||
import type { WebsiteBlock } from '../types';
|
||||
import ImageInspector from './ImageInspector.svelte';
|
||||
import GalleryInspector from './GalleryInspector.svelte';
|
||||
|
||||
interface Props {
|
||||
block: WebsiteBlock;
|
||||
|
|
@ -12,6 +15,24 @@
|
|||
|
||||
const spec = $derived(getBlockSpec(block.type));
|
||||
|
||||
/**
|
||||
* Some blocks need app-level features (image upload via mana-media)
|
||||
* that can't live in the registry package (which is pure Svelte +
|
||||
* Zod). Override table: block.type → app-side inspector component.
|
||||
* Missing entries fall back to `spec.Inspector` from the registry.
|
||||
*
|
||||
* Typed as `Component<BlockInspectorProps<unknown>>` because each
|
||||
* override targets a different block-type's props shape — Svelte's
|
||||
* generic-component-assignability check can't know the union without
|
||||
* widening.
|
||||
*/
|
||||
const CUSTOM_INSPECTORS: Record<string, Component<BlockInspectorProps<unknown>> | undefined> = {
|
||||
image: ImageInspector as unknown as Component<BlockInspectorProps<unknown>>,
|
||||
gallery: GalleryInspector as unknown as Component<BlockInspectorProps<unknown>>,
|
||||
};
|
||||
|
||||
const CustomInspector = $derived(CUSTOM_INSPECTORS[block.type]);
|
||||
|
||||
let lastError = $state<string | null>(null);
|
||||
|
||||
// Typed as `unknown` to match the registry's Inspector contract
|
||||
|
|
@ -60,7 +81,11 @@
|
|||
</header>
|
||||
|
||||
<div class="wb-inspector__body">
|
||||
<spec.Inspector block={asRegistryBlock(block)} {onChange} />
|
||||
{#if CustomInspector}
|
||||
<CustomInspector block={asRegistryBlock(block)} {onChange} />
|
||||
{:else}
|
||||
<spec.Inspector block={asRegistryBlock(block)} {onChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lastError}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,27 @@
|
|||
|
||||
let { blocks, mode, selectedBlockId, onSelect }: Props = $props();
|
||||
|
||||
// Top-level blocks for M1 — containers come in M3 (columns/rows).
|
||||
const topLevel = $derived(
|
||||
blocks.filter((b) => b.parentBlockId === null).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
/**
|
||||
* Build a parent→children map once, sort each bucket. The renderChild
|
||||
* snippet below does the recursive lookup against this map so both
|
||||
* top-level blocks and container children render through the same
|
||||
* chrome (click-to-select, outline).
|
||||
*/
|
||||
const byParent = $derived.by(() => {
|
||||
const map = new Map<string | null, WebsiteBlock[]>();
|
||||
for (const b of blocks) {
|
||||
const parent = b.parentBlockId;
|
||||
const list = map.get(parent);
|
||||
if (list) list.push(b);
|
||||
else map.set(parent, [b]);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const topLevel = $derived(byParent.get(null) ?? []);
|
||||
|
||||
function asRegistryBlock(b: WebsiteBlock): BlockType<unknown> {
|
||||
return {
|
||||
|
|
@ -25,32 +42,49 @@
|
|||
order: b.order,
|
||||
parentBlockId: b.parentBlockId,
|
||||
slotKey: b.slotKey,
|
||||
// `children` is intentionally omitted at this level — containers
|
||||
// look up their own via the `children` prop we pass below.
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each topLevel as block (block.id)}
|
||||
{#snippet renderBlock(block: WebsiteBlock)}
|
||||
{@const spec = getBlockSpec(block.type)}
|
||||
{#if spec}
|
||||
{@const children = (byParent.get(block.id) ?? []).map(asRegistryBlock)}
|
||||
{#if mode === 'edit'}
|
||||
<div
|
||||
class="wb-block-wrap wb-block-wrap--editable"
|
||||
class:wb-block-wrap--selected={selectedBlockId === block.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onSelect?.(block.id)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect?.(block.id);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect?.(block.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<spec.Component block={asRegistryBlock(block)} {mode} />
|
||||
<spec.Component
|
||||
block={asRegistryBlock(block)}
|
||||
{mode}
|
||||
{children}
|
||||
renderChild={renderInnerChild}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="wb-block-wrap">
|
||||
<spec.Component block={asRegistryBlock(block)} {mode} />
|
||||
<spec.Component
|
||||
block={asRegistryBlock(block)}
|
||||
{mode}
|
||||
{children}
|
||||
renderChild={renderInnerChild}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if mode === 'edit'}
|
||||
|
|
@ -58,6 +92,17 @@
|
|||
Unbekannter Block-Typ: {block.type}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderInnerChild(child: BlockType<unknown>)}
|
||||
{@const fullBlock = blocks.find((b) => b.id === child.id)}
|
||||
{#if fullBlock}
|
||||
{@render renderBlock(fullBlock)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#each topLevel as block (block.id)}
|
||||
{@render renderBlock(block)}
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* App-side gallery inspector with multi-image upload via mana-media.
|
||||
* Overrides the URL-only fallback from @mana/website-blocks.
|
||||
*/
|
||||
import type { BlockInspectorProps } from '@mana/website-blocks';
|
||||
import type { GalleryProps, GalleryImage } from '@mana/website-blocks';
|
||||
import { uploadImage, UploadError } from '../upload';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<GalleryProps> = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state<string | null>(null);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
async function handleFiles(files: FileList | File[]) {
|
||||
uploading = true;
|
||||
uploadError = null;
|
||||
const added: GalleryImage[] = [];
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const result = await uploadImage(file);
|
||||
added.push({ url: result.url, altText: '', caption: '' });
|
||||
}
|
||||
onChange({ images: [...block.props.images, ...added] });
|
||||
} catch (err) {
|
||||
if (err instanceof UploadError) uploadError = err.message;
|
||||
else uploadError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target.files?.length) void handleFiles(target.files);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer?.files?.length) void handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function updateImage(index: number, patch: Partial<GalleryImage>) {
|
||||
const next = block.props.images.map((img, i) => (i === index ? { ...img, ...patch } : img));
|
||||
onChange({ images: next });
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
onChange({ images: block.props.images.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
function moveImage(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= block.props.images.length) return;
|
||||
const next = [...block.props.images];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange({ images: 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 })}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="wb-dropzone"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInput?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if uploading}
|
||||
<span>Lade hoch…</span>
|
||||
{:else}
|
||||
<span>+ Bilder hinzufügen — ziehen oder klicken (mehrere möglich)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style="display: none"
|
||||
onchange={onFileChange}
|
||||
/>
|
||||
|
||||
{#if uploadError}
|
||||
<p class="wb-error">{uploadError}</p>
|
||||
{/if}
|
||||
|
||||
<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-row">
|
||||
<label class="wb-field">
|
||||
<span>Abstand</span>
|
||||
<select
|
||||
value={block.props.gap}
|
||||
onchange={(e) => onChange({ gap: e.currentTarget.value as GalleryProps['gap'] })}
|
||||
>
|
||||
<option value="sm">Klein</option>
|
||||
<option value="md">Mittel</option>
|
||||
<option value="lg">Groß</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wb-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.props.lightbox}
|
||||
onchange={(e) => onChange({ lightbox: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>Lightbox (Vollbild)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wb-images">
|
||||
<div class="wb-images__head">Bilder ({block.props.images.length})</div>
|
||||
{#each block.props.images as img, i (i)}
|
||||
<div class="wb-image-row">
|
||||
<img src={img.url} alt={img.altText} class="wb-image-row__thumb" />
|
||||
<div class="wb-image-row__fields">
|
||||
<input
|
||||
type="text"
|
||||
value={img.altText}
|
||||
oninput={(e) => updateImage(i, { altText: e.currentTarget.value })}
|
||||
placeholder="Alt-Text"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={img.caption}
|
||||
oninput={(e) => updateImage(i, { caption: e.currentTarget.value })}
|
||||
placeholder="Bildunterschrift"
|
||||
/>
|
||||
</div>
|
||||
<div class="wb-image-row__actions">
|
||||
<button class="wb-btn wb-btn--icon" onclick={() => moveImage(i, -1)} disabled={i === 0}>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="wb-btn wb-btn--icon"
|
||||
onclick={() => moveImage(i, 1)}
|
||||
disabled={i === block.props.images.length - 1}>↓</button
|
||||
>
|
||||
<button class="wb-btn wb-btn--icon wb-btn--danger" onclick={() => removeImage(i)}
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
</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-field select,
|
||||
.wb-image-row input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.55rem;
|
||||
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-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wb-dropzone {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.wb-dropzone:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
.wb-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-images__head {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-image-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.wb-image-row__thumb {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.wb-image-row__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-image-row__actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-btn {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wb-btn--icon {
|
||||
width: 1.5rem;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wb-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.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-error {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* App-side image inspector with upload wired to mana-media. Replaces
|
||||
* the URL-only fallback from @mana/website-blocks via the custom
|
||||
* inspector registry (see inspector-overrides.ts).
|
||||
*/
|
||||
import type { BlockInspectorProps } from '@mana/website-blocks';
|
||||
import type { ImageProps } from '@mana/website-blocks';
|
||||
import { uploadImage, UploadError } from '../upload';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<ImageProps> = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state<string | null>(null);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
uploading = true;
|
||||
uploadError = null;
|
||||
try {
|
||||
const result = await uploadImage(file);
|
||||
onChange({ url: result.url });
|
||||
} catch (err) {
|
||||
if (err instanceof UploadError) uploadError = err.message;
|
||||
else uploadError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<div
|
||||
class="wb-dropzone"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInput?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="wb-dropzone__hint">Lade hoch…</span>
|
||||
{:else if block.props.url}
|
||||
<img src={block.props.url} alt={block.props.altText} class="wb-dropzone__preview" />
|
||||
<span class="wb-dropzone__hint">Klicken / ziehen, um zu ersetzen</span>
|
||||
{:else}
|
||||
<span class="wb-dropzone__hint">Bild hier hinziehen oder klicken</span>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
onchange={onFileChange}
|
||||
/>
|
||||
|
||||
{#if uploadError}
|
||||
<p class="wb-error">{uploadError}</p>
|
||||
{/if}
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Oder URL einsetzen</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-dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
min-height: 8rem;
|
||||
}
|
||||
.wb-dropzone:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
.wb-dropzone__hint {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wb-dropzone__preview {
|
||||
max-width: 100%;
|
||||
max-height: 8rem;
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.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-error {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
<script lang="ts">
|
||||
import { PRESET_LABELS, THEME_PRESETS, type ThemePreset } from '@mana/website-blocks/themes';
|
||||
import { sitesStore } from '../stores/sites.svelte';
|
||||
import type { Website, ThemeConfig } from '../types';
|
||||
|
||||
interface Props {
|
||||
site: Website;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { site, onClose }: Props = $props();
|
||||
|
||||
// Working copy — committed on save so escape/close discards unsaved
|
||||
// edits. The initial-value-only warnings are intentional here: this
|
||||
// dialog is a snapshot-based form; it does not track further changes
|
||||
// to `site` while open.
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let draftPreset = $state<ThemePreset>((site.theme?.preset ?? 'classic') as ThemePreset);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let draftPrimary = $state(site.theme?.overrides?.primary ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let draftBackground = $state(site.theme?.overrides?.background ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let draftForeground = $state(site.theme?.overrides?.foreground ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let draftFooterText = $state(site.footerConfig?.text ?? '');
|
||||
let saving = $state(false);
|
||||
|
||||
const presets = Object.keys(PRESET_LABELS) as ThemePreset[];
|
||||
const previewTokens = $derived(THEME_PRESETS[draftPreset]);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const overrides: ThemeConfig['overrides'] = {};
|
||||
if (draftPrimary) overrides.primary = draftPrimary;
|
||||
if (draftBackground) overrides.background = draftBackground;
|
||||
if (draftForeground) overrides.foreground = draftForeground;
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
preset: draftPreset,
|
||||
...(Object.keys(overrides).length > 0 ? { overrides } : {}),
|
||||
};
|
||||
|
||||
await sitesStore.updateSite(site.id, {
|
||||
theme,
|
||||
footerConfig: {
|
||||
...site.footerConfig,
|
||||
text: draftFooterText,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetOverrides() {
|
||||
draftPrimary = '';
|
||||
draftBackground = '';
|
||||
draftForeground = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="wb-modal__backdrop"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Schließen"
|
||||
></div>
|
||||
|
||||
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-settings-title">
|
||||
<header class="wb-modal__head">
|
||||
<h3 id="wb-settings-title">Website-Einstellungen</h3>
|
||||
<button class="wb-modal__close" onclick={onClose} aria-label="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<div class="wb-modal__body">
|
||||
<section class="wb-section">
|
||||
<h4>Theme</h4>
|
||||
<div class="wb-presets">
|
||||
{#each presets as preset (preset)}
|
||||
{@const tokens = THEME_PRESETS[preset]}
|
||||
<button
|
||||
class="wb-preset"
|
||||
class:wb-preset--active={draftPreset === preset}
|
||||
onclick={() => (draftPreset = preset)}
|
||||
>
|
||||
<div
|
||||
class="wb-preset__swatch"
|
||||
style="background:{tokens.background};border-color:{tokens.border};"
|
||||
>
|
||||
<span class="wb-preset__dot" style="background:{tokens.primary};"></span>
|
||||
<span class="wb-preset__text" style="color:{tokens.foreground};">Aa</span>
|
||||
</div>
|
||||
<span class="wb-preset__label">{PRESET_LABELS[preset]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wb-section">
|
||||
<div class="wb-section__head">
|
||||
<h4>Farben überschreiben</h4>
|
||||
<button class="wb-btn wb-btn--ghost wb-btn--sm" onclick={resetOverrides}>
|
||||
Auf Preset zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
<div class="wb-colors">
|
||||
<label class="wb-color">
|
||||
<span>Primär</span>
|
||||
<input
|
||||
type="color"
|
||||
value={draftPrimary || previewTokens.primary}
|
||||
oninput={(e) => (draftPrimary = e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draftPrimary}
|
||||
oninput={(e) => (draftPrimary = e.currentTarget.value)}
|
||||
placeholder={previewTokens.primary}
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-color">
|
||||
<span>Hintergrund</span>
|
||||
<input
|
||||
type="color"
|
||||
value={draftBackground || previewTokens.background}
|
||||
oninput={(e) => (draftBackground = e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draftBackground}
|
||||
oninput={(e) => (draftBackground = e.currentTarget.value)}
|
||||
placeholder={previewTokens.background}
|
||||
/>
|
||||
</label>
|
||||
<label class="wb-color">
|
||||
<span>Text</span>
|
||||
<input
|
||||
type="color"
|
||||
value={draftForeground || previewTokens.foreground}
|
||||
oninput={(e) => (draftForeground = e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draftForeground}
|
||||
oninput={(e) => (draftForeground = e.currentTarget.value)}
|
||||
placeholder={previewTokens.foreground}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wb-section">
|
||||
<h4>Footer</h4>
|
||||
<label class="wb-field">
|
||||
<span>Footer-Text</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draftFooterText}
|
||||
oninput={(e) => (draftFooterText = e.currentTarget.value)}
|
||||
placeholder="© 2026 — Meine Website"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="wb-modal__foot">
|
||||
<button class="wb-btn wb-btn--ghost" onclick={onClose} disabled={saving}>Abbrechen</button>
|
||||
<button class="wb-btn wb-btn--primary" onclick={save} disabled={saving}>
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 40;
|
||||
border: none;
|
||||
}
|
||||
.wb-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(92vw, 36rem);
|
||||
max-height: 85vh;
|
||||
background: rgb(15, 18, 24);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wb-modal__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.wb-modal__head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wb-modal__close {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: inherit;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-modal__body {
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.wb-modal__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.wb-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wb-section__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.wb-section h4 {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.wb-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
.wb-preset--active {
|
||||
border-color: rgba(99, 102, 241, 0.9);
|
||||
}
|
||||
.wb-preset__swatch {
|
||||
width: 100%;
|
||||
aspect-ratio: 2;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.wb-preset__dot {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.wb-preset__text {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.wb-preset__label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wb-colors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-color {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 2.25rem 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wb-color input[type='color'] {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0.15rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-color input[type='text'],
|
||||
.wb-field input {
|
||||
padding: 0.4rem 0.55rem;
|
||||
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-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-btn--sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wb-btn--ghost {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.wb-btn--primary {
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
}
|
||||
.wb-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
78
apps/mana/apps/web/src/lib/modules/website/upload.ts
Normal file
78
apps/mana/apps/web/src/lib/modules/website/upload.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Client-side upload helper for the website builder.
|
||||
*
|
||||
* Forwards the file to mana-media under `app=website` so uploads are
|
||||
* scoped + listable in admin. Returns `{ mediaId, url }` — the block
|
||||
* stores the full URL (simpler for the public renderer, which doesn't
|
||||
* have auth and can't re-resolve a mediaId on every render).
|
||||
*
|
||||
* The `url` is the CDN-friendly `/file/large` variant by default. If a
|
||||
* block needs a different size (gallery thumbnails), call
|
||||
* `mediaFileUrl(mediaId, variant)` with 'small' / 'medium' / 'large' /
|
||||
* 'original'.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
function getMediaUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injected = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
|
||||
.__PUBLIC_MANA_MEDIA_URL__;
|
||||
if (injected) return injected;
|
||||
}
|
||||
return (
|
||||
(import.meta as unknown as { env?: Record<string, string> }).env?.PUBLIC_MANA_MEDIA_URL ??
|
||||
process.env.PUBLIC_MANA_MEDIA_URL ??
|
||||
'http://localhost:3015'
|
||||
);
|
||||
}
|
||||
|
||||
export type MediaVariant = 'small' | 'medium' | 'large' | 'original';
|
||||
|
||||
export function mediaFileUrl(mediaId: string, variant: MediaVariant = 'large'): string {
|
||||
return `${getMediaUrl()}/api/v1/media/${mediaId}/file/${variant}`;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
mediaId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class UploadError extends Error {
|
||||
readonly status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'UploadError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image file. Throws `UploadError` on non-2xx responses or
|
||||
* non-image content. Caller is responsible for rendering the error.
|
||||
*/
|
||||
export async function uploadImage(file: File): Promise<UploadResult> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new UploadError('Bitte wähle ein Bild (PNG, JPG, WEBP, GIF).', 400);
|
||||
}
|
||||
if (file.size > 25 * 1024 * 1024) {
|
||||
throw new UploadError('Datei zu groß (max 25 MB).', 400);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('app', 'website');
|
||||
|
||||
const res = await fetch(`${getMediaUrl()}/api/v1/media/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new UploadError(`Upload fehlgeschlagen (${res.status})`, res.status);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { id?: string };
|
||||
if (!data.id) throw new UploadError('Upload-Antwort ohne Media-ID', 500);
|
||||
|
||||
return { mediaId: data.id, url: mediaFileUrl(data.id, 'large') };
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
import InsertPalette from '../components/InsertPalette.svelte';
|
||||
import PageList from '../components/PageList.svelte';
|
||||
import PublishBar from '../components/PublishBar.svelte';
|
||||
import SiteSettingsDialog from '../components/SiteSettingsDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
const pageBlocks = $derived(blocksForPage(blocks.value, props.pageId));
|
||||
|
||||
let selectedBlockId = $state<string | null>(null);
|
||||
let showSettings = $state(false);
|
||||
|
||||
const selectedBlock = $derived(
|
||||
selectedBlockId ? (pageBlocks.find((b) => b.id === selectedBlockId) ?? null) : null
|
||||
|
|
@ -57,8 +59,19 @@
|
|||
<aside class="wb-editor__left">
|
||||
{#if site}
|
||||
<div class="wb-editor__site-meta">
|
||||
<p class="wb-editor__site-name">{site.name}</p>
|
||||
<p class="wb-editor__site-slug">/s/{site.slug}</p>
|
||||
<div class="wb-editor__site-row">
|
||||
<div class="wb-editor__site-id">
|
||||
<p class="wb-editor__site-name">{site.name}</p>
|
||||
<p class="wb-editor__site-slug">/s/{site.slug}</p>
|
||||
</div>
|
||||
<button
|
||||
class="wb-editor__settings-btn"
|
||||
onclick={() => (showSettings = true)}
|
||||
title="Website-Einstellungen"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -99,6 +112,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSettings && site}
|
||||
<SiteSettingsDialog {site} onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wb-editor-layout {
|
||||
display: flex;
|
||||
|
|
@ -155,6 +172,32 @@
|
|||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.wb-editor__site-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-editor__site-id {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.wb-editor__settings-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: inherit;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wb-editor__settings-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
.wb-editor__site-name {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { resolveTheme, themeCssVars, type ThemePreset } from '@mana/website-blocks/themes';
|
||||
|
||||
interface Props {
|
||||
data: LayoutData;
|
||||
|
|
@ -10,23 +11,14 @@
|
|||
let { data, children }: Props = $props();
|
||||
|
||||
const site = $derived(data.snapshot.site);
|
||||
const theme = $derived(site.theme);
|
||||
|
||||
// Theme preset → CSS variables. Three presets for M3+, classic for M2.
|
||||
const themeVars = $derived.by(() => {
|
||||
const preset = theme?.preset ?? 'classic';
|
||||
const base =
|
||||
preset === 'modern'
|
||||
? { primary: '#6366f1', bg: '#0b0d12', fg: '#f5f6f8' }
|
||||
: preset === 'warm'
|
||||
? { primary: '#f97316', bg: '#1a140f', fg: '#f7ede2' }
|
||||
: { primary: '#3b82f6', bg: '#ffffff', fg: '#0f172a' };
|
||||
const overrides = theme?.overrides ?? {};
|
||||
const primary = overrides.primary ?? base.primary;
|
||||
const bg = overrides.background ?? base.bg;
|
||||
const fg = overrides.foreground ?? base.fg;
|
||||
return `--wb-primary:${primary};--wb-bg:${bg};--wb-fg:${fg};`;
|
||||
});
|
||||
const themeTokens = $derived(
|
||||
resolveTheme(
|
||||
((site.theme?.preset as ThemePreset) ?? 'classic') satisfies ThemePreset,
|
||||
site.theme?.overrides
|
||||
)
|
||||
);
|
||||
const themeVars = $derived(themeCssVars(themeTokens));
|
||||
|
||||
const navItems = $derived(site.navConfig?.items ?? []);
|
||||
const footer = $derived(site.footerConfig);
|
||||
|
|
@ -94,19 +86,19 @@
|
|||
color: var(--wb-fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-family: var(--wb-font);
|
||||
}
|
||||
.wb-public :global(h1),
|
||||
.wb-public :global(h2),
|
||||
.wb-public :global(h3) {
|
||||
font-family: var(--wb-font-heading);
|
||||
}
|
||||
.wb-public__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(127, 127, 127, 0.15);
|
||||
border-bottom: 1px solid var(--wb-border);
|
||||
}
|
||||
.wb-public__nav--minimal {
|
||||
border-bottom: none;
|
||||
|
|
@ -137,10 +129,10 @@
|
|||
}
|
||||
.wb-public__footer {
|
||||
padding: 2rem 1.5rem;
|
||||
border-top: 1px solid rgba(127, 127, 127, 0.15);
|
||||
border-top: 1px solid var(--wb-border);
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
color: var(--wb-muted);
|
||||
}
|
||||
.wb-public__footer ul {
|
||||
list-style: none;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue