feat(web): wallpaper system + sticky PageHeader

Wallpaper system with four sources (predefined images, CSS gradients,
custom uploads via mana-media, and theme default). Configurable per-scene
or globally, with overlay controls (blur + opacity) and hover preview.

Adds sticky prop to shared PageHeader component and applies it across
themes, settings, credits, subscription, help, and profile pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 16:00:03 +02:00
parent a9c51517eb
commit 8c2f9306e9
22 changed files with 1557 additions and 66 deletions

View file

@ -134,6 +134,8 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
const isDev = process.env.NODE_ENV !== 'production';
setSecurityHeaders(response, {
// Allow mana-media images (localhost in dev, https in prod)
imgSrc: isDev ? ['http://localhost:*'] : [],
// @huggingface/transformers (used by @mana/local-llm) lazy-loads the
// onnxruntime-web WASM loader from jsDelivr at backend selection
// time via a dynamic import(). Browsers route dynamic imports

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { WallpaperConfig } from '@mana/shared-theme';
import { PREDEFINED_WALLPAPERS } from '$lib/config/wallpapers';
import { fade } from 'svelte/transition';
interface Props {
config: WallpaperConfig;
}
let { config }: Props = $props();
let bgStyle = $derived.by(() => {
const s = config.source;
switch (s.type) {
case 'none':
return '';
case 'predefined': {
const wp = PREDEFINED_WALLPAPERS.find((w) => w.id === s.id);
if (!wp) return '';
return `background-image: url(${wp.url}); background-size: cover; background-position: center;`;
}
case 'generated': {
const p = s.params;
if (p.type === 'solid') {
return `background-color: ${p.color};`;
}
const angle = p.angle ?? 180;
const stops = p.colors.join(', ');
return `background: linear-gradient(${angle}deg, ${stops});`;
}
case 'upload':
return `background-image: url(${s.url}); background-size: cover; background-position: center;`;
default:
return '';
}
});
let overlayStyle = $derived.by(() => {
const o = config.overlay;
if (!o) return '';
const parts: string[] = [];
if (o.opacity && o.opacity > 0) {
parts.push(`background: hsl(var(--color-background) / ${o.opacity})`);
}
if (o.blur && o.blur > 0) {
parts.push(`backdrop-filter: blur(${o.blur}px)`);
}
return parts.join('; ');
});
let isActive = $derived(config.source.type !== 'none' && bgStyle !== '');
// Preload image for uploaded/predefined wallpapers so transitions are smooth
$effect(() => {
const s = config.source;
let url: string | null = null;
if (s.type === 'upload') url = s.url;
if (s.type === 'predefined') {
const wp = PREDEFINED_WALLPAPERS.find((w) => w.id === s.id);
if (wp) url = wp.url;
}
if (url) {
const img = new Image();
img.src = url;
}
});
</script>
{#if isActive}
<!--
Use a keyed block so Svelte re-creates the div when the source changes,
enabling a fade transition between different wallpapers.
The key is a fingerprint of the source.
-->
{#key bgStyle}
<div class="wallpaper-layer" style={bgStyle} transition:fade={{ duration: 400 }}>
{#if overlayStyle}
<div class="wallpaper-overlay" style={overlayStyle}></div>
{/if}
</div>
{/key}
{/if}
<style>
.wallpaper-layer {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
.wallpaper-overlay {
position: absolute;
inset: 0;
transition:
background 0.3s ease,
backdrop-filter 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
.wallpaper-layer {
transition: none;
}
.wallpaper-overlay {
transition: none;
}
}
</style>

View file

@ -0,0 +1,746 @@
<script lang="ts">
import type { WallpaperConfig, WallpaperGradient, ThemeVariant } from '@mana/shared-theme';
import {
Image,
Palette,
UploadSimple,
X,
Check,
Prohibit,
Trash,
SpinnerGap,
} from '@mana/shared-icons';
import { browser } from '$app/environment';
import { PREDEFINED_WALLPAPERS, GRADIENT_PRESETS } from '$lib/config/wallpapers';
import { wallpaperStore } from '$lib/stores/wallpaper.svelte';
import { theme } from '$lib/stores/theme';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
// ── Media URL ───────────────────────────────────────────────
const MEDIA_URL =
(browser
? (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }).__PUBLIC_MANA_MEDIA_URL__
: undefined) ||
import.meta.env.PUBLIC_MANA_MEDIA_URL ||
'http://localhost:3015';
// ── Types ────────────────────────────────────────────────────
interface UploadedMedia {
id: string;
url: string;
thumbUrl: string;
originalName: string;
}
type Tab = 'gradients' | 'images' | 'upload';
let activeTab = $state<Tab>('gradients');
let scope = $state<'global' | 'scene'>('global');
// Overlay controls
let blur = $state(wallpaperStore.persisted.overlay?.blur ?? 0);
let overlayOpacity = $state(wallpaperStore.persisted.overlay?.opacity ?? 0);
// Upload state
let uploading = $state(false);
let uploadError = $state<string | null>(null);
let uploadedWallpapers = $state<UploadedMedia[]>([]);
let loadingGallery = $state(false);
let isDragging = $state(false);
// Current variant for filtering
let currentVariant = $derived<ThemeVariant>(theme.variant);
// Predefined wallpapers for current variant (+ all others)
let variantWallpapers = $derived(
PREDEFINED_WALLPAPERS.filter((w) => w.variant === currentVariant)
);
let otherWallpapers = $derived(PREDEFINED_WALLPAPERS.filter((w) => w.variant !== currentVariant));
// Gradient presets for current variant
let gradientPresets = $derived(GRADIENT_PRESETS[currentVariant] ?? []);
// Current persisted wallpaper source (for highlighting active selection)
let currentSource = $derived(wallpaperStore.persisted.source);
// Has multiple scenes?
let hasMultipleScenes = $derived(workbenchScenesStore.scenes.length > 1);
// ── Load uploaded wallpapers on mount ────────────────────────
async function loadUploadedWallpapers() {
loadingGallery = true;
try {
const res = await fetch(`${MEDIA_URL}/api/v1/media?app=wallpapers&limit=50`);
if (!res.ok) return;
const data = await res.json();
const items: UploadedMedia[] = (data.items ?? data ?? []).map(
(m: { id: string; originalName?: string }) => ({
id: m.id,
url: `${MEDIA_URL}/api/v1/media/${m.id}/file/large`,
thumbUrl: `${MEDIA_URL}/api/v1/media/${m.id}/file/thumb`,
originalName: m.originalName ?? 'Bild',
})
);
uploadedWallpapers = items;
} catch {
// mana-media may not be running in dev
} finally {
loadingGallery = false;
}
}
// Load gallery when switching to upload tab
$effect(() => {
if (activeTab === 'upload' && uploadedWallpapers.length === 0 && !loadingGallery) {
loadUploadedWallpapers();
}
});
// ── Helpers ──────────────────────────────────────────────────
function gradientCss(g: WallpaperGradient): string {
const angle = g.angle ?? 180;
return `linear-gradient(${angle}deg, ${g.colors.join(', ')})`;
}
function isGradientActive(g: WallpaperGradient): boolean {
if (currentSource.type !== 'generated') return false;
const p = currentSource.params;
if (p.type !== 'gradient') return false;
return p.colors.join(',') === g.colors.join(',') && (p.angle ?? 180) === (g.angle ?? 180);
}
function isPredefinedActive(id: string): boolean {
return currentSource.type === 'predefined' && currentSource.id === id;
}
function isUploadActive(mediaId: string): boolean {
return currentSource.type === 'upload' && currentSource.mediaId === mediaId;
}
// ── Actions ─────────────────────────────────────────────────
function buildConfig(source: WallpaperConfig['source']): WallpaperConfig {
return {
source,
overlay: blur > 0 || overlayOpacity > 0 ? { blur, opacity: overlayOpacity } : undefined,
};
}
async function applyWallpaper(config: WallpaperConfig) {
if (scope === 'scene') {
await wallpaperStore.setSceneWallpaper(config);
} else {
await wallpaperStore.setGlobal(config);
}
}
async function selectGradient(g: WallpaperGradient) {
await applyWallpaper(buildConfig({ type: 'generated', params: g }));
}
async function selectPredefined(id: string) {
await applyWallpaper(buildConfig({ type: 'predefined', id }));
}
async function selectUpload(media: UploadedMedia) {
await applyWallpaper(buildConfig({ type: 'upload', mediaId: media.id, url: media.url }));
}
async function clearWallpaper() {
if (scope === 'scene') {
await wallpaperStore.clearSceneWallpaper();
} else {
await wallpaperStore.clearGlobal();
}
}
async function updateOverlay() {
const current = wallpaperStore.persisted;
if (current.source.type === 'none') return;
const updated: WallpaperConfig = {
...current,
overlay: blur > 0 || overlayOpacity > 0 ? { blur, opacity: overlayOpacity } : undefined,
};
await applyWallpaper(updated);
}
// ── Hover preview ───────────────────────────────────────────
function previewGradient(g: WallpaperGradient) {
wallpaperStore.preview(buildConfig({ type: 'generated', params: g }));
}
function previewPredefined(id: string) {
wallpaperStore.preview(buildConfig({ type: 'predefined', id }));
}
function previewUpload(media: UploadedMedia) {
wallpaperStore.preview(buildConfig({ type: 'upload', mediaId: media.id, url: media.url }));
}
function clearPreview() {
wallpaperStore.clearPreview();
}
// ── File upload ─────────────────────────────────────────────
let fileInput: HTMLInputElement | undefined = $state();
async function uploadFile(file: File) {
if (!file.type.startsWith('image/')) return;
uploading = true;
uploadError = null;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('app', 'wallpapers');
const res = await fetch(`${MEDIA_URL}/api/v1/media/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error(`Upload fehlgeschlagen (${res.status})`);
}
const data = await res.json();
const mediaId: string = data.id;
const url =
data.urls?.large ||
data.urls?.original ||
`${MEDIA_URL}/api/v1/media/${mediaId}/file/large`;
const thumbUrl = data.urls?.thumbnail || `${MEDIA_URL}/api/v1/media/${mediaId}/file/thumb`;
const newMedia: UploadedMedia = {
id: mediaId,
url,
thumbUrl,
originalName: data.originalName ?? file.name,
};
uploadedWallpapers = [newMedia, ...uploadedWallpapers];
await applyWallpaper(buildConfig({ type: 'upload', mediaId, url }));
} catch (err) {
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploading = false;
}
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
await uploadFile(file);
input.value = '';
}
async function deleteUpload(mediaId: string) {
try {
await fetch(`${MEDIA_URL}/api/v1/media/${mediaId}`, { method: 'DELETE' });
} catch {
// ignore
}
uploadedWallpapers = uploadedWallpapers.filter((w) => w.id !== mediaId);
if (currentSource.type === 'upload' && currentSource.mediaId === mediaId) {
await clearWallpaper();
}
}
// ── Drag & drop handlers ────────────────────────────────────
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const file = e.dataTransfer?.files?.[0];
if (file && file.type.startsWith('image/')) {
await uploadFile(file);
}
}
// Tab items
const tabs: { id: Tab; label: string; icon: typeof Image }[] = [
{ id: 'gradients', label: 'Farben', icon: Palette },
{ id: 'images', label: 'Bilder', icon: Image },
{ id: 'upload', label: 'Upload', icon: UploadSimple },
];
</script>
<div class="wallpaper-picker">
<!-- Header row: Scope toggle + Reset -->
<div class="flex items-center justify-between mb-3">
{#if hasMultipleScenes}
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
class:bg-surface={scope === 'global'}
class:text-foreground={scope === 'global'}
class:shadow-sm={scope === 'global'}
class:text-muted-foreground={scope !== 'global'}
onclick={() => (scope = 'global')}
>
Alle Szenen
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
class:bg-surface={scope === 'scene'}
class:text-foreground={scope === 'scene'}
class:shadow-sm={scope === 'scene'}
class:text-muted-foreground={scope !== 'scene'}
onclick={() => (scope = 'scene')}
>
Nur diese Szene
</button>
</div>
{:else}
<div></div>
{/if}
{#if currentSource.type !== 'none'}
<button
type="button"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onclick={clearWallpaper}
>
<Prohibit size={12} />
Zurücksetzen
</button>
{/if}
</div>
<!-- Tab bar -->
<div class="flex gap-1 rounded-lg bg-muted p-1 mb-4">
{#each tabs as tab}
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-surface={activeTab === tab.id}
class:text-foreground={activeTab === tab.id}
class:shadow-sm={activeTab === tab.id}
class:text-muted-foreground={activeTab !== tab.id}
onclick={() => (activeTab = tab.id)}
>
<tab.icon size={15} weight={activeTab === tab.id ? 'fill' : 'regular'} />
{tab.label}
</button>
{/each}
</div>
<!-- Tab content -->
{#if activeTab === 'gradients'}
<!-- Current theme gradients (prominent) -->
<p class="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Empfohlen
<span class="normal-case tracking-normal font-normal">({currentVariant})</span>
</p>
<div class="grid grid-cols-2 gap-2.5 mb-5">
{#each gradientPresets as gradient}
<button
type="button"
class="gradient-swatch gradient-swatch-lg"
class:ring-2={isGradientActive(gradient)}
class:ring-primary={isGradientActive(gradient)}
style="background: {gradientCss(gradient)}"
onclick={() => selectGradient(gradient)}
onmouseenter={() => previewGradient(gradient)}
onmouseleave={clearPreview}
>
{#if isGradientActive(gradient)}
<Check size={20} weight="bold" class="text-white drop-shadow" />
{/if}
</button>
{/each}
</div>
<!-- All other theme gradients -->
{#each Object.entries(GRADIENT_PRESETS).filter(([v]) => v !== currentVariant) as [variant, presets]}
<p class="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{variant}
</p>
<div class="grid grid-cols-4 gap-2 mb-4">
{#each presets as gradient}
<button
type="button"
class="gradient-swatch"
class:ring-2={isGradientActive(gradient)}
class:ring-primary={isGradientActive(gradient)}
style="background: {gradientCss(gradient)}"
onclick={() => selectGradient(gradient)}
onmouseenter={() => previewGradient(gradient)}
onmouseleave={clearPreview}
>
{#if isGradientActive(gradient)}
<Check size={16} weight="bold" class="text-white drop-shadow" />
{/if}
</button>
{/each}
</div>
{/each}
{:else if activeTab === 'images'}
{#if PREDEFINED_WALLPAPERS.length === 0}
<div class="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Image size={32} class="mb-2 opacity-40" />
<p class="text-sm">Hintergrundbilder kommen bald</p>
</div>
{:else}
{#if variantWallpapers.length > 0}
<p class="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Empfohlen
<span class="normal-case tracking-normal font-normal">({currentVariant})</span>
</p>
<div class="grid grid-cols-2 gap-2.5 mb-5">
{#each variantWallpapers as wp}
<button
type="button"
class="image-swatch"
class:ring-2={isPredefinedActive(wp.id)}
class:ring-primary={isPredefinedActive(wp.id)}
onclick={() => selectPredefined(wp.id)}
onmouseenter={() => previewPredefined(wp.id)}
onmouseleave={clearPreview}
>
<img src={wp.thumbUrl} alt={wp.label} class="w-full h-full object-cover" />
{#if isPredefinedActive(wp.id)}
<div class="swatch-check">
<Check size={20} weight="bold" class="text-white drop-shadow" />
</div>
{/if}
</button>
{/each}
</div>
{/if}
{#if otherWallpapers.length > 0}
<p class="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Weitere
</p>
<div class="grid grid-cols-4 gap-2 mb-4">
{#each otherWallpapers as wp}
<button
type="button"
class="image-swatch"
class:ring-2={isPredefinedActive(wp.id)}
class:ring-primary={isPredefinedActive(wp.id)}
onclick={() => selectPredefined(wp.id)}
onmouseenter={() => previewPredefined(wp.id)}
onmouseleave={clearPreview}
>
<img src={wp.thumbUrl} alt={wp.label} class="w-full h-full object-cover" />
{#if isPredefinedActive(wp.id)}
<div class="swatch-check">
<Check size={16} weight="bold" class="text-white drop-shadow" />
</div>
{/if}
</button>
{/each}
</div>
{/if}
{/if}
{:else if activeTab === 'upload'}
<!-- Dropzone -->
<div
class="upload-zone"
class:upload-zone-active={isDragging}
class:upload-zone-uploading={uploading}
role="button"
tabindex="0"
onclick={() => !uploading && fileInput?.click()}
onkeydown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !uploading) {
e.preventDefault();
fileInput?.click();
}
}}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if uploading}
<SpinnerGap size={28} class="text-primary mb-1 animate-spin" />
<p class="text-sm text-foreground">Wird hochgeladen...</p>
{:else}
<UploadSimple size={28} class="text-muted-foreground mb-1" />
<p class="text-sm text-muted-foreground">
{isDragging ? 'Hier ablegen' : 'Bild hochladen'}
</p>
<p class="text-xs text-muted-foreground/60">JPG, PNG, WebP — Drag & Drop oder Klick</p>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleFileSelect}
/>
<!-- Upload error -->
{#if uploadError}
<div
class="mt-2 flex items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300"
>
<span class="flex-1">{uploadError}</span>
<button type="button" class="p-0.5" onclick={() => (uploadError = null)}>
<X size={14} />
</button>
</div>
{/if}
<!-- Previously uploaded wallpapers gallery -->
{#if loadingGallery}
<div class="mt-4 flex items-center justify-center py-4 text-muted-foreground">
<SpinnerGap size={20} class="animate-spin mr-2" />
<span class="text-sm">Lade Bilder...</span>
</div>
{:else if uploadedWallpapers.length > 0}
<p class="mt-4 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Eigene Bilder
</p>
<div class="grid grid-cols-3 gap-2">
{#each uploadedWallpapers as media (media.id)}
<div class="image-swatch-wrapper">
<button
type="button"
class="image-swatch"
class:ring-2={isUploadActive(media.id)}
class:ring-primary={isUploadActive(media.id)}
onclick={() => selectUpload(media)}
onmouseenter={() => previewUpload(media)}
onmouseleave={clearPreview}
>
<img
src={media.thumbUrl}
alt={media.originalName}
class="w-full h-full object-cover"
loading="lazy"
/>
{#if isUploadActive(media.id)}
<div class="swatch-check">
<Check size={18} weight="bold" class="text-white drop-shadow" />
</div>
{/if}
</button>
<button
type="button"
class="delete-btn"
title="Bild löschen"
onclick={() => deleteUpload(media.id)}
>
<Trash size={12} />
</button>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Overlay controls (always visible, disabled when no wallpaper) -->
<div class="mt-4 border-t border-border pt-4" class:opacity-40={currentSource.type === 'none'}>
<p class="mb-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Overlay</p>
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label for="wp-blur" class="text-sm text-foreground">Weichzeichner</label>
<span class="text-xs text-muted-foreground tabular-nums">{blur}px</span>
</div>
<input
id="wp-blur"
type="range"
min="0"
max="20"
step="1"
bind:value={blur}
oninput={updateOverlay}
disabled={currentSource.type === 'none'}
class="slider"
/>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label for="wp-opacity" class="text-sm text-foreground">Abdunklung</label>
<span class="text-xs text-muted-foreground tabular-nums"
>{Math.round(overlayOpacity * 100)}%</span
>
</div>
<input
id="wp-opacity"
type="range"
min="0"
max="0.6"
step="0.05"
bind:value={overlayOpacity}
oninput={updateOverlay}
disabled={currentSource.type === 'none'}
class="slider"
/>
</div>
</div>
</div>
<style>
.wallpaper-picker {
width: 100%;
}
.gradient-swatch {
aspect-ratio: 16/9;
border-radius: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
border: 1px solid hsl(var(--color-border) / 0.3);
}
.gradient-swatch:hover {
transform: scale(1.03);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.gradient-swatch-lg {
aspect-ratio: 2/1;
border-radius: 0.75rem;
}
.image-swatch {
aspect-ratio: 16/9;
border-radius: 0.5rem;
cursor: pointer;
overflow: hidden;
position: relative;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
border: 1px solid hsl(var(--color-border) / 0.3);
}
.image-swatch:hover {
transform: scale(1.03);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.swatch-check {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
border: 2px dashed hsl(var(--color-border));
border-radius: 0.75rem;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
}
.upload-zone:hover {
border-color: hsl(var(--color-primary));
}
.upload-zone-active {
border-color: hsl(var(--color-primary));
background-color: hsl(var(--color-primary) / 0.05);
}
.upload-zone-uploading {
cursor: wait;
opacity: 0.8;
}
.image-swatch-wrapper {
position: relative;
}
.delete-btn {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
opacity: 0;
transition: opacity 0.15s;
cursor: pointer;
border: none;
}
.image-swatch-wrapper:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(220, 38, 38, 0.9);
}
/* Range slider styling */
.slider {
width: 100%;
height: 6px;
appearance: none;
-webkit-appearance: none;
background: hsl(var(--color-muted));
border-radius: 3px;
outline: none;
cursor: pointer;
}
.slider:disabled {
cursor: not-allowed;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: hsl(var(--color-primary));
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: hsl(var(--color-primary));
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
</style>

View file

@ -0,0 +1,85 @@
/**
* Predefined Wallpaper Registry
*
* Bundled wallpaper images shipped with the app. Each theme variant
* gets 23 curated images. Images live in /static/wallpapers/ as WebP,
* with thumbnails in /static/wallpapers/thumbs/.
*
* Phase 2 will populate actual images for now this is the registry structure.
*/
import type { ThemeVariant, WallpaperGradient } from '@mana/shared-theme';
export interface PredefinedWallpaper {
id: string;
/** Theme variant this wallpaper was designed for (shown first in that theme). */
variant: ThemeVariant;
/** Full-size image URL (1920×1080 WebP). */
url: string;
/** Thumbnail URL (320×180 WebP) for the picker grid. */
thumbUrl: string;
/** Display label in the picker. */
label: string;
}
/**
* All predefined wallpapers. Will be populated in Phase 2 with actual images.
* For now, the array is empty so the system works end-to-end without images.
*/
export const PREDEFINED_WALLPAPERS: PredefinedWallpaper[] = [
// Phase 2: add entries like:
// { id: 'ocean-1', variant: 'ocean', url: '/wallpapers/ocean-1.webp', thumbUrl: '/wallpapers/thumbs/ocean-1.webp', label: 'Ocean Waves' },
];
/**
* Look up a predefined wallpaper by ID.
*/
export function getPredefinedWallpaper(id: string): PredefinedWallpaper | undefined {
return PREDEFINED_WALLPAPERS.find((w) => w.id === id);
}
/**
* Get predefined wallpapers for a specific theme variant.
*/
export function getWallpapersForVariant(variant: ThemeVariant): PredefinedWallpaper[] {
return PREDEFINED_WALLPAPERS.filter((w) => w.variant === variant);
}
/**
* Theme-aware gradient presets. Each variant gets a few curated gradients
* that complement its color palette.
*/
export const GRADIENT_PRESETS: Record<ThemeVariant, WallpaperGradient[]> = {
ocean: [
{ type: 'gradient', colors: ['#0f0c29', '#302b63', '#24243e'], angle: 180 },
{ type: 'gradient', colors: ['#005c97', '#363795'], angle: 135 },
],
lume: [
{ type: 'gradient', colors: ['#ffecd2', '#fcb69f'], angle: 135 },
{ type: 'gradient', colors: ['#f5f7fa', '#c3cfe2'], angle: 180 },
],
nature: [
{ type: 'gradient', colors: ['#134e5e', '#71b280'], angle: 180 },
{ type: 'gradient', colors: ['#0b8793', '#360033'], angle: 135 },
],
stone: [
{ type: 'gradient', colors: ['#3e3e3e', '#1a1a1a'], angle: 180 },
{ type: 'gradient', colors: ['#bdc3c7', '#2c3e50'], angle: 135 },
],
sunset: [
{ type: 'gradient', colors: ['#ff6b6b', '#feca57', '#48dbfb'], angle: 135 },
{ type: 'gradient', colors: ['#f12711', '#f5af19'], angle: 180 },
],
midnight: [
{ type: 'gradient', colors: ['#0f2027', '#203a43', '#2c5364'], angle: 180 },
{ type: 'gradient', colors: ['#141e30', '#243b55'], angle: 135 },
],
rose: [
{ type: 'gradient', colors: ['#fbc2eb', '#a6c1ee'], angle: 135 },
{ type: 'gradient', colors: ['#ee9ca7', '#ffdde1'], angle: 180 },
],
lavender: [
{ type: 'gradient', colors: ['#667eea', '#764ba2'], angle: 135 },
{ type: 'gradient', colors: ['#a18cd1', '#fbc2eb'], angle: 180 },
],
};

View file

@ -0,0 +1,165 @@
/**
* Wallpaper Store reactive wallpaper resolution and mutations.
*
* Uses a local $state for immediate UI feedback. Persists to userSettings
* (global) and Dexie (per-scene) in the background.
*
* Resolution order:
* 1. Preview (transient hover state, not persisted)
* 2. Active scene's wallpaper (per-scene override)
* 3. Local global state (synced to userSettings)
* 4. Default: { source: { type: 'none' } }
*/
import { browser } from '$app/environment';
import type { WallpaperConfig } from '@mana/shared-theme';
import { userSettings } from './user-settings.svelte';
import { workbenchScenesStore } from './workbench-scenes.svelte';
import { DEFAULT_WALLPAPER_CONFIG } from '$lib/types/wallpaper';
const LS_KEY = 'mana:wallpaper:global';
// ─── Local state (immediate, survives page nav) ──────────────
function loadFromStorage(): WallpaperConfig {
if (!browser) return DEFAULT_WALLPAPER_CONFIG;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) return JSON.parse(raw) as WallpaperConfig;
} catch {
/* ignore */
}
return DEFAULT_WALLPAPER_CONFIG;
}
function saveToStorage(config: WallpaperConfig) {
if (!browser) return;
try {
if (config.source.type === 'none') {
localStorage.removeItem(LS_KEY);
} else {
localStorage.setItem(LS_KEY, JSON.stringify(config));
}
} catch {
/* ignore */
}
}
let localGlobal = $state<WallpaperConfig>(loadFromStorage());
// ─── Preview state (transient, not persisted) ────────────────
let previewConfig = $state<WallpaperConfig | null>(null);
// ─── Reactive derivation ─────────────────────────────────────
/** The persisted effective wallpaper (without preview). */
let persistedEffective = $derived.by((): WallpaperConfig => {
const scene = workbenchScenesStore.activeScene;
if (scene?.wallpaper && scene.wallpaper.source.type !== 'none') {
return scene.wallpaper;
}
if (localGlobal.source.type !== 'none') {
return localGlobal;
}
return DEFAULT_WALLPAPER_CONFIG;
});
/** What the WallpaperLayer actually renders: preview > persisted. */
let displayState = $derived.by((): WallpaperConfig => {
if (previewConfig && previewConfig.source.type !== 'none') {
return previewConfig;
}
return persistedEffective;
});
// ─── Public store ────────────────────────────────────────────
export const wallpaperStore = {
/** What should be rendered (preview if active, otherwise persisted). */
get effective(): WallpaperConfig {
return displayState;
},
/** The persisted wallpaper (ignoring any hover preview). */
get persisted(): WallpaperConfig {
return persistedEffective;
},
/** Whether a wallpaper (not 'none') is currently displayed. */
get hasWallpaper(): boolean {
return displayState.source.type !== 'none';
},
/** Whether a hover preview is currently active. */
get isPreviewing(): boolean {
return previewConfig !== null;
},
/** The global wallpaper config. */
get global(): WallpaperConfig {
return localGlobal;
},
/** The active scene's wallpaper override (may be undefined). */
get sceneOverride(): WallpaperConfig | undefined {
return workbenchScenesStore.activeScene?.wallpaper;
},
// ── Preview (hover) ───────────────────────────────────────
/** Show a transient preview (e.g. on hover). Not persisted. */
preview(config: WallpaperConfig) {
previewConfig = config;
},
/** Clear the transient preview (e.g. on mouse leave). */
clearPreview() {
previewConfig = null;
},
// ── Mutations (persisted) ─────────────────────────────────
/** Set the global wallpaper (applies to all scenes without an override). */
async setGlobal(config: WallpaperConfig) {
previewConfig = null;
localGlobal = config;
saveToStorage(config);
userSettings.updateGlobal({ wallpaper: config }).catch(() => {});
},
/** Clear the global wallpaper (revert to theme default). */
async clearGlobal() {
previewConfig = null;
localGlobal = DEFAULT_WALLPAPER_CONFIG;
saveToStorage(DEFAULT_WALLPAPER_CONFIG);
userSettings.updateGlobal({ wallpaper: DEFAULT_WALLPAPER_CONFIG }).catch(() => {});
},
/** Set a wallpaper override for the currently active scene. */
async setSceneWallpaper(config: WallpaperConfig) {
previewConfig = null;
const sceneId = workbenchScenesStore.activeSceneId;
if (!sceneId) return;
await patchSceneWallpaper(sceneId, config);
},
/** Clear the active scene's wallpaper override (fall back to global). */
async clearSceneWallpaper() {
previewConfig = null;
const sceneId = workbenchScenesStore.activeSceneId;
if (!sceneId) return;
await patchSceneWallpaper(sceneId, undefined);
},
};
// ─── Internal: patch scene wallpaper via Dexie ───────────────
async function patchSceneWallpaper(sceneId: string, wallpaper: WallpaperConfig | undefined) {
const { db } = await import('$lib/data/database');
const clean = wallpaper ? structuredClone(wallpaper) : undefined;
await db.table('workbenchScenes').update(sceneId, {
wallpaper: clean,
updatedAt: new Date().toISOString(),
});
}

View file

@ -65,6 +65,7 @@ function toScene(local: LocalWorkbenchScene): WorkbenchScene {
icon: local.icon,
openApps: local.openApps ?? [],
order: local.order,
wallpaper: local.wallpaper,
};
}
@ -97,7 +98,7 @@ function pickActiveId(scenes: WorkbenchScene[], current: string | null): string
async function patchScene(
id: string,
patch: Partial<Pick<LocalWorkbenchScene, 'name' | 'icon' | 'openApps' | 'order'>>
patch: Partial<Pick<LocalWorkbenchScene, 'name' | 'icon' | 'openApps' | 'order' | 'wallpaper'>>
) {
// Strip Svelte 5 $state proxies — IndexedDB's structured clone can't serialize them.
const clean = $state.snapshot({ ...patch, updatedAt: nowIso() });

View file

@ -0,0 +1,32 @@
/**
* Wallpaper / Background Configuration Types
*
* Re-exports from @mana/shared-theme (canonical source) plus app-local constants.
*
* The wallpaper system supports four sources:
* - none: theme default (solid color via CSS variables)
* - predefined: bundled images per theme variant
* - generated: CSS gradients/solids stored as parameters
* - upload: user-uploaded images via mana-media
*
* Resolution: activeScene.wallpaper > globalSettings.wallpaper > DEFAULT_WALLPAPER_CONFIG
*/
export type {
WallpaperSource,
WallpaperSourceNone,
WallpaperSourcePredefined,
WallpaperSourceGenerated,
WallpaperSourceUpload,
WallpaperSolid,
WallpaperGradient,
WallpaperOverlay,
WallpaperConfig,
} from '@mana/shared-theme';
import type { WallpaperConfig } from '@mana/shared-theme';
/** Default (empty) wallpaper config — theme background color, no image. */
export const DEFAULT_WALLPAPER_CONFIG: WallpaperConfig = {
source: { type: 'none' },
};

View file

@ -12,6 +12,7 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { WallpaperConfig } from '@mana/shared-theme';
export interface WorkbenchSceneApp {
appId: string;
@ -29,6 +30,8 @@ export interface WorkbenchScene {
openApps: WorkbenchSceneApp[];
/** Sort order in the scene tab bar. */
order: number;
/** Per-scene wallpaper override. When set, takes priority over globalSettings.wallpaper. */
wallpaper?: WallpaperConfig;
}
/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */

View file

@ -44,6 +44,16 @@
import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue';
import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm';
import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
import {
getLocalSttStatus,
loadLocalStt,
isLocalSttSupported,
MODELS as STT_MODELS,
DEFAULT_MODEL as STT_DEFAULT_MODEL,
type ModelKey as SttModelKey,
} from '@mana/local-stt';
import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte';
import { Microphone, Stop } from '@mana/shared-icons';
import {
startMemoroLlmWatcher,
stopMemoroLlmWatcher,
@ -76,6 +86,8 @@
import { registerAllProviders } from '$lib/search/providers';
import { initSharedUload } from '@mana/shared-uload';
import type { DragPayload } from '@mana/shared-ui/dnd';
import WallpaperLayer from '$lib/components/wallpaper/WallpaperLayer.svelte';
import { wallpaperStore } from '$lib/stores/wallpaper.svelte';
let { children }: { children: Snippet } = $props();
@ -176,6 +188,9 @@
// ── AI Tier Selector (PillNav dropdown) ─────────────────
const webgpuSupported = isLocalLlmSupported();
const localLlmStatus = getLocalLlmStatus();
const sttSupported = isLocalSttSupported();
const localSttStatus = getLocalSttStatus();
let selectedSttModel = $state<SttModelKey>(STT_DEFAULT_MODEL);
const llmSettings = $derived(llmSettingsState.current);
function toggleAiTier(tier: LlmTier) {
@ -187,36 +202,148 @@
}
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [
{ tier: 'browser', shortLabel: 'Browser (Gemma 4)', icon: 'cpu' },
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'server' },
{ tier: 'browser', shortLabel: 'Lokal (Gemma 4)', icon: 'robot' },
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'globe' },
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' },
];
let aiTierItems = $derived<PillDropdownItem[]>([
// Tier toggles
// Tier toggles — browser tier item and its model-status buddy share a
// group so PillDropdownBar renders them as a paired pill.
...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => ({
id: `ai-tier-${t.tier}`,
label: t.shortLabel,
icon: t.icon,
active: llmSettings.allowedTiers.includes(t.tier),
onClick: () => toggleAiTier(t.tier),
...(t.tier === 'browser' ? { group: 'local-llm' } : {}),
})),
// Browser model status / load button
// Browser model status / load button (grouped with the "Lokal" toggle).
// Handles all LoadingStatus states so the user sees feedback during
// download, initialization, and on error (e.g. worker crash).
...(llmSettings.allowedTiers.includes('browser') && webgpuSupported
? [
{
(() => {
const s = localLlmStatus.current;
const state = s.state;
let label: string;
let icon: string;
let danger = false;
let disabled = false;
switch (state) {
case 'ready':
label = 'Geladen';
icon = 'check';
disabled = true;
break;
case 'downloading':
label = `Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`;
icon = 'clock';
disabled = true;
break;
case 'loading':
label = 'Initialisiere…';
icon = 'clock';
disabled = true;
break;
case 'checking':
label = 'Prüfe…';
icon = 'clock';
disabled = true;
break;
case 'error':
label = 'Fehler — erneut versuchen';
icon = 'bell';
danger = true;
break;
default:
label = 'Modell laden';
icon = 'cloud';
}
return {
id: 'ai-browser-status',
label:
localLlmStatus.current.state === 'ready'
? '✓ Modell geladen'
: localLlmStatus.current.state === 'downloading'
? `Lade… ${((localLlmStatus.current as { progress: number }).progress * 100).toFixed(0)}%`
: 'Modell laden (~500 MB)',
icon: localLlmStatus.current.state === 'ready' ? 'check' : 'download',
disabled: localLlmStatus.current.state === 'ready',
onClick:
localLlmStatus.current.state !== 'ready' ? () => void loadLocalLlm() : undefined,
label,
icon,
group: 'local-llm',
danger,
disabled,
progress: state === 'downloading' ? (s as { progress: number }).progress : undefined,
onClick: !disabled ? () => void loadLocalLlm() : undefined,
};
})(),
]
: []),
// ── STT section ──────────────────────────────────
{ id: 'stt-divider', label: '', divider: true },
// STT model selector — each model is a pill, active = currently selected
...(sttSupported
? (Object.entries(STT_MODELS) as [SttModelKey, (typeof STT_MODELS)[SttModelKey]][]).map(
([key, model]) => ({
id: `stt-model-${key}`,
label: model.displayName,
icon: 'mic' as const,
active: selectedSttModel === key,
onClick: () => {
selectedSttModel = key;
void loadLocalStt(key);
},
})
)
: []),
// STT model status (grouped with selected model)
...(sttSupported
? [
(() => {
const s = localSttStatus.current;
const state = s.state;
let label: string;
let icon: string;
let danger = false;
let disabled = false;
switch (state) {
case 'ready':
label = 'STT bereit';
icon = 'check';
disabled = true;
break;
case 'downloading':
label = `STT Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`;
icon = 'clock';
disabled = true;
break;
case 'loading':
label = 'STT lädt…';
icon = 'clock';
disabled = true;
break;
case 'checking':
label = 'STT prüft…';
icon = 'clock';
disabled = true;
break;
case 'error':
label = 'STT Fehler';
icon = 'bell';
danger = true;
break;
default:
label = 'STT Modell laden';
icon = 'mic';
}
return {
id: 'stt-status',
label,
icon,
danger,
disabled,
progress: state === 'downloading' ? (s as { progress: number }).progress : undefined,
onClick: !disabled ? () => void loadLocalStt(selectedSttModel) : undefined,
};
})(),
]
: []),
// Divider + settings link
@ -262,7 +389,7 @@
items.push({
id: 'sync-active',
label: 'Cloud Sync aktiv',
icon: 'cloudCheck',
icon: 'cloud',
active: true,
disabled: true,
});
@ -275,6 +402,7 @@
items.push({
id: 'sync-next',
label: `Nächste Abbuchung: ${date}`,
icon: 'calendar',
disabled: true,
});
}
@ -282,19 +410,20 @@
items.push({
id: 'sync-paused',
label: 'Sync pausiert — Credits aufladen',
icon: 'warning',
icon: 'bell',
onClick: () => goto('/credits?tab=packages'),
});
} else {
items.push({
id: 'sync-inactive',
label: 'Sync aktivieren',
icon: 'cloudArrowUp',
icon: 'cloud',
onClick: () => goto('/settings/sync'),
});
items.push({
id: 'sync-info',
label: 'Nur lokal — ab 30 Credits/Monat',
icon: 'creditCard',
disabled: true,
});
}
@ -303,7 +432,7 @@
items.push({
id: 'sync-settings',
label: 'Sync-Einstellungen',
icon: 'gear',
icon: 'settings',
onClick: () => goto('/settings/sync'),
});
@ -669,6 +798,28 @@
return searchRegistry.search(query, { signal });
};
// ── Local STT (speech-to-text via Whisper in browser) ───
const localStt = useLocalStt({ language: ($locale || 'de') === 'de' ? 'de' : 'en' });
// When STT finishes transcription, feed the text into the current
// module's QuickInputBar adapter (create action). This makes voice
// input context-aware: on /todo it creates a task, on /calendar an
// event, on / it searches, etc.
// Transcribed text is injected into the QuickInputBar so the user
// can see, edit, and confirm it before creating anything.
let sttInjectedText = $state('');
$effect(() => {
const t = localStt.text;
const e = localStt.error;
if (e) {
console.warn('[layout-stt] Error:', e);
}
if (t) {
console.log('[layout-stt] Transcribed text:', t);
sttInjectedText = t;
}
});
// ── QuickInputBar — context-aware adapter per module ─────
let inputBarAdapter = $state<InputBarAdapter>(createFallbackAdapter(searchRegistry));
let activeModulePrefix = $state<string | null>(null);
@ -740,7 +891,9 @@
</div>
{/if}
<div class="min-h-screen bg-background">
<div class="min-h-screen" class:bg-background={!wallpaperStore.hasWallpaper}>
<WallpaperLayer config={wallpaperStore.effective} />
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
Hidden entirely when fullscreen mode is active (press "f"). -->
{#if !isFullscreen}
@ -830,7 +983,30 @@
onDefaultChange={inputBarAdapter.onDefaultChange}
highlightPatterns={inputBarAdapter.highlightPatterns}
positioning="static"
injectedText={sttInjectedText}
>
{#snippet leftAction()}
<button
class="stt-mic-btn"
class:recording={localStt.state === 'recording'}
class:busy={localStt.state === 'loading' || localStt.state === 'transcribing'}
onclick={() => localStt.toggle()}
disabled={localStt.state === 'loading' || localStt.state === 'transcribing'}
title={localStt.state === 'recording'
? 'Aufnahme beenden'
: localStt.state === 'transcribing'
? 'Wird transkribiert…'
: localStt.state === 'loading'
? 'Modell wird geladen…'
: 'Spracheingabe'}
>
{#if localStt.state === 'recording'}
<Stop size={16} weight="fill" />
{:else}
<Microphone size={16} weight={localStt.state === 'idle' ? 'regular' : 'fill'} />
{/if}
</button>
{/snippet}
{#snippet rightAction()}
<button
class="pill-nav-toggle"
@ -869,7 +1045,6 @@
items={activeBar.items}
label={activeBar.label}
icon={activeBar.icon}
onClose={closeActiveBar}
positioning="static"
/>
{/if}
@ -935,7 +1110,8 @@
padding on the inner max-w-7xl wrapper below. -->
<main
style="padding-bottom: {bottomChromeHeight +
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 2.5rem;"
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 1.5rem;"
class="pt-2"
>
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if routeBlocked && routeAppId}
@ -1060,6 +1236,65 @@
pointer-events: auto;
}
/* STT mic button inside QuickInputBar leftAction slot */
.stt-mic-btn {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 50%;
border: none;
background: hsl(var(--color-foreground, 0 0% 90%) / 0.08);
color: hsl(var(--color-foreground, 0 0% 90%) / 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
}
.stt-mic-btn:hover:not(:disabled) {
background: hsl(var(--color-foreground, 0 0% 90%) / 0.15);
color: hsl(var(--color-primary, 239 84% 67%));
}
.stt-mic-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.stt-mic-btn.recording {
background: hsl(var(--color-error, 0 84% 60%) / 0.15);
color: hsl(var(--color-error, 0 84% 60%));
animation: stt-pulse 1.5s ease-in-out infinite;
}
.stt-mic-btn.busy {
animation: stt-spin 1s linear infinite;
}
@keyframes stt-pulse {
0%,
100% {
box-shadow: 0 0 0 0 hsl(var(--color-error, 0 84% 60%) / 0.3);
}
50% {
box-shadow: 0 0 0 4px hsl(var(--color-error, 0 84% 60%) / 0);
}
}
@keyframes stt-spin {
from {
opacity: 0.5;
}
50% {
opacity: 1;
}
to {
opacity: 0.5;
}
}
.pill-nav-toggle {
width: 36px;
height: 36px;

View file

@ -13,7 +13,8 @@
import { DragPreview } from '@mana/shared-ui/dnd';
import type { DragType } from '@mana/shared-ui/dnd';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { Pencil, Copy, Trash } from '@mana/shared-icons';
import { Pencil, Copy, Trash, Image } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
@ -177,6 +178,12 @@
icon: Copy,
action: () => handleDuplicateScene(scene.id),
},
{
id: 'wallpaper',
label: 'Hintergrund ändern',
icon: Image,
action: () => goto('/themes'),
},
];
if (scenes.length > 1) {
items.push({ id: 'div', label: '', type: 'divider' });
@ -307,22 +314,22 @@
display: flex;
flex-direction: column;
position: relative;
/* Break out of layout's max-w-7xl px-4 container */
margin: -2rem -1rem 0;
width: calc(100% + 2rem);
/* Break out of layout's max-w-7xl px-3 container;
only negate the inner wrapper's py-2, keep main's pt-4 */
margin: -0.5rem -0.75rem 0;
width: calc(100% + 1.5rem);
}
@media (min-width: 640px) {
.workbench {
margin: -2rem -1.5rem 0;
margin: -0.75rem -1.5rem 0;
width: calc(100% + 3rem);
}
}
@media (min-width: 1024px) {
.workbench {
margin: -2rem -2rem 0;
padding-top: 2rem;
margin: -0.75rem -2rem 0;
width: calc(100% + 4rem);
}
}

View file

@ -215,7 +215,7 @@
</script>
<div>
<PageHeader title="Credits" description="Verwalte deine Mana Credits" size="lg" />
<PageHeader title="Credits" backHref="/" sticky size="lg" />
{#if loading}
<div class="flex items-center justify-center py-12">

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { HelpPage, getHelpTranslations } from '@mana/help';
import { PageHeader } from '@mana/shared-ui';
import { getManaHelpContent } from '$lib/content/help/index.js';
const content = $derived(getManaHelpContent($locale ?? 'de'));
@ -19,13 +20,14 @@
<title>{translations.title} | Mana</title>
</svelte:head>
<PageHeader title={translations.title} backHref="/" sticky />
<HelpPage
{content}
appName="Mana"
appId="mana"
{translations}
showBackButton
onBack={() => goto('/')}
showBackButton={false}
showGettingStarted={false}
showChangelog={false}
defaultSection="faq"

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@mana/shared-ui';
import { ProfilePage, PageHeader } from '@mana/shared-ui';
import type { UserProfile, ProfileActions } from '@mana/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
@ -86,6 +86,8 @@
}
</script>
<PageHeader title="Profil" backHref="/" sticky />
{#if loading}
<div class="flex items-center justify-center py-12">
<div

View file

@ -48,11 +48,7 @@
</script>
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6">
<PageHeader
title={$_('common.settings')}
description="Verwalte deine Kontoeinstellungen und Präferenzen"
size="lg"
/>
<PageHeader title={$_('common.settings')} backHref="/" sticky size="lg" />
<div class="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
<SettingsSidebar {activeCategory} onSelect={(id) => (activeCategory = id)} onJump={jumpTo} />

View file

@ -106,11 +106,7 @@
</script>
<div>
<PageHeader
title="Cloud Sync"
description="Synchronisiere deine Daten über alle Geräte"
size="lg"
/>
<PageHeader title="Cloud Sync" backHref="/settings" sticky size="lg" />
{#if syncBilling.loading}
<div class="flex items-center justify-center py-12">

View file

@ -195,11 +195,7 @@
</script>
<div>
<PageHeader
title="Abonnement"
description="Verwalte dein Abonnement und sieh dir deine Rechnungen an"
size="lg"
/>
<PageHeader title="Abonnement" backHref="/" sticky size="lg" />
{#if loading}
<div class="flex items-center justify-center py-12">

View file

@ -1,19 +1,28 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ThemePage } from '@mana/shared-theme-ui';
import { PageHeader } from '@mana/shared-ui';
import { theme } from '$lib/stores/theme';
import { wallpaperStore } from '$lib/stores/wallpaper.svelte';
import WallpaperPicker from '$lib/components/wallpaper/WallpaperPicker.svelte';
</script>
<svelte:head>
<title>Themes | Mana</title>
</svelte:head>
<PageHeader title="Themes" backHref="/" sticky />
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/')}
/>
showBackButton={false}
transparent={wallpaperStore.hasWallpaper}
>
<section class="mt-8 pt-8 border-t border-border">
<h2 class="text-sm font-medium text-muted-foreground mb-4">Hintergrund</h2>
<WallpaperPicker />
</section>
</ThemePage>

View file

@ -1,10 +1,6 @@
<script lang="ts">
import type {
ThemeVariant,
ThemeMode,
A11yStore,
UserSettingsStore,
} from '@mana/shared-theme';
import type { Snippet } from 'svelte';
import type { ThemeVariant, ThemeMode, A11yStore, UserSettingsStore } from '@mana/shared-theme';
import { ArrowLeft, Sun, Moon, Desktop } from '@mana/shared-icons';
import type { ThemeCardData, ThemePageTranslations, A11yTranslations } from '../types';
import { defaultTranslations, defaultA11yTranslations } from '../types';
@ -26,7 +22,7 @@
currentMode?: ThemeMode;
onModeChange?: (mode: ThemeMode) => void;
// Back navigation
// Back navigation (deprecated — use PageHeader sticky on the consuming page instead)
showBackButton?: boolean;
onBack?: () => void;
@ -46,6 +42,13 @@
userSettingsStore?: UserSettingsStore;
pinnedThemes?: ThemeVariant[];
onTogglePin?: (variant: ThemeVariant) => void;
// Visual
/** Make outer wrapper transparent (so a wallpaper layer behind it shows through). */
transparent?: boolean;
/** Extra content rendered below the theme grid (e.g. wallpaper picker). */
children?: Snippet;
}
let {
@ -67,6 +70,8 @@
a11yTranslations = {},
pinnedThemes = [],
onTogglePin,
transparent = false,
children,
}: Props = $props();
// Merge translations with defaults
@ -80,8 +85,8 @@
]);
</script>
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="min-h-screen" class:bg-background={!transparent}>
<div class="max-w-3xl mx-auto px-4 py-8" class:theme-page-card={transparent}>
<!-- Header -->
<header class="mb-6">
<div class="flex items-center gap-3 mb-2">
@ -155,5 +160,25 @@
<A11ySettings store={a11yStore} translations={a11yTranslations} />
</section>
{/if}
<!-- Extra content (e.g. wallpaper picker) -->
{#if children}
{@render children()}
{/if}
</div>
</div>
<style>
.theme-page-card {
background: hsl(var(--color-card, 0 0% 100%) / 0.82);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1.25rem;
margin-top: 2rem;
margin-bottom: 2rem;
padding-left: 2rem;
padding-right: 2rem;
border: 1px solid hsl(var(--color-border) / 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
</style>

View file

@ -28,6 +28,16 @@ export type {
StartPageConfig,
WeekStartDay,
GeneralSettings,
// Wallpaper Types
WallpaperSource,
WallpaperSourceNone,
WallpaperSourcePredefined,
WallpaperSourceGenerated,
WallpaperSourceUpload,
WallpaperSolid,
WallpaperGradient,
WallpaperOverlay,
WallpaperConfig,
} from './types';
// User Settings Constants

View file

@ -182,6 +182,68 @@ export interface ThemeStore {
initialize: () => () => void;
}
// ============================================================================
// Wallpaper / Background Types
// ============================================================================
/** No wallpaper — the theme's default bg-background color shows through. */
export interface WallpaperSourceNone {
type: 'none';
}
/** A bundled, predefined wallpaper image (e.g. "ocean-1"). */
export interface WallpaperSourcePredefined {
type: 'predefined';
id: string;
}
/** Solid color background for generated wallpapers. */
export interface WallpaperSolid {
type: 'solid';
color: string;
}
/** Gradient background for generated wallpapers. */
export interface WallpaperGradient {
type: 'gradient';
colors: string[];
angle?: number;
}
/** A CSS gradient or solid color generated client-side. Only parameters are stored. */
export interface WallpaperSourceGenerated {
type: 'generated';
params: WallpaperSolid | WallpaperGradient;
}
/** A user-uploaded image served by mana-media / MinIO. */
export interface WallpaperSourceUpload {
type: 'upload';
mediaId: string;
url: string;
}
/** All wallpaper source types. */
export type WallpaperSource =
| WallpaperSourceNone
| WallpaperSourcePredefined
| WallpaperSourceGenerated
| WallpaperSourceUpload;
/** Overlay applied on top of the wallpaper for readability. */
export interface WallpaperOverlay {
/** Backdrop blur in px (020, default 0). */
blur?: number;
/** Semi-transparent overlay darkness (00.6, default 0). */
opacity?: number;
}
/** Complete wallpaper configuration. */
export interface WallpaperConfig {
source: WallpaperSource;
overlay?: WallpaperOverlay;
}
// ============================================================================
// Accessibility (A11y) Types
// ============================================================================
@ -322,6 +384,8 @@ export interface GlobalSettings {
general: GeneralSettings;
/** Recently used emojis (shared across all apps) - max 16 */
recentEmojis?: string[];
/** Global wallpaper / background config (can be overridden per scene) */
wallpaper?: WallpaperConfig;
}
/**

View file

@ -267,6 +267,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
},
},
recentEmojis: settings.recentEmojis ?? globalSettings.recentEmojis,
wallpaper: settings.wallpaper !== undefined ? settings.wallpaper : globalSettings.wallpaper,
};
saveToStorage();

View file

@ -58,6 +58,8 @@
centered?: boolean;
/** Back navigation href (shows back arrow button) */
backHref?: string;
/** Sticky position at top of viewport with frosted-glass background */
sticky?: boolean;
/** Icon snippet (before title) */
icon?: Snippet;
/** Breadcrumb snippet (above title) */
@ -77,6 +79,7 @@
bordered = false,
centered = false,
backHref,
sticky = false,
icon,
breadcrumb,
actions,
@ -84,6 +87,9 @@
class: className = '',
}: Props = $props();
const stickyClasses =
'sticky top-0 z-40 backdrop-blur-lg bg-[hsl(var(--color-background,0_0%_100%)/0.8)] border-b border-[hsl(var(--color-border)/0.3)]';
const sizeClasses: Record<HeaderSize, { container: string; title: string }> = {
sm: {
container: 'py-3',
@ -101,8 +107,8 @@
</script>
<header
class="page-header {sizeClasses[size].container} {bordered
? 'border-b border-theme'
class="page-header {sizeClasses[size].container} {bordered ? 'border-b border-theme' : ''} {sticky
? stickyClasses
: ''} {className}"
>
<!-- Breadcrumb -->