diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4a8d873..1e8fdeb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -31,9 +31,6 @@ "WWWGROUP": "20", "LARAVEL_SAIL": "1" }, - "mounts": [ - "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached" - ], "forwardPorts": [ 5173, 9000 diff --git a/docker-compose.yml b/docker-compose.yml index ae33b91..0d74f9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: REDIS_HOST: global-redis volumes: - './backend:/var/www/html' + - '.:/workspace:cached' networks: - sail - proxy diff --git a/frontend/dev/IMPROVEMENTS-floating-lines.md b/frontend/dev/IMPROVEMENTS-floating-lines.md new file mode 100644 index 0000000..39b422d --- /dev/null +++ b/frontend/dev/IMPROVEMENTS-floating-lines.md @@ -0,0 +1,200 @@ +# Verbesserungsvorschläge: floating-lines.js + +Analyse von `floating-lines.js` (Dev-Klasse) im Vergleich zur produktiven `FloatingLines.vue`. +**Stand: April 2026 — alle wesentlichen Punkte umgesetzt.** + +--- + +## Was der Code macht (Kurzübersicht) + +Ein WebGL-Fullscreen-Shader via Three.js mit drei visuellen Schichten: + +- **Top/Bottom**: Einfache Sinus-Wellen mit Rotation (`wave()`) +- **Middle**: Bézier-Kurven zwischen Kontrollpunkten mit animierten Fächer-Linien (`waveFocal()`) + Kreise an den Punkten +- **Hintergrund**: Radialer Verlauf von Mitte → Rand (oder Horizont-Split bei Modus „Trennung") + +--- + +## Fehler / Bugs + +### 1. Totes Uniform `bgColor` ✅ Umgesetzt +**Datei:** `floating-lines.js`, Zeile 508 +Das tote `bgColor`-Uniform wurde entfernt. Der Shader nutzt ausschließlich `bgColorCenter` + `bgColorEdge`. + +```js +// Entfernt: +bgColor: { value: new Vector3(0, 0, 0) }, +``` + +### 2. Konstruktor-Parameter `middleWavePosition` wird ignoriert ✅ Umgesetzt +Der ungenutzte Parameter wurde aus der Konstruktorsignatur entfernt. + +### 3. Kein Konstruktor-Interface für `bgColorCenter` / `bgColorEdge` ✅ Umgesetzt +Beide Uniforms sind jetzt als Konstruktor-Parameter verfügbar und werden korrekt initialisiert: + +```js +constructor({ ..., bgColorCenter = '#0a0514', bgColorEdge = '#000000', ... }) +``` + +Die Uniforms werden beim Konstruktoraufruf aus den Hex-Strings in `Vector3`-Werte konvertiert. + +--- + +## Performance-Problem + +### 4. `bezierClosestT` wird pro Linie neu berechnet (kritisch) ✅ Umgesetzt +`bezierClosestT` wird jetzt **einmal pro Segment** berechnet. Die Ergebnisse (`bt`, `bPos`, `bNorm`) werden als Parameter an `waveFocal()` übergeben: + +```glsl +// Neue waveFocal()-Signatur (precomputed values): +float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) + +// Im Segment-Loop (einmal pro Segment, nicht pro Linie): +float bt = bezierClosestT(baseUv, sp, pc, ep); +vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep; +vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc)); +vec2 bNorm = vec2(-bTang.y, bTang.x); +// → alle middleLineCount Aufrufe nutzen dieselben Werte +``` + +Reduktion von O(Segmente × Linien) auf O(Segmente) `bezierClosestT`-Aufrufe pro Pixel. + +--- + +## Fehlende Features (in `.vue` vorhanden, in `.js` nicht) + +### 5. `lineBrightness` Uniform fehlt ✅ Umgesetzt +`lineBrightness` ist jetzt als Konstruktor-Parameter und Uniform vorhanden. Im Shader: + +```glsl +col *= lineBrightness; // vor Background-Composite +``` + +### 6. Kein Pause bei verstecktem Tab ✅ Umgesetzt +Der `requestAnimationFrame`-Loop pausiert jetzt bei `document.hidden`: + +```js +this._handleVisibility = () => { + if (document.hidden) { + cancelAnimationFrame(this.raf) + this.raf = 0 + } else if (!this.raf) { + renderLoop() + } +} +document.addEventListener('visibilitychange', this._handleVisibility) +// destroy() ruft removeEventListener auf +``` + +### 7. Kein adaptives DPR ⏭️ Offen / Optional +Die Vue-Version misst FPS live und reduziert `devicePixelRatio` bei schlechter Performance (vor allem Mobile). Die Dev-Klasse ist ein Test-Tool und nicht für Mobile ausgelegt — diese Komplexität lohnt sich hier nicht. + +--- + +## Code-Qualität + +### 8. Legacy-Code: `background_color()`, `BLACK`, `PINK`, `BLUE` ✅ Umgesetzt +Die Shader-Konstanten `BLACK`, `PINK`, `BLUE` und die Funktion `background_color()` wurden entfernt. Der Hintergrund wird immer über `bgColorCenter`/`bgColorEdge` gesteuert. + +### 9. Redundante `enabledWaves.includes()` Checks ✅ Umgesetzt +Die doppelten Prüfungen in den Hilfsfunktionen wurden entfernt. Die äußere Prüfung im Aufrufer ist die einzige Guard. + +### 10. Hardcoded `* 0.5` in `getLineColor()` ✅ Umgesetzt +Der feste `* 0.5`-Faktor wurde entfernt. `getLineColor()` gibt jetzt die volle Gradient-Farbe zurück. Die Kompensation mit `* 2.5` an den Kreisen wurde auf `* 1.5` angepasst. Die Helligkeit wird über das `lineBrightness`-Uniform gesteuert (→ Punkt 5). + +--- + +## Optionale Verbesserungen / Ideen + +### 11. Glättere Kreise bei höherem DPR ⏭️ Offen / Optional +Der AA-Radius passt sich durch `iResolution` (physische Pixel bei gesetztem DPR) bereits implizit an. Keine Änderung nötig. + +### 12. `pointSpacingX` + `pointsOffsetX` vs. explizite X-Koordinaten ⏭️ Offen / Optional +Die Dev-Klasse behält das Auto-Spacing-Modell für einfache Testzwecke. Die Vue-Komponente nutzt explizite X-Koordinaten für die Lebenszeitlinie. Beide Ansätze sind intentional verschieden. + +### 13. GLSL `precision highp` → `mediump` ✅ Umgesetzt +Fragment-Shader nutzt jetzt `precision mediump float` — ausreichend für diese Visualisierung, effizienter auf Mobile/Low-End. + +--- + +## Neue Punkte (nachträglich ergänzt) + +### 14. Resize-Bug: Kreise und Linien desynchronisieren sich ✅ Umgesetzt +**Betrifft:** `LifeWaveLayout.vue` + +**Ursache:** `layoutResizeObserver` aktualisierte `layoutWidth/Height` sofort, während `TimelineView`s `@view-update` (mit den neuen CSS-Event-Positionen) erst im nächsten Frame kam — führte zu 1-Frame UV-Desync. + +**Fix:** `requestAnimationFrame`-Wrapper im Callback: + +```js +layoutResizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + if (!layoutRef.value) return + layoutWidth.value = layoutRef.value.clientWidth + layoutHeight.value = layoutRef.value.clientHeight + }) +}) +``` + +### 15. Feature: Horizont ✅ Umgesetzt (erweitert) +Statt einer einzelnen Linie wurden **drei wählbare Horizont-Modi** implementiert: + +| Modus | Wert | Beschreibung | +|-------|------|--------------| +| Aus | `'off'` | Kein Horizont-Effekt | +| Nebel | `'fog'` | Leuchtender Band-Effekt auf Y=0, Farbe aus Gradient | +| Trennung | `'split'` | Hintergrund wird vertikal geteilt: `bgColorCenter` oben, `bgColorEdge` unten — mit einstellbarer Blend-Breite | +| Glow | `'glow'` | Weiches + hartes Leuchten auf dem Horizont, Farbe aus Gradient | + +**Shader (alle Modi):** +```glsl +uniform int horizonMode; // 0=off 1=fog 2=split 3=glow +uniform float horizonOpacity; +uniform float horizonBlend; + +if (horizonMode == 1) { + float band = exp(-baseUv.y * baseUv.y * 5.0); + vec3 fogColor = getLineColor(0.5, bg) * 2.0; + col += fogColor * band * horizonOpacity; +} else if (horizonMode == 2) { + float blendW = max(horizonBlend * 0.7, 0.001); + float t = smoothstep(-blendW, blendW, baseUv.y); + bg = mix(bgColorEdge, bgColorCenter, t); +} else if (horizonMode == 3) { + float d2 = baseUv.y * baseUv.y; + float softGlow = exp(-d2 * 10.0); + float coreGlow = exp(-d2 * 70.0) * 0.7; + vec3 glowColor = getLineColor(0.5, bg) * 3.0; + col += glowColor * (softGlow + coreGlow) * horizonOpacity; +} +``` + +**Umgesetzt in:** +- `floating-lines.js` — Shader + Konstruktor-Parameter + Uniforms +- `FloatingLines.vue` — Shader (mit `gradientMid()` Hilfsfunktion) + Props + Uniforms + Watches +- `settings.js` — `horizonMode: 'off'`, `horizonOpacity: 0.5`, `horizonBlend: 0.2` +- `LifeWaveSettings.vue` — Segmented Control + bedingte Slider (Deckkraft / Übergang) +- `LifeWaveLayout.vue` — Props weitergeleitet +- `init-fl.html` — 4 Modus-Buttons + bedingte Slider-Sichtbarkeit + +--- + +## Zusammenfassung Prioritäten + +| # | Typ | Priorität | Aufwand | Status | +|---|-----|-----------|---------|--------| +| 4 | Performance: `bezierClosestT` Hoisting | **Hoch** | Mittel | ✅ | +| 14 | Bug: Resize-Desync (LifeWaveLayout) | **Hoch** | Minimal | ✅ | +| 1 | Bug: totes `bgColor` Uniform | Mittel | Minimal | ✅ | +| 3 | Bug: `bgColorCenter/Edge` nicht setzbar | Mittel | Klein | ✅ | +| 5 | Feature: `lineBrightness` | Mittel | Klein | ✅ | +| 10 | Qualität: hardcoded `* 0.5` | Mittel | Klein | ✅ | +| 15 | Feature: Horizont (3 Modi) | Mittel | Mittel | ✅ | +| 8 | Qualität: Legacy-Code entfernen | Niedrig | Klein | ✅ | +| 6 | Feature: Tab-Pause | Niedrig | Klein | ✅ | +| 2 | Bug: ignorierter Parameter | Niedrig | Minimal | ✅ | +| 9 | Qualität: redundante Checks | Niedrig | Minimal | ✅ | +| 13 | Perf: `mediump` Precision | Optional | Minimal | ✅ | +| 7 | Feature: adaptives DPR | Optional | Groß | ⏭️ | +| 11 | Qualität: AA-Radius explizit | Optional | Minimal | ⏭️ | +| 12 | API: explizite X-Koordinaten | Optional | Mittel | ⏭️ | diff --git a/frontend/dev/floating-lines.js b/frontend/dev/floating-lines.js index 3f81070..f0be204 100644 --- a/frontend/dev/floating-lines.js +++ b/frontend/dev/floating-lines.js @@ -7,7 +7,6 @@ import { ShaderMaterial, Vector3, Vector2, - Clock, } from 'three' const vertexShader = ` @@ -19,7 +18,7 @@ void main() { ` const fragmentShader = ` -precision highp float; +precision mediump float; uniform float iTime; uniform vec3 iResolution; @@ -58,34 +57,24 @@ uniform float bendRadius; uniform float bendStrength; uniform float bendInfluence; +uniform int horizonMode; // 0=off 1=fog 2=split 3=glow +uniform float horizonOpacity; // Nebel + Glow: Helligkeit/Dichte +uniform float horizonBlend; // Trennung: 0=scharf, 1=weicher Übergang + uniform bool parallax; uniform float parallaxStrength; uniform vec2 parallaxOffset; +uniform float lineBrightness; uniform vec3 lineGradient[8]; uniform int lineGradientCount; uniform vec3 bgColorCenter; uniform vec3 bgColorEdge; -const vec3 BLACK = vec3(0.0); -const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0; -const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0; - mat2 rotate(float r) { return mat2(cos(r), sin(r), -sin(r), cos(r)); } -vec3 background_color(vec2 uv) { - vec3 col = vec3(0.0); - - float y = sin(uv.x - 0.2) * 0.3 - 0.1; - float m = uv.y - y; - - col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m))); - col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8))); - return col * 0.5; -} - vec3 getLineColor(float t, vec3 baseColor) { if (lineGradientCount <= 0) { return baseColor; @@ -108,7 +97,7 @@ vec3 getLineColor(float t, vec3 baseColor) { gradientColor = mix(c1, c2, f); } - return gradientColor * 0.5; + return gradientColor; } vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) { @@ -163,25 +152,9 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) { return t; } -float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) { - // Bézier-Kontrollpunkt: Mittelpunkt + senkrechter Versatz - vec2 seg = ep - sp; - float segLen = length(seg); - if (segLen < 0.001) return 0.0; - vec2 segDir = seg / segLen; - vec2 segPerp = vec2(-segDir.y, segDir.x); - vec2 pc = (sp + ep) * 0.5 + segPerp * segLen * bezierCurvature; - - float t = bezierClosestT(uv, sp, pc, ep); - float mt = 1.0 - t; - - // Position und Tangente auf der Kurve - vec2 curvePos = mt*mt*sp + 2.0*mt*t*pc + t*t*ep; - vec2 tang = normalize(2.0*mt*(pc - sp) + 2.0*t*(ep - pc)); - vec2 norm = vec2(-tang.y, tang.x); - - // Senkrechter Abstand von der Kurve - float s = dot(uv - curvePos, norm); +// Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment +float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) { + float s = dot(uv - bPos, bNorm); float time = iTime * animationSpeed; float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5; @@ -227,7 +200,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec3 col = vec3(0.0); - vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv); + vec3 b = bgColorCenter; vec2 mouseUv = vec2(0.0); if (interactive) { @@ -269,33 +242,34 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 sp = vec2(x0, pointY[s]); vec2 ep = vec2(x1, pointY[s + 1]); - // Gradient: globaler t-Bereich [s, s+1] / (numPoints-1) - vec2 pd = ep - sp; - float pl = length(pd); - vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0); - float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.0); + // Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt) + vec2 seg = ep - sp; + float segL = length(seg); + vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0); + vec2 sPerp = vec2(-segDir.y, segDir.x); + vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature; + + // Gradient + float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0); float t_global = (float(s) + t_seg) * tScale; - vec3 lineCol = getLineColor(t_global, b); + vec3 lineCol = getLineColor(t_global, b); - // Bézier-Kontrollpunkt für Nebel (gleiche Logik wie in waveFocal) - vec2 segD = ep - sp; - float segL = length(segD); - vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0); - vec2 sPerp = vec2(-segDir.y, segDir.x); - vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature; + // Bézier einmal pro Segment — geteilt von Nebel + allen Linien + float bt = bezierClosestT(baseUv, sp, pc, ep); + float bmt = 1.0 - bt; + vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep; + vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc)); + vec2 bNorm = vec2(-bTang.y, bTang.x); - // Weicher Nebel entlang der Bézier-Kurve → füllt dunkle Winkel organisch - float bt = bezierClosestT(baseUv, sp, pc, ep); - float bmt = 1.0 - bt; - vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep; - float bDist = length(baseUv - bPos); + // Weicher Nebel entlang der Kurve + float bDist = length(baseUv - bPos); float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt); float fogEnv = sin(bt * 3.14159265359); float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001); col += lineCol * segFog; for (int i = 0; i < middleLineCount; ++i) { - col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep); + col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm); } } @@ -304,7 +278,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { if (p >= numPoints) break; float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX; float t_pt = numPoints > 1 ? float(p) * tScale : 0.0; - vec3 circCol = getLineColor(t_pt, b) * 2.5; + vec3 circCol = getLineColor(t_pt, b) * 1.5; col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol); } } @@ -328,9 +302,33 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { } } + col *= lineBrightness; + // Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand) float dist = length(baseUv) / 1.8; vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0)); + + if (horizonMode == 1) { + // Nebel: breites weiches Gaussband in Gradient-Mittelfarbe + float band = exp(-baseUv.y * baseUv.y * 5.0); + vec3 fogColor = getLineColor(0.5, bg) * 2.0; + col += fogColor * band * horizonOpacity; + } else if (horizonMode == 2) { + // Farbtrennung: vertikaler Split an Y=0 + // bgColorCenter → oben (positives UV-Y), bgColorEdge → unten + // horizonBlend: 0=harter Schnitt, 1=sehr weicher Übergang + float blendW = max(horizonBlend * 0.7, 0.001); + float t = smoothstep(-blendW, blendW, baseUv.y); + bg = mix(bgColorEdge, bgColorCenter, t); + } else if (horizonMode == 3) { + // Glow: konzentriertes Leuchten in Gradient-Mittelfarbe + float d2 = baseUv.y * baseUv.y; + float softGlow = exp(-d2 * 10.0); + float coreGlow = exp(-d2 * 70.0) * 0.7; + vec3 glowColor = getLineColor(0.5, bg) * 3.0; + col += glowColor * (softGlow + coreGlow) * horizonOpacity; + } + fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0); } @@ -376,7 +374,6 @@ export default class FloatingLines { lineCount = [6], lineDistance = [5], topWavePosition, - middleWavePosition, bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 }, numPoints = 4, pointSpacingX = 0.8, @@ -389,6 +386,7 @@ export default class FloatingLines { bezierCurvature = 0.3, circleRadiusPx = 50, animationSpeed = 1, + lineBrightness = 1.0, interactive = true, bendRadius = 5.0, bendStrength = -0.5, @@ -397,6 +395,11 @@ export default class FloatingLines { parallaxStrength = 0.2, circleGlowSize = 18.0, circleGlowStrength = 1.5, + horizonMode = 'off', + horizonOpacity = 0.5, + horizonBlend = 0.2, + bgColorCenter = '#0a0514', + bgColorEdge = '#000000', mixBlendMode = 'screen', } = {}, ) { @@ -427,14 +430,12 @@ export default class FloatingLines { return lineDistance[index] ?? 0.1 } - const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0 - const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0 - const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0 + const topLineCount = getLineCount('top') + const middleLineCount = getLineCount('middle') + const bottomLineCount = getLineCount('bottom') - const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01 - const bottomLineDistance = enabledWaves.includes('bottom') - ? getLineDistance('bottom') * 0.01 - : 0.01 + const topLineDistance = getLineDistance('top') * 0.01 + const bottomLineDistance = getLineDistance('bottom') * 0.01 this.scene = new Scene() this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1) @@ -451,6 +452,7 @@ export default class FloatingLines { iTime: { value: 0 }, iResolution: { value: new Vector3(1, 1, 1) }, animationSpeed: { value: animationSpeed }, + lineBrightness: { value: lineBrightness }, enableTop: { value: enabledWaves.includes('top') }, enableMiddle: { value: enabledWaves.includes('middle') }, @@ -497,6 +499,10 @@ export default class FloatingLines { bendStrength: { value: bendStrength }, bendInfluence: { value: 0 }, + horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 }, + horizonOpacity: { value: horizonOpacity }, + horizonBlend: { value: horizonBlend }, + parallax: { value: parallax }, parallaxStrength: { value: parallaxStrength }, parallaxOffset: { value: new Vector2(0, 0) }, @@ -505,7 +511,8 @@ export default class FloatingLines { value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)), }, lineGradientCount: { value: 0 }, - bgColor: { value: new Vector3(0, 0, 0) }, + bgColorCenter: { value: new Vector3(0, 0, 0) }, + bgColorEdge: { value: new Vector3(0, 0, 0) }, } if (linesGradient && linesGradient.length > 0) { @@ -517,6 +524,11 @@ export default class FloatingLines { }) } + const center = hexToVec3(bgColorCenter) + this.uniforms.bgColorCenter.value.set(center.x, center.y, center.z) + const edge = hexToVec3(bgColorEdge) + this.uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z) + const material = new ShaderMaterial({ uniforms: this.uniforms, vertexShader, @@ -529,7 +541,7 @@ export default class FloatingLines { this.geometry = geometry this.material = material - this.clock = new Clock() + this._startTime = performance.now() this._setSize = () => { const width = container.clientWidth || 1 @@ -571,7 +583,7 @@ export default class FloatingLines { this.raf = 0 const renderLoop = () => { - this.uniforms.iTime.value = this.clock.getElapsedTime() + this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001 if (this.interactive) { this.currentMouse.lerp(this.targetMouse, this.mouseDamping) @@ -589,12 +601,24 @@ export default class FloatingLines { this.renderer.render(this.scene, this.camera) this.raf = requestAnimationFrame(renderLoop) } + + this._handleVisibility = () => { + if (document.hidden) { + cancelAnimationFrame(this.raf) + this.raf = 0 + } else if (!this.raf) { + renderLoop() + } + } + document.addEventListener('visibilitychange', this._handleVisibility) + renderLoop() } destroy() { cancelAnimationFrame(this.raf) if (this.ro) this.ro.disconnect() + document.removeEventListener('visibilitychange', this._handleVisibility) this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove) this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave) diff --git a/frontend/dev/init-fl.html b/frontend/dev/init-fl.html index c253d8f..ba0171a 100644 --- a/frontend/dev/init-fl.html +++ b/frontend/dev/init-fl.html @@ -50,7 +50,7 @@ border-top: 1px solid #222; padding: 10px 14px; display: grid; - grid-template-columns: 1fr 1fr 2fr 1.2fr; + grid-template-columns: 1fr 1fr 2fr 0.8fr 1.2fr; gap: 10px 16px; max-height: 230px; overflow-y: auto; @@ -287,6 +287,27 @@ + +
+

Horizont

+
+ + + + +
+
+ + + 0.50 +
+ +
+

Hintergrundbild

@@ -501,6 +522,27 @@ hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge) }) + // ── Horizont ───────────────────────────────────────────────────── + const rowOpacity = document.getElementById('row-horizonOpacity') + const rowBlend = document.getElementById('row-horizonBlend') + + function updateHorizonRows(mode) { + rowOpacity.style.display = mode === 2 ? 'none' : 'flex' + rowBlend.style.display = mode === 2 ? 'flex' : 'none' + } + + document.querySelectorAll('[data-mode]').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('[data-mode]').forEach((b) => b.classList.remove('active')) + btn.classList.add('active') + const mode = parseInt(btn.dataset.mode) + fl.uniforms.horizonMode.value = mode + updateHorizonRows(mode) + }) + }) + slider('horizonOpacity', 2, (v) => (fl.uniforms.horizonOpacity.value = v)) + slider('horizonBlend', 2, (v) => (fl.uniforms.horizonBlend.value = v)) + // ── Gradient ────────────────────────────────────────────────────── const MAX_STOPS = 8 function applyGradient() { diff --git a/frontend/src/components/AppSettingsModal.vue b/frontend/src/components/AppSettingsModal.vue index 09ab13b..e4443b6 100644 --- a/frontend/src/components/AppSettingsModal.vue +++ b/frontend/src/components/AppSettingsModal.vue @@ -74,6 +74,44 @@
+ +
+ Emotionsverlauf +
+
+
+
+ Start + +
+ +
+ Ende + +
+
+
+
+
+
Sprache @@ -100,7 +138,7 @@ :model-value="settingsStore.showFps" @update:model-value="v => { settingsStore.showFps = v }" dense - color="green" + color="primary" />
@@ -146,14 +184,18 @@ const tabs = [ const currentAccentHex = computed(() => { const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor) - return found?.hex ?? '#9e9e9e' + return found?.hex ?? '#737373' }) const currentAccentLabel = computed(() => { const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor) - return found?.label ?? 'Standard' + return found?.label ?? 'Base' }) +const emotionGradientCss = computed(() => + `linear-gradient(90deg, ${settingsStore.emotionGradientStart} 0%, ${settingsStore.emotionGradientEnd} 100%)` +) + function selectAccent(value) { settingsStore.accentColor = value accentDropdownOpen.value = false @@ -198,6 +240,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true }) min-height: 40px; } +.settings-row--stack { + align-items: flex-start; + flex-direction: column; + gap: 10px; +} + .settings-row__label { font-size: 15px; } @@ -242,6 +290,7 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true }) padding: 6px 10px; border-radius: 8px; cursor: pointer; + border-color: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.35); } .settings-accent-btn--dark { @@ -294,12 +343,53 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true }) } .settings-dropdown__item:hover { - background: rgba(128, 128, 128, 0.1); + background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.12); } .settings-dropdown__check { margin-left: auto; opacity: 0.7; + color: var(--tm-accent, #737373); +} + +.settings-gradient { + width: 100%; +} + +.settings-gradient__preview { + height: 10px; + border-radius: 999px; + border: 1px solid rgba(128, 128, 128, 0.2); + margin-bottom: 10px; +} + +.settings-gradient__controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.settings-gradient__control { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + opacity: 0.7; +} + +.settings-gradient__control--right { + justify-content: flex-end; +} + +.settings-color-input { + width: 34px; + height: 22px; + border: none; + background: transparent; + padding: 0; + cursor: pointer; + border-radius: 6px; } /* Placeholder text */ diff --git a/frontend/src/components/EventPanel.vue b/frontend/src/components/EventPanel.vue index ea0d2de..5f57d38 100644 --- a/frontend/src/components/EventPanel.vue +++ b/frontend/src/components/EventPanel.vue @@ -20,12 +20,43 @@
- Key Image + + +
+ + +
-
+
Key Image hinzufügen
+
@@ -38,22 +69,42 @@ :dark="isDark" /> - -
- - {{ formattedDate }} - - +
+ Datum + + + + +
+ +
+ Ort + - + > + + +
- +
Emotional Level @@ -85,28 +136,26 @@ Sehr positiv
- -
- Farbverlauf -
-
- -
- -
+
+
+ Punktfarbe (optional) + +
+
+
@@ -129,10 +178,41 @@
Weitere Medien
-
+
+ + + +
+
+
@@ -149,6 +229,60 @@ />
+ + + + +
Key Image entfernen?
+
+ Das Key Image wird endgültig aus diesem Event entfernt. +
+
+ + + + + +
+
+ + + +
@@ -156,12 +290,14 @@ @@ -311,8 +572,12 @@ function onAddMedia() { position: absolute; top: 12px; left: 12px; + max-width: min(180px, calc(100% - 24px)); + border: 1px solid rgba(255, 255, 255, 0.16); + outline: none; background: rgba(0, 0, 0, 0.55); color: #fff; + font-family: inherit; font-size: 11px; font-weight: 600; padding: 4px 10px; @@ -320,6 +585,56 @@ function onAddMedia() { backdrop-filter: blur(4px); } +.event-panel__image-badge::placeholder { + color: rgba(255, 255, 255, 0.78); +} + +.event-panel__image-actions { + position: absolute; + right: 12px; + bottom: 12px; + display: flex; + gap: 8px; +} + +.event-panel__image-action { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.48); + color: #fff; + font: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; + backdrop-filter: blur(6px); +} + +.event-panel__image-gallery { + position: absolute; + top: 12px; + right: 12px; + width: 36px; + height: 36px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: rgba(0, 0, 0, 0.48); + color: #fff; + cursor: pointer; + backdrop-filter: blur(6px); +} + +.event-panel__image-action--danger { + background: rgba(150, 35, 35, 0.62); +} + .event-panel__image-placeholder { display: flex; flex-direction: column; @@ -339,6 +654,34 @@ function onAddMedia() { opacity: 0.8; } +.event-panel__file-input { + display: none; +} + +.event-panel__confirm { + width: min(360px, calc(100vw - 32px)); + border-radius: 18px; + background: rgba(255, 255, 255, 0.96); + color: #1a1a1a; +} + +.event-panel__confirm--dark { + background: rgba(30, 30, 30, 0.96); + color: #f5f5f5; +} + +.event-panel__confirm-title { + font-size: 18px; + font-weight: 700; +} + +.event-panel__confirm-copy { + margin-top: 6px; + font-size: 13px; + line-height: 1.4; + opacity: 0.72; +} + /* Title */ .event-panel__title { margin-bottom: 0; @@ -350,27 +693,65 @@ function onAddMedia() { line-height: 1.3; } -/* Date row */ -.event-panel__date-row { +.event-panel__meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 6px 0 12px; +} + +.event-panel__meta-item { + background: rgba(128, 128, 128, 0.08); + border: 1px solid rgba(128, 128, 128, 0.14); + border-radius: 10px; + padding: 8px 10px; + min-height: 56px; +} + +.event-panel__meta-label { + display: block; + font-size: 11px; + font-weight: 600; + opacity: 0.55; + margin-bottom: 4px; +} + +.event-panel__date-btn { + width: 100%; + border: none; + background: transparent; + color: inherit; + font-family: inherit; + padding: 0; display: flex; align-items: center; gap: 6px; - margin-bottom: 20px; - opacity: 0.6; + text-align: left; cursor: pointer; - transition: opacity 0.2s; -} - -.event-panel__date-row:hover { - opacity: 0.9; } .event-panel__date-icon { - flex-shrink: 0; + opacity: 0.7; } .event-panel__date-label { - font-size: 14px; + font-size: 13px; + font-weight: 500; +} + +.event-panel__location-input { + margin-top: -2px; +} + +.event-panel__location-input :deep(.q-field__prepend) { + margin-right: 4px; + color: inherit; + opacity: 0.65; +} + +.event-panel__location-input :deep(.q-field__native) { + font-size: 13px; + font-weight: 500; } /* Card sections */ @@ -447,51 +828,39 @@ function onAddMedia() { margin-top: 4px; } -/* Gradient Preset Selector */ -.event-panel__presets { +.event-panel__gradient-edit { margin-top: 16px; border-top: 1px solid rgba(128, 128, 128, 0.1); padding-top: 12px; } +.event-panel__gradient-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + .event-panel__presets-label { font-size: 11px; font-weight: 600; opacity: 0.5; - display: block; - margin-bottom: 8px; } -.event-panel__presets-grid { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.event-panel__preset { - width: 45px; - height: 25px; +.event-panel__color-input { + width: 44px; + height: 24px; + border: none; border-radius: 6px; + padding: 0; + background: transparent; cursor: pointer; - border: 2px solid #eee; - transition: border-color 0.2s, transform 0.15s; } -.event-panel__preset:hover { - transform: scale(1.1); -} - -.event-panel__preset--active { - border-color: currentColor; - transform: scale(1.1); - box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3); -} - -.event-panel__preset--none { - background: rgba(128, 128, 128, 0.15); +.event-panel__gradient-actions { display: flex; - align-items: center; - justify-content: center; + justify-content: flex-end; } /* Note */ @@ -502,15 +871,66 @@ function onAddMedia() { /* Media grid */ .event-panel__media-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.event-panel__media-item { + position: relative; + aspect-ratio: 1; + border-radius: 10px; + overflow: hidden; + background: rgba(128, 128, 128, 0.12); +} + +.event-panel__media-img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.event-panel__media-remove { + position: absolute; + top: 8px; + right: 8px; + width: 30px; + height: 30px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 50%; display: flex; - gap: 8px; - margin-top: 8px; - flex-wrap: wrap; + align-items: center; + justify-content: center; + padding: 0; + background: rgba(0, 0, 0, 0.58); + color: #fff; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.event-panel__media-open { + position: absolute; + top: 8px; + left: 8px; + width: 30px; + height: 30px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: rgba(0, 0, 0, 0.58); + color: #fff; + cursor: pointer; + backdrop-filter: blur(4px); } .event-panel__media-add { - width: 64px; - height: 64px; + min-height: 128px; + aspect-ratio: 1; border-radius: 10px; border: 2px dashed rgba(128, 128, 128, 0.2); display: flex; @@ -520,10 +940,72 @@ function onAddMedia() { transition: border-color 0.2s; } +@media (max-width: 380px) { + .event-panel__media-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .event-panel__media-add { + min-height: 112px; + } +} + +@media (min-width: 640px) { + .event-panel__media-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + .event-panel__media-add:hover { border-color: rgba(128, 128, 128, 0.4); } +.event-panel__gallery { + position: fixed; + inset: 0; + background: #000; + color: #fff; +} + +.event-panel__gallery-close { + position: fixed; + top: max(16px, env(safe-area-inset-top)); + right: 16px; + z-index: 2; + width: 44px; + height: 44px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: rgba(0, 0, 0, 0.48); + color: #fff; + cursor: pointer; + backdrop-filter: blur(8px); +} + +.event-panel__gallery-carousel { + width: 100%; + height: 100dvh; + background: #000; +} + +.event-panel__gallery-slide { + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.event-panel__gallery-img { + width: 100%; + height: 100%; + object-fit: contain; +} + /* Delete */ .event-panel__delete { display: flex; diff --git a/frontend/src/components/FloatingLines.vue b/frontend/src/components/FloatingLines.vue index 054bdd3..e0a00ff 100644 --- a/frontend/src/components/FloatingLines.vue +++ b/frontend/src/components/FloatingLines.vue @@ -29,6 +29,7 @@ const props = defineProps({ lineSpread: { type: Number, default: 0.05 }, fanSpread: { type: Number, default: 0.05 }, lineSharpness: { type: Number, default: 8.0 }, + lineThickness: { type: Number, default: 1.0 }, waveFrequency: { type: Number, default: 7.0 }, bezierCurvature: { type: Number, default: 0.2 }, circleRadiusPx: { type: Number, default: 75 }, @@ -43,7 +44,13 @@ const props = defineProps({ bgColorEdge: { type: String, default: '#000000' }, backgroundImage: { type: String, default: '' }, mixBlendMode: { type: String, default: 'screen' }, - parallax: { type: Boolean, default: false } + parallax: { type: Boolean, default: false }, + horizonMode: { type: String, default: 'off' }, + horizonOpacity: { type: Number, default: 0.5 }, + horizonBlend: { type: Number, default: 0.2 }, + lineMode: { type: String, default: 'glow' }, // 'glow' | 'static' + staticLineColor: { type: String, default: '#2196F3' }, + staticLineShadowStrength: { type: Number, default: 0 } }) // FPS display @@ -71,7 +78,7 @@ void main() { ` const fragmentShader = ` -precision mediump float; +precision highp float; uniform float iTime; uniform vec3 iResolution; @@ -84,18 +91,36 @@ uniform float pointY[16]; uniform float lineSpread; uniform float fanSpread; uniform float lineSharpness; +uniform float lineThickness; uniform float waveFrequency; uniform float bezierCurvature; uniform float lineBrightness; uniform vec3 pointColor[16]; +uniform int horizonMode; // 0=off 1=fog 2=split 3=glow +uniform float horizonOpacity; +uniform float horizonBlend; + +uniform int lineMode; // 0=glow 1=static +uniform vec3 staticLineColor; +uniform float staticLineShadowStrength; + uniform bool parallax; uniform vec2 parallaxOffset; uniform vec3 lineGradient[8]; -uniform int lineGradientCount; +uniform int lineGradientCount; uniform vec3 bgColorCenter; uniform vec3 bgColorEdge; +uniform int hasBackgroundImage; + +vec3 gradientMid() { + if (lineGradientCount <= 0) return vec3(0.45, 0.5, 0.8); + if (lineGradientCount == 1) return lineGradient[0]; + float t = 0.4999 * float(lineGradientCount - 1); + int idx = int(t); + return mix(lineGradient[idx], lineGradient[min(idx + 1, lineGradientCount - 1)], fract(t)); +} float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) { float bestT = 0.0; @@ -126,7 +151,6 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) { return t; } -// Accepts precomputed bezier values (t, curvePos, norm) — computed once per segment float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) { float s = dot(uv - curvePos, norm); @@ -139,10 +163,34 @@ float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp * sin(fi * 0.9 + time * 0.18); + float widthScale = max(lineThickness, 0.1); float dist = s - linePos - waveDisp; + float scaledDist = abs(dist) * lineSharpness / widthScale; + float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t); + float tailMask = smoothstep(1.35, 0.02, scaledDist); + float core = 0.013 / max(scaledDist + 0.004, 1e-4); + float aura = 0.003 * widthScale * smoothstep(0.42, 0.06, scaledDist); + + return fade * tailMask * core + fade * aura; +} + +float staticLineFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) { + float s = dot(uv - curvePos, norm); + + float time = iTime * animationSpeed; + float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5; + + float envelope = sin(t * 3.14159265359); + float linePos = (normalizedI - 0.5) * fanSpread * envelope; + float amp = lineSpread * 0.3 * envelope; + float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp + * sin(fi * 0.9 + time * 0.18); + + float widthScale = max(lineThickness, 0.1); + float dist = abs(s - linePos - waveDisp) * lineSharpness / widthScale; float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t); - return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003); + return fade * smoothstep(0.022, 0.012, dist); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { @@ -153,9 +201,9 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { baseUv += parallaxOffset; } - vec3 col = vec3(0.0); + vec3 col = vec3(0.0); + float totalIntensity = 0.0; - const int MAX_PTS = 16; const int MAX_SEGS = 15; for (int s = 0; s < MAX_SEGS; ++s) { @@ -166,36 +214,105 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 segD = ep - sp; float segL = length(segD); - vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0); + if (segL < 1e-4) continue; + vec2 segDir = segD / segL; vec2 sPerp = vec2(-segDir.y, segDir.x); vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature; - float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0); - vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg); + float t_seg = clamp(dot(baseUv - sp, segDir) / max(segL, 1e-4), 0.0, 1.0); + vec3 lineCol = lineMode == 1 + ? staticLineColor + : mix(pointColor[s], pointColor[s + 1], t_seg); - // bezierClosestT computed ONCE per segment — shared by fog + all lines float bt = bezierClosestT(baseUv, sp, pc, ep); float bmt = 1.0 - bt; vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep; - vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc)); + vec2 bTangRaw = bmt*(pc - sp) + bt*(ep - pc); + float bTangLen = length(bTangRaw); + vec2 bTang = bTangLen > 1e-4 ? bTangRaw / bTangLen : segDir; vec2 bNorm = vec2(-bTang.y, bTang.x); - float bDist = length(baseUv - bPos); - float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt); - float fogEnv = sin(bt * 3.14159265359); - float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001); - col += lineCol * segFog; + float shadowStrength = clamp(staticLineShadowStrength, 0.0, 1.0); + if (lineMode != 1 || shadowStrength > 0.0) { + float bDist = length(baseUv - bPos); + float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt); + float fogEnv = sin(bt * 3.14159265359); + float fogVal = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001); + float fogScale = lineMode == 1 ? shadowStrength : 1.0; + col += lineCol * fogVal * fogScale; + totalIntensity += fogVal * fogScale; + } for (int i = 0; i < middleLineCount; ++i) { - col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm); + float glowFv = waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm); + float fv = glowFv; + if (lineMode == 1) { + float crispFv = staticLineFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm); + fv = mix(crispFv, glowFv, shadowStrength); + } + col += lineCol * fv; + totalIntensity += fv; } } - col *= lineBrightness; + col *= lineBrightness; + totalIntensity *= lineBrightness; + if (lineMode != 1) { + // Drop only the distant low-energy glow tails that accumulate into cloudy artifacts. + float glowPeak = max(col.r, max(col.g, col.b)); + float cleanupMask = smoothstep(0.018, 0.055, glowPeak); + col *= cleanupMask; + totalIntensity *= cleanupMask; + } + + // Radial background — shared by both modes float dist = length(baseUv) / 1.8; vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0)); - fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0); + + // "Split" horizon changes the background orientation (vertical) in both modes. + if (horizonMode == 2) { + float blendW = max(horizonBlend * 0.7, 0.001); + float t = smoothstep(-blendW, blendW, baseUv.y); + bg = mix(bgColorEdge, bgColorCenter, t); + } + + if (lineMode == 1) { + // Static Lines: radial bg + lines mixed by intensity (no additive glow) + float staticMix = clamp(totalIntensity, 0.0, 1.0); + vec3 staticCol = mix(bg, staticLineColor, staticMix); + if (hasBackgroundImage == 1) { + // Keep background image visible by outputting line-only color with alpha. + float alpha = staticMix < 0.03 ? 0.0 : staticMix; + fragColor = vec4(staticLineColor * alpha, alpha); + return; + } + fragColor = vec4(staticCol, 1.0); + return; + } + + // Glowing Lines: additive line colors + horizon effects + + if (horizonMode == 1) { + float band = exp(-baseUv.y * baseUv.y * 5.0); + col += gradientMid() * band * horizonOpacity; + } else if (horizonMode == 3) { + float d2 = baseUv.y * baseUv.y; + float softGlow = exp(-d2 * 10.0); + float coreGlow = exp(-d2 * 70.0) * 0.7; + col += gradientMid() * (softGlow + coreGlow) * horizonOpacity; + } + + vec3 composed = clamp(bg + col, 0.0, 1.0); + if (hasBackgroundImage == 1) { + // Render only glowing lines above CSS background image. + vec3 lineOnly = clamp(col, 0.0, 1.0); + float alpha = clamp(max(lineOnly.r, max(lineOnly.g, lineOnly.b)), 0.0, 1.0); + alpha = alpha < 0.03 ? 0.0 : alpha; + fragColor = vec4(lineOnly * alpha, alpha); + return; + } + fragColor = vec4(composed, 1.0); } void main() { @@ -238,8 +355,14 @@ let rafId = null let resizeObserver = null let uniforms = null let scrollHandler = null +let boundScrollContainer = null +let cachedScrollLeft = 0 let scrollIdleTimer = null let visibilityHandler = null +// Exposed hook so parent can trigger a hard canvas resize sync. +let forceResizeHandler = null +let pointerMoveHandler = null +let pointerTargetEl = null // Parallax tracking let targetParallax = null @@ -282,6 +405,11 @@ function applyBgColors() { uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z) } +function applyBackgroundImageFlag() { + if (!uniforms) return + uniforms.hasBackgroundImage.value = props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0 +} + // Watch all props for live updates watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v }) watch(() => props.lineCount, () => { @@ -291,6 +419,7 @@ watch(() => props.lineCount, () => { watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v }) watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v }) watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v }) +watch(() => props.lineThickness, (v) => { if (uniforms) uniforms.lineThickness.value = v }) watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v }) watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v }) watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v }) @@ -311,6 +440,33 @@ watch(() => props.pointColors, applyPointColors, { deep: true }) watch(() => props.linesGradient, applyGradient, { deep: true }) watch(() => props.bgColorCenter, applyBgColors) watch(() => props.bgColorEdge, applyBgColors) +watch(() => props.backgroundImage, applyBackgroundImageFlag) +watch(() => props.horizonMode, (v) => { + if (uniforms) uniforms.horizonMode.value = ({ off: 0, fog: 1, split: 2, glow: 3 })[v] ?? 0 +}) +watch(() => props.horizonOpacity, (v) => { if (uniforms) uniforms.horizonOpacity.value = v }) +watch(() => props.horizonBlend, (v) => { if (uniforms) uniforms.horizonBlend.value = v }) +watch(() => props.lineMode, (v) => { + if (uniforms) uniforms.lineMode.value = v === 'static' ? 1 : 0 +}) +watch(() => props.staticLineColor, (v) => { + if (uniforms) { const c = hexToVec3(v); uniforms.staticLineColor.value.set(c.x, c.y, c.z) } +}) +watch(() => props.staticLineShadowStrength, (v) => { + if (uniforms) uniforms.staticLineShadowStrength.value = v +}) + +watch(() => props.scrollContainer, (nextEl, prevEl) => { + if (prevEl && scrollHandler) { + prevEl.removeEventListener('scroll', scrollHandler) + if (boundScrollContainer === prevEl) boundScrollContainer = null + } + if (nextEl && scrollHandler) { + cachedScrollLeft = nextEl.scrollLeft || 0 + nextEl.addEventListener('scroll', scrollHandler, { passive: true }) + boundScrollContainer = nextEl + } +}) onMounted(() => { if (!containerRef.value) return @@ -331,8 +487,9 @@ onMounted(() => { let currentDpr = DPR_IDLE let scrolling = false - renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' }) + renderer = new WebGLRenderer({ antialias: !isMobile, alpha: true, powerPreference: 'high-performance' }) renderer.setPixelRatio(currentDpr) + renderer.setClearAlpha(0) renderer.domElement.style.width = '100%' renderer.domElement.style.height = '100%' renderer.domElement.style.display = 'block' @@ -357,6 +514,7 @@ onMounted(() => { lineSpread: { value: props.lineSpread }, fanSpread: { value: props.fanSpread }, lineSharpness: { value: props.lineSharpness }, + lineThickness: { value: props.lineThickness }, waveFrequency: { value: props.waveFrequency }, bezierCurvature: { value: props.bezierCurvature }, lineBrightness: { value: props.lineBrightness }, @@ -364,6 +522,14 @@ onMounted(() => { value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1)) }, + horizonMode: { value: ({ off: 0, fog: 1, split: 2, glow: 3 })[props.horizonMode] ?? 0 }, + horizonOpacity: { value: props.horizonOpacity }, + horizonBlend: { value: props.horizonBlend }, + + lineMode: { value: props.lineMode === 'static' ? 1 : 0 }, + staticLineColor: { value: hexToVec3(props.staticLineColor) }, + staticLineShadowStrength: { value: props.staticLineShadowStrength }, + parallax: { value: props.parallax }, parallaxOffset: { value: new Vector2(0, 0) }, @@ -372,12 +538,14 @@ onMounted(() => { }, lineGradientCount: { value: 0 }, bgColorCenter: { value: new Vector3(0, 0, 0) }, - bgColorEdge: { value: new Vector3(0, 0, 0) } + bgColorEdge: { value: new Vector3(0, 0, 0) }, + hasBackgroundImage: { value: props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0 } } // Apply initial values applyGradient() applyBgColors() + applyBackgroundImageFlag() applyPointColors() material = new ShaderMaterial({ @@ -402,6 +570,7 @@ onMounted(() => { const canvasHeight = renderer.domElement.height uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1) } + forceResizeHandler = setSize setSize() resizeObserver = new ResizeObserver(setSize) @@ -409,7 +578,7 @@ onMounted(() => { // Pointer events (parallax only) if (props.parallax) { - const handlePointerMove = (event) => { + pointerMoveHandler = (event) => { const rect = renderer.domElement.getBoundingClientRect() const x = event.clientX - rect.left const y = event.clientY - rect.top @@ -420,11 +589,11 @@ onMounted(() => { (-(y - centerY) / rect.height) * 0.2 ) } - renderer.domElement.addEventListener('pointermove', handlePointerMove) + pointerTargetEl = renderer.domElement + pointerTargetEl.addEventListener('pointermove', pointerMoveHandler) } // Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction. - let cachedScrollLeft = 0 function setDpr(dpr) { if (dpr === currentDpr) return @@ -458,6 +627,7 @@ onMounted(() => { if (props.scrollContainer) { cachedScrollLeft = props.scrollContainer.scrollLeft || 0 props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }) + boundScrollContainer = props.scrollContainer } // Fast inline scroll sync — reads cached scrollLeft instead of DOM during render @@ -519,8 +689,8 @@ onMounted(() => { dprDisplay.value = currentDpr.toFixed(2) } - // Read latest scrollLeft from DOM in case scroll event was missed - if (props.scrollContainer) { + // Fallback read only when no listener is bound. + if (!boundScrollContainer && props.scrollContainer) { cachedScrollLeft = props.scrollContainer.scrollLeft || 0 } @@ -539,15 +709,23 @@ onMounted(() => { renderLoop() }) -defineExpose({ fpsDisplay, dprDisplay }) +function forceResize() { + if (forceResizeHandler) forceResizeHandler() +} + +defineExpose({ fpsDisplay, dprDisplay, forceResize }) onBeforeUnmount(() => { if (rafId) cancelAnimationFrame(rafId) if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler) if (resizeObserver) resizeObserver.disconnect() - if (props.scrollContainer && scrollHandler) { - props.scrollContainer.removeEventListener('scroll', scrollHandler) + if (boundScrollContainer && scrollHandler) { + boundScrollContainer.removeEventListener('scroll', scrollHandler) } + boundScrollContainer = null + if (pointerTargetEl && pointerMoveHandler) pointerTargetEl.removeEventListener('pointermove', pointerMoveHandler) + pointerTargetEl = null + pointerMoveHandler = null clearTimeout(scrollIdleTimer) if (geometry) geometry.dispose() if (material) material.dispose() @@ -557,6 +735,7 @@ onBeforeUnmount(() => { renderer.domElement.parentNode.removeChild(renderer.domElement) } } + forceResizeHandler = null }) diff --git a/frontend/src/components/GlowDot.vue b/frontend/src/components/GlowDot.vue index 627de28..b5a154f 100644 --- a/frontend/src/components/GlowDot.vue +++ b/frontend/src/components/GlowDot.vue @@ -8,9 +8,14 @@ 'glow-dot--label-above': labelAbove }" :style="dotStyle" + :role="isGhost ? undefined : 'button'" + :tabindex="isGhost ? -1 : 0" + :aria-label="dotAriaLabel" @click.stop="onSelect" + @keydown.enter.prevent.stop="onSelect" + @keydown.space.prevent.stop="onSelect" > -
+
+ {{ event.title }} {{ formattedDate }}
@@ -43,9 +49,9 @@ const eventsStore = useEventsStore() const settingsStore = useSettingsStore() // Resolve image: cached thumbnail from IndexedDB or fetch & cache -const { resolvedSrc: imageSrc } = props.event.image - ? useImageCache(props.event.image, props.event.id) - : { resolvedSrc: computed(() => null) } +const imageUrl = computed(() => props.event.image || null) +const eventId = computed(() => props.event.id) +const { resolvedSrc: imageSrc } = useImageCache(imageUrl, eventId) const fl = computed(() => settingsStore.floatingLines) const glowColor = computed(() => eventsStore.getGlowColor(props.event)) @@ -67,19 +73,42 @@ const formattedDate = computed(() => { const d = new Date(props.event.date) return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}` }) +const dotAriaLabel = computed(() => { + if (props.isGhost) return undefined + const title = props.event.title || 'Event' + return `${title}, ${formattedDate.value}` +}) // Label font sizes per setting -const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } } +const LABEL_FONT = { + small: { title: 10, date: 9 }, + medium: { title: 12, date: 11 }, + large: { title: 14, date: 13 }, + xlarge: { title: 18, date: 16 } +} const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small) const labelColor = computed(() => fl.value.labelColor ?? '#ffffff') +const labelOpacity = computed(() => Math.max(0.5, Math.min(1, fl.value.labelOpacity ?? 0.75))) +const connectorLengthScale = computed(() => Math.max(0, Math.min(1, fl.value.labelConnectorLength ?? 0.2))) +// 0 -> no connector, 1 -> ~5x current connector length (about 70px) +const connectorLengthPx = computed(() => connectorLengthScale.value * 70) +const labelGapPx = computed(() => 4 + connectorLengthPx.value) const labelStyle = computed(() => ({ - maxWidth: labelFont.value.title >= 14 ? '120px' : '90px' + maxWidth: labelFont.value.title >= 18 ? '150px' : labelFont.value.title >= 14 ? '120px' : '90px', + '--label-gap': `${labelGapPx.value}px`, + '--label-connector-len': `${connectorLengthPx.value}px`, + '--label-opacity': labelOpacity.value.toFixed(2), + '--label-connector-color': labelColor.value, + '--label-connector-opacity': Math.max(0.45, labelOpacity.value * 0.9).toFixed(2) +})) +const connectorStyle = computed(() => ({ + display: connectorLengthPx.value <= 0.5 ? 'none' : 'block' })) const titleStyle = computed(() => ({ fontSize: `${labelFont.value.title}px`, color: labelColor.value, - maxWidth: labelFont.value.title >= 14 ? '120px' : '90px' + maxWidth: labelFont.value.title >= 18 ? '150px' : labelFont.value.title >= 14 ? '120px' : '90px' })) const dateStyle = computed(() => ({ fontSize: `${labelFont.value.date}px`, @@ -93,14 +122,18 @@ const dotStyle = computed(() => ({ height: `${dotSize.value}px` })) -// Two-layer box-shadow: tight bright core + wide soft halo -const glowShadow = computed(() => { - const size = fl.value.glowSize +const innerStyle = computed(() => { + const size = fl.value.glowSize const strength = fl.value.glowStrength - const color = glowColor.value - const core = alphaHex(Math.min(strength / 3, 1)) - const halo = alphaHex(Math.min(strength / 7, 1)) - return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}` + const color = glowColor.value + const core = alphaHex(Math.min(strength / 3, 1)) + const halo = alphaHex(Math.min(strength / 7, 1)) + const shadow = `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}` + const bw = fl.value.dotBorderWidth ?? 0 + return { + boxShadow: shadow, + border: bw > 0 ? `${bw}px solid ${fl.value.dotBorderColor ?? '#ffffff'}` : 'none' + } }) function alphaHex(a) { @@ -163,25 +196,41 @@ function onSelect() { position: absolute; left: 50%; transform: translateX(-50%); - top: calc(100% + 6px); + top: calc(100% + var(--label-gap, 18px)); display: flex; flex-direction: column; align-items: center; gap: 1px; max-width: 90px; pointer-events: none; + opacity: var(--label-opacity, 0.75); } /* When dot is in lower half, show label above */ .glow-dot--label-above .glow-dot__label { top: auto; - bottom: calc(100% + 6px); + bottom: calc(100% + var(--label-gap, 18px)); +} + +.glow-dot__connector { + position: absolute; + left: 50%; + width: 1px; + height: var(--label-connector-len, 14px); + top: calc(-1 * var(--label-connector-len, 14px)); + transform: translateX(-50%); + background: var(--label-connector-color, #ffffff); + opacity: var(--label-connector-opacity, 0.65); +} + +.glow-dot--label-above .glow-dot__connector { + top: auto; + bottom: calc(-1 * var(--label-connector-len, 14px)); } .glow-dot__title { font-size: 10px; font-weight: 600; - opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -193,7 +242,6 @@ function onSelect() { .glow-dot__date { font-size: 9px; font-weight: 400; - opacity: 0.4; white-space: nowrap; line-height: 1.2; } diff --git a/frontend/src/components/LifeWaveSettings.vue b/frontend/src/components/LifeWaveSettings.vue index c36579a..2e1a665 100644 --- a/frontend/src/components/LifeWaveSettings.vue +++ b/frontend/src/components/LifeWaveSettings.vue @@ -17,6 +17,23 @@
Einstellungen
+ +
+ Modus +
+ + +
+
+
Linien @@ -28,7 +45,7 @@
@@ -38,7 +55,7 @@
@@ -48,7 +65,7 @@
@@ -58,7 +75,7 @@
@@ -68,7 +85,17 @@ + +
+ Linienstärke + {{ (fl.lineThickness ?? 1).toFixed(2) }} +
+
@@ -78,7 +105,7 @@
@@ -88,7 +115,7 @@
@@ -98,7 +125,7 @@
@@ -108,7 +135,7 @@
@@ -118,7 +145,7 @@
@@ -128,7 +155,52 @@ + +
+ Rahmen Stärke + {{ (fl.dotBorderWidth ?? 0) === 0 ? 'aus' : `${fl.dotBorderWidth}px` }} +
+ + +
+ Rahmen Farbe + +
+
+ + +
+ Linienfarbe + +
+ Farbe + +
+ +
+ Schatten + {{ (fl.staticLineShadowStrength ?? 0).toFixed(2) }} +
+
@@ -160,6 +232,26 @@ class="lw-settings__color-input" />
+ +
+ Transparenz + {{ (fl.labelOpacity ?? 0.75).toFixed(2) }} +
+ + +
+ Strichlänge + {{ (fl.labelConnectorLength ?? 0.2).toFixed(2) }} +
+
@@ -174,6 +266,13 @@ > Keins +
+
@@ -191,7 +297,7 @@ Hintergrundfarbe
- BG Mitte + {{ isSplit ? 'Unten' : 'BG Mitte' }}
- BG Rand + {{ isSplit ? 'Oben' : 'BG Rand' }}
- -
- Farbverlauf (je Zeile ein Hex) - - -
-
Extras
+ Horizont +
+
+ +
+ + + + + +
{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}
+ +
+ Presets + + + + +
+
+ + + + +
Preset speichern
+
+ Speichert alle aktuellen Life Wave Settings inklusive Hintergrundbild. +
+
+ + + + + + + + + +
+
\ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 226eb50..61939f8 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,6 +1,7 @@ import { defineRouter } from '#q-app/wrappers' import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router' import routes from './routes' +import { AUTH_STORAGE_KEY, DEMO_USERS } from 'stores/auth' /* * If not building with SSR mode, you can @@ -26,5 +27,28 @@ export default defineRouter(function (/* { store, ssrContext } */) { history: createHistory(process.env.VUE_ROUTER_BASE) }) + Router.beforeEach((to) => { + const stored = localStorage.getItem(AUTH_STORAGE_KEY) + let userId = null + + try { + userId = stored ? JSON.parse(stored)?.userId ?? null : null + } catch { + userId = null + } + + const isAuthenticated = DEMO_USERS.some(user => user.id === userId) + + if (to.meta.requiresAuth && !isAuthenticated) { + return { path: '/login', query: { redirect: to.fullPath } } + } + + if (to.path === '/login' && isAuthenticated) { + return { path: '/' } + } + + return true + }) + return Router }) diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index ccc6533..cd4a478 100644 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -1,7 +1,13 @@ const routes = [ + { + path: '/login', + component: () => import('pages/LoginPage.vue'), + meta: { public: true } + }, { path: '/', component: () => import('layouts/LifeWaveLayout.vue'), + meta: { requiresAuth: true }, children: [ { path: '', component: () => import('pages/LifeWavePage.vue') } ] diff --git a/frontend/src/services/syncService.js b/frontend/src/services/syncService.js index f360f61..b3e0f36 100644 --- a/frontend/src/services/syncService.js +++ b/frontend/src/services/syncService.js @@ -167,9 +167,12 @@ async function pullRemoteChanges() { id: remote.id, title: remote.title, date: remote.date, + location: remote.location ?? '', emotion: remote.emotion, customColor: remote.customColor, gradientPreset: remote.gradientPreset, + gradientStartColor: remote.gradientStartColor ?? null, + gradientEndColor: remote.gradientEndColor ?? null, image: remote.image, note: remote.note, syncStatus: 'synced', @@ -181,9 +184,12 @@ async function pullRemoteChanges() { await db.events.update(remote.id, { title: remote.title, date: remote.date, + location: remote.location ?? '', emotion: remote.emotion, customColor: remote.customColor, gradientPreset: remote.gradientPreset, + gradientStartColor: remote.gradientStartColor ?? null, + gradientEndColor: remote.gradientEndColor ?? null, image: remote.image, note: remote.note, syncStatus: 'synced', diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..03de3ab --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,67 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +export const AUTH_STORAGE_KEY = 'thatsme-auth' + +export const DEMO_USERS = Array.from({ length: 5 }, (_, index) => { + const number = index + 1 + return { + id: `demo-user-${number}`, + email: `user${number}@thats-me.app`, + password: 'pass', + name: `User ${number}`, + avatar: `U${number}` + } +}) + +function loadStoredUserId() { + try { + const stored = localStorage.getItem(AUTH_STORAGE_KEY) + return stored ? JSON.parse(stored)?.userId ?? null : null + } catch { + return null + } +} + +export const useAuthStore = defineStore('auth', () => { + const currentUserId = ref(loadStoredUserId()) + const lastError = ref('') + + const currentUser = computed(() => + DEMO_USERS.find(user => user.id === currentUserId.value) ?? null + ) + const isAuthenticated = computed(() => currentUser.value !== null) + + function login(email, password) { + const normalizedEmail = String(email).trim().toLowerCase() + const user = DEMO_USERS.find(candidate => + candidate.email === normalizedEmail && candidate.password === password + ) + + if (!user) { + lastError.value = 'E-Mail oder Passwort ist falsch.' + return false + } + + currentUserId.value = user.id + lastError.value = '' + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ userId: user.id })) + return true + } + + function logout() { + currentUserId.value = null + lastError.value = '' + localStorage.removeItem(AUTH_STORAGE_KEY) + } + + return { + users: DEMO_USERS, + currentUserId, + currentUser, + isAuthenticated, + lastError, + login, + logout + } +}) diff --git a/frontend/src/stores/events.js b/frontend/src/stores/events.js index ab4eb55..935b00d 100644 --- a/frontend/src/stores/events.js +++ b/frontend/src/stores/events.js @@ -1,7 +1,10 @@ import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' +import Dexie from 'dexie' import { db } from 'src/db' import { startAutoSync, getToken } from 'src/services/syncService' +import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings' +import { useAuthStore } from 'stores/auth' // Color interpolation function lerpColor(a, b, t) { @@ -17,55 +20,24 @@ function lerpColor(a, b, t) { return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}` } -// Gradient presets: [negative, neutral, positive] -const GRADIENT_PRESETS = [ - { name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] }, - { name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] }, - { name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] }, - { name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] }, - { name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] }, - { name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] }, - { name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] }, - { name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] }, - { name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] }, - { name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] } -] - -// Glow color logic: emotion value → color, with optional gradient preset -function emotionToColor(emotion, gradientIdx = null) { - const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null - if (preset) { - const [neg, mid, pos] = preset.colors - if (emotion >= 0) { - return lerpColor(mid, pos, emotion) - } else { - return lerpColor(mid, neg, Math.abs(emotion)) - } - } - if (emotion >= 0) { - if (emotion < 0.5) { - return lerpColor('#FF6B35', '#FFD700', emotion / 0.5) - } - return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5) - } else { - const abs = Math.abs(emotion) - if (abs < 0.5) { - return lerpColor('#2196F3', '#9C27B0', abs / 0.5) - } - return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5) - } +// Glow color logic: emotion value mapped on one continuous gradient. +function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = null) { + const start = gradientStartColor || DEFAULT_EMOTION_GRADIENT_START + const end = gradientEndColor || DEFAULT_EMOTION_GRADIENT_END + const t = Math.max(0, Math.min(1, (emotion + 1) / 2)) + return lerpColor(start, end, t) } // Demo seed data const demoEvents = [ - { id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, - { id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() } + { id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', location: '', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', location: '', emotion: 0.85, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', location: '', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', location: '', emotion: 0.75, customColor: null, gradientPreset: null, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', location: '', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', location: '', emotion: 0.95, customColor: null, gradientPreset: null, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', location: '', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', location: '', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() } ] // Generate realistic demo events for testing at scale @@ -144,16 +116,14 @@ function generateManyEvents(count = 500) { const note = hasNote ? pick(cat.notes) : '' const hasImage = rand() < 0.15 // 15% chance const image = hasImage ? pick(demoImages) : null - const hasPreset = rand() < 0.25 // 25% chance - const gradientPreset = hasPreset ? randInt(0, 9) : null - evts.push({ id: crypto.randomUUID(), title, date, + location: '', emotion, customColor: null, - gradientPreset, + gradientPreset: null, image, note, syncStatus: 'local', @@ -167,21 +137,44 @@ function generateManyEvents(count = 500) { return evts } -export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents } +export { + emotionToColor, + demoEvents, + generateManyEvents +} export const useEventsStore = defineStore('events', () => { + const settingsStore = useSettingsStore() + const authStore = useAuthStore() const events = ref([]) const isLoaded = ref(false) const selectedEventId = ref(null) const panelOpen = ref(false) const editingEventId = ref(null) + const AUTOSAVE_DELAY_MS = 300 + let persistTimer = null + let skipNextPersist = false // Load events from IndexedDB; seed demo data on first launch async function init() { + const userId = authStore.currentUserId + if (!userId) { + events.value = [] + isLoaded.value = true + return + } + + isLoaded.value = false try { - let stored = await db.events.orderBy('date').toArray() + let stored = await db.events + .where('[userId+date]') + .between([userId, Dexie.minKey], [userId, Dexie.maxKey]) + .toArray() if (stored.length === 0) { - const seed = generateManyEvents(500) + const seed = generateManyEvents(500).map(event => ({ + ...event, + userId + })) await db.events.bulkPut(seed) stored = seed } @@ -200,7 +193,7 @@ export const useEventsStore = defineStore('events', () => { // Fire-and-forget DB write (UI already updated via ref) function dbPut(event) { - db.events.put(event).catch(e => console.warn('Dexie put failed:', e)) + db.events.put({ ...event, userId: event.userId ?? authStore.currentUserId }).catch(e => console.warn('Dexie put failed:', e)) } function dbDelete(id) { @@ -208,27 +201,95 @@ export const useEventsStore = defineStore('events', () => { } function dbQueueSync(eventId, action, payload) { - db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() }) - .catch(e => console.warn('Dexie sync queue failed:', e)) + const userId = authStore.currentUserId + if (!userId) return + + const queue = async () => { + if (action === 'update') { + await db.syncQueue + .where('eventId') + .equals(eventId) + .and(item => item.userId === userId && item.action === 'update') + .delete() + } + + await db.syncQueue.add({ userId, eventId, action, payload, createdAt: Date.now() }) + } + + queue().catch(e => console.warn('Dexie sync queue failed:', e)) + } + + function cloneMedia(media) { + return Array.isArray(media) + ? media.map(item => ({ ...item })) + : [] + } + + function mediaMeta(media) { + return cloneMedia(media).map(({ id, type, name, createdAt }) => ({ + id, + type, + name, + createdAt + })) + } + + function persistEventMedia(eventId, userId, media) { + const mediaItems = cloneMedia(media) + const persist = async () => { + await db.eventMedia.where('eventId').equals(eventId).delete() + if (mediaItems.length === 0) return + await db.eventMedia.bulkPut(mediaItems.map(item => ({ + ...item, + eventId, + userId + }))) + } + + persist().catch(e => console.warn('Dexie media persist failed:', e)) + } + + async function loadEventMedia(event) { + const fallback = Array.isArray(event.media) ? cloneMedia(event.media) : [] + ghostMedia.value = fallback + + try { + const stored = await db.eventMedia + .where('eventId') + .equals(event.id) + .toArray() + + if (editingEventId.value !== event.id || stored.length === 0) return + + ghostMedia.value = stored + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) + .map(({ eventId, userId, ...item }) => item) + } catch (e) { + console.warn('Dexie media load failed:', e) + } } // Ghost event for live preview while creating/editing const ghostEmotion = ref(0) const ghostCustomColor = ref(null) - const ghostGradientPreset = ref(null) const ghostTitle = ref('') const ghostDate = ref(new Date().toISOString().slice(0, 10)) + const ghostLocation = ref('') const ghostNote = ref('') const ghostImage = ref(null) + const ghostKeyImageTitle = ref('') + const ghostMedia = ref([]) const ghostEvent = computed(() => ({ id: '__ghost__', title: ghostTitle.value || 'New Event', date: ghostDate.value, + location: ghostLocation.value, emotion: ghostEmotion.value, customColor: ghostCustomColor.value, - gradientPreset: ghostGradientPreset.value, image: ghostImage.value, + keyImageTitle: ghostKeyImageTitle.value, + media: ghostMedia.value, note: ghostNote.value })) @@ -245,22 +306,27 @@ export const useEventsStore = defineStore('events', () => { editingEventId.value = eventId const event = events.value.find((e) => e.id === eventId) if (event) { + skipNextPersist = true ghostTitle.value = event.title ghostDate.value = event.date + ghostLocation.value = event.location || '' ghostEmotion.value = event.emotion ghostCustomColor.value = event.customColor - ghostGradientPreset.value = event.gradientPreset ?? null ghostImage.value = event.image || null + ghostKeyImageTitle.value = event.keyImageTitle || '' + loadEventMedia(event) ghostNote.value = event.note } } else { editingEventId.value = null ghostTitle.value = '' ghostDate.value = new Date().toISOString().slice(0, 10) + ghostLocation.value = '' ghostEmotion.value = 0 ghostCustomColor.value = null - ghostGradientPreset.value = null ghostImage.value = null + ghostKeyImageTitle.value = '' + ghostMedia.value = [] ghostNote.value = '' } panelOpen.value = true @@ -275,33 +341,75 @@ export const useEventsStore = defineStore('events', () => { ...events.value[idx], title: ghostTitle.value, date: ghostDate.value, + location: ghostLocation.value, emotion: ghostEmotion.value, customColor: ghostCustomColor.value, - gradientPreset: ghostGradientPreset.value, + gradientPreset: null, image: ghostImage.value, + keyImageTitle: ghostKeyImageTitle.value, + media: mediaMeta(ghostMedia.value), note: ghostNote.value, syncStatus: 'modified', + userId: events.value[idx].userId ?? authStore.currentUserId, updatedAt: Date.now() } events.value[idx] = updated dbPut(updated) + persistEventMedia(updated.id, updated.userId, ghostMedia.value) + dbQueueSync(updated.id, 'update', { ...updated }) + } + + function schedulePersistToEvent() { + if (!editingEventId.value) return + if (persistTimer) clearTimeout(persistTimer) + persistTimer = setTimeout(() => { + persistTimer = null + persistToEvent() + }, AUTOSAVE_DELAY_MS) + } + + function flushPersistToEvent() { + if (!persistTimer) return + clearTimeout(persistTimer) + persistTimer = null + persistToEvent() + } + + function saveGhostNow() { + if (persistTimer) { + clearTimeout(persistTimer) + persistTimer = null + } + persistToEvent() } watch( - [ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote], - () => { persistToEvent() } + [ghostTitle, ghostDate, ghostLocation, ghostEmotion, ghostCustomColor, ghostImage, ghostKeyImageTitle, ghostMedia, ghostNote], + () => { + if (skipNextPersist) { + skipNextPersist = false + return + } + schedulePersistToEvent() + } ) function closePanel() { + flushPersistToEvent() + if (!editingEventId.value && ghostTitle.value.trim()) { const newEvent = { id: crypto.randomUUID(), + userId: authStore.currentUserId, title: ghostTitle.value, date: ghostDate.value, + location: ghostLocation.value, emotion: ghostEmotion.value, customColor: ghostCustomColor.value, - gradientPreset: ghostGradientPreset.value, + gradientPreset: null, image: ghostImage.value, + keyImageTitle: ghostKeyImageTitle.value, + media: mediaMeta(ghostMedia.value), note: ghostNote.value, syncStatus: 'local', createdAt: Date.now(), @@ -309,6 +417,7 @@ export const useEventsStore = defineStore('events', () => { } events.value.push(newEvent) dbPut(newEvent) + persistEventMedia(newEvent.id, newEvent.userId, ghostMedia.value) dbQueueSync(newEvent.id, 'create', { ...newEvent }) } panelOpen.value = false @@ -317,6 +426,10 @@ export const useEventsStore = defineStore('events', () => { } function deleteEvent(id) { + if (editingEventId.value === id) { + if (persistTimer) clearTimeout(persistTimer) + persistTimer = null + } events.value = events.value.filter((e) => e.id !== id) dbDelete(id) dbQueueSync(id, 'delete', null) @@ -325,12 +438,27 @@ export const useEventsStore = defineStore('events', () => { function getGlowColor(event) { if (event.customColor) return event.customColor - return emotionToColor(event.emotion, event.gradientPreset ?? null) + return emotionToColor( + event.emotion, + settingsStore.emotionGradientStart || DEFAULT_EMOTION_GRADIENT_START, + settingsStore.emotionGradientEnd || DEFAULT_EMOTION_GRADIENT_END + ) } // Auto-init on store creation init() + watch(() => authStore.currentUserId, () => { + panelOpen.value = false + editingEventId.value = null + selectedEventId.value = null + if (persistTimer) { + clearTimeout(persistTimer) + persistTimer = null + } + init() + }) + return { events, isLoaded, @@ -339,17 +467,20 @@ export const useEventsStore = defineStore('events', () => { editingEventId, ghostEmotion, ghostCustomColor, - ghostGradientPreset, ghostTitle, ghostDate, + ghostLocation, ghostNote, ghostImage, + ghostKeyImageTitle, + ghostMedia, ghostEvent, sortedEvents, selectEvent, openPanel, closePanel, deleteEvent, + saveGhostNow, getGlowColor } }) diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index bea0137..74eaaa1 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,15 +1,33 @@ import { defineStore } from 'pinia' import { ref, watch } from 'vue' +import { useAuthStore } from 'stores/auth' -const STORAGE_KEY = 'thatsme-settings' +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: 'Standard', value: 'default', hex: '#9e9e9e' }, - { label: 'Blau', value: 'blue', hex: '#2196F3' }, - { label: 'Grün', value: 'green', hex: '#4CAF50' }, - { label: 'Gelb', value: 'yellow', hex: '#FFC107' }, - { label: 'Rosa', value: 'pink', hex: '#E91E63' }, - { label: 'Orange', value: 'orange', hex: '#FF9800' } + { label: 'Base', value: 'base', hex: '#737373' }, + { label: 'Red', value: 'red', hex: '#ef4444' }, + { label: 'Orange', value: 'orange', hex: '#f97316' }, + { label: 'Amber', value: 'amber', hex: '#f59e0b' }, + { label: 'Yellow', value: 'yellow', hex: '#eab308' }, + { label: 'Lime', value: 'lime', hex: '#84cc16' }, + { label: 'Green', value: 'green', hex: '#22c55e' }, + { label: 'Emerald', value: 'emerald', hex: '#10b981' }, + { label: 'Teal', value: 'teal', hex: '#14b8a6' }, + { label: 'Cyan', value: 'cyan', hex: '#06b6d4' }, + { label: 'Sky', value: 'sky', hex: '#0ea5e9' }, + { label: 'Blue', value: 'blue', hex: '#3b82f6' }, + { label: 'Indigo', value: 'indigo', hex: '#6366f1' }, + { label: 'Violet', value: 'violet', hex: '#8b5cf6' }, + { label: 'Purple', value: 'purple', hex: '#a855f7' }, + { label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' }, + { label: 'Pink', value: 'pink', hex: '#ec4899' }, + { label: 'Rose', value: 'rose', hex: '#f43f5e' } ] export const LANGUAGES = [ @@ -24,6 +42,7 @@ const FLOATING_LINES_DEFAULTS = { spread: 0.05, fanSpread: 0.05, lineSharpness: 8.0, + lineThickness: 1.0, waveFrequency: 7.0, bezierCurvature: 0.2, circleRadius: 75, @@ -35,51 +54,177 @@ const FLOATING_LINES_DEFAULTS = { bgEdge: '#000000', gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', backgroundImage: '', + // Horizont + horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow' + horizonOpacity: 0.5, // Nebel + Glow: Helligkeit + horizonBlend: 0.2, // Trennung: 0=scharf 1=weich // Labels - labelSize: 'small', // 'small' | 'medium' | 'large' - labelColor: '#ffffff' + labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge' + labelColor: '#ffffff', + 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 loadFromStorage() { +function getStorageKey(userId) { + return `${STORAGE_KEY_PREFIX}:${userId || 'guest'}` +} + +function loadFromStorage(userId) { try { - const stored = localStorage.getItem(STORAGE_KEY) + const stored = localStorage.getItem(getStorageKey(userId)) return stored ? JSON.parse(stored) : null } catch { return null } } +function clone(value) { + return JSON.parse(JSON.stringify(value)) +} + +function normalizeAccentColor(value) { + if (value === 'default') return 'base' + return ACCENT_COLORS.some(c => c.value === value) ? value : 'base' +} + +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 stored = loadFromStorage() + 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 - const theme = ref(stored?.theme ?? 'light') - const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS }) + const theme = ref(initialSettings?.theme ?? 'light') + const floatingLines = ref({ + ...FLOATING_LINES_DEFAULTS, + ...(initialSettings?.floatingLines ?? {}) + }) // App preferences - const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark' - const accentColor = ref(stored?.accentColor ?? 'default') - const language = ref(stored?.language ?? 'de') + const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark' + const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'base')) + 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(stored?.showFps ?? false) + 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 applySnapshot(snapshot) { + theme.value = snapshot?.theme ?? 'light' + floatingLines.value = { + ...FLOATING_LINES_DEFAULTS, + ...(snapshot?.floatingLines ?? {}) + } + appearance.value = snapshot?.appearance ?? 'system' + accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'base') + 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) return + localStorage.setItem( - STORAGE_KEY, + getStorageKey(authStore.currentUserId), JSON.stringify({ - theme: theme.value, - floatingLines: floatingLines.value, - appearance: appearance.value, - accentColor: accentColor.value, - language: language.value, - showFps: showFps.value + ...createSnapshot(), + timelineScrollLeft: timelineScrollLeft.value, + presets: presets.value, + activePresetId: activePresetId.value }) ) } - watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true }) + function schedulePersist() { + if (persistTimer) clearTimeout(persistTimer) + persistTimer = setTimeout(persist, PERSIST_DELAY_MS) + } + + function applyStoredSettingsForUser(userId) { + if (persistTimer) { + clearTimeout(persistTimer) + persistTimer = null + } + + const nextStored = loadFromStorage(userId) + 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 + } + + 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' @@ -93,15 +238,67 @@ export const useSettingsStore = defineStore('settings', () => { floatingLines.value = { ...FLOATING_LINES_DEFAULTS } } + 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 + } + + function applyPreset(presetId) { + const preset = presets.value.find(candidate => candidate.id === presetId) + if (!preset) return false + + activePresetId.value = preset.id + applySnapshot(preset.settings) + persist() + return true + } + return { theme, floatingLines, appearance, accentColor, language, + emotionGradientStart, + emotionGradientEnd, + timelineZoom, + timelineScrollLeft, + presets, + activePresetId, showFps, toggleTheme, updateFloatingLines, - resetFloatingLines + resetFloatingLines, + resetEmotionGradient, + saveTimelineScrollLeft, + savePreset, + applyPreset } }) diff --git a/workspace.code-workspace b/workspace.code-workspace new file mode 100644 index 0000000..114863a --- /dev/null +++ b/workspace.code-workspace @@ -0,0 +1,30 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#2c58b1", + "activityBar.background": "#2c58b1", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#4d1326", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#2c58b1", + "statusBar.background": "#224488", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#2c58b1", + "statusBarItem.remoteBackground": "#224488", + "statusBarItem.remoteForeground": "#e7e7e7", + "titleBar.activeBackground": "#224488", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#22448899", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#224488", //not sure if this is relevant; I have an extension called Peacock + "editor.formatOnSave": true + } +} \ No newline at end of file