thats-me/frontend/src/stores/settings.js
Kevin Adametz 522deffa1a Hintergründe: 15 kuratierte Bilder statt Testfotos
Elf universelle Hintergründe von grafisch bis fotografisch (Tinte, Goldstaub,
Horizont, Kohle, Leinen, Nebel, Dämmerung, Nachthimmel, Tusche, Papier,
Aquarell) plus vier v2-Fassungen zur Abstimmung, alle 2048² JPEG mit
320er Vorschauen (7 MB statt 48 MB). Auswahl, Reihenfolge und Helligkeit
stehen in src/config/backgrounds.js; der Picker zeigt Name und aktuelle
Wahl, das Logo folgt der Bildhelligkeit, Presets zeigen auf die neuen
Bilder, alte Pfade werden beim Laden auf den Nachfolger umgeschrieben.
Der Service Worker cacht nur die Vorschauen vor.

Werkzeuge zum Neu-Rendern (SVG-Builder, Gemini-Prompts) liegen in
dev/tools/backgrounds, die Sichtungs-Renders bleiben lokal (gitignore).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:09 +00:00

449 lines
15 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { apiJson } from 'src/services/apiClient'
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
import { normalizeBackgroundImage } from 'src/config/backgrounds'
import { useAuthStore } from 'stores/auth'
const STORAGE_KEY_PREFIX = 'thatsme-settings'
const PERSIST_DELAY_MS = 250
export const DEFAULT_EMOTION_GRADIENT_START = '#2d2e83'
export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35'
export const DEFAULT_TIMELINE_ZOOM = 1
export const DEFAULT_TIMELINE_SCROLL_LEFT = null
export const ACCENT_COLORS = [
{ label: 'Gold', value: 'gold', hex: '#f5c26b' },
{ label: 'Grau', value: 'grey', hex: '#737373' },
{ label: 'Rot', value: 'red', hex: '#ef4444' },
{ label: 'Orange', value: 'orange', hex: '#f97316' },
{ label: 'Bernstein', value: 'amber', hex: '#f59e0b' },
{ label: 'Gelb', value: 'yellow', hex: '#eab308' },
{ label: 'Limette', value: 'lime', hex: '#84cc16' },
{ label: 'Grün', value: 'green', hex: '#22c55e' },
{ label: 'Smaragd', value: 'emerald', hex: '#10b981' },
{ label: 'Petrol', value: 'teal', hex: '#14b8a6' },
{ label: 'Cyan', value: 'cyan', hex: '#06b6d4' },
{ label: 'Himmelblau', value: 'sky', hex: '#0ea5e9' },
{ label: 'Blau', value: 'blue', hex: '#3b82f6' },
{ label: 'Indigo', value: 'indigo', hex: '#6366f1' },
{ label: 'Violett', value: 'violet', hex: '#8b5cf6' },
{ label: 'Purpur', value: 'purple', hex: '#a855f7' },
{ label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' },
{ label: 'Pink', value: 'pink', hex: '#ec4899' },
{ label: 'Rosé', value: 'rose', hex: '#f43f5e' }
]
export const LANGUAGES = [
{ label: 'Deutsch', value: 'de' },
{ label: 'English', value: 'en' }
]
// The defaults are the brand look ("Gold" preset): gold lines on ink.
const FLOATING_LINES_DEFAULTS = {
// Linien
speed: 1.0,
lineCount: 10,
spread: 0.05,
fanSpread: 0.05,
lineSharpness: 8.0,
lineThickness: 1.0,
waveFrequency: 7.0,
bezierCurvature: 0.2,
circleRadius: 75,
glowSize: 18,
glowStrength: 1.6,
lineBrightness: 0.9,
// Hintergrund
bgCenter: '#2f3032',
bgEdge: '#1a1b1c',
gradientStops: '#f5c26b\n#d59040\n#3a2a14',
backgroundImage: '',
// Horizont
horizonMode: 'glow', // 'off' | 'fog' | 'split' | 'glow'
horizonOpacity: 0.25, // Nebel + Glow: Helligkeit
horizonBlend: 0.2, // Trennung: 0=scharf 1=weich
// Labels
labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
labelColor: '#f7f3ea',
labelOpacity: 0.75,
labelConnectorLength: 0.2,
// Dot-Rahmen
dotBorderWidth: 0, // 0 = kein Rahmen, in px
dotBorderColor: '#ffffff',
// Modus
lineMode: 'glow', // 'glow' | 'static'
staticLineColor: '#2196F3', // Linienfarbe im Static-Modus
staticLineShadowStrength: 0 // 0 = klar, 1 = ursprünglicher Schatten
}
function getStorageKey(userId) {
return `${STORAGE_KEY_PREFIX}:${userId || 'guest'}`
}
function loadFromStorage(userId) {
try {
const stored = localStorage.getItem(getStorageKey(userId))
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
// Defaults plus stored values; a background path from the retired test set
// is rewritten to its successor so the setting does not point at a 404.
function normalizeFloatingLines(stored) {
const merged = { ...FLOATING_LINES_DEFAULTS, ...(stored ?? {}) }
merged.backgroundImage = normalizeBackgroundImage(merged.backgroundImage)
return merged
}
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
// 'default' and 'base' are what accounts from before the brand palette hold:
// nobody chose that grey, it was simply the old default. Both become gold;
// a deliberate grey is stored as 'grey' since 2026-09-25.
function normalizeAccentColor(value) {
if (value === 'default' || value === 'base') return 'gold'
return ACCENT_COLORS.some(c => c.value === value) ? value : 'gold'
}
function hexToRgb(hex) {
const clean = hex.replace('#', '')
const full = clean.length === 3
? clean.split('').map(ch => ch + ch).join('')
: clean
const r = parseInt(full.slice(0, 2), 16)
const g = parseInt(full.slice(2, 4), 16)
const b = parseInt(full.slice(4, 6), 16)
return { r, g, b }
}
function applyAccentCssVariables(value) {
if (typeof document === 'undefined') return
const key = normalizeAccentColor(value)
const accent = ACCENT_COLORS.find(c => c.value === key) ?? ACCENT_COLORS[0]
const { r, g, b } = hexToRgb(accent.hex)
document.documentElement.style.setProperty('--tm-accent', accent.hex)
document.documentElement.style.setProperty('--tm-accent-rgb', `${r}, ${g}, ${b}`)
// Keep Quasar semantic color channels in sync with the active accent.
document.documentElement.style.setProperty('--q-primary', accent.hex)
document.documentElement.style.setProperty('--q-secondary', accent.hex)
document.documentElement.style.setProperty('--q-accent', accent.hex)
}
export { FLOATING_LINES_DEFAULTS }
export const useSettingsStore = defineStore('settings', () => {
const authStore = useAuthStore()
const stored = loadFromStorage(authStore.currentUserId)
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
const initialSettings = initialActivePreset?.settings ?? stored
let persistTimer = null
let isApplyingSettings = false
// Incremented on every user switch so a late settings response can be
// recognised as belonging to a session that has already ended.
let settingsGeneration = 0
const lastError = ref(null)
const theme = ref(initialSettings?.theme ?? 'light')
const floatingLines = ref(normalizeFloatingLines(initialSettings?.floatingLines))
// App preferences
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'gold'))
const language = ref(initialSettings?.language ?? 'de')
const emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START)
const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END)
const timelineZoom = ref(initialSettings?.timelineZoom ?? DEFAULT_TIMELINE_ZOOM)
const timelineScrollLeft = ref(stored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT)
const presets = ref(stored?.presets ?? [])
const activePresetId = ref(stored?.activePresetId ?? null)
// Developer / debug
const showFps = ref(initialSettings?.showFps ?? false)
function createSnapshot() {
return {
theme: theme.value,
floatingLines: clone(floatingLines.value),
appearance: appearance.value,
accentColor: accentColor.value,
language: language.value,
emotionGradientStart: emotionGradientStart.value,
emotionGradientEnd: emotionGradientEnd.value,
timelineZoom: timelineZoom.value,
showFps: showFps.value
}
}
function createStoredSettings() {
return {
...createSnapshot(),
timelineScrollLeft: timelineScrollLeft.value,
presets: presets.value,
activePresetId: activePresetId.value
}
}
function isRemoteUser() {
return authStore.currentUser?.mode === 'remote'
}
function applySnapshot(snapshot) {
theme.value = snapshot?.theme ?? 'light'
floatingLines.value = normalizeFloatingLines(snapshot?.floatingLines)
appearance.value = snapshot?.appearance ?? 'system'
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold')
language.value = snapshot?.language ?? 'de'
emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START
emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END
timelineZoom.value = snapshot?.timelineZoom ?? DEFAULT_TIMELINE_ZOOM
showFps.value = snapshot?.showFps ?? false
}
function persist() {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (!authStore.currentUserId || isApplyingSettings) return
const storedSettings = createStoredSettings()
try {
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings))
} catch (error) {
// Private mode, full quota, or a torn-down environment: the remote
// copy below still happens, the local mirror is best effort.
console.warn('Settings could not be written to localStorage:', error)
}
if (isRemoteUser()) {
const generation = settingsGeneration
apiJson('/settings', {
method: 'PUT',
body: { settings: storedSettings }
}).catch((error) => {
console.warn('Remote settings persist failed:', error)
if (generation !== settingsGeneration) return
lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.'
})
}
}
function schedulePersist() {
if (isApplyingSettings) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(persist, PERSIST_DELAY_MS)
}
async function loadRemoteSettings() {
const payload = await apiJson('/settings')
return payload.data ?? null
}
async function applyStoredSettingsForUser(userId) {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
// Everything below belongs to this user. A response arriving after the
// next switch must not overwrite the settings of whoever is active then.
settingsGeneration += 1
const generation = settingsGeneration
isApplyingSettings = true
lastError.value = null
try {
let nextStored = loadFromStorage(userId)
if (userId && isRemoteUser()) {
try {
const remoteSettings = await loadRemoteSettings()
if (generation !== settingsGeneration) return
nextStored = remoteSettings
if (nextStored) {
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
}
} catch (error) {
console.warn('Remote settings load failed:', error)
if (generation !== settingsGeneration) return
// The locally cached settings from `loadFromStorage` stay in
// effect; the user is told the server copy could not be read.
lastError.value = 'Die Einstellungen konnten nicht geladen werden. Angezeigt wird der lokale Stand.'
}
}
presets.value = nextStored?.presets ?? []
activePresetId.value = nextStored?.activePresetId ?? null
const activePreset = presets.value.find(preset => preset.id === activePresetId.value)
applySnapshot(activePreset?.settings ?? nextStored)
timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT
} finally {
// Must run on every exit path: leaving this true would silently
// disable persisting for the rest of the session.
isApplyingSettings = false
}
}
watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true })
watch(() => authStore.currentUserId, applyStoredSettingsForUser)
watch(accentColor, (value) => {
const normalized = normalizeAccentColor(value)
if (accentColor.value !== normalized) {
accentColor.value = normalized
return
}
applyAccentCssVariables(normalized)
}, { immediate: true })
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function updateFloatingLines(updates) {
floatingLines.value = { ...floatingLines.value, ...updates }
}
async function uploadBackgroundImage(file) {
if (!isRemoteUser()) return null
const formData = new FormData()
formData.append('file', file)
const payload = await apiJson('/settings/media/background', {
method: 'POST',
body: formData
})
const url = payload?.data?.url
if (url) {
updateFloatingLines({ backgroundImage: url })
}
return payload?.data ?? null
}
async function clearBackgroundImage() {
updateFloatingLines({ backgroundImage: '' })
if (!isRemoteUser()) return
try {
await apiJson('/settings/media/background', { method: 'DELETE' })
} catch (error) {
console.warn('Remote settings background delete failed:', error)
}
}
function resetFloatingLines() {
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
activePresetId.value = null
}
function resetEmotionGradient() {
emotionGradientStart.value = DEFAULT_EMOTION_GRADIENT_START
emotionGradientEnd.value = DEFAULT_EMOTION_GRADIENT_END
}
function saveTimelineScrollLeft(value, immediate = false) {
timelineScrollLeft.value = Number.isFinite(value) ? value : DEFAULT_TIMELINE_SCROLL_LEFT
if (immediate) {
persist()
}
}
function savePreset(name) {
const trimmedName = String(name || '').trim()
if (!trimmedName) return null
const existing = presets.value.find(preset => preset.name.toLowerCase() === trimmedName.toLowerCase())
const savedPreset = {
id: existing?.id ?? crypto.randomUUID(),
name: trimmedName,
settings: createSnapshot(),
updatedAt: Date.now()
}
presets.value = existing
? presets.value.map(preset => preset.id === existing.id ? savedPreset : preset)
: [...presets.value, savedPreset]
activePresetId.value = savedPreset.id
persist()
return savedPreset
}
/**
* Apply a built-in or a user preset. Built-in presets change the wave
* only; a user preset restores the full snapshot it was saved with.
*/
function applyPreset(presetId) {
if (isBuiltInPresetId(presetId)) {
const builtIn = findWavePreset(presetId)
if (!builtIn) return false
activePresetId.value = builtIn.id
floatingLines.value = { ...FLOATING_LINES_DEFAULTS, ...builtIn.floatingLines }
persist()
return true
}
const preset = presets.value.find(candidate => candidate.id === presetId)
if (!preset) return false
activePresetId.value = preset.id
applySnapshot(preset.settings)
persist()
return true
}
function deletePreset(presetId) {
if (isBuiltInPresetId(presetId)) return false
const before = presets.value.length
presets.value = presets.value.filter(preset => preset.id !== presetId)
if (presets.value.length === before) return false
if (activePresetId.value === presetId) activePresetId.value = null
persist()
return true
}
applyStoredSettingsForUser(authStore.currentUserId)
return {
lastError,
theme,
floatingLines,
appearance,
accentColor,
language,
emotionGradientStart,
emotionGradientEnd,
timelineZoom,
timelineScrollLeft,
presets,
builtInPresets: WAVE_PRESETS,
activePresetId,
showFps,
toggleTheme,
updateFloatingLines,
resetFloatingLines,
resetEmotionGradient,
saveTimelineScrollLeft,
uploadBackgroundImage,
clearBackgroundImage,
savePreset,
applyPreset,
deletePreset
}
})