mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +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';
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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() });
|
||||
|
|
|
|||
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 { 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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
{
|
||||
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,
|
||||
},
|
||||
(() => {
|
||||
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,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (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
|
||||
// ============================================================================
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
|
|||
},
|
||||
},
|
||||
recentEmojis: settings.recentEmojis ?? globalSettings.recentEmojis,
|
||||
wallpaper: settings.wallpaper !== undefined ? settings.wallpaper : globalSettings.wallpaper,
|
||||
};
|
||||
saveToStorage();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue