30-04-2026

This commit is contained in:
Kevin Adametz 2026-04-30 14:54:39 +02:00
parent 761b1156c1
commit d054732bf5
35 changed files with 2796 additions and 505 deletions

View file

@ -31,9 +31,6 @@
"WWWGROUP": "20", "WWWGROUP": "20",
"LARAVEL_SAIL": "1" "LARAVEL_SAIL": "1"
}, },
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
],
"forwardPorts": [ "forwardPorts": [
5173, 5173,
9000 9000

View file

@ -32,6 +32,7 @@ services:
REDIS_HOST: global-redis REDIS_HOST: global-redis
volumes: volumes:
- './backend:/var/www/html' - './backend:/var/www/html'
- '.:/workspace:cached'
networks: networks:
- sail - sail
- proxy - proxy

View file

@ -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 | ⏭️ |

View file

@ -7,7 +7,6 @@ import {
ShaderMaterial, ShaderMaterial,
Vector3, Vector3,
Vector2, Vector2,
Clock,
} from 'three' } from 'three'
const vertexShader = ` const vertexShader = `
@ -19,7 +18,7 @@ void main() {
` `
const fragmentShader = ` const fragmentShader = `
precision highp float; precision mediump float;
uniform float iTime; uniform float iTime;
uniform vec3 iResolution; uniform vec3 iResolution;
@ -58,34 +57,24 @@ uniform float bendRadius;
uniform float bendStrength; uniform float bendStrength;
uniform float bendInfluence; 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 bool parallax;
uniform float parallaxStrength; uniform float parallaxStrength;
uniform vec2 parallaxOffset; uniform vec2 parallaxOffset;
uniform float lineBrightness;
uniform vec3 lineGradient[8]; uniform vec3 lineGradient[8];
uniform int lineGradientCount; uniform int lineGradientCount;
uniform vec3 bgColorCenter; uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge; 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) { mat2 rotate(float r) {
return mat2(cos(r), sin(r), -sin(r), cos(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) { vec3 getLineColor(float t, vec3 baseColor) {
if (lineGradientCount <= 0) { if (lineGradientCount <= 0) {
return baseColor; return baseColor;
@ -108,7 +97,7 @@ vec3 getLineColor(float t, vec3 baseColor) {
gradientColor = mix(c1, c2, f); gradientColor = mix(c1, c2, f);
} }
return gradientColor * 0.5; return gradientColor;
} }
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) { 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; return t;
} }
float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) { // Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment
// Bézier-Kontrollpunkt: Mittelpunkt + senkrechter Versatz float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) {
vec2 seg = ep - sp; float s = dot(uv - bPos, bNorm);
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);
float time = iTime * animationSpeed; float time = iTime * animationSpeed;
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5; 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 col = vec3(0.0);
vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv); vec3 b = bgColorCenter;
vec2 mouseUv = vec2(0.0); vec2 mouseUv = vec2(0.0);
if (interactive) { if (interactive) {
@ -269,33 +242,34 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 sp = vec2(x0, pointY[s]); vec2 sp = vec2(x0, pointY[s]);
vec2 ep = vec2(x1, pointY[s + 1]); vec2 ep = vec2(x1, pointY[s + 1]);
// Gradient: globaler t-Bereich [s, s+1] / (numPoints-1) // Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt)
vec2 pd = ep - sp; vec2 seg = ep - sp;
float pl = length(pd); float segL = length(seg);
vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0); vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0);
float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.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; 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) // Bézier einmal pro Segment — geteilt von Nebel + allen Linien
vec2 segD = ep - sp; float bt = bezierClosestT(baseUv, sp, pc, ep);
float segL = length(segD); float bmt = 1.0 - bt;
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0); vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
vec2 sPerp = vec2(-segDir.y, segDir.x); vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature; vec2 bNorm = vec2(-bTang.y, bTang.x);
// Weicher Nebel entlang der Bézier-Kurve → füllt dunkle Winkel organisch // Weicher Nebel entlang der Kurve
float bt = bezierClosestT(baseUv, sp, pc, ep); float bDist = length(baseUv - bPos);
float bmt = 1.0 - bt;
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
float bDist = length(baseUv - bPos);
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt); float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
float fogEnv = sin(bt * 3.14159265359); float fogEnv = sin(bt * 3.14159265359);
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001); float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
col += lineCol * segFog; col += lineCol * segFog;
for (int i = 0; i < middleLineCount; ++i) { 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; if (p >= numPoints) break;
float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX; float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX;
float t_pt = numPoints > 1 ? float(p) * tScale : 0.0; 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); 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) // Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand)
float dist = length(baseUv) / 1.8; float dist = length(baseUv) / 1.8;
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0)); 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); fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
} }
@ -376,7 +374,6 @@ export default class FloatingLines {
lineCount = [6], lineCount = [6],
lineDistance = [5], lineDistance = [5],
topWavePosition, topWavePosition,
middleWavePosition,
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 }, bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
numPoints = 4, numPoints = 4,
pointSpacingX = 0.8, pointSpacingX = 0.8,
@ -389,6 +386,7 @@ export default class FloatingLines {
bezierCurvature = 0.3, bezierCurvature = 0.3,
circleRadiusPx = 50, circleRadiusPx = 50,
animationSpeed = 1, animationSpeed = 1,
lineBrightness = 1.0,
interactive = true, interactive = true,
bendRadius = 5.0, bendRadius = 5.0,
bendStrength = -0.5, bendStrength = -0.5,
@ -397,6 +395,11 @@ export default class FloatingLines {
parallaxStrength = 0.2, parallaxStrength = 0.2,
circleGlowSize = 18.0, circleGlowSize = 18.0,
circleGlowStrength = 1.5, circleGlowStrength = 1.5,
horizonMode = 'off',
horizonOpacity = 0.5,
horizonBlend = 0.2,
bgColorCenter = '#0a0514',
bgColorEdge = '#000000',
mixBlendMode = 'screen', mixBlendMode = 'screen',
} = {}, } = {},
) { ) {
@ -427,14 +430,12 @@ export default class FloatingLines {
return lineDistance[index] ?? 0.1 return lineDistance[index] ?? 0.1
} }
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0 const topLineCount = getLineCount('top')
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0 const middleLineCount = getLineCount('middle')
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0 const bottomLineCount = getLineCount('bottom')
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01 const topLineDistance = getLineDistance('top') * 0.01
const bottomLineDistance = enabledWaves.includes('bottom') const bottomLineDistance = getLineDistance('bottom') * 0.01
? getLineDistance('bottom') * 0.01
: 0.01
this.scene = new Scene() this.scene = new Scene()
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1) this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
@ -451,6 +452,7 @@ export default class FloatingLines {
iTime: { value: 0 }, iTime: { value: 0 },
iResolution: { value: new Vector3(1, 1, 1) }, iResolution: { value: new Vector3(1, 1, 1) },
animationSpeed: { value: animationSpeed }, animationSpeed: { value: animationSpeed },
lineBrightness: { value: lineBrightness },
enableTop: { value: enabledWaves.includes('top') }, enableTop: { value: enabledWaves.includes('top') },
enableMiddle: { value: enabledWaves.includes('middle') }, enableMiddle: { value: enabledWaves.includes('middle') },
@ -497,6 +499,10 @@ export default class FloatingLines {
bendStrength: { value: bendStrength }, bendStrength: { value: bendStrength },
bendInfluence: { value: 0 }, bendInfluence: { value: 0 },
horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 },
horizonOpacity: { value: horizonOpacity },
horizonBlend: { value: horizonBlend },
parallax: { value: parallax }, parallax: { value: parallax },
parallaxStrength: { value: parallaxStrength }, parallaxStrength: { value: parallaxStrength },
parallaxOffset: { value: new Vector2(0, 0) }, 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)), value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
}, },
lineGradientCount: { value: 0 }, 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) { 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({ const material = new ShaderMaterial({
uniforms: this.uniforms, uniforms: this.uniforms,
vertexShader, vertexShader,
@ -529,7 +541,7 @@ export default class FloatingLines {
this.geometry = geometry this.geometry = geometry
this.material = material this.material = material
this.clock = new Clock() this._startTime = performance.now()
this._setSize = () => { this._setSize = () => {
const width = container.clientWidth || 1 const width = container.clientWidth || 1
@ -571,7 +583,7 @@ export default class FloatingLines {
this.raf = 0 this.raf = 0
const renderLoop = () => { const renderLoop = () => {
this.uniforms.iTime.value = this.clock.getElapsedTime() this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001
if (this.interactive) { if (this.interactive) {
this.currentMouse.lerp(this.targetMouse, this.mouseDamping) this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
@ -589,12 +601,24 @@ export default class FloatingLines {
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera)
this.raf = requestAnimationFrame(renderLoop) 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() renderLoop()
} }
destroy() { destroy() {
cancelAnimationFrame(this.raf) cancelAnimationFrame(this.raf)
if (this.ro) this.ro.disconnect() if (this.ro) this.ro.disconnect()
document.removeEventListener('visibilitychange', this._handleVisibility)
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove) this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave) this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)

View file

@ -50,7 +50,7 @@
border-top: 1px solid #222; border-top: 1px solid #222;
padding: 10px 14px; padding: 10px 14px;
display: grid; display: grid;
grid-template-columns: 1fr 1fr 2fr 1.2fr; grid-template-columns: 1fr 1fr 2fr 0.8fr 1.2fr;
gap: 10px 16px; gap: 10px 16px;
max-height: 230px; max-height: 230px;
overflow-y: auto; overflow-y: auto;
@ -287,6 +287,27 @@
</div> </div>
</div> </div>
<!-- Col 3.5: Horizont -->
<div class="ctrl-group" style="grid-column: span 1">
<h3>Horizont</h3>
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px;">
<button class="img-btn active" data-mode="0">Aus</button>
<button class="img-btn" data-mode="1">Nebel</button>
<button class="img-btn" data-mode="2">Trennung</button>
<button class="img-btn" data-mode="3">Glow</button>
</div>
<div class="row" id="row-horizonOpacity">
<label for="horizonOpacity">Deckkraft</label>
<input type="range" id="horizonOpacity" min="0.05" max="1" step="0.05" value="0.5" />
<span class="val" id="horizonOpacity-val">0.50</span>
</div>
<div class="row" id="row-horizonBlend" style="display:none">
<label for="horizonBlend">Übergang</label>
<input type="range" id="horizonBlend" min="0" max="1" step="0.02" value="0.2" />
<span class="val" id="horizonBlend-val">0.20</span>
</div>
</div>
<!-- Col 4: Hintergrundbild + Farben --> <!-- Col 4: Hintergrundbild + Farben -->
<div class="ctrl-group"> <div class="ctrl-group">
<h3>Hintergrundbild</h3> <h3>Hintergrundbild</h3>
@ -501,6 +522,27 @@
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge) 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 ────────────────────────────────────────────────────── // ── Gradient ──────────────────────────────────────────────────────
const MAX_STOPS = 8 const MAX_STOPS = 8
function applyGradient() { function applyGradient() {

View file

@ -74,6 +74,44 @@
</div> </div>
<div class="settings-section__divider" /> <div class="settings-section__divider" />
<!-- Emotionsverlauf -->
<div class="settings-row settings-row--stack">
<span class="settings-row__label">Emotionsverlauf</span>
<div class="settings-gradient">
<div class="settings-gradient__preview" :style="{ background: emotionGradientCss }"></div>
<div class="settings-gradient__controls">
<div class="settings-gradient__control">
<span>Start</span>
<input
type="color"
:value="settingsStore.emotionGradientStart"
@input="e => { settingsStore.emotionGradientStart = e.target.value }"
class="settings-color-input"
/>
</div>
<q-btn
flat
dense
no-caps
size="sm"
icon="restart_alt"
label="Reset"
@click="settingsStore.resetEmotionGradient()"
/>
<div class="settings-gradient__control settings-gradient__control--right">
<span>Ende</span>
<input
type="color"
:value="settingsStore.emotionGradientEnd"
@input="e => { settingsStore.emotionGradientEnd = e.target.value }"
class="settings-color-input"
/>
</div>
</div>
</div>
</div>
<div class="settings-section__divider" />
<!-- Sprache --> <!-- Sprache -->
<div class="settings-row"> <div class="settings-row">
<span class="settings-row__label">Sprache</span> <span class="settings-row__label">Sprache</span>
@ -100,7 +138,7 @@
:model-value="settingsStore.showFps" :model-value="settingsStore.showFps"
@update:model-value="v => { settingsStore.showFps = v }" @update:model-value="v => { settingsStore.showFps = v }"
dense dense
color="green" color="primary"
/> />
</div> </div>
</div> </div>
@ -146,14 +184,18 @@ const tabs = [
const currentAccentHex = computed(() => { const currentAccentHex = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor) const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
return found?.hex ?? '#9e9e9e' return found?.hex ?? '#737373'
}) })
const currentAccentLabel = computed(() => { const currentAccentLabel = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor) 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) { function selectAccent(value) {
settingsStore.accentColor = value settingsStore.accentColor = value
accentDropdownOpen.value = false accentDropdownOpen.value = false
@ -198,6 +240,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
min-height: 40px; min-height: 40px;
} }
.settings-row--stack {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.settings-row__label { .settings-row__label {
font-size: 15px; font-size: 15px;
} }
@ -242,6 +290,7 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
padding: 6px 10px; padding: 6px 10px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
border-color: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.35);
} }
.settings-accent-btn--dark { .settings-accent-btn--dark {
@ -294,12 +343,53 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
} }
.settings-dropdown__item:hover { .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 { .settings-dropdown__check {
margin-left: auto; margin-left: auto;
opacity: 0.7; 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 */ /* Placeholder text */

View file

@ -20,12 +20,43 @@
<div class="event-panel__image-section"> <div class="event-panel__image-section">
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap"> <div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
<img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" /> <img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
<span class="event-panel__image-badge">Key Image</span> <input
v-model="eventsStore.ghostKeyImageTitle"
class="event-panel__image-badge"
maxlength="18"
placeholder="Key Image"
aria-label="Key Image Titel"
/>
<button
type="button"
class="event-panel__image-gallery"
aria-label="Galerie öffnen"
@click="openKeyImageGallery"
>
<q-icon name="collections" size="18px" />
</button>
<div class="event-panel__image-actions">
<button type="button" class="event-panel__image-action" @click="openKeyImageUpload">
<q-icon name="photo_camera" size="18px" />
<span>Ersetzen</span>
</button>
<button type="button" class="event-panel__image-action event-panel__image-action--danger" @click="confirmRemoveKeyImage">
<q-icon name="delete_outline" size="18px" />
<span>Löschen</span>
</button>
</div>
</div> </div>
<div v-else class="event-panel__image-placeholder" @click="onAddImage"> <div v-else class="event-panel__image-placeholder" @click="openKeyImageUpload">
<q-icon name="add_photo_alternate" size="32px" color="grey-5" /> <q-icon name="add_photo_alternate" size="32px" color="grey-5" />
<span>Key Image hinzufügen</span> <span>Key Image hinzufügen</span>
</div> </div>
<input
ref="keyImageInputRef"
type="file"
accept="image/*"
class="event-panel__file-input"
@change="onKeyImageSelected"
/>
</div> </div>
<!-- Title large, editable inline --> <!-- Title large, editable inline -->
@ -38,22 +69,42 @@
:dark="isDark" :dark="isDark"
/> />
<!-- Date row tap to open QDate picker --> <div class="event-panel__meta-grid">
<div class="event-panel__date-row"> <div class="event-panel__meta-item event-panel__meta-item--date">
<q-icon name="event" size="18px" class="event-panel__date-icon" /> <span class="event-panel__meta-label">Datum</span>
<span class="event-panel__date-label">{{ formattedDate }}</span> <button class="event-panel__date-btn" type="button">
<q-popup-proxy transition-show="scale" transition-hide="scale"> <q-icon name="event" size="16px" class="event-panel__date-icon" />
<q-date <span class="event-panel__date-label">{{ formattedDate }}</span>
v-model="ghostDateSlash" </button>
mask="YYYY/MM/DD" <q-popup-proxy transition-show="scale" transition-hide="scale">
<q-date
v-model="ghostDateSlash"
mask="YYYY/MM/DD"
:dark="isDark"
:locale="dateLocale"
minimal
/>
</q-popup-proxy>
</div>
<div class="event-panel__meta-item">
<span class="event-panel__meta-label">Ort</span>
<q-input
v-model="eventsStore.ghostLocation"
borderless
dense
placeholder="z. B. Berlin"
class="event-panel__location-input"
:dark="isDark" :dark="isDark"
:locale="dateLocale" >
minimal <template #prepend>
/> <q-icon name="place" size="16px" />
</q-popup-proxy> </template>
</q-input>
</div>
</div> </div>
<!-- Emotional Level card style with gradient track --> <!-- Emotional Level card style with fixed gradient track -->
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }"> <div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
<div class="event-panel__card-header"> <div class="event-panel__card-header">
<span class="event-panel__card-label">Emotional Level</span> <span class="event-panel__card-label">Emotional Level</span>
@ -85,28 +136,26 @@
<span>Sehr positiv</span> <span>Sehr positiv</span>
</div> </div>
<!-- Gradient Preset Selector --> <div class="event-panel__gradient-edit">
<div class="event-panel__presets"> <div class="event-panel__gradient-row">
<span class="event-panel__presets-label">Farbverlauf</span> <span class="event-panel__presets-label">Punktfarbe (optional)</span>
<div class="event-panel__presets-grid"> <input
<div type="color"
v-for="(preset, index) in gradientPresets" :value="eventsStore.ghostCustomColor || emotionColor"
:key="index" @input="e => { eventsStore.ghostCustomColor = e.target.value }"
class="event-panel__preset" class="event-panel__color-input"
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === index }" />
:style="{ background: presetGradientCSS(preset.colors) }" </div>
:title="preset.name" <div class="event-panel__gradient-actions">
@click="selectPreset(index)" <q-btn
></div> flat
<!-- "None" option to clear preset --> dense
<div no-caps
class="event-panel__preset event-panel__preset--none" size="sm"
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === null }" icon="palette"
title="Standard" label="Aus Verlauf"
@click="selectPreset(null)" @click="eventsStore.ghostCustomColor = null"
> />
<q-icon name="auto_awesome" size="12px" />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -129,10 +178,41 @@
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }"> <div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
<span class="event-panel__card-label">Weitere Medien</span> <span class="event-panel__card-label">Weitere Medien</span>
<div class="event-panel__media-grid"> <div class="event-panel__media-grid">
<div class="event-panel__media-add" @click="onAddMedia"> <div
v-for="item in eventsStore.ghostMedia"
:key="item.id"
class="event-panel__media-item"
>
<img :src="item.src" class="event-panel__media-img" alt="" />
<button
type="button"
class="event-panel__media-remove"
aria-label="Bild entfernen"
@click="removeMediaImage(item.id)"
>
<q-icon name="close" size="16px" />
</button>
<button
type="button"
class="event-panel__media-open"
aria-label="Galerie öffnen"
@click="openMediaGallery(item.id)"
>
<q-icon name="collections" size="16px" />
</button>
</div>
<div class="event-panel__media-add" @click="openMediaUpload">
<q-icon name="add_photo_alternate" size="24px" color="grey-5" /> <q-icon name="add_photo_alternate" size="24px" color="grey-5" />
</div> </div>
</div> </div>
<input
ref="mediaInputRef"
type="file"
accept="image/*"
multiple
class="event-panel__file-input"
@change="onMediaSelected"
/>
</div> </div>
<!-- Delete (edit mode only) --> <!-- Delete (edit mode only) -->
@ -149,6 +229,60 @@
/> />
</div> </div>
</div> </div>
<q-dialog v-model="removeKeyImageDialogOpen">
<q-card class="event-panel__confirm" :class="{ 'event-panel__confirm--dark': isDark }">
<q-card-section>
<div class="event-panel__confirm-title">Key Image entfernen?</div>
<div class="event-panel__confirm-copy">
Das Key Image wird endgültig aus diesem Event entfernt.
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Abbrechen" v-close-popup />
<q-btn
unelevated
no-caps
color="negative"
label="Endgültig entfernen"
@click="removeKeyImage"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="mediaGalleryOpen" maximized>
<div class="event-panel__gallery">
<button
type="button"
class="event-panel__gallery-close"
aria-label="Galerie schließen"
@click="mediaGalleryOpen = false"
>
<q-icon name="close" size="24px" />
</button>
<q-carousel
v-model="mediaGalleryIndex"
class="event-panel__gallery-carousel"
animated
swipeable
arrows
navigation
infinite
>
<q-carousel-slide
v-for="(item, index) in galleryItems"
:key="item.id"
:name="index"
class="event-panel__gallery-slide"
>
<img :src="item.src" class="event-panel__gallery-img" alt="" />
</q-carousel-slide>
</q-carousel>
</div>
</q-dialog>
</div> </div>
</Transition> </Transition>
</template> </template>
@ -156,12 +290,14 @@
<script setup> <script setup>
import { computed, watch, ref } from 'vue' import { computed, watch, ref } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events' import { useEventsStore, emotionToColor } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
import { usePanelDrag } from 'composables/usePanelDrag' import { usePanelDrag } from 'composables/usePanelDrag'
import { resolveFullRes } from 'composables/useImageCache' import { resolveFullRes } from 'composables/useImageCache'
const $q = useQuasar() const $q = useQuasar()
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel()) const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
// Resolve key image: full-res when online, cached thumbnail when offline // Resolve key image: full-res when online, cached thumbnail when offline
@ -177,12 +313,18 @@ watch(
// Reset height when panel opens // Reset height when panel opens
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() }) watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
const gradientPresets = GRADIENT_PRESETS
const gradientStartColor = computed(() => settingsStore.emotionGradientStart)
const gradientEndColor = computed(() => settingsStore.emotionGradientEnd)
// Current glow color based on emotion + gradient // Current glow color based on emotion + gradient
const emotionColor = computed(() => { const emotionColor = computed(() => {
if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor
return emotionToColor(eventsStore.ghostEmotion, eventsStore.ghostGradientPreset) return emotionToColor(
eventsStore.ghostEmotion,
gradientStartColor.value,
gradientEndColor.value
)
}) })
// Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD // Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD
@ -215,32 +357,151 @@ const emotionLabel = computed(() => {
// CSS gradient for the slider track // CSS gradient for the slider track
const sliderGradientCSS = computed(() => { const sliderGradientCSS = computed(() => {
const idx = eventsStore.ghostGradientPreset return `linear-gradient(90deg, ${gradientStartColor.value} 0%, ${gradientEndColor.value} 100%)`
if (idx !== null && GRADIENT_PRESETS[idx]) {
const [neg, mid, pos] = GRADIENT_PRESETS[idx].colors
return `linear-gradient(90deg, ${neg} 0%, ${mid} 50%, ${pos} 100%)`
}
// Default gradient matching the default emotionToColor
return 'linear-gradient(90deg, #E91E63 0%, #9C27B0 20%, #2196F3 35%, #FFD700 50%, #FF6B35 65%, #FFD700 80%, #4CAF50 100%)'
}) })
// CSS gradient for a preset swatch const keyImageInputRef = ref(null)
function presetGradientCSS(colors) { const mediaInputRef = ref(null)
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]})` const removeKeyImageDialogOpen = ref(false)
const mediaGalleryOpen = ref(false)
const mediaGalleryIndex = ref(0)
const galleryItems = computed(() => {
const items = []
if (eventsStore.ghostImage) {
items.push({
id: '__key_image__',
type: 'image',
name: eventsStore.ghostKeyImageTitle || 'Key Image',
src: keyImageSrc.value || eventsStore.ghostImage,
isKeyImage: true
})
}
return [...items, ...eventsStore.ghostMedia]
})
function openKeyImageUpload() {
keyImageInputRef.value?.click()
} }
function selectPreset(index) { function openMediaUpload() {
eventsStore.ghostGradientPreset = index mediaInputRef.value?.click()
// Clear custom color when selecting a gradient
eventsStore.ghostCustomColor = null
} }
function onAddImage() { function readImageAsDataUrl(file) {
// TODO: File picker for key image return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'))
reader.readAsDataURL(file)
})
} }
function onAddMedia() { function loadImage(src) {
// TODO: File picker for additional media return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'))
img.src = src
})
}
async function optimizeImageDataUrl(dataUrl) {
const img = await loadImage(dataUrl)
const MAX_SIDE = 1600
const scale = Math.min(1, MAX_SIDE / Math.max(img.width, img.height))
const width = Math.max(1, Math.round(img.width * scale))
const height = Math.max(1, Math.round(img.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return dataUrl
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', 0.84)
}
async function onKeyImageSelected(event) {
const input = event.target
const file = input?.files?.[0]
if (!file) return
try {
const sourceDataUrl = await readImageAsDataUrl(file)
eventsStore.ghostImage = await optimizeImageDataUrl(sourceDataUrl)
if (!eventsStore.ghostKeyImageTitle) {
eventsStore.ghostKeyImageTitle = 'Key Image'
}
eventsStore.saveGhostNow()
} catch {
$q.notify?.({
type: 'negative',
message: 'Bild konnte nicht geladen werden.'
})
} finally {
if (input) input.value = ''
}
}
async function onMediaSelected(event) {
const input = event.target
const files = Array.from(input?.files ?? [])
if (files.length === 0) return
try {
const images = await Promise.all(files.map(async (file) => ({
id: crypto.randomUUID(),
type: 'image',
name: file.name,
src: await optimizeImageDataUrl(await readImageAsDataUrl(file)),
createdAt: Date.now()
})))
eventsStore.ghostMedia = [...eventsStore.ghostMedia, ...images]
eventsStore.saveGhostNow()
} catch {
$q.notify?.({
type: 'negative',
message: 'Mindestens ein Bild konnte nicht geladen werden.'
})
} finally {
if (input) input.value = ''
}
}
function removeMediaImage(id) {
eventsStore.ghostMedia = eventsStore.ghostMedia.filter(item => item.id !== id)
eventsStore.saveGhostNow()
if (mediaGalleryIndex.value >= galleryItems.value.length) {
mediaGalleryIndex.value = Math.max(0, galleryItems.value.length - 1)
}
if (galleryItems.value.length === 0) {
mediaGalleryOpen.value = false
}
}
function openMediaGallery(id) {
const index = galleryItems.value.findIndex(item => item.id === id)
if (index === -1) return
mediaGalleryIndex.value = index
mediaGalleryOpen.value = true
}
function openKeyImageGallery() {
if (!eventsStore.ghostImage) return
mediaGalleryIndex.value = 0
mediaGalleryOpen.value = true
}
function confirmRemoveKeyImage() {
removeKeyImageDialogOpen.value = true
}
function removeKeyImage() {
eventsStore.ghostImage = null
eventsStore.ghostKeyImageTitle = ''
keyImageSrc.value = null
removeKeyImageDialogOpen.value = false
eventsStore.saveGhostNow()
} }
</script> </script>
@ -311,8 +572,12 @@ function onAddMedia() {
position: absolute; position: absolute;
top: 12px; top: 12px;
left: 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); background: rgba(0, 0, 0, 0.55);
color: #fff; color: #fff;
font-family: inherit;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
padding: 4px 10px; padding: 4px 10px;
@ -320,6 +585,56 @@ function onAddMedia() {
backdrop-filter: blur(4px); 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 { .event-panel__image-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -339,6 +654,34 @@ function onAddMedia() {
opacity: 0.8; 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 */ /* Title */
.event-panel__title { .event-panel__title {
margin-bottom: 0; margin-bottom: 0;
@ -350,27 +693,65 @@ function onAddMedia() {
line-height: 1.3; line-height: 1.3;
} }
/* Date row */ .event-panel__meta-grid {
.event-panel__date-row { 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; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-bottom: 20px; text-align: left;
opacity: 0.6;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s;
}
.event-panel__date-row:hover {
opacity: 0.9;
} }
.event-panel__date-icon { .event-panel__date-icon {
flex-shrink: 0; opacity: 0.7;
} }
.event-panel__date-label { .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 */ /* Card sections */
@ -447,51 +828,39 @@ function onAddMedia() {
margin-top: 4px; margin-top: 4px;
} }
/* Gradient Preset Selector */ .event-panel__gradient-edit {
.event-panel__presets {
margin-top: 16px; margin-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1); border-top: 1px solid rgba(128, 128, 128, 0.1);
padding-top: 12px; 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 { .event-panel__presets-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
opacity: 0.5; opacity: 0.5;
display: block;
margin-bottom: 8px;
} }
.event-panel__presets-grid { .event-panel__color-input {
display: flex; width: 44px;
gap: 6px; height: 24px;
flex-wrap: wrap; border: none;
}
.event-panel__preset {
width: 45px;
height: 25px;
border-radius: 6px; border-radius: 6px;
padding: 0;
background: transparent;
cursor: pointer; cursor: pointer;
border: 2px solid #eee;
transition: border-color 0.2s, transform 0.15s;
} }
.event-panel__preset:hover { .event-panel__gradient-actions {
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);
display: flex; display: flex;
align-items: center; justify-content: flex-end;
justify-content: center;
} }
/* Note */ /* Note */
@ -502,15 +871,66 @@ function onAddMedia() {
/* Media grid */ /* Media grid */
.event-panel__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; display: flex;
gap: 8px; align-items: center;
margin-top: 8px; justify-content: center;
flex-wrap: wrap; 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 { .event-panel__media-add {
width: 64px; min-height: 128px;
height: 64px; aspect-ratio: 1;
border-radius: 10px; border-radius: 10px;
border: 2px dashed rgba(128, 128, 128, 0.2); border: 2px dashed rgba(128, 128, 128, 0.2);
display: flex; display: flex;
@ -520,10 +940,72 @@ function onAddMedia() {
transition: border-color 0.2s; 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 { .event-panel__media-add:hover {
border-color: rgba(128, 128, 128, 0.4); 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 */ /* Delete */
.event-panel__delete { .event-panel__delete {
display: flex; display: flex;

View file

@ -29,6 +29,7 @@ const props = defineProps({
lineSpread: { type: Number, default: 0.05 }, lineSpread: { type: Number, default: 0.05 },
fanSpread: { type: Number, default: 0.05 }, fanSpread: { type: Number, default: 0.05 },
lineSharpness: { type: Number, default: 8.0 }, lineSharpness: { type: Number, default: 8.0 },
lineThickness: { type: Number, default: 1.0 },
waveFrequency: { type: Number, default: 7.0 }, waveFrequency: { type: Number, default: 7.0 },
bezierCurvature: { type: Number, default: 0.2 }, bezierCurvature: { type: Number, default: 0.2 },
circleRadiusPx: { type: Number, default: 75 }, circleRadiusPx: { type: Number, default: 75 },
@ -43,7 +44,13 @@ const props = defineProps({
bgColorEdge: { type: String, default: '#000000' }, bgColorEdge: { type: String, default: '#000000' },
backgroundImage: { type: String, default: '' }, backgroundImage: { type: String, default: '' },
mixBlendMode: { type: String, default: 'screen' }, 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 // FPS display
@ -71,7 +78,7 @@ void main() {
` `
const fragmentShader = ` const fragmentShader = `
precision mediump float; precision highp float;
uniform float iTime; uniform float iTime;
uniform vec3 iResolution; uniform vec3 iResolution;
@ -84,18 +91,36 @@ uniform float pointY[16];
uniform float lineSpread; uniform float lineSpread;
uniform float fanSpread; uniform float fanSpread;
uniform float lineSharpness; uniform float lineSharpness;
uniform float lineThickness;
uniform float waveFrequency; uniform float waveFrequency;
uniform float bezierCurvature; uniform float bezierCurvature;
uniform float lineBrightness; uniform float lineBrightness;
uniform vec3 pointColor[16]; 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 bool parallax;
uniform vec2 parallaxOffset; uniform vec2 parallaxOffset;
uniform vec3 lineGradient[8]; uniform vec3 lineGradient[8];
uniform int lineGradientCount; uniform int lineGradientCount;
uniform vec3 bgColorCenter; uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge; 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 bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
float bestT = 0.0; float bestT = 0.0;
@ -126,7 +151,6 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
return t; 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 waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
float s = dot(uv - curvePos, 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 float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
* sin(fi * 0.9 + time * 0.18); * sin(fi * 0.9 + time * 0.18);
float widthScale = max(lineThickness, 0.1);
float dist = s - linePos - waveDisp; 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); 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) { void mainImage(out vec4 fragColor, in vec2 fragCoord) {
@ -153,9 +201,9 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
baseUv += parallaxOffset; 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; const int MAX_SEGS = 15;
for (int s = 0; s < MAX_SEGS; ++s) { for (int s = 0; s < MAX_SEGS; ++s) {
@ -166,36 +214,105 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 segD = ep - sp; vec2 segD = ep - sp;
float segL = length(segD); 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 sPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature; vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0); float t_seg = clamp(dot(baseUv - sp, segDir) / max(segL, 1e-4), 0.0, 1.0);
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg); 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 bt = bezierClosestT(baseUv, sp, pc, ep);
float bmt = 1.0 - bt; float bmt = 1.0 - bt;
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep; 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); vec2 bNorm = vec2(-bTang.y, bTang.x);
float bDist = length(baseUv - bPos); float shadowStrength = clamp(staticLineShadowStrength, 0.0, 1.0);
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt); if (lineMode != 1 || shadowStrength > 0.0) {
float fogEnv = sin(bt * 3.14159265359); float bDist = length(baseUv - bPos);
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001); float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
col += lineCol * segFog; 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) { 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; float dist = length(baseUv) / 1.8;
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0)); 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() { void main() {
@ -238,8 +355,14 @@ let rafId = null
let resizeObserver = null let resizeObserver = null
let uniforms = null let uniforms = null
let scrollHandler = null let scrollHandler = null
let boundScrollContainer = null
let cachedScrollLeft = 0
let scrollIdleTimer = null let scrollIdleTimer = null
let visibilityHandler = 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 // Parallax tracking
let targetParallax = null let targetParallax = null
@ -282,6 +405,11 @@ function applyBgColors() {
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z) 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 all props for live updates
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v }) watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
watch(() => props.lineCount, () => { watch(() => props.lineCount, () => {
@ -291,6 +419,7 @@ watch(() => props.lineCount, () => {
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v }) watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v }) watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.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.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v }) watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.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.linesGradient, applyGradient, { deep: true })
watch(() => props.bgColorCenter, applyBgColors) watch(() => props.bgColorCenter, applyBgColors)
watch(() => props.bgColorEdge, 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(() => { onMounted(() => {
if (!containerRef.value) return if (!containerRef.value) return
@ -331,8 +487,9 @@ onMounted(() => {
let currentDpr = DPR_IDLE let currentDpr = DPR_IDLE
let scrolling = false 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.setPixelRatio(currentDpr)
renderer.setClearAlpha(0)
renderer.domElement.style.width = '100%' renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%' renderer.domElement.style.height = '100%'
renderer.domElement.style.display = 'block' renderer.domElement.style.display = 'block'
@ -357,6 +514,7 @@ onMounted(() => {
lineSpread: { value: props.lineSpread }, lineSpread: { value: props.lineSpread },
fanSpread: { value: props.fanSpread }, fanSpread: { value: props.fanSpread },
lineSharpness: { value: props.lineSharpness }, lineSharpness: { value: props.lineSharpness },
lineThickness: { value: props.lineThickness },
waveFrequency: { value: props.waveFrequency }, waveFrequency: { value: props.waveFrequency },
bezierCurvature: { value: props.bezierCurvature }, bezierCurvature: { value: props.bezierCurvature },
lineBrightness: { value: props.lineBrightness }, lineBrightness: { value: props.lineBrightness },
@ -364,6 +522,14 @@ onMounted(() => {
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1)) 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 }, parallax: { value: props.parallax },
parallaxOffset: { value: new Vector2(0, 0) }, parallaxOffset: { value: new Vector2(0, 0) },
@ -372,12 +538,14 @@ onMounted(() => {
}, },
lineGradientCount: { value: 0 }, lineGradientCount: { value: 0 },
bgColorCenter: { value: new Vector3(0, 0, 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 // Apply initial values
applyGradient() applyGradient()
applyBgColors() applyBgColors()
applyBackgroundImageFlag()
applyPointColors() applyPointColors()
material = new ShaderMaterial({ material = new ShaderMaterial({
@ -402,6 +570,7 @@ onMounted(() => {
const canvasHeight = renderer.domElement.height const canvasHeight = renderer.domElement.height
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1) uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
} }
forceResizeHandler = setSize
setSize() setSize()
resizeObserver = new ResizeObserver(setSize) resizeObserver = new ResizeObserver(setSize)
@ -409,7 +578,7 @@ onMounted(() => {
// Pointer events (parallax only) // Pointer events (parallax only)
if (props.parallax) { if (props.parallax) {
const handlePointerMove = (event) => { pointerMoveHandler = (event) => {
const rect = renderer.domElement.getBoundingClientRect() const rect = renderer.domElement.getBoundingClientRect()
const x = event.clientX - rect.left const x = event.clientX - rect.left
const y = event.clientY - rect.top const y = event.clientY - rect.top
@ -420,11 +589,11 @@ onMounted(() => {
(-(y - centerY) / rect.height) * 0.2 (-(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. // Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
let cachedScrollLeft = 0
function setDpr(dpr) { function setDpr(dpr) {
if (dpr === currentDpr) return if (dpr === currentDpr) return
@ -458,6 +627,7 @@ onMounted(() => {
if (props.scrollContainer) { if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0 cachedScrollLeft = props.scrollContainer.scrollLeft || 0
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }) props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
boundScrollContainer = props.scrollContainer
} }
// Fast inline scroll sync reads cached scrollLeft instead of DOM during render // Fast inline scroll sync reads cached scrollLeft instead of DOM during render
@ -519,8 +689,8 @@ onMounted(() => {
dprDisplay.value = currentDpr.toFixed(2) dprDisplay.value = currentDpr.toFixed(2)
} }
// Read latest scrollLeft from DOM in case scroll event was missed // Fallback read only when no listener is bound.
if (props.scrollContainer) { if (!boundScrollContainer && props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0 cachedScrollLeft = props.scrollContainer.scrollLeft || 0
} }
@ -539,15 +709,23 @@ onMounted(() => {
renderLoop() renderLoop()
}) })
defineExpose({ fpsDisplay, dprDisplay }) function forceResize() {
if (forceResizeHandler) forceResizeHandler()
}
defineExpose({ fpsDisplay, dprDisplay, forceResize })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId) if (rafId) cancelAnimationFrame(rafId)
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler) if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
if (resizeObserver) resizeObserver.disconnect() if (resizeObserver) resizeObserver.disconnect()
if (props.scrollContainer && scrollHandler) { if (boundScrollContainer && scrollHandler) {
props.scrollContainer.removeEventListener('scroll', scrollHandler) boundScrollContainer.removeEventListener('scroll', scrollHandler)
} }
boundScrollContainer = null
if (pointerTargetEl && pointerMoveHandler) pointerTargetEl.removeEventListener('pointermove', pointerMoveHandler)
pointerTargetEl = null
pointerMoveHandler = null
clearTimeout(scrollIdleTimer) clearTimeout(scrollIdleTimer)
if (geometry) geometry.dispose() if (geometry) geometry.dispose()
if (material) material.dispose() if (material) material.dispose()
@ -557,6 +735,7 @@ onBeforeUnmount(() => {
renderer.domElement.parentNode.removeChild(renderer.domElement) renderer.domElement.parentNode.removeChild(renderer.domElement)
} }
} }
forceResizeHandler = null
}) })
</script> </script>

View file

@ -8,9 +8,14 @@
'glow-dot--label-above': labelAbove 'glow-dot--label-above': labelAbove
}" }"
:style="dotStyle" :style="dotStyle"
:role="isGhost ? undefined : 'button'"
:tabindex="isGhost ? -1 : 0"
:aria-label="dotAriaLabel"
@click.stop="onSelect" @click.stop="onSelect"
@keydown.enter.prevent.stop="onSelect"
@keydown.space.prevent.stop="onSelect"
> >
<div class="glow-dot__inner" :style="{ boxShadow: glowShadow }"> <div class="glow-dot__inner" :style="innerStyle">
<img <img
v-if="imageSrc" v-if="imageSrc"
:src="imageSrc" :src="imageSrc"
@ -19,6 +24,7 @@
/> />
</div> </div>
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle"> <div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
<span class="glow-dot__connector" :style="connectorStyle"></span>
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span> <span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span> <span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
</div> </div>
@ -43,9 +49,9 @@ const eventsStore = useEventsStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
// Resolve image: cached thumbnail from IndexedDB or fetch & cache // Resolve image: cached thumbnail from IndexedDB or fetch & cache
const { resolvedSrc: imageSrc } = props.event.image const imageUrl = computed(() => props.event.image || null)
? useImageCache(props.event.image, props.event.id) const eventId = computed(() => props.event.id)
: { resolvedSrc: computed(() => null) } const { resolvedSrc: imageSrc } = useImageCache(imageUrl, eventId)
const fl = computed(() => settingsStore.floatingLines) const fl = computed(() => settingsStore.floatingLines)
const glowColor = computed(() => eventsStore.getGlowColor(props.event)) const glowColor = computed(() => eventsStore.getGlowColor(props.event))
@ -67,19 +73,42 @@ const formattedDate = computed(() => {
const d = new Date(props.event.date) const d = new Date(props.event.date)
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}` 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 // 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 labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff') 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(() => ({ 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(() => ({ const titleStyle = computed(() => ({
fontSize: `${labelFont.value.title}px`, fontSize: `${labelFont.value.title}px`,
color: labelColor.value, 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(() => ({ const dateStyle = computed(() => ({
fontSize: `${labelFont.value.date}px`, fontSize: `${labelFont.value.date}px`,
@ -93,14 +122,18 @@ const dotStyle = computed(() => ({
height: `${dotSize.value}px` height: `${dotSize.value}px`
})) }))
// Two-layer box-shadow: tight bright core + wide soft halo const innerStyle = computed(() => {
const glowShadow = computed(() => { const size = fl.value.glowSize
const size = fl.value.glowSize
const strength = fl.value.glowStrength const strength = fl.value.glowStrength
const color = glowColor.value const color = glowColor.value
const core = alphaHex(Math.min(strength / 3, 1)) const core = alphaHex(Math.min(strength / 3, 1))
const halo = alphaHex(Math.min(strength / 7, 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 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) { function alphaHex(a) {
@ -163,25 +196,41 @@ function onSelect() {
position: absolute; position: absolute;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
top: calc(100% + 6px); top: calc(100% + var(--label-gap, 18px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1px; gap: 1px;
max-width: 90px; max-width: 90px;
pointer-events: none; pointer-events: none;
opacity: var(--label-opacity, 0.75);
} }
/* When dot is in lower half, show label above */ /* When dot is in lower half, show label above */
.glow-dot--label-above .glow-dot__label { .glow-dot--label-above .glow-dot__label {
top: auto; 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 { .glow-dot__title {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
opacity: 0.7;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -193,7 +242,6 @@ function onSelect() {
.glow-dot__date { .glow-dot__date {
font-size: 9px; font-size: 9px;
font-weight: 400; font-weight: 400;
opacity: 0.4;
white-space: nowrap; white-space: nowrap;
line-height: 1.2; line-height: 1.2;
} }

View file

@ -17,6 +17,23 @@
<div class="lw-settings__scroll"> <div class="lw-settings__scroll">
<div class="lw-settings__title">Einstellungen</div> <div class="lw-settings__title">Einstellungen</div>
<!-- Modus -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Modus</span>
<div class="lw-settings__segmented">
<button
class="lw-settings__seg-btn"
:class="{ 'lw-settings__seg-btn--active': !isStatic }"
@click="update({ lineMode: 'glow' })"
>Glowing Lines</button>
<button
class="lw-settings__seg-btn"
:class="{ 'lw-settings__seg-btn--active': isStatic }"
@click="update({ lineMode: 'static' })"
>Color Lines</button>
</div>
</div>
<!-- Linien --> <!-- Linien -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }"> <div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Linien</span> <span class="lw-settings__card-label">Linien</span>
@ -28,7 +45,7 @@
<q-slider <q-slider
:model-value="fl.speed" :model-value="fl.speed"
@update:model-value="v => update({ speed: v })" @update:model-value="v => update({ speed: v })"
:min="0.1" :max="3" :step="0.05" :min="0" :max="2" :step="0.05"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -38,7 +55,7 @@
<q-slider <q-slider
:model-value="fl.lineCount" :model-value="fl.lineCount"
@update:model-value="v => update({ lineCount: v })" @update:model-value="v => update({ lineCount: v })"
:min="1" :max="40" :step="1" :min="1" :max="10" :step="1"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -48,7 +65,7 @@
<q-slider <q-slider
:model-value="fl.spread" :model-value="fl.spread"
@update:model-value="v => update({ spread: v })" @update:model-value="v => update({ spread: v })"
:min="0.01" :max="1" :step="0.01" :min="0.01" :max="0.5" :step="0.01"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -58,7 +75,7 @@
<q-slider <q-slider
:model-value="fl.fanSpread" :model-value="fl.fanSpread"
@update:model-value="v => update({ fanSpread: v })" @update:model-value="v => update({ fanSpread: v })"
:min="0.01" :max="0.5" :step="0.005" :min="0.01" :max="0.3" :step="0.005"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -68,7 +85,17 @@
<q-slider <q-slider
:model-value="fl.lineSharpness" :model-value="fl.lineSharpness"
@update:model-value="v => update({ lineSharpness: v })" @update:model-value="v => update({ lineSharpness: v })"
:min="0.3" :max="10" :step="0.1" :min="5" :max="10" :step="0.1"
/>
<div class="lw-settings__row">
<span>Linienstärke</span>
<span class="lw-settings__value">{{ (fl.lineThickness ?? 1).toFixed(2) }}</span>
</div>
<q-slider
:model-value="fl.lineThickness ?? 1"
@update:model-value="v => update({ lineThickness: v })"
:min="0.5" :max="4" :step="0.05"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -78,7 +105,7 @@
<q-slider <q-slider
:model-value="fl.waveFrequency" :model-value="fl.waveFrequency"
@update:model-value="v => update({ waveFrequency: v })" @update:model-value="v => update({ waveFrequency: v })"
:min="1" :max="30" :step="0.5" :min="1" :max="10" :step="0.5"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -88,7 +115,7 @@
<q-slider <q-slider
:model-value="fl.bezierCurvature" :model-value="fl.bezierCurvature"
@update:model-value="v => update({ bezierCurvature: v })" @update:model-value="v => update({ bezierCurvature: v })"
:min="-1" :max="1" :step="0.05" :min="-0.5" :max="0.5" :step="0.05"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -98,7 +125,7 @@
<q-slider <q-slider
:model-value="fl.circleRadius" :model-value="fl.circleRadius"
@update:model-value="v => update({ circleRadius: v })" @update:model-value="v => update({ circleRadius: v })"
:min="10" :max="200" :step="5" :min="50" :max="200" :step="5"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -108,7 +135,7 @@
<q-slider <q-slider
:model-value="fl.glowSize" :model-value="fl.glowSize"
@update:model-value="v => update({ glowSize: v })" @update:model-value="v => update({ glowSize: v })"
:min="5" :max="100" :step="1" :min="0" :max="50" :step="1"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -118,7 +145,7 @@
<q-slider <q-slider
:model-value="fl.glowStrength" :model-value="fl.glowStrength"
@update:model-value="v => update({ glowStrength: v })" @update:model-value="v => update({ glowStrength: v })"
:min="0.5" :max="12" :step="0.5" :min="0" :max="10" :step="0.5"
/> />
<div class="lw-settings__row"> <div class="lw-settings__row">
@ -128,7 +155,52 @@
<q-slider <q-slider
:model-value="fl.lineBrightness ?? 1" :model-value="fl.lineBrightness ?? 1"
@update:model-value="v => update({ lineBrightness: v })" @update:model-value="v => update({ lineBrightness: v })"
:min="0.05" :max="2" :step="0.05" :min="0.05" :max="1" :step="0.05"
/>
<div class="lw-settings__row">
<span>Rahmen Stärke</span>
<span class="lw-settings__value">{{ (fl.dotBorderWidth ?? 0) === 0 ? 'aus' : `${fl.dotBorderWidth}px` }}</span>
</div>
<q-slider
:model-value="fl.dotBorderWidth ?? 0"
@update:model-value="v => update({ dotBorderWidth: v })"
:min="0" :max="8" :step="0.5"
/>
<div class="lw-settings__row" style="margin-top: 4px;">
<span>Rahmen Farbe</span>
<input
type="color"
:value="fl.dotBorderColor ?? '#ffffff'"
@input="e => update({ dotBorderColor: e.target.value })"
class="lw-settings__color-input"
/>
</div>
</div>
<!-- Linienfarbe (Static Lines) -->
<div v-if="isStatic" class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Linienfarbe</span>
<div class="lw-settings__row">
<span>Farbe</span>
<input
type="color"
:value="fl.staticLineColor ?? '#2196F3'"
@input="e => update({ staticLineColor: e.target.value })"
class="lw-settings__color-input"
/>
</div>
<div class="lw-settings__row">
<span>Schatten</span>
<span class="lw-settings__value">{{ (fl.staticLineShadowStrength ?? 0).toFixed(2) }}</span>
</div>
<q-slider
:model-value="fl.staticLineShadowStrength ?? 0"
@update:model-value="v => update({ staticLineShadowStrength: v })"
:min="0" :max="1" :step="0.05"
/> />
</div> </div>
@ -160,6 +232,26 @@
class="lw-settings__color-input" class="lw-settings__color-input"
/> />
</div> </div>
<div class="lw-settings__row">
<span>Transparenz</span>
<span class="lw-settings__value">{{ (fl.labelOpacity ?? 0.75).toFixed(2) }}</span>
</div>
<q-slider
:model-value="fl.labelOpacity ?? 0.75"
@update:model-value="v => update({ labelOpacity: v })"
:min="0.5" :max="1" :step="0.05"
/>
<div class="lw-settings__row">
<span>Strichlänge</span>
<span class="lw-settings__value">{{ (fl.labelConnectorLength ?? 0.2).toFixed(2) }}</span>
</div>
<q-slider
:model-value="fl.labelConnectorLength ?? 0.2"
@update:model-value="v => update({ labelConnectorLength: v })"
:min="0" :max="1" :step="0.01"
/>
</div> </div>
<!-- Hintergrundbild --> <!-- Hintergrundbild -->
@ -174,6 +266,13 @@
> >
Keins Keins
</button> </button>
<button
class="lw-settings__img-btn"
:class="{ 'lw-settings__img-btn--active': isCustomBackground }"
@click="openBackgroundUpload"
>
Eigenes Bild
</button>
<button <button
v-for="n in 10" v-for="n in 10"
:key="'bg' + n" :key="'bg' + n"
@ -184,6 +283,13 @@
{{ n }} {{ n }}
</button> </button>
</div> </div>
<input
ref="backgroundUploadRef"
type="file"
accept="image/*"
class="lw-settings__file-input"
@change="onBackgroundUpload"
/>
</div> </div>
<!-- Hintergrundfarbe --> <!-- Hintergrundfarbe -->
@ -191,7 +297,7 @@
<span class="lw-settings__card-label">Hintergrundfarbe</span> <span class="lw-settings__card-label">Hintergrundfarbe</span>
<div class="lw-settings__row"> <div class="lw-settings__row">
<span>BG Mitte</span> <span>{{ isSplit ? 'Unten' : 'BG Mitte' }}</span>
<input <input
type="color" type="color"
:value="fl.bgCenter" :value="fl.bgCenter"
@ -201,7 +307,7 @@
</div> </div>
<div class="lw-settings__row"> <div class="lw-settings__row">
<span>BG Rand</span> <span>{{ isSplit ? 'Oben' : 'BG Rand' }}</span>
<input <input
type="color" type="color"
:value="fl.bgEdge" :value="fl.bgEdge"
@ -211,24 +317,51 @@
</div> </div>
</div> </div>
<!-- Farbstopps -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Farbverlauf (je Zeile ein Hex)</span>
<textarea
:value="fl.gradientStops"
@input="e => update({ gradientStops: e.target.value })"
class="lw-settings__gradient-input"
rows="4"
spellcheck="false"
></textarea>
</div>
<!-- Extras --> <!-- Extras -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }"> <div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Extras</span> <span class="lw-settings__card-label">Extras</span>
<div class="lw-settings__row"> <div class="lw-settings__row">
<span>Horizont</span>
</div>
<div class="lw-settings__segmented" style="margin-top: 6px;">
<button
v-for="m in HORIZON_MODES"
:key="m.value"
class="lw-settings__seg-btn"
:class="{ 'lw-settings__seg-btn--active': (fl.horizonMode ?? 'off') === m.value }"
@click="update({ horizonMode: m.value })"
>{{ m.label }}</button>
</div>
<template v-if="isSplit">
<div class="lw-settings__row">
<span>Übergang</span>
<span class="lw-settings__value lw-settings__value--hint">
{{ (fl.horizonBlend ?? 0.2) < 0.1 ? 'scharf' : (fl.horizonBlend ?? 0.2) > 0.6 ? 'weich' : '' }}
{{ (fl.horizonBlend ?? 0.2).toFixed(2) }}
</span>
</div>
<q-slider
:model-value="fl.horizonBlend ?? 0.2"
@update:model-value="v => update({ horizonBlend: v })"
:min="0" :max="1" :step="0.02"
/>
</template>
<template v-else-if="(fl.horizonMode ?? 'off') !== 'off'">
<div class="lw-settings__row">
<span>Deckkraft</span>
<span class="lw-settings__value">{{ (fl.horizonOpacity ?? 0.5).toFixed(2) }}</span>
</div>
<q-slider
:model-value="fl.horizonOpacity ?? 0.5"
@update:model-value="v => update({ horizonOpacity: v })"
:min="0.05" :max="1" :step="0.05"
/>
</template>
<div class="lw-settings__row" style="margin-top: 12px;">
<span>{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}</span> <span>{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}</span>
<q-toggle <q-toggle
:model-value="isDark" :model-value="isDark"
@ -238,6 +371,34 @@
</div> </div>
</div> </div>
<!-- Presets -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Presets</span>
<q-select
v-model="selectedPresetId"
:options="presetOptions"
emit-value
map-options
dense
outlined
clearable
options-dense
label="Preset auswählen"
:dark="isDark"
:disable="presetOptions.length === 0"
/>
<q-btn
class="lw-settings__save-preset"
unelevated
no-caps
icon="bookmark_add"
label="Speichern"
@click="openPresetDialog"
/>
</div>
<!-- Reset --> <!-- Reset -->
<div class="lw-settings__reset"> <div class="lw-settings__reset">
<q-btn <q-btn
@ -249,19 +410,54 @@
/> />
</div> </div>
</div> </div>
<q-dialog v-model="presetDialogOpen">
<q-card class="lw-settings__dialog" :class="{ 'lw-settings__dialog--dark': isDark }">
<q-card-section>
<div class="lw-settings__dialog-title">Preset speichern</div>
<div class="lw-settings__dialog-copy">
Speichert alle aktuellen Life Wave Settings inklusive Hintergrundbild.
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="presetName"
autofocus
dense
outlined
label="Name"
:dark="isDark"
@keyup.enter="confirmSavePreset"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Abbrechen" v-close-popup />
<q-btn unelevated no-caps label="Bestätigen" @click="confirmSavePreset" />
</q-card-actions>
</q-card>
</q-dialog>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { usePanelDrag } from 'composables/usePanelDrag' import { usePanelDrag } from 'composables/usePanelDrag'
const props = defineProps({ open: { type: Boolean, default: false } }) const props = defineProps({ open: { type: Boolean, default: false } })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => emit('close')) const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(
() => emit('close'),
{
initialDvh: 50,
maxDvh: 50,
snapPoints: [50, 25]
}
)
watch(() => props.open, (open) => { if (open) resetHeight() }) watch(() => props.open, (open) => { if (open) resetHeight() })
@ -269,20 +465,130 @@ const $q = useQuasar()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
const fl = computed(() => settingsStore.floatingLines) const fl = computed(() => settingsStore.floatingLines)
const isStatic = computed(() => fl.value.lineMode === 'static')
const isSplit = computed(() => (fl.value.horizonMode ?? 'off') === 'split')
const backgroundUploadRef = ref(null)
const presetDialogOpen = ref(false)
const presetName = ref('')
const presetOptions = computed(() =>
settingsStore.presets.map(preset => ({
label: preset.name,
value: preset.id
}))
)
const selectedPresetId = computed({
get: () => settingsStore.activePresetId,
set: (presetId) => {
if (!presetId) return
settingsStore.applyPreset(presetId)
}
})
const isCustomBackground = computed(() => {
const bg = fl.value.backgroundImage ?? ''
return bg.startsWith('data:image/')
})
const HORIZON_MODES = [
{ label: 'Aus', value: 'off' },
{ label: 'Nebel', value: 'fog' },
{ label: 'Trennung', value: 'split' },
{ label: 'Glow', value: 'glow' },
]
const LABEL_SIZES = [ const LABEL_SIZES = [
{ label: 'Klein', value: 'small' }, { label: 'Klein', value: 'small' },
{ label: 'Mittel', value: 'medium' }, { label: 'Mittel', value: 'medium' },
{ label: 'Groß', value: 'large' } { label: 'Groß', value: 'large' },
{ label: 'Extra groß', value: 'xlarge' }
] ]
function update(changes) { function update(changes) {
settingsStore.updateFloatingLines(changes) settingsStore.updateFloatingLines(changes)
} }
function openPresetDialog() {
presetName.value = ''
presetDialogOpen.value = true
}
function confirmSavePreset() {
const saved = settingsStore.savePreset(presetName.value)
if (!saved) {
$q.notify?.({
type: 'warning',
message: 'Bitte gib einen Namen für das Preset ein.'
})
return
}
presetDialogOpen.value = false
$q.notify?.({
type: 'positive',
message: `Preset "${saved.name}" gespeichert.`
})
}
function toggleDark() { function toggleDark() {
$q.dark.toggle() $q.dark.toggle()
} }
function openBackgroundUpload() {
backgroundUploadRef.value?.click()
}
function readImageAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'))
reader.readAsDataURL(file)
})
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'))
img.src = src
})
}
async function optimizeImageDataUrl(dataUrl) {
const img = await loadImage(dataUrl)
const MAX_SIDE = 1600
const scale = Math.min(1, MAX_SIDE / Math.max(img.width, img.height))
const width = Math.max(1, Math.round(img.width * scale))
const height = Math.max(1, Math.round(img.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return dataUrl
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', 0.86)
}
async function onBackgroundUpload(event) {
const input = event.target
const file = input?.files?.[0]
if (!file) return
try {
const sourceDataUrl = await readImageAsDataUrl(file)
const optimized = await optimizeImageDataUrl(sourceDataUrl)
update({ backgroundImage: optimized })
} catch {
$q.notify?.({
type: 'negative',
message: 'Bild konnte nicht geladen werden.'
})
} finally {
// Allow selecting the same file again.
if (input) input.value = ''
}
}
</script> </script>
<style scoped> <style scoped>
@ -292,7 +598,7 @@ function toggleDark() {
left: 0; left: 0;
right: 0; right: 0;
z-index: 20; z-index: 20;
height: 75dvh; height: 50dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
@ -374,6 +680,11 @@ function toggleDark() {
opacity: 0.6; opacity: 0.6;
} }
.lw-settings__value--hint {
font-size: 11px;
opacity: 0.5;
}
/* Segmented control */ /* Segmented control */
.lw-settings__segmented { .lw-settings__segmented {
display: flex; display: flex;
@ -398,7 +709,7 @@ function toggleDark() {
} }
.lw-settings__seg-btn--active { .lw-settings__seg-btn--active {
background: rgba(168, 85, 247, 0.25); background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.25);
opacity: 1; opacity: 1;
font-weight: 600; font-weight: 600;
} }
@ -424,15 +735,19 @@ function toggleDark() {
.lw-settings__img-btn:hover { .lw-settings__img-btn:hover {
opacity: 1; opacity: 1;
border-color: #a855f7; border-color: var(--tm-accent, #737373);
} }
.lw-settings__img-btn--active { .lw-settings__img-btn--active {
border-color: #a855f7; border-color: var(--tm-accent, #737373);
color: #a855f7; color: var(--tm-accent, #737373);
opacity: 1; opacity: 1;
} }
.lw-settings__file-input {
display: none;
}
.lw-settings__color-input { .lw-settings__color-input {
width: 36px; width: 36px;
height: 22px; height: 22px;
@ -443,6 +758,38 @@ function toggleDark() {
border-radius: 4px; border-radius: 4px;
} }
.lw-settings__save-preset {
width: 100%;
margin-top: 14px;
border-radius: 10px;
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.24);
color: inherit;
}
.lw-settings__dialog {
width: min(360px, calc(100vw - 32px));
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
color: #1a1a1a;
}
.lw-settings__dialog--dark {
background: rgba(30, 30, 30, 0.96);
color: #f5f5f5;
}
.lw-settings__dialog-title {
font-size: 18px;
font-weight: 700;
}
.lw-settings__dialog-copy {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
line-height: 1.4;
}
.lw-settings__gradient-input { .lw-settings__gradient-input {
width: 100%; width: 100%;
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);

View file

@ -142,11 +142,13 @@ const isDark = computed(() => $q.dark.isActive)
.modal-card__tab--active { .modal-card__tab--active {
opacity: 1; opacity: 1;
background: rgba(128, 128, 128, 0.18); background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.2);
color: var(--tm-accent, #737373);
} }
.modal-card--dark .modal-card__tab--active { .modal-card--dark .modal-card__tab--active {
background: rgba(255, 255, 255, 0.12); background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.25);
color: #fff;
} }
/* Body */ /* Body */

View file

@ -72,20 +72,22 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings'
import GlowDot from 'components/GlowDot.vue' import GlowDot from 'components/GlowDot.vue'
const emit = defineEmits(['dotSelect', 'viewUpdate']) const emit = defineEmits(['dotSelect', 'viewUpdate'])
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
const timelineRef = ref(null) const timelineRef = ref(null)
const scrollLeft = ref(0) const scrollLeft = ref(0)
const viewportWidth = ref(400) const viewportWidth = ref(400)
const containerHeight = ref(400) const containerHeight = ref(400)
// Zoom: 1.0 = default, range 0.43.0 // Zoom: 1.0 = default, range 0.43.0
const zoomLevel = ref(1)
const MIN_ZOOM = 0.4 const MIN_ZOOM = 0.4
const MAX_ZOOM = 3.0 const MAX_ZOOM = 3.0
const ZOOM_STEP = 0.08 const ZOOM_STEP = 0.08
const zoomLevel = ref(clampZoom(settingsStore.timelineZoom ?? DEFAULT_TIMELINE_ZOOM))
// Spacing: ~4 events visible at a time, scaled by zoom // Spacing: ~4 events visible at a time, scaled by zoom
const BASE_SPACING = computed(() => viewportWidth.value / 2.5) const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
@ -210,6 +212,8 @@ const stickyYearLabels = computed(() => {
// Virtualization: only render events near the viewport // Virtualization: only render events near the viewport
const VIS_BUFFER = 2 const VIS_BUFFER = 2
const VIEW_EMIT_BUFFER = 3
const MAX_SHADER_POINTS = 16
const visibleRange = computed(() => { const visibleRange = computed(() => {
const total = displayEvents.value.length const total = displayEvents.value.length
@ -258,6 +262,8 @@ const activeLabel = computed(() => {
function onScroll() { function onScroll() {
if (timelineRef.value) { if (timelineRef.value) {
scrollLeft.value = timelineRef.value.scrollLeft scrollLeft.value = timelineRef.value.scrollLeft
if (restoringScrollFromSettings) return
persistScrollPosition()
} }
} }
@ -288,10 +294,9 @@ function scrollToYearCenter(year) {
} }
function updateViewportWidth() { function updateViewportWidth() {
if (timelineRef.value) { if (!timelineRef.value) return
viewportWidth.value = timelineRef.value.clientWidth || 400 viewportWidth.value = timelineRef.value.clientWidth || 400
containerHeight.value = timelineRef.value.clientHeight || 400 containerHeight.value = timelineRef.value.clientHeight || 400
}
} }
// Zoom while keeping the viewport center stable // Zoom while keeping the viewport center stable
@ -316,6 +321,7 @@ function applyZoom(newZoom, centerClientX) {
nextTick(() => { nextTick(() => {
el.scrollLeft = worldXBefore * ratio - cx el.scrollLeft = worldXBefore * ratio - cx
scrollLeft.value = el.scrollLeft scrollLeft.value = el.scrollLeft
persistScrollPosition()
}) })
} }
@ -334,6 +340,7 @@ function onWheel(e) {
if (el) { if (el) {
el.scrollLeft += e.deltaX || e.deltaY el.scrollLeft += e.deltaX || e.deltaY
scrollLeft.value = el.scrollLeft scrollLeft.value = el.scrollLeft
persistScrollPosition()
} }
} }
} }
@ -369,51 +376,244 @@ function onTouchEnd() {
touchStartDist = 0 touchStartDist = 0
} }
// Scroll to center on the last event on mount
let resizeObserver = null let resizeObserver = null
let resizeRafId = 0
let isInitialized = false
let viewUpdateRafId = 0
let restoringZoomFromSettings = false
let scrollPersistTimer = 0
let restoreScrollRafId = 0
let restoringScrollFromSettings = false
let initialScrollPositionApplied = false
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}
function clampZoom(value) {
return clamp(Number(value) || DEFAULT_TIMELINE_ZOOM, MIN_ZOOM, MAX_ZOOM)
}
function persistScrollPosition(immediate = false) {
if (!initialScrollPositionApplied || restoringScrollFromSettings) return
if (scrollPersistTimer) {
clearTimeout(scrollPersistTimer)
scrollPersistTimer = 0
}
if (immediate) {
settingsStore.saveTimelineScrollLeft(scrollLeft.value, true)
return
}
scrollPersistTimer = setTimeout(() => {
scrollPersistTimer = 0
settingsStore.saveTimelineScrollLeft(scrollLeft.value, true)
}, 120)
}
function restoreSavedScrollPosition() {
const el = timelineRef.value
if (!el) return false
const hasSavedScrollLeft = settingsStore.timelineScrollLeft !== null && settingsStore.timelineScrollLeft !== undefined
const savedScrollLeft = Number(settingsStore.timelineScrollLeft)
if (!hasSavedScrollLeft || !Number.isFinite(savedScrollLeft)) return false
const maxScroll = Math.max(0, trackWidth.value - viewportWidth.value)
if (maxScroll <= 0 && savedScrollLeft > 0) return false
restoringScrollFromSettings = true
el.scrollLeft = clamp(savedScrollLeft, 0, maxScroll)
scrollLeft.value = el.scrollLeft
initialScrollPositionApplied = true
nextTick(() => {
restoringScrollFromSettings = false
})
return true
}
function applyInitialScrollPosition() {
if (initialScrollPositionApplied || !timelineRef.value) return
updateViewportWidth()
if (restoreSavedScrollPosition()) return
const events = displayEvents.value
const maxScroll = Math.max(0, trackWidth.value - viewportWidth.value)
if (events.length === 0 || maxScroll <= 0) return
const lastX = getEventX(events.length - 1)
timelineRef.value.scrollLeft = clamp(lastX - viewportWidth.value / 2, 0, maxScroll)
scrollLeft.value = timelineRef.value.scrollLeft
initialScrollPositionApplied = true
persistScrollPosition()
}
function recalculateLayoutAfterResize() {
const el = timelineRef.value
if (!el) return
if (!initialScrollPositionApplied) {
updateViewportWidth()
applyInitialScrollPosition()
return
}
// Preserve center in event-index space so dots/labels stay aligned after resize.
const oldViewport = viewportWidth.value || 1
const oldPadding = PADDING.value
const oldSpacing = EVENT_SPACING.value || 1
const oldCenterWorld = el.scrollLeft + oldViewport / 2
const centerIndex = (oldCenterWorld - oldPadding) / oldSpacing
updateViewportWidth()
const newPadding = PADDING.value
const newSpacing = EVENT_SPACING.value || 1
const newCenterWorld = newPadding + centerIndex * newSpacing
const maxScroll = Math.max(0, trackWidth.value - viewportWidth.value)
el.scrollLeft = clamp(newCenterWorld - viewportWidth.value / 2, 0, maxScroll)
scrollLeft.value = el.scrollLeft
persistScrollPosition()
}
function forceReflow() {
recalculateLayoutAfterResize()
emitViewState()
}
function onPageHide() {
persistScrollPosition(true)
}
function getShaderEvents() {
const total = displayEvents.value.length
if (total === 0) return []
const { start, end } = visibleRange.value
if (end < start) return []
const rangeStart = Math.max(0, start - VIEW_EMIT_BUFFER)
const rangeEnd = Math.min(total - 1, end + VIEW_EMIT_BUFFER)
let windowStart = rangeStart
let windowEnd = rangeEnd
if (windowEnd - windowStart + 1 > MAX_SHADER_POINTS) {
const centerIndex = Math.floor((start + end) / 2)
const maxStart = Math.max(rangeStart, rangeEnd - MAX_SHADER_POINTS + 1)
windowStart = Math.max(
rangeStart,
Math.min(maxStart, centerIndex - Math.floor(MAX_SHADER_POINTS / 2))
)
windowEnd = windowStart + MAX_SHADER_POINTS - 1
}
return displayEvents.value.slice(windowStart, windowEnd + 1).map((event, i) => ({
emotion: event.emotion,
x: getEventX(windowStart + i),
color: eventsStore.getGlowColor(event)
}))
}
// Emit timeline state so the layout can position shader points // Emit timeline state so the layout can position shader points
function emitViewState() { function emitViewState() {
const { start, end } = visibleRange.value const { start, end } = visibleRange.value
if (viewUpdateRafId) {
cancelAnimationFrame(viewUpdateRafId)
viewUpdateRafId = 0
}
emit('viewUpdate', { emit('viewUpdate', {
scrollLeft: scrollLeft.value, scrollLeft: scrollLeft.value,
viewportWidth: viewportWidth.value, viewportWidth: viewportWidth.value,
containerHeight: containerHeight.value, containerHeight: containerHeight.value,
visibleStart: start, visibleStart: start,
visibleEnd: end, visibleEnd: end,
events: displayEvents.value.map((e, i) => ({ events: getShaderEvents()
emotion: e.emotion,
x: getEventX(i),
color: eventsStore.getGlowColor(e)
}))
}) })
} }
function scheduleViewStateEmit() {
if (viewUpdateRafId) return
viewUpdateRafId = requestAnimationFrame(emitViewState)
}
watch( watch(
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel], [scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
emitViewState, scheduleViewStateEmit
{ deep: true } )
watch(
[displayEvents, trackWidth, viewportWidth],
() => {
nextTick(applyInitialScrollPosition)
},
{ flush: 'post' }
)
watch(zoomLevel, (value) => {
if (restoringZoomFromSettings) return
settingsStore.timelineZoom = clampZoom(value)
})
watch(() => settingsStore.timelineZoom, (value) => {
const nextZoom = clampZoom(value)
if (nextZoom === zoomLevel.value) return
restoringZoomFromSettings = true
if (timelineRef.value) {
applyZoom(nextZoom)
} else {
zoomLevel.value = nextZoom
}
nextTick(() => {
restoringZoomFromSettings = false
})
})
watch(
[() => settingsStore.emotionGradientStart, () => settingsStore.emotionGradientEnd],
scheduleViewStateEmit
) )
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
if (!timelineRef.value) return if (!timelineRef.value) return
updateViewportWidth() updateViewportWidth()
applyInitialScrollPosition()
const events = displayEvents.value // Resize observer runs once per frame and keeps timeline center stable.
if (events.length === 0) return isInitialized = true
const lastX = getEventX(events.length - 1) resizeObserver = new ResizeObserver(() => {
timelineRef.value.scrollLeft = lastX - viewportWidth.value / 2 if (!isInitialized) return
scrollLeft.value = timelineRef.value.scrollLeft if (resizeRafId) cancelAnimationFrame(resizeRafId)
resizeRafId = requestAnimationFrame(() => {
// Update viewport width on resize resizeRafId = 0
resizeObserver = new ResizeObserver(updateViewportWidth) recalculateLayoutAfterResize()
})
})
resizeObserver.observe(timelineRef.value) resizeObserver.observe(timelineRef.value)
window.addEventListener('pagehide', onPageHide)
window.addEventListener('beforeunload', onPageHide)
// ResizeObserver can fire immediately after mount; restore once more after layout settles.
restoreScrollRafId = requestAnimationFrame(() => {
restoreScrollRafId = 0
applyInitialScrollPosition()
})
// Emit initial state // Emit initial state
emitViewState() emitViewState()
}) })
onUnmounted(() => { onUnmounted(() => {
if (resizeRafId) cancelAnimationFrame(resizeRafId)
if (viewUpdateRafId) cancelAnimationFrame(viewUpdateRafId)
if (restoreScrollRafId) cancelAnimationFrame(restoreScrollRafId)
window.removeEventListener('pagehide', onPageHide)
window.removeEventListener('beforeunload', onPageHide)
persistScrollPosition(true)
resizeObserver?.disconnect() resizeObserver?.disconnect()
}) })
@ -427,7 +627,12 @@ function zoomOut() {
if (newZoom !== zoomLevel.value) applyZoom(newZoom) if (newZoom !== zoomLevel.value) applyZoom(newZoom)
} }
defineExpose({ timelineRef, zoomIn, zoomOut, zoomLevel, MIN_ZOOM, MAX_ZOOM }) function zoomTo(value) {
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value))
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
}
defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZOOM, forceReflow })
</script> </script>
<style scoped> <style scoped>

View file

@ -4,11 +4,11 @@
<!-- User header --> <!-- User header -->
<div class="user-menu__header"> <div class="user-menu__header">
<div class="user-menu__avatar"> <div class="user-menu__avatar">
<span>K</span> <span>{{ authStore.currentUser?.avatar ?? '?' }}</span>
</div> </div>
<div class="user-menu__info"> <div class="user-menu__info">
<div class="user-menu__name">k-adam</div> <div class="user-menu__name">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
<div class="user-menu__handle">@k-adam</div> <div class="user-menu__handle">{{ authStore.currentUser?.email ?? '' }}</div>
</div> </div>
</div> </div>
@ -92,11 +92,11 @@
<!-- Footer: user + plan --> <!-- Footer: user + plan -->
<div class="user-menu__footer"> <div class="user-menu__footer">
<div class="user-menu__avatar user-menu__avatar--sm"> <div class="user-menu__avatar user-menu__avatar--sm">
<span>K</span> <span>{{ authStore.currentUser?.avatar ?? '?' }}</span>
</div> </div>
<div class="user-menu__info"> <div class="user-menu__info">
<div class="user-menu__name user-menu__name--sm">Kevin Ada</div> <div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
<div class="user-menu__plan">Free</div> <div class="user-menu__plan">Demo Account</div>
</div> </div>
</div> </div>
</div> </div>
@ -105,10 +105,12 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useAuthStore } from 'stores/auth'
defineProps({ open: { type: Boolean, default: false } }) defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close', 'navigate']) defineEmits(['close', 'navigate'])
const authStore = useAuthStore()
const helpOpen = ref(false) const helpOpen = ref(false)
</script> </script>
@ -215,11 +217,11 @@ const helpOpen = ref(false)
.user-menu__item:hover, .user-menu__item:hover,
.user-menu__item--active { .user-menu__item--active {
background: rgba(128, 128, 128, 0.12); background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.14);
} }
.user-menu__item:active { .user-menu__item:active {
background: rgba(128, 128, 128, 0.2); background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.22);
} }
.user-menu__item--sub { .user-menu__item--sub {

View file

@ -1,4 +1,4 @@
import { ref } from 'vue' import { ref, unref, watch } from 'vue'
import { db } from 'src/db' import { db } from 'src/db'
const THUMB_SIZE = 200 const THUMB_SIZE = 200
@ -101,41 +101,52 @@ async function getCachedImage(imageUrl) {
export function useImageCache(imageUrl, eventId) { export function useImageCache(imageUrl, eventId) {
const resolvedSrc = ref(null) const resolvedSrc = ref(null)
const loading = ref(false) const loading = ref(false)
let requestId = 0
async function resolve() { async function resolve(nextUrl = unref(imageUrl)) {
if (!imageUrl) { const currentRequestId = ++requestId
if (!nextUrl) {
resolvedSrc.value = null resolvedSrc.value = null
loading.value = false
return return
} }
// 1. Memory cache (instant) // 1. Memory cache (instant)
if (memoryCache.has(imageUrl)) { if (memoryCache.has(nextUrl)) {
resolvedSrc.value = memoryCache.get(imageUrl) resolvedSrc.value = memoryCache.get(nextUrl)
loading.value = false
return return
} }
// 2. IndexedDB cache // 2. IndexedDB cache
const cached = await getCachedImage(imageUrl) const cached = await getCachedImage(nextUrl)
if (cached) { if (cached) {
if (currentRequestId !== requestId) return
resolvedSrc.value = cached resolvedSrc.value = cached
loading.value = false
return return
} }
// 3. Fetch, create thumbnail, cache // 3. Fetch, create thumbnail, cache
loading.value = true loading.value = true
try { try {
const blobUrl = await fetchAndCache(imageUrl, eventId) const blobUrl = await fetchAndCache(nextUrl, unref(eventId))
if (currentRequestId !== requestId) return
resolvedSrc.value = blobUrl resolvedSrc.value = blobUrl
} catch (e) { } catch (e) {
// Fallback: use original URL directly (works when online) // Fallback: use original URL directly (works when online)
console.warn('Image cache failed, using direct URL:', e) console.warn('Image cache failed, using direct URL:', e)
resolvedSrc.value = imageUrl if (currentRequestId !== requestId) return
resolvedSrc.value = nextUrl
} finally { } finally {
loading.value = false if (currentRequestId === requestId) loading.value = false
} }
} }
resolve() watch(() => unref(imageUrl), (nextUrl) => {
resolve(nextUrl)
}, { immediate: true })
return { resolvedSrc, loading } return { resolvedSrc, loading }
} }

View file

@ -3,15 +3,19 @@ import { ref, onBeforeUnmount } from 'vue'
/** /**
* Composable for draggable bottom-sheet panels with snap points. * Composable for draggable bottom-sheet panels with snap points.
* *
* Snap stops (in dvh): 100, 75, 50 * Default snap stops (in dvh): 100, 75, 50
* Close threshold: below 25dvh * Close threshold: below 25dvh
* *
* @param {Function} onClose - called when panel is dragged below threshold * @param {Function} onClose - called when panel is dragged below threshold
* @param {Object} options - drag/snap behavior overrides
* @returns {{ panelHeight, handleListeners, resetHeight }} * @returns {{ panelHeight, handleListeners, resetHeight }}
*/ */
export function usePanelDrag(onClose) { export function usePanelDrag(onClose, options = {}) {
const SNAP_POINTS = [100, 75, 50, 25] // dvh values const SNAP_POINTS = options.snapPoints ?? [100, 75, 50, 25] // dvh values
const CLOSE_THRESHOLD = 15 // below this → close const CLOSE_THRESHOLD = options.closeThreshold ?? 15 // below this → close
const INITIAL_DVH = options.initialDvh ?? 75
const MIN_DVH = options.minDvh ?? 10
const MAX_DVH = options.maxDvh ?? 100
// Current panel height in dvh (null = use CSS default) // Current panel height in dvh (null = use CSS default)
const panelHeight = ref(null) const panelHeight = ref(null)
@ -52,7 +56,7 @@ export function usePanelDrag(onClose) {
startY = clientY startY = clientY
// Current height: if panelHeight is set use it, else measure from CSS // Current height: if panelHeight is set use it, else measure from CSS
const currentDvh = panelHeight.value ?? 75 const currentDvh = panelHeight.value ?? INITIAL_DVH
startHeight = currentDvh startHeight = currentDvh
document.addEventListener('pointermove', onPointerMove, { passive: false }) document.addEventListener('pointermove', onPointerMove, { passive: false })
@ -80,7 +84,7 @@ export function usePanelDrag(onClose) {
function handleMove(clientY) { function handleMove(clientY) {
const deltaY = clientY - startY const deltaY = clientY - startY
const deltaDvh = pxToDvh(deltaY) const deltaDvh = pxToDvh(deltaY)
const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh)) const newHeight = Math.max(MIN_DVH, Math.min(MAX_DVH, startHeight - deltaDvh))
panelHeight.value = newHeight panelHeight.value = newHeight
} }
@ -99,7 +103,7 @@ export function usePanelDrag(onClose) {
cleanup() cleanup()
const currentHeight = panelHeight.value ?? 75 const currentHeight = panelHeight.value ?? INITIAL_DVH
if (currentHeight < CLOSE_THRESHOLD) { if (currentHeight < CLOSE_THRESHOLD) {
panelHeight.value = null panelHeight.value = null
onClose() onClose()

View file

@ -1,7 +1,15 @@
:root {
--tm-accent: #737373;
--tm-accent-rgb: 115, 115, 115;
--q-primary: #737373;
--q-secondary: #737373;
--q-accent: #737373;
}
// Glass button style // Glass button style
.glass--button { .glass--button {
background: rgba(128, 128, 128, 0.1); background: rgba(128, 128, 128, 0.1);
border: 1px solid rgba(128, 128, 128, 0.15); border: 1px solid rgba(var(--tm-accent-rgb), 0.24);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
transition: background 0.2s ease; transition: background 0.2s ease;
@ -18,7 +26,7 @@
// Glass panel style strong blur for slide-up panels // Glass panel style strong blur for slide-up panels
.glass--panel { .glass--panel {
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
border-top: 1px solid rgba(255, 255, 255, 0.3); border-top: 1px solid rgba(var(--tm-accent-rgb), 0.24);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
color: #1a1a1a; color: #1a1a1a;

View file

@ -15,3 +15,19 @@ db.version(1).stores({
// Metadata: key-value pairs (lastSyncCursor, userId, etc.) // Metadata: key-value pairs (lastSyncCursor, userId, etc.)
meta: 'key' meta: 'key'
}) })
db.version(2).stores({
// Events are locally namespaced by userId until the backend owns persistence.
events: 'id, userId, [userId+date], updatedAt, syncStatus',
syncQueue: '++queueId, userId, eventId, action, createdAt',
imageCache: 'url, eventId, type, cachedAt',
meta: 'key'
})
db.version(3).stores({
events: 'id, userId, [userId+date], updatedAt, syncStatus',
syncQueue: '++queueId, userId, eventId, action, createdAt',
imageCache: 'url, eventId, type, cachedAt',
eventMedia: 'id, eventId, userId, createdAt',
meta: 'key'
})

View file

@ -15,6 +15,7 @@
:line-spread="fl.spread" :line-spread="fl.spread"
:fan-spread="fl.fanSpread" :fan-spread="fl.fanSpread"
:line-sharpness="fl.lineSharpness" :line-sharpness="fl.lineSharpness"
:line-thickness="fl.lineThickness ?? 1"
:wave-frequency="fl.waveFrequency" :wave-frequency="fl.waveFrequency"
:bezier-curvature="fl.bezierCurvature" :bezier-curvature="fl.bezierCurvature"
:circle-radius-px="fl.circleRadius" :circle-radius-px="fl.circleRadius"
@ -25,7 +26,13 @@
:bg-color-center="fl.bgCenter" :bg-color-center="fl.bgCenter"
:bg-color-edge="fl.bgEdge" :bg-color-edge="fl.bgEdge"
:background-image="fl.backgroundImage" :background-image="fl.backgroundImage"
:mix-blend-mode="'screen'" :mix-blend-mode="fl.lineMode === 'static' ? 'normal' : 'screen'"
:horizon-mode="fl.horizonMode ?? 'off'"
:horizon-opacity="fl.horizonOpacity ?? 0.5"
:horizon-blend="fl.horizonBlend ?? 0.2"
:line-mode="fl.lineMode ?? 'glow'"
:static-line-color="fl.staticLineColor ?? '#2196F3'"
:static-line-shadow-strength="fl.staticLineShadowStrength ?? 0"
/> />
<!-- Scrollable Timeline --> <!-- Scrollable Timeline -->
@ -142,6 +149,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import AddEventButton from 'components/AddEventButton.vue' import AddEventButton from 'components/AddEventButton.vue'
import EventPanel from 'components/EventPanel.vue' import EventPanel from 'components/EventPanel.vue'
import FloatingLines from 'components/FloatingLines.vue' import FloatingLines from 'components/FloatingLines.vue'
@ -150,10 +158,13 @@ import TimelineView from 'components/TimelineView.vue'
import AppSettingsModal from 'components/AppSettingsModal.vue' import AppSettingsModal from 'components/AppSettingsModal.vue'
import UserMenu from 'components/UserMenu.vue' import UserMenu from 'components/UserMenu.vue'
import ZoomControl from 'components/ZoomControl.vue' import ZoomControl from 'components/ZoomControl.vue'
import { useAuthStore } from 'stores/auth'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
const $q = useQuasar() const $q = useQuasar()
const router = useRouter()
const authStore = useAuthStore()
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
@ -165,27 +176,61 @@ const fl = computed(() => settingsStore.floatingLines)
// Timeline view ref (for direct scroll access in render loop) // Timeline view ref (for direct scroll access in render loop)
const timelineViewRef = ref(null) const timelineViewRef = ref(null)
const scrollContainerEl = computed(() => timelineViewRef.value?.timelineRef ?? null) const scrollContainerEl = computed(() => {
const exposed = timelineViewRef.value?.timelineRef
if (!exposed) return null
const el = exposed.value ?? exposed
return el && typeof el.addEventListener === 'function' ? el : null
})
// Layout dimensions (for screenUV conversion) // Layout dimensions (for screenUV conversion)
const layoutRef = ref(null) const layoutRef = ref(null)
const layoutWidth = ref(window.innerWidth) const layoutWidth = ref(window.innerWidth)
const layoutHeight = ref(window.innerHeight) const layoutHeight = ref(window.innerHeight)
let layoutResizeObserver = null let layoutResizeObserver = null
let hardResizeTimer = null
let hardResizeRaf = 0
// After resize settles, force both timeline math and shader canvas to resync.
function runHardResizeSync() {
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
hardResizeRaf = requestAnimationFrame(() => {
hardResizeRaf = 0
if (layoutRef.value) {
layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight
}
timelineViewRef.value?.forceReflow?.()
floatingLinesRef.value?.forceResize?.()
})
}
function onWindowResize() {
if (hardResizeTimer) clearTimeout(hardResizeTimer)
hardResizeTimer = setTimeout(runHardResizeSync, 120)
}
onMounted(() => { onMounted(() => {
if (layoutRef.value) { if (layoutRef.value) {
layoutWidth.value = layoutRef.value.clientWidth layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight layoutHeight.value = layoutRef.value.clientHeight
layoutResizeObserver = new ResizeObserver(() => { layoutResizeObserver = new ResizeObserver(() => {
layoutWidth.value = layoutRef.value.clientWidth // Read fresh dimensions in the next frame to avoid transient layout values.
layoutHeight.value = layoutRef.value.clientHeight requestAnimationFrame(() => {
if (!layoutRef.value) return
layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight
})
}) })
layoutResizeObserver.observe(layoutRef.value) layoutResizeObserver.observe(layoutRef.value)
} }
window.addEventListener('resize', onWindowResize, { passive: true })
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowResize)
if (hardResizeTimer) clearTimeout(hardResizeTimer)
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
layoutResizeObserver?.disconnect() layoutResizeObserver?.disconnect()
}) })
@ -213,30 +258,13 @@ function screenToUV(sx, sy) {
// Compute shader point positions from event positions // Compute shader point positions from event positions
const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px } const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px }
// Select up to 8 points from visible window + boundary events for shader lines // Select up to 16 points for shader lines (shader uniform limit).
// TimelineView already emits a small contiguous window around the viewport.
const shaderSelection = computed(() => { const shaderSelection = computed(() => {
if (!timelineState.value) return [] if (!timelineState.value) return []
const { events, visibleStart, visibleEnd } = timelineState.value const { events } = timelineState.value
if (events.length === 0) return [] if (events.length === 0) return []
return events.slice(0, 16)
// Include 3 events before and after visible range for smooth line continuity
const rangeStart = Math.max(0, (visibleStart ?? 0) - 3)
const rangeEnd = Math.min(events.length - 1, (visibleEnd ?? events.length - 1) + 3)
let candidates = events.slice(rangeStart, rangeEnd + 1)
// If more than 16, subsample evenly (keep first + last)
if (candidates.length > 16) {
const sampled = [candidates[0]]
const step = (candidates.length - 1) / 15
for (let i = 1; i < 15; i++) {
sampled.push(candidates[Math.round(i * step)])
}
sampled.push(candidates[candidates.length - 1])
candidates = sampled
}
return candidates
}) })
const shaderNumPoints = computed(() => shaderSelection.value.length) const shaderNumPoints = computed(() => shaderSelection.value.length)
@ -290,19 +318,7 @@ const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0)
function onZoomTo(value) { function onZoomTo(value) {
if (!timelineViewRef.value) return if (!timelineViewRef.value) return
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value)) const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
// Use applyZoom exposed or set directly we use the internal method indirectly timelineViewRef.value.zoomTo?.(clamped)
// by computing step from current to target
const tv = timelineViewRef.value
const el = tv.timelineRef
if (!el) return
const cx = el.clientWidth / 2
const worldX = el.scrollLeft + cx
const ratio = clamped / tv.zoomLevel
tv.zoomLevel = clamped
// Restore scroll position to keep center stable
requestAnimationFrame(() => {
el.scrollLeft = worldX * ratio - cx
})
} }
const toggleDarkMode = () => { const toggleDarkMode = () => {
@ -322,6 +338,9 @@ const onUserMenuNavigate = (target) => {
userMenuOpen.value = false userMenuOpen.value = false
if (target === 'settings') { if (target === 'settings') {
appSettingsOpen.value = true appSettingsOpen.value = true
} else if (target === 'logout') {
authStore.logout()
router.replace('/login')
} }
} }
</script> </script>

View file

@ -0,0 +1,5 @@
Legacy frontend screens archived on 2026-04-30.
These files are not mounted by `frontend/src/router/routes.js`. They are kept here
as reference while the active LifeWave experience is consolidated around events,
the timeline, and Floating Lines.

View file

@ -1,68 +1,216 @@
<template> <template>
<q-page padding class="flex flex-center"> <div class="login-page">
<q-card style="width: 350px"> <div class="login-page__bg" />
<q-card-section> <form class="login-card" @submit.prevent="onSubmit">
<div class="text-h6">Login</div> <div class="login-card__brand">That&apos;s Me</div>
</q-card-section> <h1>Login</h1>
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
<q-card-section>
<q-form @submit.prevent="onSubmit" class="q-gutter-md"> <label class="login-field">
<q-input <span>E-Mail</span>
v-model="email" <select v-model="email">
type="email" <option v-for="user in authStore.users" :key="user.id" :value="user.email">
label="Email" {{ user.email }}
filled </option>
:rules="[val => !!val || 'Email is required']" </select>
/> <q-icon name="person_outline" size="22px" />
</label>
<q-input
v-model="password" <label class="login-field">
type="password" <span>Passwort</span>
label="Password" <input
filled v-model="password"
:rules="[val => !!val || 'Password is required']" :type="showPassword ? 'text' : 'password'"
/> autocomplete="current-password"
>
<div class="q-mt-md"> <button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
<q-btn label="Login" type="submit" color="primary" class="full-width"/> <q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
</div> </button>
</label>
<div class="text-center q-mt-sm">
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link> <label class="login-remember">
</div> <input v-model="remember" type="checkbox">
</q-form> <span>Remember me</span>
</q-card-section> </label>
</q-card>
</q-page> <div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
<button class="login-submit" type="submit">Login</button>
<div class="login-card__hint">
Demo-Accounts: <strong>user1-user5@thats-me.app</strong>, Passwort <strong>pass</strong>
</div>
</form>
</div>
</template> </template>
<script> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth'
export default { const authStore = useAuthStore()
name: 'LoginPage', const router = useRouter()
setup() { const route = useRoute()
const email = ref('')
const password = ref('') const email = ref(authStore.users[0]?.email ?? '')
const password = ref('pass')
const onSubmit = () => { const showPassword = ref(false)
console.log('Login attempt with:', email.value, password.value) const remember = ref(true)
// Save login status function onSubmit() {
localStorage.setItem('isLoggedIn', 'true') if (!authStore.login(email.value, password.value)) return
window.dispatchEvent(new Event('storage')) router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
console.log('Redirecting to wave page...')
// Direct navigation
window.location.href = '/#/wave'
}
return {
email,
password,
onSubmit
}
}
} }
</script> </script>
<style scoped>
.login-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
overflow: hidden;
background: #162914;
color: #fff;
}
.login-page__bg {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.35), transparent 28%),
linear-gradient(135deg, rgba(213, 218, 98, 0.9), rgba(33, 181, 113, 0.75) 45%, rgba(20, 68, 28, 0.95));
background-size: cover;
background-position: center;
filter: saturate(1.08);
}
.login-page__bg::after {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)),
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 60px);
}
.login-card {
position: relative;
z-index: 1;
width: min(420px, 100%);
padding: 40px 34px 28px;
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: 24px;
background: rgba(22, 29, 25, 0.32);
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.login-card__brand {
margin-bottom: 28px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.72;
}
.login-card h1 {
margin: 0 0 6px;
font-size: 34px;
line-height: 1;
}
.login-card p {
margin: 0 0 28px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.45;
}
.login-field {
position: relative;
display: block;
margin-bottom: 16px;
}
.login-field span {
display: block;
margin: 0 0 6px 2px;
font-size: 12px;
opacity: 0.75;
}
.login-field input,
.login-field select {
width: 100%;
height: 56px;
padding: 0 52px 0 20px;
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 14px;
outline: none;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font: inherit;
}
.login-field select option {
color: #1a1a1a;
}
.login-field > .q-icon,
.login-field__icon-btn {
position: absolute;
right: 18px;
bottom: 16px;
color: rgba(255, 255, 255, 0.78);
}
.login-field__icon-btn {
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
}
.login-remember {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 22px;
font-size: 14px;
}
.login-remember input {
accent-color: #6ce36c;
}
.login-error {
margin-bottom: 14px;
color: #ffd7d7;
font-size: 14px;
}
.login-submit {
width: 100%;
height: 58px;
border: 0;
border-radius: 14px;
background: linear-gradient(90deg, #cfdd34, #18c77a);
color: #fff;
font: inherit;
font-size: 20px;
font-weight: 800;
cursor: pointer;
box-shadow: 0 14px 35px rgba(16, 188, 102, 0.28);
}
.login-card__hint {
margin-top: 18px;
text-align: center;
color: rgba(255, 255, 255, 0.78);
font-size: 13px;
line-height: 1.45;
}
</style>

View file

@ -1,6 +1,7 @@
import { defineRouter } from '#q-app/wrappers' import { defineRouter } from '#q-app/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router' import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes' import routes from './routes'
import { AUTH_STORAGE_KEY, DEMO_USERS } from 'stores/auth'
/* /*
* If not building with SSR mode, you can * 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) 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 return Router
}) })

View file

@ -1,7 +1,13 @@
const routes = [ const routes = [
{
path: '/login',
component: () => import('pages/LoginPage.vue'),
meta: { public: true }
},
{ {
path: '/', path: '/',
component: () => import('layouts/LifeWaveLayout.vue'), component: () => import('layouts/LifeWaveLayout.vue'),
meta: { requiresAuth: true },
children: [ children: [
{ path: '', component: () => import('pages/LifeWavePage.vue') } { path: '', component: () => import('pages/LifeWavePage.vue') }
] ]

View file

@ -167,9 +167,12 @@ async function pullRemoteChanges() {
id: remote.id, id: remote.id,
title: remote.title, title: remote.title,
date: remote.date, date: remote.date,
location: remote.location ?? '',
emotion: remote.emotion, emotion: remote.emotion,
customColor: remote.customColor, customColor: remote.customColor,
gradientPreset: remote.gradientPreset, gradientPreset: remote.gradientPreset,
gradientStartColor: remote.gradientStartColor ?? null,
gradientEndColor: remote.gradientEndColor ?? null,
image: remote.image, image: remote.image,
note: remote.note, note: remote.note,
syncStatus: 'synced', syncStatus: 'synced',
@ -181,9 +184,12 @@ async function pullRemoteChanges() {
await db.events.update(remote.id, { await db.events.update(remote.id, {
title: remote.title, title: remote.title,
date: remote.date, date: remote.date,
location: remote.location ?? '',
emotion: remote.emotion, emotion: remote.emotion,
customColor: remote.customColor, customColor: remote.customColor,
gradientPreset: remote.gradientPreset, gradientPreset: remote.gradientPreset,
gradientStartColor: remote.gradientStartColor ?? null,
gradientEndColor: remote.gradientEndColor ?? null,
image: remote.image, image: remote.image,
note: remote.note, note: remote.note,
syncStatus: 'synced', syncStatus: 'synced',

View file

@ -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
}
})

View file

@ -1,7 +1,10 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import Dexie from 'dexie'
import { db } from 'src/db' import { db } from 'src/db'
import { startAutoSync, getToken } from 'src/services/syncService' 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 // Color interpolation
function lerpColor(a, b, t) { 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')}` return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
} }
// Gradient presets: [negative, neutral, positive] // Glow color logic: emotion value mapped on one continuous gradient.
const GRADIENT_PRESETS = [ function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = null) {
{ name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] }, const start = gradientStartColor || DEFAULT_EMOTION_GRADIENT_START
{ name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] }, const end = gradientEndColor || DEFAULT_EMOTION_GRADIENT_END
{ name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] }, const t = Math.max(0, Math.min(1, (emotion + 1) / 2))
{ name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] }, return lerpColor(start, end, t)
{ 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)
}
} }
// Demo seed data // Demo seed data
const demoEvents = [ 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: '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', 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: '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', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', 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', 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: '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', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', 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', 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: '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', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', 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', 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: '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 // Generate realistic demo events for testing at scale
@ -144,16 +116,14 @@ function generateManyEvents(count = 500) {
const note = hasNote ? pick(cat.notes) : '' const note = hasNote ? pick(cat.notes) : ''
const hasImage = rand() < 0.15 // 15% chance const hasImage = rand() < 0.15 // 15% chance
const image = hasImage ? pick(demoImages) : null const image = hasImage ? pick(demoImages) : null
const hasPreset = rand() < 0.25 // 25% chance
const gradientPreset = hasPreset ? randInt(0, 9) : null
evts.push({ evts.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
title, title,
date, date,
location: '',
emotion, emotion,
customColor: null, customColor: null,
gradientPreset, gradientPreset: null,
image, image,
note, note,
syncStatus: 'local', syncStatus: 'local',
@ -167,21 +137,44 @@ function generateManyEvents(count = 500) {
return evts return evts
} }
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents } export {
emotionToColor,
demoEvents,
generateManyEvents
}
export const useEventsStore = defineStore('events', () => { export const useEventsStore = defineStore('events', () => {
const settingsStore = useSettingsStore()
const authStore = useAuthStore()
const events = ref([]) const events = ref([])
const isLoaded = ref(false) const isLoaded = ref(false)
const selectedEventId = ref(null) const selectedEventId = ref(null)
const panelOpen = ref(false) const panelOpen = ref(false)
const editingEventId = ref(null) 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 // Load events from IndexedDB; seed demo data on first launch
async function init() { async function init() {
const userId = authStore.currentUserId
if (!userId) {
events.value = []
isLoaded.value = true
return
}
isLoaded.value = false
try { 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) { if (stored.length === 0) {
const seed = generateManyEvents(500) const seed = generateManyEvents(500).map(event => ({
...event,
userId
}))
await db.events.bulkPut(seed) await db.events.bulkPut(seed)
stored = seed stored = seed
} }
@ -200,7 +193,7 @@ export const useEventsStore = defineStore('events', () => {
// Fire-and-forget DB write (UI already updated via ref) // Fire-and-forget DB write (UI already updated via ref)
function dbPut(event) { 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) { function dbDelete(id) {
@ -208,27 +201,95 @@ export const useEventsStore = defineStore('events', () => {
} }
function dbQueueSync(eventId, action, payload) { function dbQueueSync(eventId, action, payload) {
db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() }) const userId = authStore.currentUserId
.catch(e => console.warn('Dexie sync queue failed:', e)) 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 // Ghost event for live preview while creating/editing
const ghostEmotion = ref(0) const ghostEmotion = ref(0)
const ghostCustomColor = ref(null) const ghostCustomColor = ref(null)
const ghostGradientPreset = ref(null)
const ghostTitle = ref('') const ghostTitle = ref('')
const ghostDate = ref(new Date().toISOString().slice(0, 10)) const ghostDate = ref(new Date().toISOString().slice(0, 10))
const ghostLocation = ref('')
const ghostNote = ref('') const ghostNote = ref('')
const ghostImage = ref(null) const ghostImage = ref(null)
const ghostKeyImageTitle = ref('')
const ghostMedia = ref([])
const ghostEvent = computed(() => ({ const ghostEvent = computed(() => ({
id: '__ghost__', id: '__ghost__',
title: ghostTitle.value || 'New Event', title: ghostTitle.value || 'New Event',
date: ghostDate.value, date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value, emotion: ghostEmotion.value,
customColor: ghostCustomColor.value, customColor: ghostCustomColor.value,
gradientPreset: ghostGradientPreset.value,
image: ghostImage.value, image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: ghostMedia.value,
note: ghostNote.value note: ghostNote.value
})) }))
@ -245,22 +306,27 @@ export const useEventsStore = defineStore('events', () => {
editingEventId.value = eventId editingEventId.value = eventId
const event = events.value.find((e) => e.id === eventId) const event = events.value.find((e) => e.id === eventId)
if (event) { if (event) {
skipNextPersist = true
ghostTitle.value = event.title ghostTitle.value = event.title
ghostDate.value = event.date ghostDate.value = event.date
ghostLocation.value = event.location || ''
ghostEmotion.value = event.emotion ghostEmotion.value = event.emotion
ghostCustomColor.value = event.customColor ghostCustomColor.value = event.customColor
ghostGradientPreset.value = event.gradientPreset ?? null
ghostImage.value = event.image || null ghostImage.value = event.image || null
ghostKeyImageTitle.value = event.keyImageTitle || ''
loadEventMedia(event)
ghostNote.value = event.note ghostNote.value = event.note
} }
} else { } else {
editingEventId.value = null editingEventId.value = null
ghostTitle.value = '' ghostTitle.value = ''
ghostDate.value = new Date().toISOString().slice(0, 10) ghostDate.value = new Date().toISOString().slice(0, 10)
ghostLocation.value = ''
ghostEmotion.value = 0 ghostEmotion.value = 0
ghostCustomColor.value = null ghostCustomColor.value = null
ghostGradientPreset.value = null
ghostImage.value = null ghostImage.value = null
ghostKeyImageTitle.value = ''
ghostMedia.value = []
ghostNote.value = '' ghostNote.value = ''
} }
panelOpen.value = true panelOpen.value = true
@ -275,33 +341,75 @@ export const useEventsStore = defineStore('events', () => {
...events.value[idx], ...events.value[idx],
title: ghostTitle.value, title: ghostTitle.value,
date: ghostDate.value, date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value, emotion: ghostEmotion.value,
customColor: ghostCustomColor.value, customColor: ghostCustomColor.value,
gradientPreset: ghostGradientPreset.value, gradientPreset: null,
image: ghostImage.value, image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: mediaMeta(ghostMedia.value),
note: ghostNote.value, note: ghostNote.value,
syncStatus: 'modified', syncStatus: 'modified',
userId: events.value[idx].userId ?? authStore.currentUserId,
updatedAt: Date.now() updatedAt: Date.now()
} }
events.value[idx] = updated events.value[idx] = updated
dbPut(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( watch(
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote], [ghostTitle, ghostDate, ghostLocation, ghostEmotion, ghostCustomColor, ghostImage, ghostKeyImageTitle, ghostMedia, ghostNote],
() => { persistToEvent() } () => {
if (skipNextPersist) {
skipNextPersist = false
return
}
schedulePersistToEvent()
}
) )
function closePanel() { function closePanel() {
flushPersistToEvent()
if (!editingEventId.value && ghostTitle.value.trim()) { if (!editingEventId.value && ghostTitle.value.trim()) {
const newEvent = { const newEvent = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId: authStore.currentUserId,
title: ghostTitle.value, title: ghostTitle.value,
date: ghostDate.value, date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value, emotion: ghostEmotion.value,
customColor: ghostCustomColor.value, customColor: ghostCustomColor.value,
gradientPreset: ghostGradientPreset.value, gradientPreset: null,
image: ghostImage.value, image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: mediaMeta(ghostMedia.value),
note: ghostNote.value, note: ghostNote.value,
syncStatus: 'local', syncStatus: 'local',
createdAt: Date.now(), createdAt: Date.now(),
@ -309,6 +417,7 @@ export const useEventsStore = defineStore('events', () => {
} }
events.value.push(newEvent) events.value.push(newEvent)
dbPut(newEvent) dbPut(newEvent)
persistEventMedia(newEvent.id, newEvent.userId, ghostMedia.value)
dbQueueSync(newEvent.id, 'create', { ...newEvent }) dbQueueSync(newEvent.id, 'create', { ...newEvent })
} }
panelOpen.value = false panelOpen.value = false
@ -317,6 +426,10 @@ export const useEventsStore = defineStore('events', () => {
} }
function deleteEvent(id) { function deleteEvent(id) {
if (editingEventId.value === id) {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = null
}
events.value = events.value.filter((e) => e.id !== id) events.value = events.value.filter((e) => e.id !== id)
dbDelete(id) dbDelete(id)
dbQueueSync(id, 'delete', null) dbQueueSync(id, 'delete', null)
@ -325,12 +438,27 @@ export const useEventsStore = defineStore('events', () => {
function getGlowColor(event) { function getGlowColor(event) {
if (event.customColor) return event.customColor 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 // Auto-init on store creation
init() init()
watch(() => authStore.currentUserId, () => {
panelOpen.value = false
editingEventId.value = null
selectedEventId.value = null
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
init()
})
return { return {
events, events,
isLoaded, isLoaded,
@ -339,17 +467,20 @@ export const useEventsStore = defineStore('events', () => {
editingEventId, editingEventId,
ghostEmotion, ghostEmotion,
ghostCustomColor, ghostCustomColor,
ghostGradientPreset,
ghostTitle, ghostTitle,
ghostDate, ghostDate,
ghostLocation,
ghostNote, ghostNote,
ghostImage, ghostImage,
ghostKeyImageTitle,
ghostMedia,
ghostEvent, ghostEvent,
sortedEvents, sortedEvents,
selectEvent, selectEvent,
openPanel, openPanel,
closePanel, closePanel,
deleteEvent, deleteEvent,
saveGhostNow,
getGlowColor getGlowColor
} }
}) })

View file

@ -1,15 +1,33 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watch } from 'vue' 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 = [ export const ACCENT_COLORS = [
{ label: 'Standard', value: 'default', hex: '#9e9e9e' }, { label: 'Base', value: 'base', hex: '#737373' },
{ label: 'Blau', value: 'blue', hex: '#2196F3' }, { label: 'Red', value: 'red', hex: '#ef4444' },
{ label: 'Grün', value: 'green', hex: '#4CAF50' }, { label: 'Orange', value: 'orange', hex: '#f97316' },
{ label: 'Gelb', value: 'yellow', hex: '#FFC107' }, { label: 'Amber', value: 'amber', hex: '#f59e0b' },
{ label: 'Rosa', value: 'pink', hex: '#E91E63' }, { label: 'Yellow', value: 'yellow', hex: '#eab308' },
{ label: 'Orange', value: 'orange', hex: '#FF9800' } { 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 = [ export const LANGUAGES = [
@ -24,6 +42,7 @@ const FLOATING_LINES_DEFAULTS = {
spread: 0.05, spread: 0.05,
fanSpread: 0.05, fanSpread: 0.05,
lineSharpness: 8.0, lineSharpness: 8.0,
lineThickness: 1.0,
waveFrequency: 7.0, waveFrequency: 7.0,
bezierCurvature: 0.2, bezierCurvature: 0.2,
circleRadius: 75, circleRadius: 75,
@ -35,51 +54,177 @@ const FLOATING_LINES_DEFAULTS = {
bgEdge: '#000000', bgEdge: '#000000',
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
backgroundImage: '', backgroundImage: '',
// Horizont
horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow'
horizonOpacity: 0.5, // Nebel + Glow: Helligkeit
horizonBlend: 0.2, // Trennung: 0=scharf 1=weich
// Labels // Labels
labelSize: 'small', // 'small' | 'medium' | 'large' labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
labelColor: '#ffffff' 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 { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(getStorageKey(userId))
return stored ? JSON.parse(stored) : null return stored ? JSON.parse(stored) : null
} catch { } catch {
return null 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 { FLOATING_LINES_DEFAULTS }
export const useSettingsStore = defineStore('settings', () => { 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 theme = ref(initialSettings?.theme ?? 'light')
const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS }) const floatingLines = ref({
...FLOATING_LINES_DEFAULTS,
...(initialSettings?.floatingLines ?? {})
})
// App preferences // App preferences
const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark' const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
const accentColor = ref(stored?.accentColor ?? 'default') const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'base'))
const language = ref(stored?.language ?? 'de') 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 // 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() { function persist() {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (!authStore.currentUserId) return
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, getStorageKey(authStore.currentUserId),
JSON.stringify({ JSON.stringify({
theme: theme.value, ...createSnapshot(),
floatingLines: floatingLines.value, timelineScrollLeft: timelineScrollLeft.value,
appearance: appearance.value, presets: presets.value,
accentColor: accentColor.value, activePresetId: activePresetId.value
language: language.value,
showFps: showFps.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() { function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light' theme.value = theme.value === 'light' ? 'dark' : 'light'
@ -93,15 +238,67 @@ export const useSettingsStore = defineStore('settings', () => {
floatingLines.value = { ...FLOATING_LINES_DEFAULTS } 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 { return {
theme, theme,
floatingLines, floatingLines,
appearance, appearance,
accentColor, accentColor,
language, language,
emotionGradientStart,
emotionGradientEnd,
timelineZoom,
timelineScrollLeft,
presets,
activePresetId,
showFps, showFps,
toggleTheme, toggleTheme,
updateFloatingLines, updateFloatingLines,
resetFloatingLines resetFloatingLines,
resetEmotionGradient,
saveTimelineScrollLeft,
savePreset,
applyPreset
} }
}) })

30
workspace.code-workspace Normal file
View file

@ -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
}
}