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>
449 lines
15 KiB
JavaScript
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
|
|
}
|
|
})
|