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",
"LARAVEL_SAIL": "1"
},
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
],
"forwardPorts": [
5173,
9000

View file

@ -32,6 +32,7 @@ services:
REDIS_HOST: global-redis
volumes:
- './backend:/var/www/html'
- '.:/workspace:cached'
networks:
- sail
- 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,
Vector3,
Vector2,
Clock,
} from 'three'
const vertexShader = `
@ -19,7 +18,7 @@ void main() {
`
const fragmentShader = `
precision highp float;
precision mediump float;
uniform float iTime;
uniform vec3 iResolution;
@ -58,34 +57,24 @@ uniform float bendRadius;
uniform float bendStrength;
uniform float bendInfluence;
uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
uniform float horizonOpacity; // Nebel + Glow: Helligkeit/Dichte
uniform float horizonBlend; // Trennung: 0=scharf, 1=weicher Übergang
uniform bool parallax;
uniform float parallaxStrength;
uniform vec2 parallaxOffset;
uniform float lineBrightness;
uniform vec3 lineGradient[8];
uniform int lineGradientCount;
uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge;
const vec3 BLACK = vec3(0.0);
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
mat2 rotate(float r) {
return mat2(cos(r), sin(r), -sin(r), cos(r));
}
vec3 background_color(vec2 uv) {
vec3 col = vec3(0.0);
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
float m = uv.y - y;
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
return col * 0.5;
}
vec3 getLineColor(float t, vec3 baseColor) {
if (lineGradientCount <= 0) {
return baseColor;
@ -108,7 +97,7 @@ vec3 getLineColor(float t, vec3 baseColor) {
gradientColor = mix(c1, c2, f);
}
return gradientColor * 0.5;
return gradientColor;
}
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
@ -163,25 +152,9 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
return t;
}
float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) {
// Bézier-Kontrollpunkt: Mittelpunkt + senkrechter Versatz
vec2 seg = ep - sp;
float segLen = length(seg);
if (segLen < 0.001) return 0.0;
vec2 segDir = seg / segLen;
vec2 segPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + segPerp * segLen * bezierCurvature;
float t = bezierClosestT(uv, sp, pc, ep);
float mt = 1.0 - t;
// Position und Tangente auf der Kurve
vec2 curvePos = mt*mt*sp + 2.0*mt*t*pc + t*t*ep;
vec2 tang = normalize(2.0*mt*(pc - sp) + 2.0*t*(ep - pc));
vec2 norm = vec2(-tang.y, tang.x);
// Senkrechter Abstand von der Kurve
float s = dot(uv - curvePos, norm);
// Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) {
float s = dot(uv - bPos, bNorm);
float time = iTime * animationSpeed;
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
@ -227,7 +200,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 col = vec3(0.0);
vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv);
vec3 b = bgColorCenter;
vec2 mouseUv = vec2(0.0);
if (interactive) {
@ -269,25 +242,26 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 sp = vec2(x0, pointY[s]);
vec2 ep = vec2(x1, pointY[s + 1]);
// Gradient: globaler t-Bereich [s, s+1] / (numPoints-1)
vec2 pd = ep - sp;
float pl = length(pd);
vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0);
float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.0);
float t_global = (float(s) + t_seg) * tScale;
vec3 lineCol = getLineColor(t_global, b);
// Bézier-Kontrollpunkt für Nebel (gleiche Logik wie in waveFocal)
vec2 segD = ep - sp;
float segL = length(segD);
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
// Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt)
vec2 seg = ep - sp;
float segL = length(seg);
vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0);
vec2 sPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
// Weicher Nebel entlang der Bézier-Kurve → füllt dunkle Winkel organisch
// Gradient
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
float t_global = (float(s) + t_seg) * tScale;
vec3 lineCol = getLineColor(t_global, b);
// Bézier einmal pro Segment — geteilt von Nebel + allen Linien
float bt = bezierClosestT(baseUv, sp, pc, ep);
float bmt = 1.0 - bt;
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
vec2 bNorm = vec2(-bTang.y, bTang.x);
// Weicher Nebel entlang der Kurve
float bDist = length(baseUv - bPos);
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
float fogEnv = sin(bt * 3.14159265359);
@ -295,7 +269,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
col += lineCol * segFog;
for (int i = 0; i < middleLineCount; ++i) {
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep);
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
}
}
@ -304,7 +278,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
if (p >= numPoints) break;
float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX;
float t_pt = numPoints > 1 ? float(p) * tScale : 0.0;
vec3 circCol = getLineColor(t_pt, b) * 2.5;
vec3 circCol = getLineColor(t_pt, b) * 1.5;
col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol);
}
}
@ -328,9 +302,33 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
}
}
col *= lineBrightness;
// Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand)
float dist = length(baseUv) / 1.8;
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
if (horizonMode == 1) {
// Nebel: breites weiches Gaussband in Gradient-Mittelfarbe
float band = exp(-baseUv.y * baseUv.y * 5.0);
vec3 fogColor = getLineColor(0.5, bg) * 2.0;
col += fogColor * band * horizonOpacity;
} else if (horizonMode == 2) {
// Farbtrennung: vertikaler Split an Y=0
// bgColorCenter → oben (positives UV-Y), bgColorEdge → unten
// horizonBlend: 0=harter Schnitt, 1=sehr weicher Übergang
float blendW = max(horizonBlend * 0.7, 0.001);
float t = smoothstep(-blendW, blendW, baseUv.y);
bg = mix(bgColorEdge, bgColorCenter, t);
} else if (horizonMode == 3) {
// Glow: konzentriertes Leuchten in Gradient-Mittelfarbe
float d2 = baseUv.y * baseUv.y;
float softGlow = exp(-d2 * 10.0);
float coreGlow = exp(-d2 * 70.0) * 0.7;
vec3 glowColor = getLineColor(0.5, bg) * 3.0;
col += glowColor * (softGlow + coreGlow) * horizonOpacity;
}
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
}
@ -376,7 +374,6 @@ export default class FloatingLines {
lineCount = [6],
lineDistance = [5],
topWavePosition,
middleWavePosition,
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
numPoints = 4,
pointSpacingX = 0.8,
@ -389,6 +386,7 @@ export default class FloatingLines {
bezierCurvature = 0.3,
circleRadiusPx = 50,
animationSpeed = 1,
lineBrightness = 1.0,
interactive = true,
bendRadius = 5.0,
bendStrength = -0.5,
@ -397,6 +395,11 @@ export default class FloatingLines {
parallaxStrength = 0.2,
circleGlowSize = 18.0,
circleGlowStrength = 1.5,
horizonMode = 'off',
horizonOpacity = 0.5,
horizonBlend = 0.2,
bgColorCenter = '#0a0514',
bgColorEdge = '#000000',
mixBlendMode = 'screen',
} = {},
) {
@ -427,14 +430,12 @@ export default class FloatingLines {
return lineDistance[index] ?? 0.1
}
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0
const topLineCount = getLineCount('top')
const middleLineCount = getLineCount('middle')
const bottomLineCount = getLineCount('bottom')
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
const bottomLineDistance = enabledWaves.includes('bottom')
? getLineDistance('bottom') * 0.01
: 0.01
const topLineDistance = getLineDistance('top') * 0.01
const bottomLineDistance = getLineDistance('bottom') * 0.01
this.scene = new Scene()
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
@ -451,6 +452,7 @@ export default class FloatingLines {
iTime: { value: 0 },
iResolution: { value: new Vector3(1, 1, 1) },
animationSpeed: { value: animationSpeed },
lineBrightness: { value: lineBrightness },
enableTop: { value: enabledWaves.includes('top') },
enableMiddle: { value: enabledWaves.includes('middle') },
@ -497,6 +499,10 @@ export default class FloatingLines {
bendStrength: { value: bendStrength },
bendInfluence: { value: 0 },
horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 },
horizonOpacity: { value: horizonOpacity },
horizonBlend: { value: horizonBlend },
parallax: { value: parallax },
parallaxStrength: { value: parallaxStrength },
parallaxOffset: { value: new Vector2(0, 0) },
@ -505,7 +511,8 @@ export default class FloatingLines {
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
},
lineGradientCount: { value: 0 },
bgColor: { value: new Vector3(0, 0, 0) },
bgColorCenter: { value: new Vector3(0, 0, 0) },
bgColorEdge: { value: new Vector3(0, 0, 0) },
}
if (linesGradient && linesGradient.length > 0) {
@ -517,6 +524,11 @@ export default class FloatingLines {
})
}
const center = hexToVec3(bgColorCenter)
this.uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
const edge = hexToVec3(bgColorEdge)
this.uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
const material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader,
@ -529,7 +541,7 @@ export default class FloatingLines {
this.geometry = geometry
this.material = material
this.clock = new Clock()
this._startTime = performance.now()
this._setSize = () => {
const width = container.clientWidth || 1
@ -571,7 +583,7 @@ export default class FloatingLines {
this.raf = 0
const renderLoop = () => {
this.uniforms.iTime.value = this.clock.getElapsedTime()
this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001
if (this.interactive) {
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
@ -589,12 +601,24 @@ export default class FloatingLines {
this.renderer.render(this.scene, this.camera)
this.raf = requestAnimationFrame(renderLoop)
}
this._handleVisibility = () => {
if (document.hidden) {
cancelAnimationFrame(this.raf)
this.raf = 0
} else if (!this.raf) {
renderLoop()
}
}
document.addEventListener('visibilitychange', this._handleVisibility)
renderLoop()
}
destroy() {
cancelAnimationFrame(this.raf)
if (this.ro) this.ro.disconnect()
document.removeEventListener('visibilitychange', this._handleVisibility)
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)

View file

@ -50,7 +50,7 @@
border-top: 1px solid #222;
padding: 10px 14px;
display: grid;
grid-template-columns: 1fr 1fr 2fr 1.2fr;
grid-template-columns: 1fr 1fr 2fr 0.8fr 1.2fr;
gap: 10px 16px;
max-height: 230px;
overflow-y: auto;
@ -287,6 +287,27 @@
</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 -->
<div class="ctrl-group">
<h3>Hintergrundbild</h3>
@ -501,6 +522,27 @@
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge)
})
// ── Horizont ─────────────────────────────────────────────────────
const rowOpacity = document.getElementById('row-horizonOpacity')
const rowBlend = document.getElementById('row-horizonBlend')
function updateHorizonRows(mode) {
rowOpacity.style.display = mode === 2 ? 'none' : 'flex'
rowBlend.style.display = mode === 2 ? 'flex' : 'none'
}
document.querySelectorAll('[data-mode]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-mode]').forEach((b) => b.classList.remove('active'))
btn.classList.add('active')
const mode = parseInt(btn.dataset.mode)
fl.uniforms.horizonMode.value = mode
updateHorizonRows(mode)
})
})
slider('horizonOpacity', 2, (v) => (fl.uniforms.horizonOpacity.value = v))
slider('horizonBlend', 2, (v) => (fl.uniforms.horizonBlend.value = v))
// ── Gradient ──────────────────────────────────────────────────────
const MAX_STOPS = 8
function applyGradient() {

View file

@ -74,6 +74,44 @@
</div>
<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 -->
<div class="settings-row">
<span class="settings-row__label">Sprache</span>
@ -100,7 +138,7 @@
:model-value="settingsStore.showFps"
@update:model-value="v => { settingsStore.showFps = v }"
dense
color="green"
color="primary"
/>
</div>
</div>
@ -146,14 +184,18 @@ const tabs = [
const currentAccentHex = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
return found?.hex ?? '#9e9e9e'
return found?.hex ?? '#737373'
})
const currentAccentLabel = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
return found?.label ?? 'Standard'
return found?.label ?? 'Base'
})
const emotionGradientCss = computed(() =>
`linear-gradient(90deg, ${settingsStore.emotionGradientStart} 0%, ${settingsStore.emotionGradientEnd} 100%)`
)
function selectAccent(value) {
settingsStore.accentColor = value
accentDropdownOpen.value = false
@ -198,6 +240,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
min-height: 40px;
}
.settings-row--stack {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.settings-row__label {
font-size: 15px;
}
@ -242,6 +290,7 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
border-color: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.35);
}
.settings-accent-btn--dark {
@ -294,12 +343,53 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
}
.settings-dropdown__item:hover {
background: rgba(128, 128, 128, 0.1);
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.12);
}
.settings-dropdown__check {
margin-left: auto;
opacity: 0.7;
color: var(--tm-accent, #737373);
}
.settings-gradient {
width: 100%;
}
.settings-gradient__preview {
height: 10px;
border-radius: 999px;
border: 1px solid rgba(128, 128, 128, 0.2);
margin-bottom: 10px;
}
.settings-gradient__controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.settings-gradient__control {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
opacity: 0.7;
}
.settings-gradient__control--right {
justify-content: flex-end;
}
.settings-color-input {
width: 34px;
height: 22px;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
border-radius: 6px;
}
/* Placeholder text */

View file

@ -20,12 +20,43 @@
<div class="event-panel__image-section">
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
<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 v-else class="event-panel__image-placeholder" @click="onAddImage">
</div>
<div v-else class="event-panel__image-placeholder" @click="openKeyImageUpload">
<q-icon name="add_photo_alternate" size="32px" color="grey-5" />
<span>Key Image hinzufügen</span>
</div>
<input
ref="keyImageInputRef"
type="file"
accept="image/*"
class="event-panel__file-input"
@change="onKeyImageSelected"
/>
</div>
<!-- Title large, editable inline -->
@ -38,10 +69,13 @@
:dark="isDark"
/>
<!-- Date row tap to open QDate picker -->
<div class="event-panel__date-row">
<q-icon name="event" size="18px" class="event-panel__date-icon" />
<div class="event-panel__meta-grid">
<div class="event-panel__meta-item event-panel__meta-item--date">
<span class="event-panel__meta-label">Datum</span>
<button class="event-panel__date-btn" type="button">
<q-icon name="event" size="16px" class="event-panel__date-icon" />
<span class="event-panel__date-label">{{ formattedDate }}</span>
</button>
<q-popup-proxy transition-show="scale" transition-hide="scale">
<q-date
v-model="ghostDateSlash"
@ -53,7 +87,24 @@
</q-popup-proxy>
</div>
<!-- Emotional Level card style with gradient track -->
<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"
>
<template #prepend>
<q-icon name="place" size="16px" />
</template>
</q-input>
</div>
</div>
<!-- Emotional Level card style with fixed gradient track -->
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
<div class="event-panel__card-header">
<span class="event-panel__card-label">Emotional Level</span>
@ -85,28 +136,26 @@
<span>Sehr positiv</span>
</div>
<!-- Gradient Preset Selector -->
<div class="event-panel__presets">
<span class="event-panel__presets-label">Farbverlauf</span>
<div class="event-panel__presets-grid">
<div
v-for="(preset, index) in gradientPresets"
:key="index"
class="event-panel__preset"
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === index }"
:style="{ background: presetGradientCSS(preset.colors) }"
:title="preset.name"
@click="selectPreset(index)"
></div>
<!-- "None" option to clear preset -->
<div
class="event-panel__preset event-panel__preset--none"
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === null }"
title="Standard"
@click="selectPreset(null)"
>
<q-icon name="auto_awesome" size="12px" />
<div class="event-panel__gradient-edit">
<div class="event-panel__gradient-row">
<span class="event-panel__presets-label">Punktfarbe (optional)</span>
<input
type="color"
:value="eventsStore.ghostCustomColor || emotionColor"
@input="e => { eventsStore.ghostCustomColor = e.target.value }"
class="event-panel__color-input"
/>
</div>
<div class="event-panel__gradient-actions">
<q-btn
flat
dense
no-caps
size="sm"
icon="palette"
label="Aus Verlauf"
@click="eventsStore.ghostCustomColor = null"
/>
</div>
</div>
</div>
@ -129,10 +178,41 @@
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
<span class="event-panel__card-label">Weitere Medien</span>
<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" />
</div>
</div>
<input
ref="mediaInputRef"
type="file"
accept="image/*"
multiple
class="event-panel__file-input"
@change="onMediaSelected"
/>
</div>
<!-- Delete (edit mode only) -->
@ -149,6 +229,60 @@
/>
</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>
</Transition>
</template>
@ -156,12 +290,14 @@
<script setup>
import { computed, watch, ref } from 'vue'
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 { resolveFullRes } from 'composables/useImageCache'
const $q = useQuasar()
const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
// Resolve key image: full-res when online, cached thumbnail when offline
@ -177,12 +313,18 @@ watch(
// Reset height when panel opens
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
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
const emotionColor = computed(() => {
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
@ -215,32 +357,151 @@ const emotionLabel = computed(() => {
// CSS gradient for the slider track
const sliderGradientCSS = computed(() => {
const idx = eventsStore.ghostGradientPreset
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%)'
return `linear-gradient(90deg, ${gradientStartColor.value} 0%, ${gradientEndColor.value} 100%)`
})
// CSS gradient for a preset swatch
function presetGradientCSS(colors) {
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`
const keyImageInputRef = ref(null)
const mediaInputRef = ref(null)
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) {
eventsStore.ghostGradientPreset = index
// Clear custom color when selecting a gradient
eventsStore.ghostCustomColor = null
function openMediaUpload() {
mediaInputRef.value?.click()
}
function onAddImage() {
// TODO: File picker for key image
function readImageAsDataUrl(file) {
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() {
// TODO: File picker for additional media
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.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>
@ -311,8 +572,12 @@ function onAddMedia() {
position: absolute;
top: 12px;
left: 12px;
max-width: min(180px, calc(100% - 24px));
border: 1px solid rgba(255, 255, 255, 0.16);
outline: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-family: inherit;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
@ -320,6 +585,56 @@ function onAddMedia() {
backdrop-filter: blur(4px);
}
.event-panel__image-badge::placeholder {
color: rgba(255, 255, 255, 0.78);
}
.event-panel__image-actions {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.event-panel__image-action {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.48);
color: #fff;
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
backdrop-filter: blur(6px);
}
.event-panel__image-gallery {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.48);
color: #fff;
cursor: pointer;
backdrop-filter: blur(6px);
}
.event-panel__image-action--danger {
background: rgba(150, 35, 35, 0.62);
}
.event-panel__image-placeholder {
display: flex;
flex-direction: column;
@ -339,6 +654,34 @@ function onAddMedia() {
opacity: 0.8;
}
.event-panel__file-input {
display: none;
}
.event-panel__confirm {
width: min(360px, calc(100vw - 32px));
border-radius: 18px;
background: rgba(255, 255, 255, 0.96);
color: #1a1a1a;
}
.event-panel__confirm--dark {
background: rgba(30, 30, 30, 0.96);
color: #f5f5f5;
}
.event-panel__confirm-title {
font-size: 18px;
font-weight: 700;
}
.event-panel__confirm-copy {
margin-top: 6px;
font-size: 13px;
line-height: 1.4;
opacity: 0.72;
}
/* Title */
.event-panel__title {
margin-bottom: 0;
@ -350,27 +693,65 @@ function onAddMedia() {
line-height: 1.3;
}
/* Date row */
.event-panel__date-row {
.event-panel__meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 6px 0 12px;
}
.event-panel__meta-item {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.14);
border-radius: 10px;
padding: 8px 10px;
min-height: 56px;
}
.event-panel__meta-label {
display: block;
font-size: 11px;
font-weight: 600;
opacity: 0.55;
margin-bottom: 4px;
}
.event-panel__date-btn {
width: 100%;
border: none;
background: transparent;
color: inherit;
font-family: inherit;
padding: 0;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
opacity: 0.6;
text-align: left;
cursor: pointer;
transition: opacity 0.2s;
}
.event-panel__date-row:hover {
opacity: 0.9;
}
.event-panel__date-icon {
flex-shrink: 0;
opacity: 0.7;
}
.event-panel__date-label {
font-size: 14px;
font-size: 13px;
font-weight: 500;
}
.event-panel__location-input {
margin-top: -2px;
}
.event-panel__location-input :deep(.q-field__prepend) {
margin-right: 4px;
color: inherit;
opacity: 0.65;
}
.event-panel__location-input :deep(.q-field__native) {
font-size: 13px;
font-weight: 500;
}
/* Card sections */
@ -447,51 +828,39 @@ function onAddMedia() {
margin-top: 4px;
}
/* Gradient Preset Selector */
.event-panel__presets {
.event-panel__gradient-edit {
margin-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
padding-top: 12px;
}
.event-panel__gradient-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.event-panel__presets-label {
font-size: 11px;
font-weight: 600;
opacity: 0.5;
display: block;
margin-bottom: 8px;
}
.event-panel__presets-grid {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.event-panel__preset {
width: 45px;
height: 25px;
.event-panel__color-input {
width: 44px;
height: 24px;
border: none;
border-radius: 6px;
padding: 0;
background: transparent;
cursor: pointer;
border: 2px solid #eee;
transition: border-color 0.2s, transform 0.15s;
}
.event-panel__preset:hover {
transform: scale(1.1);
}
.event-panel__preset--active {
border-color: currentColor;
transform: scale(1.1);
box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3);
}
.event-panel__preset--none {
background: rgba(128, 128, 128, 0.15);
.event-panel__gradient-actions {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-end;
}
/* Note */
@ -502,15 +871,66 @@ function onAddMedia() {
/* Media grid */
.event-panel__media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
gap: 10px;
margin-top: 10px;
}
.event-panel__media-item {
position: relative;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: rgba(128, 128, 128, 0.12);
}
.event-panel__media-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.event-panel__media-remove {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.58);
color: #fff;
cursor: pointer;
backdrop-filter: blur(4px);
}
.event-panel__media-open {
position: absolute;
top: 8px;
left: 8px;
width: 30px;
height: 30px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.58);
color: #fff;
cursor: pointer;
backdrop-filter: blur(4px);
}
.event-panel__media-add {
width: 64px;
height: 64px;
min-height: 128px;
aspect-ratio: 1;
border-radius: 10px;
border: 2px dashed rgba(128, 128, 128, 0.2);
display: flex;
@ -520,10 +940,72 @@ function onAddMedia() {
transition: border-color 0.2s;
}
@media (max-width: 380px) {
.event-panel__media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.event-panel__media-add {
min-height: 112px;
}
}
@media (min-width: 640px) {
.event-panel__media-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.event-panel__media-add:hover {
border-color: rgba(128, 128, 128, 0.4);
}
.event-panel__gallery {
position: fixed;
inset: 0;
background: #000;
color: #fff;
}
.event-panel__gallery-close {
position: fixed;
top: max(16px, env(safe-area-inset-top));
right: 16px;
z-index: 2;
width: 44px;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.48);
color: #fff;
cursor: pointer;
backdrop-filter: blur(8px);
}
.event-panel__gallery-carousel {
width: 100%;
height: 100dvh;
background: #000;
}
.event-panel__gallery-slide {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.event-panel__gallery-img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Delete */
.event-panel__delete {
display: flex;

View file

@ -29,6 +29,7 @@ const props = defineProps({
lineSpread: { type: Number, default: 0.05 },
fanSpread: { type: Number, default: 0.05 },
lineSharpness: { type: Number, default: 8.0 },
lineThickness: { type: Number, default: 1.0 },
waveFrequency: { type: Number, default: 7.0 },
bezierCurvature: { type: Number, default: 0.2 },
circleRadiusPx: { type: Number, default: 75 },
@ -43,7 +44,13 @@ const props = defineProps({
bgColorEdge: { type: String, default: '#000000' },
backgroundImage: { type: String, default: '' },
mixBlendMode: { type: String, default: 'screen' },
parallax: { type: Boolean, default: false }
parallax: { type: Boolean, default: false },
horizonMode: { type: String, default: 'off' },
horizonOpacity: { type: Number, default: 0.5 },
horizonBlend: { type: Number, default: 0.2 },
lineMode: { type: String, default: 'glow' }, // 'glow' | 'static'
staticLineColor: { type: String, default: '#2196F3' },
staticLineShadowStrength: { type: Number, default: 0 }
})
// FPS display
@ -71,7 +78,7 @@ void main() {
`
const fragmentShader = `
precision mediump float;
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
@ -84,11 +91,20 @@ uniform float pointY[16];
uniform float lineSpread;
uniform float fanSpread;
uniform float lineSharpness;
uniform float lineThickness;
uniform float waveFrequency;
uniform float bezierCurvature;
uniform float lineBrightness;
uniform vec3 pointColor[16];
uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
uniform float horizonOpacity;
uniform float horizonBlend;
uniform int lineMode; // 0=glow 1=static
uniform vec3 staticLineColor;
uniform float staticLineShadowStrength;
uniform bool parallax;
uniform vec2 parallaxOffset;
@ -96,6 +112,15 @@ uniform vec3 lineGradient[8];
uniform int lineGradientCount;
uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge;
uniform int hasBackgroundImage;
vec3 gradientMid() {
if (lineGradientCount <= 0) return vec3(0.45, 0.5, 0.8);
if (lineGradientCount == 1) return lineGradient[0];
float t = 0.4999 * float(lineGradientCount - 1);
int idx = int(t);
return mix(lineGradient[idx], lineGradient[min(idx + 1, lineGradientCount - 1)], fract(t));
}
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
float bestT = 0.0;
@ -126,7 +151,6 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
return t;
}
// Accepts precomputed bezier values (t, curvePos, norm) computed once per segment
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
float s = dot(uv - curvePos, norm);
@ -139,10 +163,34 @@ float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
* sin(fi * 0.9 + time * 0.18);
float widthScale = max(lineThickness, 0.1);
float dist = s - linePos - waveDisp;
float scaledDist = abs(dist) * lineSharpness / widthScale;
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
float tailMask = smoothstep(1.35, 0.02, scaledDist);
float core = 0.013 / max(scaledDist + 0.004, 1e-4);
float aura = 0.003 * widthScale * smoothstep(0.42, 0.06, scaledDist);
return fade * tailMask * core + fade * aura;
}
float staticLineFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
float s = dot(uv - curvePos, norm);
float time = iTime * animationSpeed;
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
float envelope = sin(t * 3.14159265359);
float linePos = (normalizedI - 0.5) * fanSpread * envelope;
float amp = lineSpread * 0.3 * envelope;
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
* sin(fi * 0.9 + time * 0.18);
float widthScale = max(lineThickness, 0.1);
float dist = abs(s - linePos - waveDisp) * lineSharpness / widthScale;
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003);
return fade * smoothstep(0.022, 0.012, dist);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
@ -154,8 +202,8 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
}
vec3 col = vec3(0.0);
float totalIntensity = 0.0;
const int MAX_PTS = 16;
const int MAX_SEGS = 15;
for (int s = 0; s < MAX_SEGS; ++s) {
@ -166,36 +214,105 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 segD = ep - sp;
float segL = length(segD);
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
if (segL < 1e-4) continue;
vec2 segDir = segD / segL;
vec2 sPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
float t_seg = clamp(dot(baseUv - sp, segDir) / max(segL, 1e-4), 0.0, 1.0);
vec3 lineCol = lineMode == 1
? staticLineColor
: mix(pointColor[s], pointColor[s + 1], t_seg);
// bezierClosestT computed ONCE per segment shared by fog + all lines
float bt = bezierClosestT(baseUv, sp, pc, ep);
float bmt = 1.0 - bt;
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc));
vec2 bTangRaw = bmt*(pc - sp) + bt*(ep - pc);
float bTangLen = length(bTangRaw);
vec2 bTang = bTangLen > 1e-4 ? bTangRaw / bTangLen : segDir;
vec2 bNorm = vec2(-bTang.y, bTang.x);
float shadowStrength = clamp(staticLineShadowStrength, 0.0, 1.0);
if (lineMode != 1 || shadowStrength > 0.0) {
float bDist = length(baseUv - bPos);
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
float fogEnv = sin(bt * 3.14159265359);
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
col += lineCol * segFog;
float fogVal = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
float fogScale = lineMode == 1 ? shadowStrength : 1.0;
col += lineCol * fogVal * fogScale;
totalIntensity += fogVal * fogScale;
}
for (int i = 0; i < middleLineCount; ++i) {
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
float glowFv = waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
float fv = glowFv;
if (lineMode == 1) {
float crispFv = staticLineFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
fv = mix(crispFv, glowFv, shadowStrength);
}
col += lineCol * fv;
totalIntensity += fv;
}
}
col *= lineBrightness;
totalIntensity *= lineBrightness;
if (lineMode != 1) {
// Drop only the distant low-energy glow tails that accumulate into cloudy artifacts.
float glowPeak = max(col.r, max(col.g, col.b));
float cleanupMask = smoothstep(0.018, 0.055, glowPeak);
col *= cleanupMask;
totalIntensity *= cleanupMask;
}
// Radial background shared by both modes
float dist = length(baseUv) / 1.8;
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
// "Split" horizon changes the background orientation (vertical) in both modes.
if (horizonMode == 2) {
float blendW = max(horizonBlend * 0.7, 0.001);
float t = smoothstep(-blendW, blendW, baseUv.y);
bg = mix(bgColorEdge, bgColorCenter, t);
}
if (lineMode == 1) {
// Static Lines: radial bg + lines mixed by intensity (no additive glow)
float staticMix = clamp(totalIntensity, 0.0, 1.0);
vec3 staticCol = mix(bg, staticLineColor, staticMix);
if (hasBackgroundImage == 1) {
// Keep background image visible by outputting line-only color with alpha.
float alpha = staticMix < 0.03 ? 0.0 : staticMix;
fragColor = vec4(staticLineColor * alpha, alpha);
return;
}
fragColor = vec4(staticCol, 1.0);
return;
}
// Glowing Lines: additive line colors + horizon effects
if (horizonMode == 1) {
float band = exp(-baseUv.y * baseUv.y * 5.0);
col += gradientMid() * band * horizonOpacity;
} else if (horizonMode == 3) {
float d2 = baseUv.y * baseUv.y;
float softGlow = exp(-d2 * 10.0);
float coreGlow = exp(-d2 * 70.0) * 0.7;
col += gradientMid() * (softGlow + coreGlow) * horizonOpacity;
}
vec3 composed = clamp(bg + col, 0.0, 1.0);
if (hasBackgroundImage == 1) {
// Render only glowing lines above CSS background image.
vec3 lineOnly = clamp(col, 0.0, 1.0);
float alpha = clamp(max(lineOnly.r, max(lineOnly.g, lineOnly.b)), 0.0, 1.0);
alpha = alpha < 0.03 ? 0.0 : alpha;
fragColor = vec4(lineOnly * alpha, alpha);
return;
}
fragColor = vec4(composed, 1.0);
}
void main() {
@ -238,8 +355,14 @@ let rafId = null
let resizeObserver = null
let uniforms = null
let scrollHandler = null
let boundScrollContainer = null
let cachedScrollLeft = 0
let scrollIdleTimer = null
let visibilityHandler = null
// Exposed hook so parent can trigger a hard canvas resize sync.
let forceResizeHandler = null
let pointerMoveHandler = null
let pointerTargetEl = null
// Parallax tracking
let targetParallax = null
@ -282,6 +405,11 @@ function applyBgColors() {
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
}
function applyBackgroundImageFlag() {
if (!uniforms) return
uniforms.hasBackgroundImage.value = props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0
}
// Watch all props for live updates
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
watch(() => props.lineCount, () => {
@ -291,6 +419,7 @@ watch(() => props.lineCount, () => {
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
watch(() => props.lineThickness, (v) => { if (uniforms) uniforms.lineThickness.value = v })
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
@ -311,6 +440,33 @@ watch(() => props.pointColors, applyPointColors, { deep: true })
watch(() => props.linesGradient, applyGradient, { deep: true })
watch(() => props.bgColorCenter, applyBgColors)
watch(() => props.bgColorEdge, applyBgColors)
watch(() => props.backgroundImage, applyBackgroundImageFlag)
watch(() => props.horizonMode, (v) => {
if (uniforms) uniforms.horizonMode.value = ({ off: 0, fog: 1, split: 2, glow: 3 })[v] ?? 0
})
watch(() => props.horizonOpacity, (v) => { if (uniforms) uniforms.horizonOpacity.value = v })
watch(() => props.horizonBlend, (v) => { if (uniforms) uniforms.horizonBlend.value = v })
watch(() => props.lineMode, (v) => {
if (uniforms) uniforms.lineMode.value = v === 'static' ? 1 : 0
})
watch(() => props.staticLineColor, (v) => {
if (uniforms) { const c = hexToVec3(v); uniforms.staticLineColor.value.set(c.x, c.y, c.z) }
})
watch(() => props.staticLineShadowStrength, (v) => {
if (uniforms) uniforms.staticLineShadowStrength.value = v
})
watch(() => props.scrollContainer, (nextEl, prevEl) => {
if (prevEl && scrollHandler) {
prevEl.removeEventListener('scroll', scrollHandler)
if (boundScrollContainer === prevEl) boundScrollContainer = null
}
if (nextEl && scrollHandler) {
cachedScrollLeft = nextEl.scrollLeft || 0
nextEl.addEventListener('scroll', scrollHandler, { passive: true })
boundScrollContainer = nextEl
}
})
onMounted(() => {
if (!containerRef.value) return
@ -331,8 +487,9 @@ onMounted(() => {
let currentDpr = DPR_IDLE
let scrolling = false
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' })
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: true, powerPreference: 'high-performance' })
renderer.setPixelRatio(currentDpr)
renderer.setClearAlpha(0)
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.display = 'block'
@ -357,6 +514,7 @@ onMounted(() => {
lineSpread: { value: props.lineSpread },
fanSpread: { value: props.fanSpread },
lineSharpness: { value: props.lineSharpness },
lineThickness: { value: props.lineThickness },
waveFrequency: { value: props.waveFrequency },
bezierCurvature: { value: props.bezierCurvature },
lineBrightness: { value: props.lineBrightness },
@ -364,6 +522,14 @@ onMounted(() => {
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
},
horizonMode: { value: ({ off: 0, fog: 1, split: 2, glow: 3 })[props.horizonMode] ?? 0 },
horizonOpacity: { value: props.horizonOpacity },
horizonBlend: { value: props.horizonBlend },
lineMode: { value: props.lineMode === 'static' ? 1 : 0 },
staticLineColor: { value: hexToVec3(props.staticLineColor) },
staticLineShadowStrength: { value: props.staticLineShadowStrength },
parallax: { value: props.parallax },
parallaxOffset: { value: new Vector2(0, 0) },
@ -372,12 +538,14 @@ onMounted(() => {
},
lineGradientCount: { value: 0 },
bgColorCenter: { value: new Vector3(0, 0, 0) },
bgColorEdge: { value: new Vector3(0, 0, 0) }
bgColorEdge: { value: new Vector3(0, 0, 0) },
hasBackgroundImage: { value: props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0 }
}
// Apply initial values
applyGradient()
applyBgColors()
applyBackgroundImageFlag()
applyPointColors()
material = new ShaderMaterial({
@ -402,6 +570,7 @@ onMounted(() => {
const canvasHeight = renderer.domElement.height
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
}
forceResizeHandler = setSize
setSize()
resizeObserver = new ResizeObserver(setSize)
@ -409,7 +578,7 @@ onMounted(() => {
// Pointer events (parallax only)
if (props.parallax) {
const handlePointerMove = (event) => {
pointerMoveHandler = (event) => {
const rect = renderer.domElement.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
@ -420,11 +589,11 @@ onMounted(() => {
(-(y - centerY) / rect.height) * 0.2
)
}
renderer.domElement.addEventListener('pointermove', handlePointerMove)
pointerTargetEl = renderer.domElement
pointerTargetEl.addEventListener('pointermove', pointerMoveHandler)
}
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
let cachedScrollLeft = 0
function setDpr(dpr) {
if (dpr === currentDpr) return
@ -458,6 +627,7 @@ onMounted(() => {
if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
boundScrollContainer = props.scrollContainer
}
// Fast inline scroll sync reads cached scrollLeft instead of DOM during render
@ -519,8 +689,8 @@ onMounted(() => {
dprDisplay.value = currentDpr.toFixed(2)
}
// Read latest scrollLeft from DOM in case scroll event was missed
if (props.scrollContainer) {
// Fallback read only when no listener is bound.
if (!boundScrollContainer && props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
}
@ -539,15 +709,23 @@ onMounted(() => {
renderLoop()
})
defineExpose({ fpsDisplay, dprDisplay })
function forceResize() {
if (forceResizeHandler) forceResizeHandler()
}
defineExpose({ fpsDisplay, dprDisplay, forceResize })
onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId)
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
if (resizeObserver) resizeObserver.disconnect()
if (props.scrollContainer && scrollHandler) {
props.scrollContainer.removeEventListener('scroll', scrollHandler)
if (boundScrollContainer && scrollHandler) {
boundScrollContainer.removeEventListener('scroll', scrollHandler)
}
boundScrollContainer = null
if (pointerTargetEl && pointerMoveHandler) pointerTargetEl.removeEventListener('pointermove', pointerMoveHandler)
pointerTargetEl = null
pointerMoveHandler = null
clearTimeout(scrollIdleTimer)
if (geometry) geometry.dispose()
if (material) material.dispose()
@ -557,6 +735,7 @@ onBeforeUnmount(() => {
renderer.domElement.parentNode.removeChild(renderer.domElement)
}
}
forceResizeHandler = null
})
</script>

View file

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

View file

@ -17,6 +17,23 @@
<div class="lw-settings__scroll">
<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 -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Linien</span>
@ -28,7 +45,7 @@
<q-slider
:model-value="fl.speed"
@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">
@ -38,7 +55,7 @@
<q-slider
:model-value="fl.lineCount"
@update:model-value="v => update({ lineCount: v })"
:min="1" :max="40" :step="1"
:min="1" :max="10" :step="1"
/>
<div class="lw-settings__row">
@ -48,7 +65,7 @@
<q-slider
:model-value="fl.spread"
@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">
@ -58,7 +75,7 @@
<q-slider
:model-value="fl.fanSpread"
@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">
@ -68,7 +85,17 @@
<q-slider
:model-value="fl.lineSharpness"
@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">
@ -78,7 +105,7 @@
<q-slider
:model-value="fl.waveFrequency"
@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">
@ -88,7 +115,7 @@
<q-slider
:model-value="fl.bezierCurvature"
@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">
@ -98,7 +125,7 @@
<q-slider
:model-value="fl.circleRadius"
@update:model-value="v => update({ circleRadius: v })"
:min="10" :max="200" :step="5"
:min="50" :max="200" :step="5"
/>
<div class="lw-settings__row">
@ -108,7 +135,7 @@
<q-slider
:model-value="fl.glowSize"
@update:model-value="v => update({ glowSize: v })"
:min="5" :max="100" :step="1"
:min="0" :max="50" :step="1"
/>
<div class="lw-settings__row">
@ -118,7 +145,7 @@
<q-slider
:model-value="fl.glowStrength"
@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">
@ -128,7 +155,52 @@
<q-slider
:model-value="fl.lineBrightness ?? 1"
@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>
@ -160,6 +232,26 @@
class="lw-settings__color-input"
/>
</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>
<!-- Hintergrundbild -->
@ -174,6 +266,13 @@
>
Keins
</button>
<button
class="lw-settings__img-btn"
:class="{ 'lw-settings__img-btn--active': isCustomBackground }"
@click="openBackgroundUpload"
>
Eigenes Bild
</button>
<button
v-for="n in 10"
:key="'bg' + n"
@ -184,6 +283,13 @@
{{ n }}
</button>
</div>
<input
ref="backgroundUploadRef"
type="file"
accept="image/*"
class="lw-settings__file-input"
@change="onBackgroundUpload"
/>
</div>
<!-- Hintergrundfarbe -->
@ -191,7 +297,7 @@
<span class="lw-settings__card-label">Hintergrundfarbe</span>
<div class="lw-settings__row">
<span>BG Mitte</span>
<span>{{ isSplit ? 'Unten' : 'BG Mitte' }}</span>
<input
type="color"
:value="fl.bgCenter"
@ -201,7 +307,7 @@
</div>
<div class="lw-settings__row">
<span>BG Rand</span>
<span>{{ isSplit ? 'Oben' : 'BG Rand' }}</span>
<input
type="color"
:value="fl.bgEdge"
@ -211,24 +317,51 @@
</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 -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Extras</span>
<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>
<q-toggle
:model-value="isDark"
@ -238,6 +371,34 @@
</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 -->
<div class="lw-settings__reset">
<q-btn
@ -249,19 +410,54 @@
/>
</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>
</Transition>
</template>
<script setup>
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings'
import { usePanelDrag } from 'composables/usePanelDrag'
const props = defineProps({ open: { type: Boolean, default: false } })
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() })
@ -269,20 +465,130 @@ const $q = useQuasar()
const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive)
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 = [
{ label: 'Klein', value: 'small' },
{ label: 'Mittel', value: 'medium' },
{ label: 'Groß', value: 'large' }
{ label: 'Groß', value: 'large' },
{ label: 'Extra groß', value: 'xlarge' }
]
function update(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() {
$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>
<style scoped>
@ -292,7 +598,7 @@ function toggleDark() {
left: 0;
right: 0;
z-index: 20;
height: 75dvh;
height: 50dvh;
display: flex;
flex-direction: column;
border-radius: 20px 20px 0 0;
@ -374,6 +680,11 @@ function toggleDark() {
opacity: 0.6;
}
.lw-settings__value--hint {
font-size: 11px;
opacity: 0.5;
}
/* Segmented control */
.lw-settings__segmented {
display: flex;
@ -398,7 +709,7 @@ function toggleDark() {
}
.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;
font-weight: 600;
}
@ -424,15 +735,19 @@ function toggleDark() {
.lw-settings__img-btn:hover {
opacity: 1;
border-color: #a855f7;
border-color: var(--tm-accent, #737373);
}
.lw-settings__img-btn--active {
border-color: #a855f7;
color: #a855f7;
border-color: var(--tm-accent, #737373);
color: var(--tm-accent, #737373);
opacity: 1;
}
.lw-settings__file-input {
display: none;
}
.lw-settings__color-input {
width: 36px;
height: 22px;
@ -443,6 +758,38 @@ function toggleDark() {
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 {
width: 100%;
background: rgba(0, 0, 0, 0.15);

View file

@ -142,11 +142,13 @@ const isDark = computed(() => $q.dark.isActive)
.modal-card__tab--active {
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 {
background: rgba(255, 255, 255, 0.12);
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.25);
color: #fff;
}
/* Body */

View file

@ -72,20 +72,22 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useEventsStore } from 'stores/events'
import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings'
import GlowDot from 'components/GlowDot.vue'
const emit = defineEmits(['dotSelect', 'viewUpdate'])
const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
const timelineRef = ref(null)
const scrollLeft = ref(0)
const viewportWidth = ref(400)
const containerHeight = ref(400)
// Zoom: 1.0 = default, range 0.43.0
const zoomLevel = ref(1)
const MIN_ZOOM = 0.4
const MAX_ZOOM = 3.0
const ZOOM_STEP = 0.08
const zoomLevel = ref(clampZoom(settingsStore.timelineZoom ?? DEFAULT_TIMELINE_ZOOM))
// Spacing: ~4 events visible at a time, scaled by zoom
const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
@ -210,6 +212,8 @@ const stickyYearLabels = computed(() => {
// Virtualization: only render events near the viewport
const VIS_BUFFER = 2
const VIEW_EMIT_BUFFER = 3
const MAX_SHADER_POINTS = 16
const visibleRange = computed(() => {
const total = displayEvents.value.length
@ -258,6 +262,8 @@ const activeLabel = computed(() => {
function onScroll() {
if (timelineRef.value) {
scrollLeft.value = timelineRef.value.scrollLeft
if (restoringScrollFromSettings) return
persistScrollPosition()
}
}
@ -288,11 +294,10 @@ function scrollToYearCenter(year) {
}
function updateViewportWidth() {
if (timelineRef.value) {
if (!timelineRef.value) return
viewportWidth.value = timelineRef.value.clientWidth || 400
containerHeight.value = timelineRef.value.clientHeight || 400
}
}
// Zoom while keeping the viewport center stable
function applyZoom(newZoom, centerClientX) {
@ -316,6 +321,7 @@ function applyZoom(newZoom, centerClientX) {
nextTick(() => {
el.scrollLeft = worldXBefore * ratio - cx
scrollLeft.value = el.scrollLeft
persistScrollPosition()
})
}
@ -334,6 +340,7 @@ function onWheel(e) {
if (el) {
el.scrollLeft += e.deltaX || e.deltaY
scrollLeft.value = el.scrollLeft
persistScrollPosition()
}
}
}
@ -369,51 +376,244 @@ function onTouchEnd() {
touchStartDist = 0
}
// Scroll to center on the last event on mount
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
function emitViewState() {
const { start, end } = visibleRange.value
if (viewUpdateRafId) {
cancelAnimationFrame(viewUpdateRafId)
viewUpdateRafId = 0
}
emit('viewUpdate', {
scrollLeft: scrollLeft.value,
viewportWidth: viewportWidth.value,
containerHeight: containerHeight.value,
visibleStart: start,
visibleEnd: end,
events: displayEvents.value.map((e, i) => ({
emotion: e.emotion,
x: getEventX(i),
color: eventsStore.getGlowColor(e)
}))
events: getShaderEvents()
})
}
function scheduleViewStateEmit() {
if (viewUpdateRafId) return
viewUpdateRafId = requestAnimationFrame(emitViewState)
}
watch(
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
emitViewState,
{ deep: true }
scheduleViewStateEmit
)
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 () => {
await nextTick()
if (!timelineRef.value) return
updateViewportWidth()
applyInitialScrollPosition()
const events = displayEvents.value
if (events.length === 0) return
const lastX = getEventX(events.length - 1)
timelineRef.value.scrollLeft = lastX - viewportWidth.value / 2
scrollLeft.value = timelineRef.value.scrollLeft
// Update viewport width on resize
resizeObserver = new ResizeObserver(updateViewportWidth)
// Resize observer runs once per frame and keeps timeline center stable.
isInitialized = true
resizeObserver = new ResizeObserver(() => {
if (!isInitialized) return
if (resizeRafId) cancelAnimationFrame(resizeRafId)
resizeRafId = requestAnimationFrame(() => {
resizeRafId = 0
recalculateLayoutAfterResize()
})
})
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
emitViewState()
})
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()
})
@ -427,7 +627,12 @@ function zoomOut() {
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>
<style scoped>

View file

@ -4,11 +4,11 @@
<!-- User header -->
<div class="user-menu__header">
<div class="user-menu__avatar">
<span>K</span>
<span>{{ authStore.currentUser?.avatar ?? '?' }}</span>
</div>
<div class="user-menu__info">
<div class="user-menu__name">k-adam</div>
<div class="user-menu__handle">@k-adam</div>
<div class="user-menu__name">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
<div class="user-menu__handle">{{ authStore.currentUser?.email ?? '' }}</div>
</div>
</div>
@ -92,11 +92,11 @@
<!-- Footer: user + plan -->
<div class="user-menu__footer">
<div class="user-menu__avatar user-menu__avatar--sm">
<span>K</span>
<span>{{ authStore.currentUser?.avatar ?? '?' }}</span>
</div>
<div class="user-menu__info">
<div class="user-menu__name user-menu__name--sm">Kevin Ada</div>
<div class="user-menu__plan">Free</div>
<div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
<div class="user-menu__plan">Demo Account</div>
</div>
</div>
</div>
@ -105,10 +105,12 @@
<script setup>
import { ref } from 'vue'
import { useAuthStore } from 'stores/auth'
defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close', 'navigate'])
const authStore = useAuthStore()
const helpOpen = ref(false)
</script>
@ -215,11 +217,11 @@ const helpOpen = ref(false)
.user-menu__item:hover,
.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 {
background: rgba(128, 128, 128, 0.2);
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.22);
}
.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'
const THUMB_SIZE = 200
@ -101,41 +101,52 @@ async function getCachedImage(imageUrl) {
export function useImageCache(imageUrl, eventId) {
const resolvedSrc = ref(null)
const loading = ref(false)
let requestId = 0
async function resolve() {
if (!imageUrl) {
async function resolve(nextUrl = unref(imageUrl)) {
const currentRequestId = ++requestId
if (!nextUrl) {
resolvedSrc.value = null
loading.value = false
return
}
// 1. Memory cache (instant)
if (memoryCache.has(imageUrl)) {
resolvedSrc.value = memoryCache.get(imageUrl)
if (memoryCache.has(nextUrl)) {
resolvedSrc.value = memoryCache.get(nextUrl)
loading.value = false
return
}
// 2. IndexedDB cache
const cached = await getCachedImage(imageUrl)
const cached = await getCachedImage(nextUrl)
if (cached) {
if (currentRequestId !== requestId) return
resolvedSrc.value = cached
loading.value = false
return
}
// 3. Fetch, create thumbnail, cache
loading.value = true
try {
const blobUrl = await fetchAndCache(imageUrl, eventId)
const blobUrl = await fetchAndCache(nextUrl, unref(eventId))
if (currentRequestId !== requestId) return
resolvedSrc.value = blobUrl
} catch (e) {
// Fallback: use original URL directly (works when online)
console.warn('Image cache failed, using direct URL:', e)
resolvedSrc.value = imageUrl
if (currentRequestId !== requestId) return
resolvedSrc.value = nextUrl
} finally {
loading.value = false
if (currentRequestId === requestId) loading.value = false
}
}
resolve()
watch(() => unref(imageUrl), (nextUrl) => {
resolve(nextUrl)
}, { immediate: true })
return { resolvedSrc, loading }
}

View file

@ -3,15 +3,19 @@ import { ref, onBeforeUnmount } from 'vue'
/**
* 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
*
* @param {Function} onClose - called when panel is dragged below threshold
* @param {Object} options - drag/snap behavior overrides
* @returns {{ panelHeight, handleListeners, resetHeight }}
*/
export function usePanelDrag(onClose) {
const SNAP_POINTS = [100, 75, 50, 25] // dvh values
const CLOSE_THRESHOLD = 15 // below this → close
export function usePanelDrag(onClose, options = {}) {
const SNAP_POINTS = options.snapPoints ?? [100, 75, 50, 25] // dvh values
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)
const panelHeight = ref(null)
@ -52,7 +56,7 @@ export function usePanelDrag(onClose) {
startY = clientY
// 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
document.addEventListener('pointermove', onPointerMove, { passive: false })
@ -80,7 +84,7 @@ export function usePanelDrag(onClose) {
function handleMove(clientY) {
const deltaY = clientY - startY
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
}
@ -99,7 +103,7 @@ export function usePanelDrag(onClose) {
cleanup()
const currentHeight = panelHeight.value ?? 75
const currentHeight = panelHeight.value ?? INITIAL_DVH
if (currentHeight < CLOSE_THRESHOLD) {
panelHeight.value = null
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 {
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);
-webkit-backdrop-filter: blur(12px);
transition: background 0.2s ease;
@ -18,7 +26,7 @@
// Glass panel style strong blur for slide-up panels
.glass--panel {
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);
-webkit-backdrop-filter: blur(20px);
color: #1a1a1a;

View file

@ -15,3 +15,19 @@ db.version(1).stores({
// Metadata: key-value pairs (lastSyncCursor, userId, etc.)
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"
:fan-spread="fl.fanSpread"
:line-sharpness="fl.lineSharpness"
:line-thickness="fl.lineThickness ?? 1"
:wave-frequency="fl.waveFrequency"
:bezier-curvature="fl.bezierCurvature"
:circle-radius-px="fl.circleRadius"
@ -25,7 +26,13 @@
:bg-color-center="fl.bgCenter"
:bg-color-edge="fl.bgEdge"
: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 -->
@ -142,6 +149,7 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import AddEventButton from 'components/AddEventButton.vue'
import EventPanel from 'components/EventPanel.vue'
import FloatingLines from 'components/FloatingLines.vue'
@ -150,10 +158,13 @@ import TimelineView from 'components/TimelineView.vue'
import AppSettingsModal from 'components/AppSettingsModal.vue'
import UserMenu from 'components/UserMenu.vue'
import ZoomControl from 'components/ZoomControl.vue'
import { useAuthStore } from 'stores/auth'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
const $q = useQuasar()
const router = useRouter()
const authStore = useAuthStore()
const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
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)
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)
const layoutRef = ref(null)
const layoutWidth = ref(window.innerWidth)
const layoutHeight = ref(window.innerHeight)
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(() => {
if (layoutRef.value) {
layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight
layoutResizeObserver = new ResizeObserver(() => {
// Read fresh dimensions in the next frame to avoid transient layout values.
requestAnimationFrame(() => {
if (!layoutRef.value) return
layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight
})
})
layoutResizeObserver.observe(layoutRef.value)
}
window.addEventListener('resize', onWindowResize, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowResize)
if (hardResizeTimer) clearTimeout(hardResizeTimer)
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
layoutResizeObserver?.disconnect()
})
@ -213,30 +258,13 @@ function screenToUV(sx, sy) {
// Compute shader point positions from event positions
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(() => {
if (!timelineState.value) return []
const { events, visibleStart, visibleEnd } = timelineState.value
const { events } = timelineState.value
if (events.length === 0) return []
// 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
return events.slice(0, 16)
})
const shaderNumPoints = computed(() => shaderSelection.value.length)
@ -290,19 +318,7 @@ const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0)
function onZoomTo(value) {
if (!timelineViewRef.value) return
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
// Use applyZoom exposed or set directly we use the internal method indirectly
// 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
})
timelineViewRef.value.zoomTo?.(clamped)
}
const toggleDarkMode = () => {
@ -322,6 +338,9 @@ const onUserMenuNavigate = (target) => {
userMenuOpen.value = false
if (target === 'settings') {
appSettingsOpen.value = true
} else if (target === 'logout') {
authStore.logout()
router.replace('/login')
}
}
</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>
<q-page padding class="flex flex-center">
<q-card style="width: 350px">
<q-card-section>
<div class="text-h6">Login</div>
</q-card-section>
<div class="login-page">
<div class="login-page__bg" />
<form class="login-card" @submit.prevent="onSubmit">
<div class="login-card__brand">That&apos;s Me</div>
<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">
<q-input
v-model="email"
type="email"
label="Email"
filled
:rules="[val => !!val || 'Email is required']"
/>
<label class="login-field">
<span>E-Mail</span>
<select v-model="email">
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
{{ user.email }}
</option>
</select>
<q-icon name="person_outline" size="22px" />
</label>
<q-input
<label class="login-field">
<span>Passwort</span>
<input
v-model="password"
type="password"
label="Password"
filled
:rules="[val => !!val || 'Password is required']"
/>
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
>
<button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
<q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
</button>
</label>
<div class="q-mt-md">
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
</div>
<label class="login-remember">
<input v-model="remember" type="checkbox">
<span>Remember me</span>
</label>
<div class="text-center q-mt-sm">
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
<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>
</q-form>
</q-card-section>
</q-card>
</q-page>
</template>
<script>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth'
export default {
name: 'LoginPage',
setup() {
const email = ref('')
const password = ref('')
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const onSubmit = () => {
console.log('Login attempt with:', email.value, password.value)
const email = ref(authStore.users[0]?.email ?? '')
const password = ref('pass')
const showPassword = ref(false)
const remember = ref(true)
// Save login status
localStorage.setItem('isLoggedIn', 'true')
window.dispatchEvent(new Event('storage'))
console.log('Redirecting to wave page...')
// Direct navigation
window.location.href = '/#/wave'
}
return {
email,
password,
onSubmit
}
}
function onSubmit() {
if (!authStore.login(email.value, password.value)) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
}
</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 { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes'
import { AUTH_STORAGE_KEY, DEMO_USERS } from 'stores/auth'
/*
* If not building with SSR mode, you can
@ -26,5 +27,28 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE)
})
Router.beforeEach((to) => {
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
let userId = null
try {
userId = stored ? JSON.parse(stored)?.userId ?? null : null
} catch {
userId = null
}
const isAuthenticated = DEMO_USERS.some(user => user.id === userId)
if (to.meta.requiresAuth && !isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login' && isAuthenticated) {
return { path: '/' }
}
return true
})
return Router
})

View file

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

View file

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

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 { ref, computed, watch } from 'vue'
import Dexie from 'dexie'
import { db } from 'src/db'
import { startAutoSync, getToken } from 'src/services/syncService'
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
// Color interpolation
function lerpColor(a, b, t) {
@ -17,55 +20,24 @@ function lerpColor(a, b, t) {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
}
// Gradient presets: [negative, neutral, positive]
const GRADIENT_PRESETS = [
{ name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] },
{ name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] },
{ name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] },
{ name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] },
{ name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] },
{ name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] },
{ name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] },
{ name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] },
{ name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] },
{ name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] }
]
// Glow color logic: emotion value → color, with optional gradient preset
function emotionToColor(emotion, gradientIdx = null) {
const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null
if (preset) {
const [neg, mid, pos] = preset.colors
if (emotion >= 0) {
return lerpColor(mid, pos, emotion)
} else {
return lerpColor(mid, neg, Math.abs(emotion))
}
}
if (emotion >= 0) {
if (emotion < 0.5) {
return lerpColor('#FF6B35', '#FFD700', emotion / 0.5)
}
return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5)
} else {
const abs = Math.abs(emotion)
if (abs < 0.5) {
return lerpColor('#2196F3', '#9C27B0', abs / 0.5)
}
return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5)
}
// Glow color logic: emotion value mapped on one continuous gradient.
function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = null) {
const start = gradientStartColor || DEFAULT_EMOTION_GRADIENT_START
const end = gradientEndColor || DEFAULT_EMOTION_GRADIENT_END
const t = Math.max(0, Math.min(1, (emotion + 1) / 2))
return lerpColor(start, end, t)
}
// Demo seed data
const demoEvents = [
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', location: '', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', location: '', emotion: 0.85, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', location: '', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', location: '', emotion: 0.75, customColor: null, gradientPreset: null, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', location: '', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', location: '', emotion: 0.95, customColor: null, gradientPreset: null, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', location: '', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', location: '', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
]
// Generate realistic demo events for testing at scale
@ -144,16 +116,14 @@ function generateManyEvents(count = 500) {
const note = hasNote ? pick(cat.notes) : ''
const hasImage = rand() < 0.15 // 15% chance
const image = hasImage ? pick(demoImages) : null
const hasPreset = rand() < 0.25 // 25% chance
const gradientPreset = hasPreset ? randInt(0, 9) : null
evts.push({
id: crypto.randomUUID(),
title,
date,
location: '',
emotion,
customColor: null,
gradientPreset,
gradientPreset: null,
image,
note,
syncStatus: 'local',
@ -167,21 +137,44 @@ function generateManyEvents(count = 500) {
return evts
}
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
export {
emotionToColor,
demoEvents,
generateManyEvents
}
export const useEventsStore = defineStore('events', () => {
const settingsStore = useSettingsStore()
const authStore = useAuthStore()
const events = ref([])
const isLoaded = ref(false)
const selectedEventId = ref(null)
const panelOpen = ref(false)
const editingEventId = ref(null)
const AUTOSAVE_DELAY_MS = 300
let persistTimer = null
let skipNextPersist = false
// Load events from IndexedDB; seed demo data on first launch
async function init() {
const userId = authStore.currentUserId
if (!userId) {
events.value = []
isLoaded.value = true
return
}
isLoaded.value = false
try {
let stored = await db.events.orderBy('date').toArray()
let stored = await db.events
.where('[userId+date]')
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
.toArray()
if (stored.length === 0) {
const seed = generateManyEvents(500)
const seed = generateManyEvents(500).map(event => ({
...event,
userId
}))
await db.events.bulkPut(seed)
stored = seed
}
@ -200,7 +193,7 @@ export const useEventsStore = defineStore('events', () => {
// Fire-and-forget DB write (UI already updated via ref)
function dbPut(event) {
db.events.put(event).catch(e => console.warn('Dexie put failed:', e))
db.events.put({ ...event, userId: event.userId ?? authStore.currentUserId }).catch(e => console.warn('Dexie put failed:', e))
}
function dbDelete(id) {
@ -208,27 +201,95 @@ export const useEventsStore = defineStore('events', () => {
}
function dbQueueSync(eventId, action, payload) {
db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() })
.catch(e => console.warn('Dexie sync queue failed:', e))
const userId = authStore.currentUserId
if (!userId) return
const queue = async () => {
if (action === 'update') {
await db.syncQueue
.where('eventId')
.equals(eventId)
.and(item => item.userId === userId && item.action === 'update')
.delete()
}
await db.syncQueue.add({ userId, eventId, action, payload, createdAt: Date.now() })
}
queue().catch(e => console.warn('Dexie sync queue failed:', e))
}
function cloneMedia(media) {
return Array.isArray(media)
? media.map(item => ({ ...item }))
: []
}
function mediaMeta(media) {
return cloneMedia(media).map(({ id, type, name, createdAt }) => ({
id,
type,
name,
createdAt
}))
}
function persistEventMedia(eventId, userId, media) {
const mediaItems = cloneMedia(media)
const persist = async () => {
await db.eventMedia.where('eventId').equals(eventId).delete()
if (mediaItems.length === 0) return
await db.eventMedia.bulkPut(mediaItems.map(item => ({
...item,
eventId,
userId
})))
}
persist().catch(e => console.warn('Dexie media persist failed:', e))
}
async function loadEventMedia(event) {
const fallback = Array.isArray(event.media) ? cloneMedia(event.media) : []
ghostMedia.value = fallback
try {
const stored = await db.eventMedia
.where('eventId')
.equals(event.id)
.toArray()
if (editingEventId.value !== event.id || stored.length === 0) return
ghostMedia.value = stored
.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
.map(({ eventId, userId, ...item }) => item)
} catch (e) {
console.warn('Dexie media load failed:', e)
}
}
// Ghost event for live preview while creating/editing
const ghostEmotion = ref(0)
const ghostCustomColor = ref(null)
const ghostGradientPreset = ref(null)
const ghostTitle = ref('')
const ghostDate = ref(new Date().toISOString().slice(0, 10))
const ghostLocation = ref('')
const ghostNote = ref('')
const ghostImage = ref(null)
const ghostKeyImageTitle = ref('')
const ghostMedia = ref([])
const ghostEvent = computed(() => ({
id: '__ghost__',
title: ghostTitle.value || 'New Event',
date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value,
customColor: ghostCustomColor.value,
gradientPreset: ghostGradientPreset.value,
image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: ghostMedia.value,
note: ghostNote.value
}))
@ -245,22 +306,27 @@ export const useEventsStore = defineStore('events', () => {
editingEventId.value = eventId
const event = events.value.find((e) => e.id === eventId)
if (event) {
skipNextPersist = true
ghostTitle.value = event.title
ghostDate.value = event.date
ghostLocation.value = event.location || ''
ghostEmotion.value = event.emotion
ghostCustomColor.value = event.customColor
ghostGradientPreset.value = event.gradientPreset ?? null
ghostImage.value = event.image || null
ghostKeyImageTitle.value = event.keyImageTitle || ''
loadEventMedia(event)
ghostNote.value = event.note
}
} else {
editingEventId.value = null
ghostTitle.value = ''
ghostDate.value = new Date().toISOString().slice(0, 10)
ghostLocation.value = ''
ghostEmotion.value = 0
ghostCustomColor.value = null
ghostGradientPreset.value = null
ghostImage.value = null
ghostKeyImageTitle.value = ''
ghostMedia.value = []
ghostNote.value = ''
}
panelOpen.value = true
@ -275,33 +341,75 @@ export const useEventsStore = defineStore('events', () => {
...events.value[idx],
title: ghostTitle.value,
date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value,
customColor: ghostCustomColor.value,
gradientPreset: ghostGradientPreset.value,
gradientPreset: null,
image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: mediaMeta(ghostMedia.value),
note: ghostNote.value,
syncStatus: 'modified',
userId: events.value[idx].userId ?? authStore.currentUserId,
updatedAt: Date.now()
}
events.value[idx] = updated
dbPut(updated)
persistEventMedia(updated.id, updated.userId, ghostMedia.value)
dbQueueSync(updated.id, 'update', { ...updated })
}
function schedulePersistToEvent() {
if (!editingEventId.value) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
persistTimer = null
persistToEvent()
}, AUTOSAVE_DELAY_MS)
}
function flushPersistToEvent() {
if (!persistTimer) return
clearTimeout(persistTimer)
persistTimer = null
persistToEvent()
}
function saveGhostNow() {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
persistToEvent()
}
watch(
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote],
() => { persistToEvent() }
[ghostTitle, ghostDate, ghostLocation, ghostEmotion, ghostCustomColor, ghostImage, ghostKeyImageTitle, ghostMedia, ghostNote],
() => {
if (skipNextPersist) {
skipNextPersist = false
return
}
schedulePersistToEvent()
}
)
function closePanel() {
flushPersistToEvent()
if (!editingEventId.value && ghostTitle.value.trim()) {
const newEvent = {
id: crypto.randomUUID(),
userId: authStore.currentUserId,
title: ghostTitle.value,
date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value,
customColor: ghostCustomColor.value,
gradientPreset: ghostGradientPreset.value,
gradientPreset: null,
image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: mediaMeta(ghostMedia.value),
note: ghostNote.value,
syncStatus: 'local',
createdAt: Date.now(),
@ -309,6 +417,7 @@ export const useEventsStore = defineStore('events', () => {
}
events.value.push(newEvent)
dbPut(newEvent)
persistEventMedia(newEvent.id, newEvent.userId, ghostMedia.value)
dbQueueSync(newEvent.id, 'create', { ...newEvent })
}
panelOpen.value = false
@ -317,6 +426,10 @@ export const useEventsStore = defineStore('events', () => {
}
function deleteEvent(id) {
if (editingEventId.value === id) {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = null
}
events.value = events.value.filter((e) => e.id !== id)
dbDelete(id)
dbQueueSync(id, 'delete', null)
@ -325,12 +438,27 @@ export const useEventsStore = defineStore('events', () => {
function getGlowColor(event) {
if (event.customColor) return event.customColor
return emotionToColor(event.emotion, event.gradientPreset ?? null)
return emotionToColor(
event.emotion,
settingsStore.emotionGradientStart || DEFAULT_EMOTION_GRADIENT_START,
settingsStore.emotionGradientEnd || DEFAULT_EMOTION_GRADIENT_END
)
}
// Auto-init on store creation
init()
watch(() => authStore.currentUserId, () => {
panelOpen.value = false
editingEventId.value = null
selectedEventId.value = null
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
init()
})
return {
events,
isLoaded,
@ -339,17 +467,20 @@ export const useEventsStore = defineStore('events', () => {
editingEventId,
ghostEmotion,
ghostCustomColor,
ghostGradientPreset,
ghostTitle,
ghostDate,
ghostLocation,
ghostNote,
ghostImage,
ghostKeyImageTitle,
ghostMedia,
ghostEvent,
sortedEvents,
selectEvent,
openPanel,
closePanel,
deleteEvent,
saveGhostNow,
getGlowColor
}
})

View file

@ -1,15 +1,33 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useAuthStore } from 'stores/auth'
const STORAGE_KEY = 'thatsme-settings'
const STORAGE_KEY_PREFIX = 'thatsme-settings'
const PERSIST_DELAY_MS = 250
export const DEFAULT_EMOTION_GRADIENT_START = '#2d2e83'
export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35'
export const DEFAULT_TIMELINE_ZOOM = 1
export const DEFAULT_TIMELINE_SCROLL_LEFT = null
export const ACCENT_COLORS = [
{ label: 'Standard', value: 'default', hex: '#9e9e9e' },
{ label: 'Blau', value: 'blue', hex: '#2196F3' },
{ label: 'Grün', value: 'green', hex: '#4CAF50' },
{ label: 'Gelb', value: 'yellow', hex: '#FFC107' },
{ label: 'Rosa', value: 'pink', hex: '#E91E63' },
{ label: 'Orange', value: 'orange', hex: '#FF9800' }
{ label: 'Base', value: 'base', hex: '#737373' },
{ label: 'Red', value: 'red', hex: '#ef4444' },
{ label: 'Orange', value: 'orange', hex: '#f97316' },
{ label: 'Amber', value: 'amber', hex: '#f59e0b' },
{ label: 'Yellow', value: 'yellow', hex: '#eab308' },
{ label: 'Lime', value: 'lime', hex: '#84cc16' },
{ label: 'Green', value: 'green', hex: '#22c55e' },
{ label: 'Emerald', value: 'emerald', hex: '#10b981' },
{ label: 'Teal', value: 'teal', hex: '#14b8a6' },
{ label: 'Cyan', value: 'cyan', hex: '#06b6d4' },
{ label: 'Sky', value: 'sky', hex: '#0ea5e9' },
{ label: 'Blue', value: 'blue', hex: '#3b82f6' },
{ label: 'Indigo', value: 'indigo', hex: '#6366f1' },
{ label: 'Violet', value: 'violet', hex: '#8b5cf6' },
{ label: 'Purple', value: 'purple', hex: '#a855f7' },
{ label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' },
{ label: 'Pink', value: 'pink', hex: '#ec4899' },
{ label: 'Rose', value: 'rose', hex: '#f43f5e' }
]
export const LANGUAGES = [
@ -24,6 +42,7 @@ const FLOATING_LINES_DEFAULTS = {
spread: 0.05,
fanSpread: 0.05,
lineSharpness: 8.0,
lineThickness: 1.0,
waveFrequency: 7.0,
bezierCurvature: 0.2,
circleRadius: 75,
@ -35,51 +54,177 @@ const FLOATING_LINES_DEFAULTS = {
bgEdge: '#000000',
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
backgroundImage: '',
// Horizont
horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow'
horizonOpacity: 0.5, // Nebel + Glow: Helligkeit
horizonBlend: 0.2, // Trennung: 0=scharf 1=weich
// Labels
labelSize: 'small', // 'small' | 'medium' | 'large'
labelColor: '#ffffff'
labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
labelColor: '#ffffff',
labelOpacity: 0.75,
labelConnectorLength: 0.2,
// Dot-Rahmen
dotBorderWidth: 0, // 0 = kein Rahmen, in px
dotBorderColor: '#ffffff',
// Modus
lineMode: 'glow', // 'glow' | 'static'
staticLineColor: '#2196F3', // Linienfarbe im Static-Modus
staticLineShadowStrength: 0 // 0 = klar, 1 = ursprünglicher Schatten
}
function loadFromStorage() {
function getStorageKey(userId) {
return `${STORAGE_KEY_PREFIX}:${userId || 'guest'}`
}
function loadFromStorage(userId) {
try {
const stored = localStorage.getItem(STORAGE_KEY)
const stored = localStorage.getItem(getStorageKey(userId))
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
function normalizeAccentColor(value) {
if (value === 'default') return 'base'
return ACCENT_COLORS.some(c => c.value === value) ? value : 'base'
}
function hexToRgb(hex) {
const clean = hex.replace('#', '')
const full = clean.length === 3
? clean.split('').map(ch => ch + ch).join('')
: clean
const r = parseInt(full.slice(0, 2), 16)
const g = parseInt(full.slice(2, 4), 16)
const b = parseInt(full.slice(4, 6), 16)
return { r, g, b }
}
function applyAccentCssVariables(value) {
if (typeof document === 'undefined') return
const key = normalizeAccentColor(value)
const accent = ACCENT_COLORS.find(c => c.value === key) ?? ACCENT_COLORS[0]
const { r, g, b } = hexToRgb(accent.hex)
document.documentElement.style.setProperty('--tm-accent', accent.hex)
document.documentElement.style.setProperty('--tm-accent-rgb', `${r}, ${g}, ${b}`)
// Keep Quasar semantic color channels in sync with the active accent.
document.documentElement.style.setProperty('--q-primary', accent.hex)
document.documentElement.style.setProperty('--q-secondary', accent.hex)
document.documentElement.style.setProperty('--q-accent', accent.hex)
}
export { FLOATING_LINES_DEFAULTS }
export const useSettingsStore = defineStore('settings', () => {
const stored = loadFromStorage()
const authStore = useAuthStore()
const stored = loadFromStorage(authStore.currentUserId)
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
const initialSettings = initialActivePreset?.settings ?? stored
let persistTimer = null
const theme = ref(stored?.theme ?? 'light')
const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS })
const theme = ref(initialSettings?.theme ?? 'light')
const floatingLines = ref({
...FLOATING_LINES_DEFAULTS,
...(initialSettings?.floatingLines ?? {})
})
// App preferences
const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark'
const accentColor = ref(stored?.accentColor ?? 'default')
const language = ref(stored?.language ?? 'de')
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'base'))
const language = ref(initialSettings?.language ?? 'de')
const emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START)
const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END)
const timelineZoom = ref(initialSettings?.timelineZoom ?? DEFAULT_TIMELINE_ZOOM)
const timelineScrollLeft = ref(stored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT)
const presets = ref(stored?.presets ?? [])
const activePresetId = ref(stored?.activePresetId ?? null)
// Developer / debug
const showFps = ref(stored?.showFps ?? false)
const showFps = ref(initialSettings?.showFps ?? false)
function persist() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
function createSnapshot() {
return {
theme: theme.value,
floatingLines: floatingLines.value,
floatingLines: clone(floatingLines.value),
appearance: appearance.value,
accentColor: accentColor.value,
language: language.value,
emotionGradientStart: emotionGradientStart.value,
emotionGradientEnd: emotionGradientEnd.value,
timelineZoom: timelineZoom.value,
showFps: showFps.value
}
}
function applySnapshot(snapshot) {
theme.value = snapshot?.theme ?? 'light'
floatingLines.value = {
...FLOATING_LINES_DEFAULTS,
...(snapshot?.floatingLines ?? {})
}
appearance.value = snapshot?.appearance ?? 'system'
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'base')
language.value = snapshot?.language ?? 'de'
emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START
emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END
timelineZoom.value = snapshot?.timelineZoom ?? DEFAULT_TIMELINE_ZOOM
showFps.value = snapshot?.showFps ?? false
}
function persist() {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (!authStore.currentUserId) return
localStorage.setItem(
getStorageKey(authStore.currentUserId),
JSON.stringify({
...createSnapshot(),
timelineScrollLeft: timelineScrollLeft.value,
presets: presets.value,
activePresetId: activePresetId.value
})
)
}
watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true })
function schedulePersist() {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(persist, PERSIST_DELAY_MS)
}
function applyStoredSettingsForUser(userId) {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
const nextStored = loadFromStorage(userId)
presets.value = nextStored?.presets ?? []
activePresetId.value = nextStored?.activePresetId ?? null
const activePreset = presets.value.find(preset => preset.id === activePresetId.value)
applySnapshot(activePreset?.settings ?? nextStored)
timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT
}
watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true })
watch(() => authStore.currentUserId, applyStoredSettingsForUser)
watch(accentColor, (value) => {
const normalized = normalizeAccentColor(value)
if (accentColor.value !== normalized) {
accentColor.value = normalized
return
}
applyAccentCssVariables(normalized)
}, { immediate: true })
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
@ -93,15 +238,67 @@ export const useSettingsStore = defineStore('settings', () => {
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
}
function resetEmotionGradient() {
emotionGradientStart.value = DEFAULT_EMOTION_GRADIENT_START
emotionGradientEnd.value = DEFAULT_EMOTION_GRADIENT_END
}
function saveTimelineScrollLeft(value, immediate = false) {
timelineScrollLeft.value = Number.isFinite(value) ? value : DEFAULT_TIMELINE_SCROLL_LEFT
if (immediate) {
persist()
}
}
function savePreset(name) {
const trimmedName = String(name || '').trim()
if (!trimmedName) return null
const existing = presets.value.find(preset => preset.name.toLowerCase() === trimmedName.toLowerCase())
const savedPreset = {
id: existing?.id ?? crypto.randomUUID(),
name: trimmedName,
settings: createSnapshot(),
updatedAt: Date.now()
}
presets.value = existing
? presets.value.map(preset => preset.id === existing.id ? savedPreset : preset)
: [...presets.value, savedPreset]
activePresetId.value = savedPreset.id
persist()
return savedPreset
}
function applyPreset(presetId) {
const preset = presets.value.find(candidate => candidate.id === presetId)
if (!preset) return false
activePresetId.value = preset.id
applySnapshot(preset.settings)
persist()
return true
}
return {
theme,
floatingLines,
appearance,
accentColor,
language,
emotionGradientStart,
emotionGradientEnd,
timelineZoom,
timelineScrollLeft,
presets,
activePresetId,
showFps,
toggleTheme,
updateFloatingLines,
resetFloatingLines
resetFloatingLines,
resetEmotionGradient,
saveTimelineScrollLeft,
savePreset,
applyPreset
}
})

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