mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
a9c51517eb
commit
8c2f9306e9
22 changed files with 1557 additions and 66 deletions
|
|
@ -134,6 +134,8 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== 'production';
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
setSecurityHeaders(response, {
|
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
|
// @huggingface/transformers (used by @mana/local-llm) lazy-loads the
|
||||||
// onnxruntime-web WASM loader from jsDelivr at backend selection
|
// onnxruntime-web WASM loader from jsDelivr at backend selection
|
||||||
// time via a dynamic import(). Browsers route dynamic imports
|
// time via a dynamic import(). Browsers route dynamic imports
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
85
apps/mana/apps/web/src/lib/config/wallpapers.ts
Normal file
85
apps/mana/apps/web/src/lib/config/wallpapers.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Predefined Wallpaper Registry
|
||||||
|
*
|
||||||
|
* Bundled wallpaper images shipped with the app. Each theme variant
|
||||||
|
* gets 2–3 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
165
apps/mana/apps/web/src/lib/stores/wallpaper.svelte.ts
Normal file
165
apps/mana/apps/web/src/lib/stores/wallpaper.svelte.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,7 @@ function toScene(local: LocalWorkbenchScene): WorkbenchScene {
|
||||||
icon: local.icon,
|
icon: local.icon,
|
||||||
openApps: local.openApps ?? [],
|
openApps: local.openApps ?? [],
|
||||||
order: local.order,
|
order: local.order,
|
||||||
|
wallpaper: local.wallpaper,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +98,7 @@ function pickActiveId(scenes: WorkbenchScene[], current: string | null): string
|
||||||
|
|
||||||
async function patchScene(
|
async function patchScene(
|
||||||
id: string,
|
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.
|
// Strip Svelte 5 $state proxies — IndexedDB's structured clone can't serialize them.
|
||||||
const clean = $state.snapshot({ ...patch, updatedAt: nowIso() });
|
const clean = $state.snapshot({ ...patch, updatedAt: nowIso() });
|
||||||
|
|
|
||||||
32
apps/mana/apps/web/src/lib/types/wallpaper.ts
Normal file
32
apps/mana/apps/web/src/lib/types/wallpaper.ts
Normal 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' },
|
||||||
|
};
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BaseRecord } from '@mana/local-store';
|
import type { BaseRecord } from '@mana/local-store';
|
||||||
|
import type { WallpaperConfig } from '@mana/shared-theme';
|
||||||
|
|
||||||
export interface WorkbenchSceneApp {
|
export interface WorkbenchSceneApp {
|
||||||
appId: string;
|
appId: string;
|
||||||
|
|
@ -29,6 +30,8 @@ export interface WorkbenchScene {
|
||||||
openApps: WorkbenchSceneApp[];
|
openApps: WorkbenchSceneApp[];
|
||||||
/** Sort order in the scene tab bar. */
|
/** Sort order in the scene tab bar. */
|
||||||
order: number;
|
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). */
|
/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@
|
||||||
import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue';
|
import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue';
|
||||||
import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm';
|
import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm';
|
||||||
import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-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 {
|
import {
|
||||||
startMemoroLlmWatcher,
|
startMemoroLlmWatcher,
|
||||||
stopMemoroLlmWatcher,
|
stopMemoroLlmWatcher,
|
||||||
|
|
@ -76,6 +86,8 @@
|
||||||
import { registerAllProviders } from '$lib/search/providers';
|
import { registerAllProviders } from '$lib/search/providers';
|
||||||
import { initSharedUload } from '@mana/shared-uload';
|
import { initSharedUload } from '@mana/shared-uload';
|
||||||
import type { DragPayload } from '@mana/shared-ui/dnd';
|
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();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
|
|
@ -176,6 +188,9 @@
|
||||||
// ── AI Tier Selector (PillNav dropdown) ─────────────────
|
// ── AI Tier Selector (PillNav dropdown) ─────────────────
|
||||||
const webgpuSupported = isLocalLlmSupported();
|
const webgpuSupported = isLocalLlmSupported();
|
||||||
const localLlmStatus = getLocalLlmStatus();
|
const localLlmStatus = getLocalLlmStatus();
|
||||||
|
const sttSupported = isLocalSttSupported();
|
||||||
|
const localSttStatus = getLocalSttStatus();
|
||||||
|
let selectedSttModel = $state<SttModelKey>(STT_DEFAULT_MODEL);
|
||||||
const llmSettings = $derived(llmSettingsState.current);
|
const llmSettings = $derived(llmSettingsState.current);
|
||||||
|
|
||||||
function toggleAiTier(tier: LlmTier) {
|
function toggleAiTier(tier: LlmTier) {
|
||||||
|
|
@ -187,36 +202,148 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [
|
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [
|
||||||
{ tier: 'browser', shortLabel: 'Browser (Gemma 4)', icon: 'cpu' },
|
{ tier: 'browser', shortLabel: 'Lokal (Gemma 4)', icon: 'robot' },
|
||||||
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'server' },
|
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'globe' },
|
||||||
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' },
|
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' },
|
||||||
];
|
];
|
||||||
|
|
||||||
let aiTierItems = $derived<PillDropdownItem[]>([
|
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) => ({
|
...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => ({
|
||||||
id: `ai-tier-${t.tier}`,
|
id: `ai-tier-${t.tier}`,
|
||||||
label: t.shortLabel,
|
label: t.shortLabel,
|
||||||
icon: t.icon,
|
icon: t.icon,
|
||||||
active: llmSettings.allowedTiers.includes(t.tier),
|
active: llmSettings.allowedTiers.includes(t.tier),
|
||||||
onClick: () => toggleAiTier(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
|
...(llmSettings.allowedTiers.includes('browser') && webgpuSupported
|
||||||
? [
|
? [
|
||||||
{
|
(() => {
|
||||||
id: 'ai-browser-status',
|
const s = localLlmStatus.current;
|
||||||
label:
|
const state = s.state;
|
||||||
localLlmStatus.current.state === 'ready'
|
let label: string;
|
||||||
? '✓ Modell geladen'
|
let icon: string;
|
||||||
: localLlmStatus.current.state === 'downloading'
|
let danger = false;
|
||||||
? `Lade… ${((localLlmStatus.current as { progress: number }).progress * 100).toFixed(0)}%`
|
let disabled = false;
|
||||||
: 'Modell laden (~500 MB)',
|
|
||||||
icon: localLlmStatus.current.state === 'ready' ? 'check' : 'download',
|
switch (state) {
|
||||||
disabled: localLlmStatus.current.state === 'ready',
|
case 'ready':
|
||||||
onClick:
|
label = 'Geladen';
|
||||||
localLlmStatus.current.state !== 'ready' ? () => void loadLocalLlm() : undefined,
|
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,
|
||||||
|
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
|
// Divider + settings link
|
||||||
|
|
@ -262,7 +389,7 @@
|
||||||
items.push({
|
items.push({
|
||||||
id: 'sync-active',
|
id: 'sync-active',
|
||||||
label: 'Cloud Sync aktiv',
|
label: 'Cloud Sync aktiv',
|
||||||
icon: 'cloudCheck',
|
icon: 'cloud',
|
||||||
active: true,
|
active: true,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
});
|
});
|
||||||
|
|
@ -275,6 +402,7 @@
|
||||||
items.push({
|
items.push({
|
||||||
id: 'sync-next',
|
id: 'sync-next',
|
||||||
label: `Nächste Abbuchung: ${date}`,
|
label: `Nächste Abbuchung: ${date}`,
|
||||||
|
icon: 'calendar',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -282,19 +410,20 @@
|
||||||
items.push({
|
items.push({
|
||||||
id: 'sync-paused',
|
id: 'sync-paused',
|
||||||
label: 'Sync pausiert — Credits aufladen',
|
label: 'Sync pausiert — Credits aufladen',
|
||||||
icon: 'warning',
|
icon: 'bell',
|
||||||
onClick: () => goto('/credits?tab=packages'),
|
onClick: () => goto('/credits?tab=packages'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
id: 'sync-inactive',
|
id: 'sync-inactive',
|
||||||
label: 'Sync aktivieren',
|
label: 'Sync aktivieren',
|
||||||
icon: 'cloudArrowUp',
|
icon: 'cloud',
|
||||||
onClick: () => goto('/settings/sync'),
|
onClick: () => goto('/settings/sync'),
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
id: 'sync-info',
|
id: 'sync-info',
|
||||||
label: 'Nur lokal — ab 30 Credits/Monat',
|
label: 'Nur lokal — ab 30 Credits/Monat',
|
||||||
|
icon: 'creditCard',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +432,7 @@
|
||||||
items.push({
|
items.push({
|
||||||
id: 'sync-settings',
|
id: 'sync-settings',
|
||||||
label: 'Sync-Einstellungen',
|
label: 'Sync-Einstellungen',
|
||||||
icon: 'gear',
|
icon: 'settings',
|
||||||
onClick: () => goto('/settings/sync'),
|
onClick: () => goto('/settings/sync'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -669,6 +798,28 @@
|
||||||
return searchRegistry.search(query, { signal });
|
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 ─────
|
// ── QuickInputBar — context-aware adapter per module ─────
|
||||||
let inputBarAdapter = $state<InputBarAdapter>(createFallbackAdapter(searchRegistry));
|
let inputBarAdapter = $state<InputBarAdapter>(createFallbackAdapter(searchRegistry));
|
||||||
let activeModulePrefix = $state<string | null>(null);
|
let activeModulePrefix = $state<string | null>(null);
|
||||||
|
|
@ -740,7 +891,9 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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.
|
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
|
||||||
Hidden entirely when fullscreen mode is active (press "f"). -->
|
Hidden entirely when fullscreen mode is active (press "f"). -->
|
||||||
{#if !isFullscreen}
|
{#if !isFullscreen}
|
||||||
|
|
@ -830,7 +983,30 @@
|
||||||
onDefaultChange={inputBarAdapter.onDefaultChange}
|
onDefaultChange={inputBarAdapter.onDefaultChange}
|
||||||
highlightPatterns={inputBarAdapter.highlightPatterns}
|
highlightPatterns={inputBarAdapter.highlightPatterns}
|
||||||
positioning="static"
|
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()}
|
{#snippet rightAction()}
|
||||||
<button
|
<button
|
||||||
class="pill-nav-toggle"
|
class="pill-nav-toggle"
|
||||||
|
|
@ -869,7 +1045,6 @@
|
||||||
items={activeBar.items}
|
items={activeBar.items}
|
||||||
label={activeBar.label}
|
label={activeBar.label}
|
||||||
icon={activeBar.icon}
|
icon={activeBar.icon}
|
||||||
onClose={closeActiveBar}
|
|
||||||
positioning="static"
|
positioning="static"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -935,7 +1110,8 @@
|
||||||
padding on the inner max-w-7xl wrapper below. -->
|
padding on the inner max-w-7xl wrapper below. -->
|
||||||
<main
|
<main
|
||||||
style="padding-bottom: {bottomChromeHeight +
|
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">
|
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
|
||||||
{#if routeBlocked && routeAppId}
|
{#if routeBlocked && routeAppId}
|
||||||
|
|
@ -1060,6 +1236,65 @@
|
||||||
pointer-events: auto;
|
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 {
|
.pill-nav-toggle {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
import { DragPreview } from '@mana/shared-ui/dnd';
|
import { DragPreview } from '@mana/shared-ui/dnd';
|
||||||
import type { DragType } from '@mana/shared-ui/dnd';
|
import type { DragType } from '@mana/shared-ui/dnd';
|
||||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
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 { _ } from 'svelte-i18n';
|
||||||
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
|
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
|
||||||
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
|
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
|
||||||
|
|
@ -177,6 +178,12 @@
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
action: () => handleDuplicateScene(scene.id),
|
action: () => handleDuplicateScene(scene.id),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wallpaper',
|
||||||
|
label: 'Hintergrund ändern',
|
||||||
|
icon: Image,
|
||||||
|
action: () => goto('/themes'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (scenes.length > 1) {
|
if (scenes.length > 1) {
|
||||||
items.push({ id: 'div', label: '', type: 'divider' });
|
items.push({ id: 'div', label: '', type: 'divider' });
|
||||||
|
|
@ -307,22 +314,22 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Break out of layout's max-w-7xl px-4 container */
|
/* Break out of layout's max-w-7xl px-3 container;
|
||||||
margin: -2rem -1rem 0;
|
only negate the inner wrapper's py-2, keep main's pt-4 */
|
||||||
width: calc(100% + 2rem);
|
margin: -0.5rem -0.75rem 0;
|
||||||
|
width: calc(100% + 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
margin: -2rem -1.5rem 0;
|
margin: -0.75rem -1.5rem 0;
|
||||||
width: calc(100% + 3rem);
|
width: calc(100% + 3rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
margin: -2rem -2rem 0;
|
margin: -0.75rem -2rem 0;
|
||||||
padding-top: 2rem;
|
|
||||||
width: calc(100% + 4rem);
|
width: calc(100% + 4rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Credits" description="Verwalte deine Mana Credits" size="lg" />
|
<PageHeader title="Credits" backHref="/" sticky size="lg" />
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex items-center justify-center py-12">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { locale } from 'svelte-i18n';
|
import { locale } from 'svelte-i18n';
|
||||||
import { HelpPage, getHelpTranslations } from '@mana/help';
|
import { HelpPage, getHelpTranslations } from '@mana/help';
|
||||||
|
import { PageHeader } from '@mana/shared-ui';
|
||||||
import { getManaHelpContent } from '$lib/content/help/index.js';
|
import { getManaHelpContent } from '$lib/content/help/index.js';
|
||||||
|
|
||||||
const content = $derived(getManaHelpContent($locale ?? 'de'));
|
const content = $derived(getManaHelpContent($locale ?? 'de'));
|
||||||
|
|
@ -19,13 +20,14 @@
|
||||||
<title>{translations.title} | Mana</title>
|
<title>{translations.title} | Mana</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<PageHeader title={translations.title} backHref="/" sticky />
|
||||||
|
|
||||||
<HelpPage
|
<HelpPage
|
||||||
{content}
|
{content}
|
||||||
appName="Mana"
|
appName="Mana"
|
||||||
appId="mana"
|
appId="mana"
|
||||||
{translations}
|
{translations}
|
||||||
showBackButton
|
showBackButton={false}
|
||||||
onBack={() => goto('/')}
|
|
||||||
showGettingStarted={false}
|
showGettingStarted={false}
|
||||||
showChangelog={false}
|
showChangelog={false}
|
||||||
defaultSection="faq"
|
defaultSection="faq"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 type { UserProfile, ProfileActions } from '@mana/shared-ui';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
@ -86,6 +86,8 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title="Profil" backHref="/" sticky />
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex items-center justify-center py-12">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6">
|
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6">
|
||||||
<PageHeader
|
<PageHeader title={$_('common.settings')} backHref="/" sticky size="lg" />
|
||||||
title={$_('common.settings')}
|
|
||||||
description="Verwalte deine Kontoeinstellungen und Präferenzen"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
|
<div class="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
<SettingsSidebar {activeCategory} onSelect={(id) => (activeCategory = id)} onJump={jumpTo} />
|
<SettingsSidebar {activeCategory} onSelect={(id) => (activeCategory = id)} onJump={jumpTo} />
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader title="Cloud Sync" backHref="/settings" sticky size="lg" />
|
||||||
title="Cloud Sync"
|
|
||||||
description="Synchronisiere deine Daten über alle Geräte"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if syncBilling.loading}
|
{#if syncBilling.loading}
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex items-center justify-center py-12">
|
||||||
|
|
|
||||||
|
|
@ -195,11 +195,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader title="Abonnement" backHref="/" sticky size="lg" />
|
||||||
title="Abonnement"
|
|
||||||
description="Verwalte dein Abonnement und sieh dir deine Rechnungen an"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex items-center justify-center py-12">
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { ThemePage } from '@mana/shared-theme-ui';
|
import { ThemePage } from '@mana/shared-theme-ui';
|
||||||
|
import { PageHeader } from '@mana/shared-ui';
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
|
import { wallpaperStore } from '$lib/stores/wallpaper.svelte';
|
||||||
|
import WallpaperPicker from '$lib/components/wallpaper/WallpaperPicker.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Themes | Mana</title>
|
<title>Themes | Mana</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<PageHeader title="Themes" backHref="/" sticky />
|
||||||
|
|
||||||
<ThemePage
|
<ThemePage
|
||||||
currentVariant={theme.variant}
|
currentVariant={theme.variant}
|
||||||
onSelectTheme={(v) => theme.setVariant(v)}
|
onSelectTheme={(v) => theme.setVariant(v)}
|
||||||
showModeSelector={true}
|
showModeSelector={true}
|
||||||
currentMode={theme.mode}
|
currentMode={theme.mode}
|
||||||
onModeChange={(m) => theme.setMode(m)}
|
onModeChange={(m) => theme.setMode(m)}
|
||||||
showBackButton={true}
|
showBackButton={false}
|
||||||
onBack={() => goto('/')}
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import type { Snippet } from 'svelte';
|
||||||
ThemeVariant,
|
import type { ThemeVariant, ThemeMode, A11yStore, UserSettingsStore } from '@mana/shared-theme';
|
||||||
ThemeMode,
|
|
||||||
A11yStore,
|
|
||||||
UserSettingsStore,
|
|
||||||
} from '@mana/shared-theme';
|
|
||||||
import { ArrowLeft, Sun, Moon, Desktop } from '@mana/shared-icons';
|
import { ArrowLeft, Sun, Moon, Desktop } from '@mana/shared-icons';
|
||||||
import type { ThemeCardData, ThemePageTranslations, A11yTranslations } from '../types';
|
import type { ThemeCardData, ThemePageTranslations, A11yTranslations } from '../types';
|
||||||
import { defaultTranslations, defaultA11yTranslations } from '../types';
|
import { defaultTranslations, defaultA11yTranslations } from '../types';
|
||||||
|
|
@ -26,7 +22,7 @@
|
||||||
currentMode?: ThemeMode;
|
currentMode?: ThemeMode;
|
||||||
onModeChange?: (mode: ThemeMode) => void;
|
onModeChange?: (mode: ThemeMode) => void;
|
||||||
|
|
||||||
// Back navigation
|
// Back navigation (deprecated — use PageHeader sticky on the consuming page instead)
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
|
||||||
|
|
@ -46,6 +42,13 @@
|
||||||
userSettingsStore?: UserSettingsStore;
|
userSettingsStore?: UserSettingsStore;
|
||||||
pinnedThemes?: ThemeVariant[];
|
pinnedThemes?: ThemeVariant[];
|
||||||
onTogglePin?: (variant: ThemeVariant) => void;
|
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 {
|
let {
|
||||||
|
|
@ -67,6 +70,8 @@
|
||||||
a11yTranslations = {},
|
a11yTranslations = {},
|
||||||
pinnedThemes = [],
|
pinnedThemes = [],
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
|
transparent = false,
|
||||||
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Merge translations with defaults
|
// Merge translations with defaults
|
||||||
|
|
@ -80,8 +85,8 @@
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen" class:bg-background={!transparent}>
|
||||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
<div class="max-w-3xl mx-auto px-4 py-8" class:theme-page-card={transparent}>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
|
@ -155,5 +160,25 @@
|
||||||
<A11ySettings store={a11yStore} translations={a11yTranslations} />
|
<A11ySettings store={a11yStore} translations={a11yTranslations} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Extra content (e.g. wallpaper picker) -->
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ export type {
|
||||||
StartPageConfig,
|
StartPageConfig,
|
||||||
WeekStartDay,
|
WeekStartDay,
|
||||||
GeneralSettings,
|
GeneralSettings,
|
||||||
|
// Wallpaper Types
|
||||||
|
WallpaperSource,
|
||||||
|
WallpaperSourceNone,
|
||||||
|
WallpaperSourcePredefined,
|
||||||
|
WallpaperSourceGenerated,
|
||||||
|
WallpaperSourceUpload,
|
||||||
|
WallpaperSolid,
|
||||||
|
WallpaperGradient,
|
||||||
|
WallpaperOverlay,
|
||||||
|
WallpaperConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// User Settings Constants
|
// User Settings Constants
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,68 @@ export interface ThemeStore {
|
||||||
initialize: () => () => void;
|
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 (0–20, default 0). */
|
||||||
|
blur?: number;
|
||||||
|
/** Semi-transparent overlay darkness (0–0.6, default 0). */
|
||||||
|
opacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete wallpaper configuration. */
|
||||||
|
export interface WallpaperConfig {
|
||||||
|
source: WallpaperSource;
|
||||||
|
overlay?: WallpaperOverlay;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Accessibility (A11y) Types
|
// Accessibility (A11y) Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -322,6 +384,8 @@ export interface GlobalSettings {
|
||||||
general: GeneralSettings;
|
general: GeneralSettings;
|
||||||
/** Recently used emojis (shared across all apps) - max 16 */
|
/** Recently used emojis (shared across all apps) - max 16 */
|
||||||
recentEmojis?: string[];
|
recentEmojis?: string[];
|
||||||
|
/** Global wallpaper / background config (can be overridden per scene) */
|
||||||
|
wallpaper?: WallpaperConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recentEmojis: settings.recentEmojis ?? globalSettings.recentEmojis,
|
recentEmojis: settings.recentEmojis ?? globalSettings.recentEmojis,
|
||||||
|
wallpaper: settings.wallpaper !== undefined ? settings.wallpaper : globalSettings.wallpaper,
|
||||||
};
|
};
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@
|
||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
/** Back navigation href (shows back arrow button) */
|
/** Back navigation href (shows back arrow button) */
|
||||||
backHref?: string;
|
backHref?: string;
|
||||||
|
/** Sticky position at top of viewport with frosted-glass background */
|
||||||
|
sticky?: boolean;
|
||||||
/** Icon snippet (before title) */
|
/** Icon snippet (before title) */
|
||||||
icon?: Snippet;
|
icon?: Snippet;
|
||||||
/** Breadcrumb snippet (above title) */
|
/** Breadcrumb snippet (above title) */
|
||||||
|
|
@ -77,6 +79,7 @@
|
||||||
bordered = false,
|
bordered = false,
|
||||||
centered = false,
|
centered = false,
|
||||||
backHref,
|
backHref,
|
||||||
|
sticky = false,
|
||||||
icon,
|
icon,
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
actions,
|
actions,
|
||||||
|
|
@ -84,6 +87,9 @@
|
||||||
class: className = '',
|
class: className = '',
|
||||||
}: Props = $props();
|
}: 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 }> = {
|
const sizeClasses: Record<HeaderSize, { container: string; title: string }> = {
|
||||||
sm: {
|
sm: {
|
||||||
container: 'py-3',
|
container: 'py-3',
|
||||||
|
|
@ -101,8 +107,8 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="page-header {sizeClasses[size].container} {bordered
|
class="page-header {sizeClasses[size].container} {bordered ? 'border-b border-theme' : ''} {sticky
|
||||||
? 'border-b border-theme'
|
? stickyClasses
|
||||||
: ''} {className}"
|
: ''} {className}"
|
||||||
>
|
>
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue