30-04-2026
This commit is contained in:
parent
761b1156c1
commit
d054732bf5
35 changed files with 2796 additions and 505 deletions
|
|
@ -31,9 +31,6 @@
|
||||||
"WWWGROUP": "20",
|
"WWWGROUP": "20",
|
||||||
"LARAVEL_SAIL": "1"
|
"LARAVEL_SAIL": "1"
|
||||||
},
|
},
|
||||||
"mounts": [
|
|
||||||
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
|
|
||||||
],
|
|
||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
5173,
|
5173,
|
||||||
9000
|
9000
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ services:
|
||||||
REDIS_HOST: global-redis
|
REDIS_HOST: global-redis
|
||||||
volumes:
|
volumes:
|
||||||
- './backend:/var/www/html'
|
- './backend:/var/www/html'
|
||||||
|
- '.:/workspace:cached'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
- proxy
|
- proxy
|
||||||
|
|
|
||||||
200
frontend/dev/IMPROVEMENTS-floating-lines.md
Normal file
200
frontend/dev/IMPROVEMENTS-floating-lines.md
Normal 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 | ⏭️ |
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
ShaderMaterial,
|
ShaderMaterial,
|
||||||
Vector3,
|
Vector3,
|
||||||
Vector2,
|
Vector2,
|
||||||
Clock,
|
|
||||||
} from 'three'
|
} from 'three'
|
||||||
|
|
||||||
const vertexShader = `
|
const vertexShader = `
|
||||||
|
|
@ -19,7 +18,7 @@ void main() {
|
||||||
`
|
`
|
||||||
|
|
||||||
const fragmentShader = `
|
const fragmentShader = `
|
||||||
precision highp float;
|
precision mediump float;
|
||||||
|
|
||||||
uniform float iTime;
|
uniform float iTime;
|
||||||
uniform vec3 iResolution;
|
uniform vec3 iResolution;
|
||||||
|
|
@ -58,34 +57,24 @@ uniform float bendRadius;
|
||||||
uniform float bendStrength;
|
uniform float bendStrength;
|
||||||
uniform float bendInfluence;
|
uniform float bendInfluence;
|
||||||
|
|
||||||
|
uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
|
||||||
|
uniform float horizonOpacity; // Nebel + Glow: Helligkeit/Dichte
|
||||||
|
uniform float horizonBlend; // Trennung: 0=scharf, 1=weicher Übergang
|
||||||
|
|
||||||
uniform bool parallax;
|
uniform bool parallax;
|
||||||
uniform float parallaxStrength;
|
uniform float parallaxStrength;
|
||||||
uniform vec2 parallaxOffset;
|
uniform vec2 parallaxOffset;
|
||||||
|
|
||||||
|
uniform float lineBrightness;
|
||||||
uniform vec3 lineGradient[8];
|
uniform vec3 lineGradient[8];
|
||||||
uniform int lineGradientCount;
|
uniform int lineGradientCount;
|
||||||
uniform vec3 bgColorCenter;
|
uniform vec3 bgColorCenter;
|
||||||
uniform vec3 bgColorEdge;
|
uniform vec3 bgColorEdge;
|
||||||
|
|
||||||
const vec3 BLACK = vec3(0.0);
|
|
||||||
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
|
||||||
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
|
||||||
|
|
||||||
mat2 rotate(float r) {
|
mat2 rotate(float r) {
|
||||||
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 background_color(vec2 uv) {
|
|
||||||
vec3 col = vec3(0.0);
|
|
||||||
|
|
||||||
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
|
||||||
float m = uv.y - y;
|
|
||||||
|
|
||||||
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
|
||||||
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
|
||||||
return col * 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 getLineColor(float t, vec3 baseColor) {
|
vec3 getLineColor(float t, vec3 baseColor) {
|
||||||
if (lineGradientCount <= 0) {
|
if (lineGradientCount <= 0) {
|
||||||
return baseColor;
|
return baseColor;
|
||||||
|
|
@ -108,7 +97,7 @@ vec3 getLineColor(float t, vec3 baseColor) {
|
||||||
gradientColor = mix(c1, c2, f);
|
gradientColor = mix(c1, c2, f);
|
||||||
}
|
}
|
||||||
|
|
||||||
return gradientColor * 0.5;
|
return gradientColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
|
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
|
||||||
|
|
@ -163,25 +152,9 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) {
|
// Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment
|
||||||
// Bézier-Kontrollpunkt: Mittelpunkt + senkrechter Versatz
|
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) {
|
||||||
vec2 seg = ep - sp;
|
float s = dot(uv - bPos, bNorm);
|
||||||
float segLen = length(seg);
|
|
||||||
if (segLen < 0.001) return 0.0;
|
|
||||||
vec2 segDir = seg / segLen;
|
|
||||||
vec2 segPerp = vec2(-segDir.y, segDir.x);
|
|
||||||
vec2 pc = (sp + ep) * 0.5 + segPerp * segLen * bezierCurvature;
|
|
||||||
|
|
||||||
float t = bezierClosestT(uv, sp, pc, ep);
|
|
||||||
float mt = 1.0 - t;
|
|
||||||
|
|
||||||
// Position und Tangente auf der Kurve
|
|
||||||
vec2 curvePos = mt*mt*sp + 2.0*mt*t*pc + t*t*ep;
|
|
||||||
vec2 tang = normalize(2.0*mt*(pc - sp) + 2.0*t*(ep - pc));
|
|
||||||
vec2 norm = vec2(-tang.y, tang.x);
|
|
||||||
|
|
||||||
// Senkrechter Abstand von der Kurve
|
|
||||||
float s = dot(uv - curvePos, norm);
|
|
||||||
|
|
||||||
float time = iTime * animationSpeed;
|
float time = iTime * animationSpeed;
|
||||||
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
||||||
|
|
@ -227,7 +200,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
|
|
||||||
vec3 col = vec3(0.0);
|
vec3 col = vec3(0.0);
|
||||||
|
|
||||||
vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv);
|
vec3 b = bgColorCenter;
|
||||||
|
|
||||||
vec2 mouseUv = vec2(0.0);
|
vec2 mouseUv = vec2(0.0);
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
|
|
@ -269,33 +242,34 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
vec2 sp = vec2(x0, pointY[s]);
|
vec2 sp = vec2(x0, pointY[s]);
|
||||||
vec2 ep = vec2(x1, pointY[s + 1]);
|
vec2 ep = vec2(x1, pointY[s + 1]);
|
||||||
|
|
||||||
// Gradient: globaler t-Bereich [s, s+1] / (numPoints-1)
|
// Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt)
|
||||||
vec2 pd = ep - sp;
|
vec2 seg = ep - sp;
|
||||||
float pl = length(pd);
|
float segL = length(seg);
|
||||||
vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0);
|
vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0);
|
||||||
float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.0);
|
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||||
|
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||||
|
|
||||||
|
// Gradient
|
||||||
|
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
|
||||||
float t_global = (float(s) + t_seg) * tScale;
|
float t_global = (float(s) + t_seg) * tScale;
|
||||||
vec3 lineCol = getLineColor(t_global, b);
|
vec3 lineCol = getLineColor(t_global, b);
|
||||||
|
|
||||||
// Bézier-Kontrollpunkt für Nebel (gleiche Logik wie in waveFocal)
|
// Bézier einmal pro Segment — geteilt von Nebel + allen Linien
|
||||||
vec2 segD = ep - sp;
|
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||||
float segL = length(segD);
|
float bmt = 1.0 - bt;
|
||||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
|
||||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
||||||
|
|
||||||
// Weicher Nebel entlang der Bézier-Kurve → füllt dunkle Winkel organisch
|
// Weicher Nebel entlang der Kurve
|
||||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
float bDist = length(baseUv - bPos);
|
||||||
float bmt = 1.0 - bt;
|
|
||||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
|
||||||
float bDist = length(baseUv - bPos);
|
|
||||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||||
float fogEnv = sin(bt * 3.14159265359);
|
float fogEnv = sin(bt * 3.14159265359);
|
||||||
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
||||||
col += lineCol * segFog;
|
col += lineCol * segFog;
|
||||||
|
|
||||||
for (int i = 0; i < middleLineCount; ++i) {
|
for (int i = 0; i < middleLineCount; ++i) {
|
||||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep);
|
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +278,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
if (p >= numPoints) break;
|
if (p >= numPoints) break;
|
||||||
float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX;
|
float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX;
|
||||||
float t_pt = numPoints > 1 ? float(p) * tScale : 0.0;
|
float t_pt = numPoints > 1 ? float(p) * tScale : 0.0;
|
||||||
vec3 circCol = getLineColor(t_pt, b) * 2.5;
|
vec3 circCol = getLineColor(t_pt, b) * 1.5;
|
||||||
col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol);
|
col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -328,9 +302,33 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
col *= lineBrightness;
|
||||||
|
|
||||||
// Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand)
|
// Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand)
|
||||||
float dist = length(baseUv) / 1.8;
|
float dist = length(baseUv) / 1.8;
|
||||||
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
||||||
|
|
||||||
|
if (horizonMode == 1) {
|
||||||
|
// Nebel: breites weiches Gaussband in Gradient-Mittelfarbe
|
||||||
|
float band = exp(-baseUv.y * baseUv.y * 5.0);
|
||||||
|
vec3 fogColor = getLineColor(0.5, bg) * 2.0;
|
||||||
|
col += fogColor * band * horizonOpacity;
|
||||||
|
} else if (horizonMode == 2) {
|
||||||
|
// Farbtrennung: vertikaler Split an Y=0
|
||||||
|
// bgColorCenter → oben (positives UV-Y), bgColorEdge → unten
|
||||||
|
// horizonBlend: 0=harter Schnitt, 1=sehr weicher Übergang
|
||||||
|
float blendW = max(horizonBlend * 0.7, 0.001);
|
||||||
|
float t = smoothstep(-blendW, blendW, baseUv.y);
|
||||||
|
bg = mix(bgColorEdge, bgColorCenter, t);
|
||||||
|
} else if (horizonMode == 3) {
|
||||||
|
// Glow: konzentriertes Leuchten in Gradient-Mittelfarbe
|
||||||
|
float d2 = baseUv.y * baseUv.y;
|
||||||
|
float softGlow = exp(-d2 * 10.0);
|
||||||
|
float coreGlow = exp(-d2 * 70.0) * 0.7;
|
||||||
|
vec3 glowColor = getLineColor(0.5, bg) * 3.0;
|
||||||
|
col += glowColor * (softGlow + coreGlow) * horizonOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,7 +374,6 @@ export default class FloatingLines {
|
||||||
lineCount = [6],
|
lineCount = [6],
|
||||||
lineDistance = [5],
|
lineDistance = [5],
|
||||||
topWavePosition,
|
topWavePosition,
|
||||||
middleWavePosition,
|
|
||||||
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
||||||
numPoints = 4,
|
numPoints = 4,
|
||||||
pointSpacingX = 0.8,
|
pointSpacingX = 0.8,
|
||||||
|
|
@ -389,6 +386,7 @@ export default class FloatingLines {
|
||||||
bezierCurvature = 0.3,
|
bezierCurvature = 0.3,
|
||||||
circleRadiusPx = 50,
|
circleRadiusPx = 50,
|
||||||
animationSpeed = 1,
|
animationSpeed = 1,
|
||||||
|
lineBrightness = 1.0,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
bendRadius = 5.0,
|
bendRadius = 5.0,
|
||||||
bendStrength = -0.5,
|
bendStrength = -0.5,
|
||||||
|
|
@ -397,6 +395,11 @@ export default class FloatingLines {
|
||||||
parallaxStrength = 0.2,
|
parallaxStrength = 0.2,
|
||||||
circleGlowSize = 18.0,
|
circleGlowSize = 18.0,
|
||||||
circleGlowStrength = 1.5,
|
circleGlowStrength = 1.5,
|
||||||
|
horizonMode = 'off',
|
||||||
|
horizonOpacity = 0.5,
|
||||||
|
horizonBlend = 0.2,
|
||||||
|
bgColorCenter = '#0a0514',
|
||||||
|
bgColorEdge = '#000000',
|
||||||
mixBlendMode = 'screen',
|
mixBlendMode = 'screen',
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -427,14 +430,12 @@ export default class FloatingLines {
|
||||||
return lineDistance[index] ?? 0.1
|
return lineDistance[index] ?? 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0
|
const topLineCount = getLineCount('top')
|
||||||
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0
|
const middleLineCount = getLineCount('middle')
|
||||||
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0
|
const bottomLineCount = getLineCount('bottom')
|
||||||
|
|
||||||
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
|
const topLineDistance = getLineDistance('top') * 0.01
|
||||||
const bottomLineDistance = enabledWaves.includes('bottom')
|
const bottomLineDistance = getLineDistance('bottom') * 0.01
|
||||||
? getLineDistance('bottom') * 0.01
|
|
||||||
: 0.01
|
|
||||||
|
|
||||||
this.scene = new Scene()
|
this.scene = new Scene()
|
||||||
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||||
|
|
@ -451,6 +452,7 @@ export default class FloatingLines {
|
||||||
iTime: { value: 0 },
|
iTime: { value: 0 },
|
||||||
iResolution: { value: new Vector3(1, 1, 1) },
|
iResolution: { value: new Vector3(1, 1, 1) },
|
||||||
animationSpeed: { value: animationSpeed },
|
animationSpeed: { value: animationSpeed },
|
||||||
|
lineBrightness: { value: lineBrightness },
|
||||||
|
|
||||||
enableTop: { value: enabledWaves.includes('top') },
|
enableTop: { value: enabledWaves.includes('top') },
|
||||||
enableMiddle: { value: enabledWaves.includes('middle') },
|
enableMiddle: { value: enabledWaves.includes('middle') },
|
||||||
|
|
@ -497,6 +499,10 @@ export default class FloatingLines {
|
||||||
bendStrength: { value: bendStrength },
|
bendStrength: { value: bendStrength },
|
||||||
bendInfluence: { value: 0 },
|
bendInfluence: { value: 0 },
|
||||||
|
|
||||||
|
horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 },
|
||||||
|
horizonOpacity: { value: horizonOpacity },
|
||||||
|
horizonBlend: { value: horizonBlend },
|
||||||
|
|
||||||
parallax: { value: parallax },
|
parallax: { value: parallax },
|
||||||
parallaxStrength: { value: parallaxStrength },
|
parallaxStrength: { value: parallaxStrength },
|
||||||
parallaxOffset: { value: new Vector2(0, 0) },
|
parallaxOffset: { value: new Vector2(0, 0) },
|
||||||
|
|
@ -505,7 +511,8 @@ export default class FloatingLines {
|
||||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
||||||
},
|
},
|
||||||
lineGradientCount: { value: 0 },
|
lineGradientCount: { value: 0 },
|
||||||
bgColor: { value: new Vector3(0, 0, 0) },
|
bgColorCenter: { value: new Vector3(0, 0, 0) },
|
||||||
|
bgColorEdge: { value: new Vector3(0, 0, 0) },
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linesGradient && linesGradient.length > 0) {
|
if (linesGradient && linesGradient.length > 0) {
|
||||||
|
|
@ -517,6 +524,11 @@ export default class FloatingLines {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const center = hexToVec3(bgColorCenter)
|
||||||
|
this.uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
|
||||||
|
const edge = hexToVec3(bgColorEdge)
|
||||||
|
this.uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
|
||||||
|
|
||||||
const material = new ShaderMaterial({
|
const material = new ShaderMaterial({
|
||||||
uniforms: this.uniforms,
|
uniforms: this.uniforms,
|
||||||
vertexShader,
|
vertexShader,
|
||||||
|
|
@ -529,7 +541,7 @@ export default class FloatingLines {
|
||||||
|
|
||||||
this.geometry = geometry
|
this.geometry = geometry
|
||||||
this.material = material
|
this.material = material
|
||||||
this.clock = new Clock()
|
this._startTime = performance.now()
|
||||||
|
|
||||||
this._setSize = () => {
|
this._setSize = () => {
|
||||||
const width = container.clientWidth || 1
|
const width = container.clientWidth || 1
|
||||||
|
|
@ -571,7 +583,7 @@ export default class FloatingLines {
|
||||||
|
|
||||||
this.raf = 0
|
this.raf = 0
|
||||||
const renderLoop = () => {
|
const renderLoop = () => {
|
||||||
this.uniforms.iTime.value = this.clock.getElapsedTime()
|
this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001
|
||||||
|
|
||||||
if (this.interactive) {
|
if (this.interactive) {
|
||||||
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
|
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
|
||||||
|
|
@ -589,12 +601,24 @@ export default class FloatingLines {
|
||||||
this.renderer.render(this.scene, this.camera)
|
this.renderer.render(this.scene, this.camera)
|
||||||
this.raf = requestAnimationFrame(renderLoop)
|
this.raf = requestAnimationFrame(renderLoop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._handleVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
cancelAnimationFrame(this.raf)
|
||||||
|
this.raf = 0
|
||||||
|
} else if (!this.raf) {
|
||||||
|
renderLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', this._handleVisibility)
|
||||||
|
|
||||||
renderLoop()
|
renderLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
cancelAnimationFrame(this.raf)
|
cancelAnimationFrame(this.raf)
|
||||||
if (this.ro) this.ro.disconnect()
|
if (this.ro) this.ro.disconnect()
|
||||||
|
document.removeEventListener('visibilitychange', this._handleVisibility)
|
||||||
|
|
||||||
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
|
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
|
||||||
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
|
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
border-top: 1px solid #222;
|
border-top: 1px solid #222;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 2fr 1.2fr;
|
grid-template-columns: 1fr 1fr 2fr 0.8fr 1.2fr;
|
||||||
gap: 10px 16px;
|
gap: 10px 16px;
|
||||||
max-height: 230px;
|
max-height: 230px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -287,6 +287,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Col 3.5: Horizont -->
|
||||||
|
<div class="ctrl-group" style="grid-column: span 1">
|
||||||
|
<h3>Horizont</h3>
|
||||||
|
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px;">
|
||||||
|
<button class="img-btn active" data-mode="0">Aus</button>
|
||||||
|
<button class="img-btn" data-mode="1">Nebel</button>
|
||||||
|
<button class="img-btn" data-mode="2">Trennung</button>
|
||||||
|
<button class="img-btn" data-mode="3">Glow</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" id="row-horizonOpacity">
|
||||||
|
<label for="horizonOpacity">Deckkraft</label>
|
||||||
|
<input type="range" id="horizonOpacity" min="0.05" max="1" step="0.05" value="0.5" />
|
||||||
|
<span class="val" id="horizonOpacity-val">0.50</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" id="row-horizonBlend" style="display:none">
|
||||||
|
<label for="horizonBlend">Übergang</label>
|
||||||
|
<input type="range" id="horizonBlend" min="0" max="1" step="0.02" value="0.2" />
|
||||||
|
<span class="val" id="horizonBlend-val">0.20</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Col 4: Hintergrundbild + Farben -->
|
<!-- Col 4: Hintergrundbild + Farben -->
|
||||||
<div class="ctrl-group">
|
<div class="ctrl-group">
|
||||||
<h3>Hintergrundbild</h3>
|
<h3>Hintergrundbild</h3>
|
||||||
|
|
@ -501,6 +522,27 @@
|
||||||
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge)
|
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Horizont ─────────────────────────────────────────────────────
|
||||||
|
const rowOpacity = document.getElementById('row-horizonOpacity')
|
||||||
|
const rowBlend = document.getElementById('row-horizonBlend')
|
||||||
|
|
||||||
|
function updateHorizonRows(mode) {
|
||||||
|
rowOpacity.style.display = mode === 2 ? 'none' : 'flex'
|
||||||
|
rowBlend.style.display = mode === 2 ? 'flex' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-mode]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('[data-mode]').forEach((b) => b.classList.remove('active'))
|
||||||
|
btn.classList.add('active')
|
||||||
|
const mode = parseInt(btn.dataset.mode)
|
||||||
|
fl.uniforms.horizonMode.value = mode
|
||||||
|
updateHorizonRows(mode)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
slider('horizonOpacity', 2, (v) => (fl.uniforms.horizonOpacity.value = v))
|
||||||
|
slider('horizonBlend', 2, (v) => (fl.uniforms.horizonBlend.value = v))
|
||||||
|
|
||||||
// ── Gradient ──────────────────────────────────────────────────────
|
// ── Gradient ──────────────────────────────────────────────────────
|
||||||
const MAX_STOPS = 8
|
const MAX_STOPS = 8
|
||||||
function applyGradient() {
|
function applyGradient() {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,44 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section__divider" />
|
<div class="settings-section__divider" />
|
||||||
|
|
||||||
|
<!-- Emotionsverlauf -->
|
||||||
|
<div class="settings-row settings-row--stack">
|
||||||
|
<span class="settings-row__label">Emotionsverlauf</span>
|
||||||
|
<div class="settings-gradient">
|
||||||
|
<div class="settings-gradient__preview" :style="{ background: emotionGradientCss }"></div>
|
||||||
|
<div class="settings-gradient__controls">
|
||||||
|
<div class="settings-gradient__control">
|
||||||
|
<span>Start</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="settingsStore.emotionGradientStart"
|
||||||
|
@input="e => { settingsStore.emotionGradientStart = e.target.value }"
|
||||||
|
class="settings-color-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
size="sm"
|
||||||
|
icon="restart_alt"
|
||||||
|
label="Reset"
|
||||||
|
@click="settingsStore.resetEmotionGradient()"
|
||||||
|
/>
|
||||||
|
<div class="settings-gradient__control settings-gradient__control--right">
|
||||||
|
<span>Ende</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="settingsStore.emotionGradientEnd"
|
||||||
|
@input="e => { settingsStore.emotionGradientEnd = e.target.value }"
|
||||||
|
class="settings-color-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section__divider" />
|
||||||
|
|
||||||
<!-- Sprache -->
|
<!-- Sprache -->
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<span class="settings-row__label">Sprache</span>
|
<span class="settings-row__label">Sprache</span>
|
||||||
|
|
@ -100,7 +138,7 @@
|
||||||
:model-value="settingsStore.showFps"
|
:model-value="settingsStore.showFps"
|
||||||
@update:model-value="v => { settingsStore.showFps = v }"
|
@update:model-value="v => { settingsStore.showFps = v }"
|
||||||
dense
|
dense
|
||||||
color="green"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -146,14 +184,18 @@ const tabs = [
|
||||||
|
|
||||||
const currentAccentHex = computed(() => {
|
const currentAccentHex = computed(() => {
|
||||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||||
return found?.hex ?? '#9e9e9e'
|
return found?.hex ?? '#737373'
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentAccentLabel = computed(() => {
|
const currentAccentLabel = computed(() => {
|
||||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||||
return found?.label ?? 'Standard'
|
return found?.label ?? 'Base'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emotionGradientCss = computed(() =>
|
||||||
|
`linear-gradient(90deg, ${settingsStore.emotionGradientStart} 0%, ${settingsStore.emotionGradientEnd} 100%)`
|
||||||
|
)
|
||||||
|
|
||||||
function selectAccent(value) {
|
function selectAccent(value) {
|
||||||
settingsStore.accentColor = value
|
settingsStore.accentColor = value
|
||||||
accentDropdownOpen.value = false
|
accentDropdownOpen.value = false
|
||||||
|
|
@ -198,6 +240,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-row--stack {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-row__label {
|
.settings-row__label {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +290,7 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-color: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-accent-btn--dark {
|
.settings-accent-btn--dark {
|
||||||
|
|
@ -294,12 +343,53 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dropdown__item:hover {
|
.settings-dropdown__item:hover {
|
||||||
background: rgba(128, 128, 128, 0.1);
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dropdown__check {
|
.settings-dropdown__check {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
color: var(--tm-accent, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-gradient {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-gradient__preview {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-gradient__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-gradient__control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-gradient__control--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-color-input {
|
||||||
|
width: 34px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Placeholder text */
|
/* Placeholder text */
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,43 @@
|
||||||
<div class="event-panel__image-section">
|
<div class="event-panel__image-section">
|
||||||
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
|
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
|
||||||
<img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
|
<img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
|
||||||
<span class="event-panel__image-badge">Key Image</span>
|
<input
|
||||||
|
v-model="eventsStore.ghostKeyImageTitle"
|
||||||
|
class="event-panel__image-badge"
|
||||||
|
maxlength="18"
|
||||||
|
placeholder="Key Image"
|
||||||
|
aria-label="Key Image Titel"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="event-panel__image-gallery"
|
||||||
|
aria-label="Galerie öffnen"
|
||||||
|
@click="openKeyImageGallery"
|
||||||
|
>
|
||||||
|
<q-icon name="collections" size="18px" />
|
||||||
|
</button>
|
||||||
|
<div class="event-panel__image-actions">
|
||||||
|
<button type="button" class="event-panel__image-action" @click="openKeyImageUpload">
|
||||||
|
<q-icon name="photo_camera" size="18px" />
|
||||||
|
<span>Ersetzen</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="event-panel__image-action event-panel__image-action--danger" @click="confirmRemoveKeyImage">
|
||||||
|
<q-icon name="delete_outline" size="18px" />
|
||||||
|
<span>Löschen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="event-panel__image-placeholder" @click="onAddImage">
|
<div v-else class="event-panel__image-placeholder" @click="openKeyImageUpload">
|
||||||
<q-icon name="add_photo_alternate" size="32px" color="grey-5" />
|
<q-icon name="add_photo_alternate" size="32px" color="grey-5" />
|
||||||
<span>Key Image hinzufügen</span>
|
<span>Key Image hinzufügen</span>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref="keyImageInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="event-panel__file-input"
|
||||||
|
@change="onKeyImageSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title — large, editable inline -->
|
<!-- Title — large, editable inline -->
|
||||||
|
|
@ -38,22 +69,42 @@
|
||||||
:dark="isDark"
|
:dark="isDark"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Date row — tap to open QDate picker -->
|
<div class="event-panel__meta-grid">
|
||||||
<div class="event-panel__date-row">
|
<div class="event-panel__meta-item event-panel__meta-item--date">
|
||||||
<q-icon name="event" size="18px" class="event-panel__date-icon" />
|
<span class="event-panel__meta-label">Datum</span>
|
||||||
<span class="event-panel__date-label">{{ formattedDate }}</span>
|
<button class="event-panel__date-btn" type="button">
|
||||||
<q-popup-proxy transition-show="scale" transition-hide="scale">
|
<q-icon name="event" size="16px" class="event-panel__date-icon" />
|
||||||
<q-date
|
<span class="event-panel__date-label">{{ formattedDate }}</span>
|
||||||
v-model="ghostDateSlash"
|
</button>
|
||||||
mask="YYYY/MM/DD"
|
<q-popup-proxy transition-show="scale" transition-hide="scale">
|
||||||
|
<q-date
|
||||||
|
v-model="ghostDateSlash"
|
||||||
|
mask="YYYY/MM/DD"
|
||||||
|
:dark="isDark"
|
||||||
|
:locale="dateLocale"
|
||||||
|
minimal
|
||||||
|
/>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-panel__meta-item">
|
||||||
|
<span class="event-panel__meta-label">Ort</span>
|
||||||
|
<q-input
|
||||||
|
v-model="eventsStore.ghostLocation"
|
||||||
|
borderless
|
||||||
|
dense
|
||||||
|
placeholder="z. B. Berlin"
|
||||||
|
class="event-panel__location-input"
|
||||||
:dark="isDark"
|
:dark="isDark"
|
||||||
:locale="dateLocale"
|
>
|
||||||
minimal
|
<template #prepend>
|
||||||
/>
|
<q-icon name="place" size="16px" />
|
||||||
</q-popup-proxy>
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Emotional Level — card style with gradient track -->
|
<!-- Emotional Level — card style with fixed gradient track -->
|
||||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||||
<div class="event-panel__card-header">
|
<div class="event-panel__card-header">
|
||||||
<span class="event-panel__card-label">Emotional Level</span>
|
<span class="event-panel__card-label">Emotional Level</span>
|
||||||
|
|
@ -85,28 +136,26 @@
|
||||||
<span>Sehr positiv</span>
|
<span>Sehr positiv</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gradient Preset Selector -->
|
<div class="event-panel__gradient-edit">
|
||||||
<div class="event-panel__presets">
|
<div class="event-panel__gradient-row">
|
||||||
<span class="event-panel__presets-label">Farbverlauf</span>
|
<span class="event-panel__presets-label">Punktfarbe (optional)</span>
|
||||||
<div class="event-panel__presets-grid">
|
<input
|
||||||
<div
|
type="color"
|
||||||
v-for="(preset, index) in gradientPresets"
|
:value="eventsStore.ghostCustomColor || emotionColor"
|
||||||
:key="index"
|
@input="e => { eventsStore.ghostCustomColor = e.target.value }"
|
||||||
class="event-panel__preset"
|
class="event-panel__color-input"
|
||||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === index }"
|
/>
|
||||||
:style="{ background: presetGradientCSS(preset.colors) }"
|
</div>
|
||||||
:title="preset.name"
|
<div class="event-panel__gradient-actions">
|
||||||
@click="selectPreset(index)"
|
<q-btn
|
||||||
></div>
|
flat
|
||||||
<!-- "None" option to clear preset -->
|
dense
|
||||||
<div
|
no-caps
|
||||||
class="event-panel__preset event-panel__preset--none"
|
size="sm"
|
||||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === null }"
|
icon="palette"
|
||||||
title="Standard"
|
label="Aus Verlauf"
|
||||||
@click="selectPreset(null)"
|
@click="eventsStore.ghostCustomColor = null"
|
||||||
>
|
/>
|
||||||
<q-icon name="auto_awesome" size="12px" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -129,10 +178,41 @@
|
||||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||||
<span class="event-panel__card-label">Weitere Medien</span>
|
<span class="event-panel__card-label">Weitere Medien</span>
|
||||||
<div class="event-panel__media-grid">
|
<div class="event-panel__media-grid">
|
||||||
<div class="event-panel__media-add" @click="onAddMedia">
|
<div
|
||||||
|
v-for="item in eventsStore.ghostMedia"
|
||||||
|
:key="item.id"
|
||||||
|
class="event-panel__media-item"
|
||||||
|
>
|
||||||
|
<img :src="item.src" class="event-panel__media-img" alt="" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="event-panel__media-remove"
|
||||||
|
aria-label="Bild entfernen"
|
||||||
|
@click="removeMediaImage(item.id)"
|
||||||
|
>
|
||||||
|
<q-icon name="close" size="16px" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="event-panel__media-open"
|
||||||
|
aria-label="Galerie öffnen"
|
||||||
|
@click="openMediaGallery(item.id)"
|
||||||
|
>
|
||||||
|
<q-icon name="collections" size="16px" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="event-panel__media-add" @click="openMediaUpload">
|
||||||
<q-icon name="add_photo_alternate" size="24px" color="grey-5" />
|
<q-icon name="add_photo_alternate" size="24px" color="grey-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref="mediaInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="event-panel__file-input"
|
||||||
|
@change="onMediaSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete (edit mode only) -->
|
<!-- Delete (edit mode only) -->
|
||||||
|
|
@ -149,6 +229,60 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="removeKeyImageDialogOpen">
|
||||||
|
<q-card class="event-panel__confirm" :class="{ 'event-panel__confirm--dark': isDark }">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="event-panel__confirm-title">Key Image entfernen?</div>
|
||||||
|
<div class="event-panel__confirm-copy">
|
||||||
|
Das Key Image wird endgültig aus diesem Event entfernt.
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat no-caps label="Abbrechen" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="negative"
|
||||||
|
label="Endgültig entfernen"
|
||||||
|
@click="removeKeyImage"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="mediaGalleryOpen" maximized>
|
||||||
|
<div class="event-panel__gallery">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="event-panel__gallery-close"
|
||||||
|
aria-label="Galerie schließen"
|
||||||
|
@click="mediaGalleryOpen = false"
|
||||||
|
>
|
||||||
|
<q-icon name="close" size="24px" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<q-carousel
|
||||||
|
v-model="mediaGalleryIndex"
|
||||||
|
class="event-panel__gallery-carousel"
|
||||||
|
animated
|
||||||
|
swipeable
|
||||||
|
arrows
|
||||||
|
navigation
|
||||||
|
infinite
|
||||||
|
>
|
||||||
|
<q-carousel-slide
|
||||||
|
v-for="(item, index) in galleryItems"
|
||||||
|
:key="item.id"
|
||||||
|
:name="index"
|
||||||
|
class="event-panel__gallery-slide"
|
||||||
|
>
|
||||||
|
<img :src="item.src" class="event-panel__gallery-img" alt="" />
|
||||||
|
</q-carousel-slide>
|
||||||
|
</q-carousel>
|
||||||
|
</div>
|
||||||
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -156,12 +290,14 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events'
|
import { useEventsStore, emotionToColor } from 'stores/events'
|
||||||
|
import { useSettingsStore } from 'stores/settings'
|
||||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||||
import { resolveFullRes } from 'composables/useImageCache'
|
import { resolveFullRes } from 'composables/useImageCache'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const eventsStore = useEventsStore()
|
const eventsStore = useEventsStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
|
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
|
||||||
|
|
||||||
// Resolve key image: full-res when online, cached thumbnail when offline
|
// Resolve key image: full-res when online, cached thumbnail when offline
|
||||||
|
|
@ -177,12 +313,18 @@ watch(
|
||||||
// Reset height when panel opens
|
// Reset height when panel opens
|
||||||
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
|
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
|
||||||
const isDark = computed(() => $q.dark.isActive)
|
const isDark = computed(() => $q.dark.isActive)
|
||||||
const gradientPresets = GRADIENT_PRESETS
|
|
||||||
|
const gradientStartColor = computed(() => settingsStore.emotionGradientStart)
|
||||||
|
const gradientEndColor = computed(() => settingsStore.emotionGradientEnd)
|
||||||
|
|
||||||
// Current glow color based on emotion + gradient
|
// Current glow color based on emotion + gradient
|
||||||
const emotionColor = computed(() => {
|
const emotionColor = computed(() => {
|
||||||
if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor
|
if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor
|
||||||
return emotionToColor(eventsStore.ghostEmotion, eventsStore.ghostGradientPreset)
|
return emotionToColor(
|
||||||
|
eventsStore.ghostEmotion,
|
||||||
|
gradientStartColor.value,
|
||||||
|
gradientEndColor.value
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD
|
// Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD
|
||||||
|
|
@ -215,32 +357,151 @@ const emotionLabel = computed(() => {
|
||||||
|
|
||||||
// CSS gradient for the slider track
|
// CSS gradient for the slider track
|
||||||
const sliderGradientCSS = computed(() => {
|
const sliderGradientCSS = computed(() => {
|
||||||
const idx = eventsStore.ghostGradientPreset
|
return `linear-gradient(90deg, ${gradientStartColor.value} 0%, ${gradientEndColor.value} 100%)`
|
||||||
if (idx !== null && GRADIENT_PRESETS[idx]) {
|
|
||||||
const [neg, mid, pos] = GRADIENT_PRESETS[idx].colors
|
|
||||||
return `linear-gradient(90deg, ${neg} 0%, ${mid} 50%, ${pos} 100%)`
|
|
||||||
}
|
|
||||||
// Default gradient matching the default emotionToColor
|
|
||||||
return 'linear-gradient(90deg, #E91E63 0%, #9C27B0 20%, #2196F3 35%, #FFD700 50%, #FF6B35 65%, #FFD700 80%, #4CAF50 100%)'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// CSS gradient for a preset swatch
|
const keyImageInputRef = ref(null)
|
||||||
function presetGradientCSS(colors) {
|
const mediaInputRef = ref(null)
|
||||||
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`
|
const removeKeyImageDialogOpen = ref(false)
|
||||||
|
const mediaGalleryOpen = ref(false)
|
||||||
|
const mediaGalleryIndex = ref(0)
|
||||||
|
const galleryItems = computed(() => {
|
||||||
|
const items = []
|
||||||
|
if (eventsStore.ghostImage) {
|
||||||
|
items.push({
|
||||||
|
id: '__key_image__',
|
||||||
|
type: 'image',
|
||||||
|
name: eventsStore.ghostKeyImageTitle || 'Key Image',
|
||||||
|
src: keyImageSrc.value || eventsStore.ghostImage,
|
||||||
|
isKeyImage: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...items, ...eventsStore.ghostMedia]
|
||||||
|
})
|
||||||
|
|
||||||
|
function openKeyImageUpload() {
|
||||||
|
keyImageInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreset(index) {
|
function openMediaUpload() {
|
||||||
eventsStore.ghostGradientPreset = index
|
mediaInputRef.value?.click()
|
||||||
// Clear custom color when selecting a gradient
|
|
||||||
eventsStore.ghostCustomColor = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddImage() {
|
function readImageAsDataUrl(file) {
|
||||||
// TODO: File picker for key image
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result)
|
||||||
|
reader.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddMedia() {
|
function loadImage(src) {
|
||||||
// TODO: File picker for additional media
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => resolve(img)
|
||||||
|
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'))
|
||||||
|
img.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeImageDataUrl(dataUrl) {
|
||||||
|
const img = await loadImage(dataUrl)
|
||||||
|
const MAX_SIDE = 1600
|
||||||
|
const scale = Math.min(1, MAX_SIDE / Math.max(img.width, img.height))
|
||||||
|
const width = Math.max(1, Math.round(img.width * scale))
|
||||||
|
const height = Math.max(1, Math.round(img.height * scale))
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return dataUrl
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.84)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onKeyImageSelected(event) {
|
||||||
|
const input = event.target
|
||||||
|
const file = input?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceDataUrl = await readImageAsDataUrl(file)
|
||||||
|
eventsStore.ghostImage = await optimizeImageDataUrl(sourceDataUrl)
|
||||||
|
if (!eventsStore.ghostKeyImageTitle) {
|
||||||
|
eventsStore.ghostKeyImageTitle = 'Key Image'
|
||||||
|
}
|
||||||
|
eventsStore.saveGhostNow()
|
||||||
|
} catch {
|
||||||
|
$q.notify?.({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Bild konnte nicht geladen werden.'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (input) input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMediaSelected(event) {
|
||||||
|
const input = event.target
|
||||||
|
const files = Array.from(input?.files ?? [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const images = await Promise.all(files.map(async (file) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'image',
|
||||||
|
name: file.name,
|
||||||
|
src: await optimizeImageDataUrl(await readImageAsDataUrl(file)),
|
||||||
|
createdAt: Date.now()
|
||||||
|
})))
|
||||||
|
eventsStore.ghostMedia = [...eventsStore.ghostMedia, ...images]
|
||||||
|
eventsStore.saveGhostNow()
|
||||||
|
} catch {
|
||||||
|
$q.notify?.({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Mindestens ein Bild konnte nicht geladen werden.'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (input) input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMediaImage(id) {
|
||||||
|
eventsStore.ghostMedia = eventsStore.ghostMedia.filter(item => item.id !== id)
|
||||||
|
eventsStore.saveGhostNow()
|
||||||
|
if (mediaGalleryIndex.value >= galleryItems.value.length) {
|
||||||
|
mediaGalleryIndex.value = Math.max(0, galleryItems.value.length - 1)
|
||||||
|
}
|
||||||
|
if (galleryItems.value.length === 0) {
|
||||||
|
mediaGalleryOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMediaGallery(id) {
|
||||||
|
const index = galleryItems.value.findIndex(item => item.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
mediaGalleryIndex.value = index
|
||||||
|
mediaGalleryOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openKeyImageGallery() {
|
||||||
|
if (!eventsStore.ghostImage) return
|
||||||
|
mediaGalleryIndex.value = 0
|
||||||
|
mediaGalleryOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemoveKeyImage() {
|
||||||
|
removeKeyImageDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeyImage() {
|
||||||
|
eventsStore.ghostImage = null
|
||||||
|
eventsStore.ghostKeyImageTitle = ''
|
||||||
|
keyImageSrc.value = null
|
||||||
|
removeKeyImageDialogOpen.value = false
|
||||||
|
eventsStore.saveGhostNow()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -311,8 +572,12 @@ function onAddMedia() {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
|
max-width: min(180px, calc(100% - 24px));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
outline: none;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
|
|
@ -320,6 +585,56 @@ function onAddMedia() {
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-panel__image-badge::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__image-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__image-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__image-gallery {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__image-action--danger {
|
||||||
|
background: rgba(150, 35, 35, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
.event-panel__image-placeholder {
|
.event-panel__image-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -339,6 +654,34 @@ function onAddMedia() {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-panel__file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__confirm {
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__confirm--dark {
|
||||||
|
background: rgba(30, 30, 30, 0.96);
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__confirm-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__confirm-copy {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
/* Title */
|
/* Title */
|
||||||
.event-panel__title {
|
.event-panel__title {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
@ -350,27 +693,65 @@ function onAddMedia() {
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date row */
|
.event-panel__meta-grid {
|
||||||
.event-panel__date-row {
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 6px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__meta-item {
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.14);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__meta-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.55;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__date-btn {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 20px;
|
text-align: left;
|
||||||
opacity: 0.6;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-panel__date-row:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-panel__date-icon {
|
.event-panel__date-icon {
|
||||||
flex-shrink: 0;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-panel__date-label {
|
.event-panel__date-label {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__location-input {
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__location-input :deep(.q-field__prepend) {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__location-input :deep(.q-field__native) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card sections */
|
/* Card sections */
|
||||||
|
|
@ -447,51 +828,39 @@ function onAddMedia() {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient Preset Selector */
|
.event-panel__gradient-edit {
|
||||||
.event-panel__presets {
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-panel__gradient-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.event-panel__presets-label {
|
.event-panel__presets-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-panel__presets-grid {
|
.event-panel__color-input {
|
||||||
display: flex;
|
width: 44px;
|
||||||
gap: 6px;
|
height: 24px;
|
||||||
flex-wrap: wrap;
|
border: none;
|
||||||
}
|
|
||||||
|
|
||||||
.event-panel__preset {
|
|
||||||
width: 45px;
|
|
||||||
height: 25px;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid #eee;
|
|
||||||
transition: border-color 0.2s, transform 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-panel__preset:hover {
|
.event-panel__gradient-actions {
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-panel__preset--active {
|
|
||||||
border-color: currentColor;
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-panel__preset--none {
|
|
||||||
background: rgba(128, 128, 128, 0.15);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: flex-end;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note */
|
/* Note */
|
||||||
|
|
@ -502,15 +871,66 @@ function onAddMedia() {
|
||||||
|
|
||||||
/* Media grid */
|
/* Media grid */
|
||||||
.event-panel__media-grid {
|
.event-panel__media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__media-item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(128, 128, 128, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__media-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__media-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
margin-top: 8px;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
padding: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__media-open {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-panel__media-add {
|
.event-panel__media-add {
|
||||||
width: 64px;
|
min-height: 128px;
|
||||||
height: 64px;
|
aspect-ratio: 1;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -520,10 +940,72 @@ function onAddMedia() {
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.event-panel__media-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__media-add {
|
||||||
|
min-height: 112px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.event-panel__media-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.event-panel__media-add:hover {
|
.event-panel__media-add:hover {
|
||||||
border-color: rgba(128, 128, 128, 0.4);
|
border-color: rgba(128, 128, 128, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-panel__gallery {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__gallery-close {
|
||||||
|
position: fixed;
|
||||||
|
top: max(16px, env(safe-area-inset-top));
|
||||||
|
right: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__gallery-carousel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__gallery-slide {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-panel__gallery-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* Delete */
|
/* Delete */
|
||||||
.event-panel__delete {
|
.event-panel__delete {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const props = defineProps({
|
||||||
lineSpread: { type: Number, default: 0.05 },
|
lineSpread: { type: Number, default: 0.05 },
|
||||||
fanSpread: { type: Number, default: 0.05 },
|
fanSpread: { type: Number, default: 0.05 },
|
||||||
lineSharpness: { type: Number, default: 8.0 },
|
lineSharpness: { type: Number, default: 8.0 },
|
||||||
|
lineThickness: { type: Number, default: 1.0 },
|
||||||
waveFrequency: { type: Number, default: 7.0 },
|
waveFrequency: { type: Number, default: 7.0 },
|
||||||
bezierCurvature: { type: Number, default: 0.2 },
|
bezierCurvature: { type: Number, default: 0.2 },
|
||||||
circleRadiusPx: { type: Number, default: 75 },
|
circleRadiusPx: { type: Number, default: 75 },
|
||||||
|
|
@ -43,7 +44,13 @@ const props = defineProps({
|
||||||
bgColorEdge: { type: String, default: '#000000' },
|
bgColorEdge: { type: String, default: '#000000' },
|
||||||
backgroundImage: { type: String, default: '' },
|
backgroundImage: { type: String, default: '' },
|
||||||
mixBlendMode: { type: String, default: 'screen' },
|
mixBlendMode: { type: String, default: 'screen' },
|
||||||
parallax: { type: Boolean, default: false }
|
parallax: { type: Boolean, default: false },
|
||||||
|
horizonMode: { type: String, default: 'off' },
|
||||||
|
horizonOpacity: { type: Number, default: 0.5 },
|
||||||
|
horizonBlend: { type: Number, default: 0.2 },
|
||||||
|
lineMode: { type: String, default: 'glow' }, // 'glow' | 'static'
|
||||||
|
staticLineColor: { type: String, default: '#2196F3' },
|
||||||
|
staticLineShadowStrength: { type: Number, default: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
// FPS display
|
// FPS display
|
||||||
|
|
@ -71,7 +78,7 @@ void main() {
|
||||||
`
|
`
|
||||||
|
|
||||||
const fragmentShader = `
|
const fragmentShader = `
|
||||||
precision mediump float;
|
precision highp float;
|
||||||
|
|
||||||
uniform float iTime;
|
uniform float iTime;
|
||||||
uniform vec3 iResolution;
|
uniform vec3 iResolution;
|
||||||
|
|
@ -84,18 +91,36 @@ uniform float pointY[16];
|
||||||
uniform float lineSpread;
|
uniform float lineSpread;
|
||||||
uniform float fanSpread;
|
uniform float fanSpread;
|
||||||
uniform float lineSharpness;
|
uniform float lineSharpness;
|
||||||
|
uniform float lineThickness;
|
||||||
uniform float waveFrequency;
|
uniform float waveFrequency;
|
||||||
uniform float bezierCurvature;
|
uniform float bezierCurvature;
|
||||||
uniform float lineBrightness;
|
uniform float lineBrightness;
|
||||||
uniform vec3 pointColor[16];
|
uniform vec3 pointColor[16];
|
||||||
|
|
||||||
|
uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
|
||||||
|
uniform float horizonOpacity;
|
||||||
|
uniform float horizonBlend;
|
||||||
|
|
||||||
|
uniform int lineMode; // 0=glow 1=static
|
||||||
|
uniform vec3 staticLineColor;
|
||||||
|
uniform float staticLineShadowStrength;
|
||||||
|
|
||||||
uniform bool parallax;
|
uniform bool parallax;
|
||||||
uniform vec2 parallaxOffset;
|
uniform vec2 parallaxOffset;
|
||||||
|
|
||||||
uniform vec3 lineGradient[8];
|
uniform vec3 lineGradient[8];
|
||||||
uniform int lineGradientCount;
|
uniform int lineGradientCount;
|
||||||
uniform vec3 bgColorCenter;
|
uniform vec3 bgColorCenter;
|
||||||
uniform vec3 bgColorEdge;
|
uniform vec3 bgColorEdge;
|
||||||
|
uniform int hasBackgroundImage;
|
||||||
|
|
||||||
|
vec3 gradientMid() {
|
||||||
|
if (lineGradientCount <= 0) return vec3(0.45, 0.5, 0.8);
|
||||||
|
if (lineGradientCount == 1) return lineGradient[0];
|
||||||
|
float t = 0.4999 * float(lineGradientCount - 1);
|
||||||
|
int idx = int(t);
|
||||||
|
return mix(lineGradient[idx], lineGradient[min(idx + 1, lineGradientCount - 1)], fract(t));
|
||||||
|
}
|
||||||
|
|
||||||
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
||||||
float bestT = 0.0;
|
float bestT = 0.0;
|
||||||
|
|
@ -126,7 +151,6 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepts precomputed bezier values (t, curvePos, norm) — computed once per segment
|
|
||||||
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
|
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
|
||||||
float s = dot(uv - curvePos, norm);
|
float s = dot(uv - curvePos, norm);
|
||||||
|
|
||||||
|
|
@ -139,10 +163,34 @@ float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec
|
||||||
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
|
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
|
||||||
* sin(fi * 0.9 + time * 0.18);
|
* sin(fi * 0.9 + time * 0.18);
|
||||||
|
|
||||||
|
float widthScale = max(lineThickness, 0.1);
|
||||||
float dist = s - linePos - waveDisp;
|
float dist = s - linePos - waveDisp;
|
||||||
|
float scaledDist = abs(dist) * lineSharpness / widthScale;
|
||||||
|
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
|
||||||
|
float tailMask = smoothstep(1.35, 0.02, scaledDist);
|
||||||
|
float core = 0.013 / max(scaledDist + 0.004, 1e-4);
|
||||||
|
float aura = 0.003 * widthScale * smoothstep(0.42, 0.06, scaledDist);
|
||||||
|
|
||||||
|
return fade * tailMask * core + fade * aura;
|
||||||
|
}
|
||||||
|
|
||||||
|
float staticLineFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
|
||||||
|
float s = dot(uv - curvePos, norm);
|
||||||
|
|
||||||
|
float time = iTime * animationSpeed;
|
||||||
|
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
||||||
|
|
||||||
|
float envelope = sin(t * 3.14159265359);
|
||||||
|
float linePos = (normalizedI - 0.5) * fanSpread * envelope;
|
||||||
|
float amp = lineSpread * 0.3 * envelope;
|
||||||
|
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
|
||||||
|
* sin(fi * 0.9 + time * 0.18);
|
||||||
|
|
||||||
|
float widthScale = max(lineThickness, 0.1);
|
||||||
|
float dist = abs(s - linePos - waveDisp) * lineSharpness / widthScale;
|
||||||
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
|
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
|
||||||
|
|
||||||
return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003);
|
return fade * smoothstep(0.022, 0.012, dist);
|
||||||
}
|
}
|
||||||
|
|
||||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
|
|
@ -153,9 +201,9 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
baseUv += parallaxOffset;
|
baseUv += parallaxOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 col = vec3(0.0);
|
vec3 col = vec3(0.0);
|
||||||
|
float totalIntensity = 0.0;
|
||||||
|
|
||||||
const int MAX_PTS = 16;
|
|
||||||
const int MAX_SEGS = 15;
|
const int MAX_SEGS = 15;
|
||||||
|
|
||||||
for (int s = 0; s < MAX_SEGS; ++s) {
|
for (int s = 0; s < MAX_SEGS; ++s) {
|
||||||
|
|
@ -166,36 +214,105 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
|
|
||||||
vec2 segD = ep - sp;
|
vec2 segD = ep - sp;
|
||||||
float segL = length(segD);
|
float segL = length(segD);
|
||||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
if (segL < 1e-4) continue;
|
||||||
|
vec2 segDir = segD / segL;
|
||||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||||
|
|
||||||
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
|
float t_seg = clamp(dot(baseUv - sp, segDir) / max(segL, 1e-4), 0.0, 1.0);
|
||||||
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
|
vec3 lineCol = lineMode == 1
|
||||||
|
? staticLineColor
|
||||||
|
: mix(pointColor[s], pointColor[s + 1], t_seg);
|
||||||
|
|
||||||
// bezierClosestT computed ONCE per segment — shared by fog + all lines
|
|
||||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||||
float bmt = 1.0 - bt;
|
float bmt = 1.0 - bt;
|
||||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||||
vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc));
|
vec2 bTangRaw = bmt*(pc - sp) + bt*(ep - pc);
|
||||||
|
float bTangLen = length(bTangRaw);
|
||||||
|
vec2 bTang = bTangLen > 1e-4 ? bTangRaw / bTangLen : segDir;
|
||||||
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
||||||
|
|
||||||
float bDist = length(baseUv - bPos);
|
float shadowStrength = clamp(staticLineShadowStrength, 0.0, 1.0);
|
||||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
if (lineMode != 1 || shadowStrength > 0.0) {
|
||||||
float fogEnv = sin(bt * 3.14159265359);
|
float bDist = length(baseUv - bPos);
|
||||||
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||||
col += lineCol * segFog;
|
float fogEnv = sin(bt * 3.14159265359);
|
||||||
|
float fogVal = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
||||||
|
float fogScale = lineMode == 1 ? shadowStrength : 1.0;
|
||||||
|
col += lineCol * fogVal * fogScale;
|
||||||
|
totalIntensity += fogVal * fogScale;
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < middleLineCount; ++i) {
|
for (int i = 0; i < middleLineCount; ++i) {
|
||||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
float glowFv = waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||||
|
float fv = glowFv;
|
||||||
|
if (lineMode == 1) {
|
||||||
|
float crispFv = staticLineFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||||
|
fv = mix(crispFv, glowFv, shadowStrength);
|
||||||
|
}
|
||||||
|
col += lineCol * fv;
|
||||||
|
totalIntensity += fv;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
col *= lineBrightness;
|
col *= lineBrightness;
|
||||||
|
totalIntensity *= lineBrightness;
|
||||||
|
|
||||||
|
if (lineMode != 1) {
|
||||||
|
// Drop only the distant low-energy glow tails that accumulate into cloudy artifacts.
|
||||||
|
float glowPeak = max(col.r, max(col.g, col.b));
|
||||||
|
float cleanupMask = smoothstep(0.018, 0.055, glowPeak);
|
||||||
|
col *= cleanupMask;
|
||||||
|
totalIntensity *= cleanupMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radial background — shared by both modes
|
||||||
float dist = length(baseUv) / 1.8;
|
float dist = length(baseUv) / 1.8;
|
||||||
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
||||||
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
|
||||||
|
// "Split" horizon changes the background orientation (vertical) in both modes.
|
||||||
|
if (horizonMode == 2) {
|
||||||
|
float blendW = max(horizonBlend * 0.7, 0.001);
|
||||||
|
float t = smoothstep(-blendW, blendW, baseUv.y);
|
||||||
|
bg = mix(bgColorEdge, bgColorCenter, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineMode == 1) {
|
||||||
|
// Static Lines: radial bg + lines mixed by intensity (no additive glow)
|
||||||
|
float staticMix = clamp(totalIntensity, 0.0, 1.0);
|
||||||
|
vec3 staticCol = mix(bg, staticLineColor, staticMix);
|
||||||
|
if (hasBackgroundImage == 1) {
|
||||||
|
// Keep background image visible by outputting line-only color with alpha.
|
||||||
|
float alpha = staticMix < 0.03 ? 0.0 : staticMix;
|
||||||
|
fragColor = vec4(staticLineColor * alpha, alpha);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fragColor = vec4(staticCol, 1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glowing Lines: additive line colors + horizon effects
|
||||||
|
|
||||||
|
if (horizonMode == 1) {
|
||||||
|
float band = exp(-baseUv.y * baseUv.y * 5.0);
|
||||||
|
col += gradientMid() * band * horizonOpacity;
|
||||||
|
} else if (horizonMode == 3) {
|
||||||
|
float d2 = baseUv.y * baseUv.y;
|
||||||
|
float softGlow = exp(-d2 * 10.0);
|
||||||
|
float coreGlow = exp(-d2 * 70.0) * 0.7;
|
||||||
|
col += gradientMid() * (softGlow + coreGlow) * horizonOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 composed = clamp(bg + col, 0.0, 1.0);
|
||||||
|
if (hasBackgroundImage == 1) {
|
||||||
|
// Render only glowing lines above CSS background image.
|
||||||
|
vec3 lineOnly = clamp(col, 0.0, 1.0);
|
||||||
|
float alpha = clamp(max(lineOnly.r, max(lineOnly.g, lineOnly.b)), 0.0, 1.0);
|
||||||
|
alpha = alpha < 0.03 ? 0.0 : alpha;
|
||||||
|
fragColor = vec4(lineOnly * alpha, alpha);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fragColor = vec4(composed, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -238,8 +355,14 @@ let rafId = null
|
||||||
let resizeObserver = null
|
let resizeObserver = null
|
||||||
let uniforms = null
|
let uniforms = null
|
||||||
let scrollHandler = null
|
let scrollHandler = null
|
||||||
|
let boundScrollContainer = null
|
||||||
|
let cachedScrollLeft = 0
|
||||||
let scrollIdleTimer = null
|
let scrollIdleTimer = null
|
||||||
let visibilityHandler = null
|
let visibilityHandler = null
|
||||||
|
// Exposed hook so parent can trigger a hard canvas resize sync.
|
||||||
|
let forceResizeHandler = null
|
||||||
|
let pointerMoveHandler = null
|
||||||
|
let pointerTargetEl = null
|
||||||
|
|
||||||
// Parallax tracking
|
// Parallax tracking
|
||||||
let targetParallax = null
|
let targetParallax = null
|
||||||
|
|
@ -282,6 +405,11 @@ function applyBgColors() {
|
||||||
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
|
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyBackgroundImageFlag() {
|
||||||
|
if (!uniforms) return
|
||||||
|
uniforms.hasBackgroundImage.value = props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
// Watch all props for live updates
|
// Watch all props for live updates
|
||||||
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
|
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
|
||||||
watch(() => props.lineCount, () => {
|
watch(() => props.lineCount, () => {
|
||||||
|
|
@ -291,6 +419,7 @@ watch(() => props.lineCount, () => {
|
||||||
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
|
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
|
||||||
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
|
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
|
||||||
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
|
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
|
||||||
|
watch(() => props.lineThickness, (v) => { if (uniforms) uniforms.lineThickness.value = v })
|
||||||
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
|
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
|
||||||
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
|
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
|
||||||
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
|
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
|
||||||
|
|
@ -311,6 +440,33 @@ watch(() => props.pointColors, applyPointColors, { deep: true })
|
||||||
watch(() => props.linesGradient, applyGradient, { deep: true })
|
watch(() => props.linesGradient, applyGradient, { deep: true })
|
||||||
watch(() => props.bgColorCenter, applyBgColors)
|
watch(() => props.bgColorCenter, applyBgColors)
|
||||||
watch(() => props.bgColorEdge, applyBgColors)
|
watch(() => props.bgColorEdge, applyBgColors)
|
||||||
|
watch(() => props.backgroundImage, applyBackgroundImageFlag)
|
||||||
|
watch(() => props.horizonMode, (v) => {
|
||||||
|
if (uniforms) uniforms.horizonMode.value = ({ off: 0, fog: 1, split: 2, glow: 3 })[v] ?? 0
|
||||||
|
})
|
||||||
|
watch(() => props.horizonOpacity, (v) => { if (uniforms) uniforms.horizonOpacity.value = v })
|
||||||
|
watch(() => props.horizonBlend, (v) => { if (uniforms) uniforms.horizonBlend.value = v })
|
||||||
|
watch(() => props.lineMode, (v) => {
|
||||||
|
if (uniforms) uniforms.lineMode.value = v === 'static' ? 1 : 0
|
||||||
|
})
|
||||||
|
watch(() => props.staticLineColor, (v) => {
|
||||||
|
if (uniforms) { const c = hexToVec3(v); uniforms.staticLineColor.value.set(c.x, c.y, c.z) }
|
||||||
|
})
|
||||||
|
watch(() => props.staticLineShadowStrength, (v) => {
|
||||||
|
if (uniforms) uniforms.staticLineShadowStrength.value = v
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.scrollContainer, (nextEl, prevEl) => {
|
||||||
|
if (prevEl && scrollHandler) {
|
||||||
|
prevEl.removeEventListener('scroll', scrollHandler)
|
||||||
|
if (boundScrollContainer === prevEl) boundScrollContainer = null
|
||||||
|
}
|
||||||
|
if (nextEl && scrollHandler) {
|
||||||
|
cachedScrollLeft = nextEl.scrollLeft || 0
|
||||||
|
nextEl.addEventListener('scroll', scrollHandler, { passive: true })
|
||||||
|
boundScrollContainer = nextEl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
@ -331,8 +487,9 @@ onMounted(() => {
|
||||||
let currentDpr = DPR_IDLE
|
let currentDpr = DPR_IDLE
|
||||||
let scrolling = false
|
let scrolling = false
|
||||||
|
|
||||||
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' })
|
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: true, powerPreference: 'high-performance' })
|
||||||
renderer.setPixelRatio(currentDpr)
|
renderer.setPixelRatio(currentDpr)
|
||||||
|
renderer.setClearAlpha(0)
|
||||||
renderer.domElement.style.width = '100%'
|
renderer.domElement.style.width = '100%'
|
||||||
renderer.domElement.style.height = '100%'
|
renderer.domElement.style.height = '100%'
|
||||||
renderer.domElement.style.display = 'block'
|
renderer.domElement.style.display = 'block'
|
||||||
|
|
@ -357,6 +514,7 @@ onMounted(() => {
|
||||||
lineSpread: { value: props.lineSpread },
|
lineSpread: { value: props.lineSpread },
|
||||||
fanSpread: { value: props.fanSpread },
|
fanSpread: { value: props.fanSpread },
|
||||||
lineSharpness: { value: props.lineSharpness },
|
lineSharpness: { value: props.lineSharpness },
|
||||||
|
lineThickness: { value: props.lineThickness },
|
||||||
waveFrequency: { value: props.waveFrequency },
|
waveFrequency: { value: props.waveFrequency },
|
||||||
bezierCurvature: { value: props.bezierCurvature },
|
bezierCurvature: { value: props.bezierCurvature },
|
||||||
lineBrightness: { value: props.lineBrightness },
|
lineBrightness: { value: props.lineBrightness },
|
||||||
|
|
@ -364,6 +522,14 @@ onMounted(() => {
|
||||||
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
|
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
horizonMode: { value: ({ off: 0, fog: 1, split: 2, glow: 3 })[props.horizonMode] ?? 0 },
|
||||||
|
horizonOpacity: { value: props.horizonOpacity },
|
||||||
|
horizonBlend: { value: props.horizonBlend },
|
||||||
|
|
||||||
|
lineMode: { value: props.lineMode === 'static' ? 1 : 0 },
|
||||||
|
staticLineColor: { value: hexToVec3(props.staticLineColor) },
|
||||||
|
staticLineShadowStrength: { value: props.staticLineShadowStrength },
|
||||||
|
|
||||||
parallax: { value: props.parallax },
|
parallax: { value: props.parallax },
|
||||||
parallaxOffset: { value: new Vector2(0, 0) },
|
parallaxOffset: { value: new Vector2(0, 0) },
|
||||||
|
|
||||||
|
|
@ -372,12 +538,14 @@ onMounted(() => {
|
||||||
},
|
},
|
||||||
lineGradientCount: { value: 0 },
|
lineGradientCount: { value: 0 },
|
||||||
bgColorCenter: { value: new Vector3(0, 0, 0) },
|
bgColorCenter: { value: new Vector3(0, 0, 0) },
|
||||||
bgColorEdge: { value: new Vector3(0, 0, 0) }
|
bgColorEdge: { value: new Vector3(0, 0, 0) },
|
||||||
|
hasBackgroundImage: { value: props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply initial values
|
// Apply initial values
|
||||||
applyGradient()
|
applyGradient()
|
||||||
applyBgColors()
|
applyBgColors()
|
||||||
|
applyBackgroundImageFlag()
|
||||||
applyPointColors()
|
applyPointColors()
|
||||||
|
|
||||||
material = new ShaderMaterial({
|
material = new ShaderMaterial({
|
||||||
|
|
@ -402,6 +570,7 @@ onMounted(() => {
|
||||||
const canvasHeight = renderer.domElement.height
|
const canvasHeight = renderer.domElement.height
|
||||||
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
||||||
}
|
}
|
||||||
|
forceResizeHandler = setSize
|
||||||
setSize()
|
setSize()
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(setSize)
|
resizeObserver = new ResizeObserver(setSize)
|
||||||
|
|
@ -409,7 +578,7 @@ onMounted(() => {
|
||||||
|
|
||||||
// Pointer events (parallax only)
|
// Pointer events (parallax only)
|
||||||
if (props.parallax) {
|
if (props.parallax) {
|
||||||
const handlePointerMove = (event) => {
|
pointerMoveHandler = (event) => {
|
||||||
const rect = renderer.domElement.getBoundingClientRect()
|
const rect = renderer.domElement.getBoundingClientRect()
|
||||||
const x = event.clientX - rect.left
|
const x = event.clientX - rect.left
|
||||||
const y = event.clientY - rect.top
|
const y = event.clientY - rect.top
|
||||||
|
|
@ -420,11 +589,11 @@ onMounted(() => {
|
||||||
(-(y - centerY) / rect.height) * 0.2
|
(-(y - centerY) / rect.height) * 0.2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
pointerTargetEl = renderer.domElement
|
||||||
|
pointerTargetEl.addEventListener('pointermove', pointerMoveHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
|
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
|
||||||
let cachedScrollLeft = 0
|
|
||||||
|
|
||||||
function setDpr(dpr) {
|
function setDpr(dpr) {
|
||||||
if (dpr === currentDpr) return
|
if (dpr === currentDpr) return
|
||||||
|
|
@ -458,6 +627,7 @@ onMounted(() => {
|
||||||
if (props.scrollContainer) {
|
if (props.scrollContainer) {
|
||||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||||
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
|
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
|
||||||
|
boundScrollContainer = props.scrollContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast inline scroll sync — reads cached scrollLeft instead of DOM during render
|
// Fast inline scroll sync — reads cached scrollLeft instead of DOM during render
|
||||||
|
|
@ -519,8 +689,8 @@ onMounted(() => {
|
||||||
dprDisplay.value = currentDpr.toFixed(2)
|
dprDisplay.value = currentDpr.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read latest scrollLeft from DOM in case scroll event was missed
|
// Fallback read only when no listener is bound.
|
||||||
if (props.scrollContainer) {
|
if (!boundScrollContainer && props.scrollContainer) {
|
||||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,15 +709,23 @@ onMounted(() => {
|
||||||
renderLoop()
|
renderLoop()
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ fpsDisplay, dprDisplay })
|
function forceResize() {
|
||||||
|
if (forceResizeHandler) forceResizeHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ fpsDisplay, dprDisplay, forceResize })
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (rafId) cancelAnimationFrame(rafId)
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
|
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
|
||||||
if (resizeObserver) resizeObserver.disconnect()
|
if (resizeObserver) resizeObserver.disconnect()
|
||||||
if (props.scrollContainer && scrollHandler) {
|
if (boundScrollContainer && scrollHandler) {
|
||||||
props.scrollContainer.removeEventListener('scroll', scrollHandler)
|
boundScrollContainer.removeEventListener('scroll', scrollHandler)
|
||||||
}
|
}
|
||||||
|
boundScrollContainer = null
|
||||||
|
if (pointerTargetEl && pointerMoveHandler) pointerTargetEl.removeEventListener('pointermove', pointerMoveHandler)
|
||||||
|
pointerTargetEl = null
|
||||||
|
pointerMoveHandler = null
|
||||||
clearTimeout(scrollIdleTimer)
|
clearTimeout(scrollIdleTimer)
|
||||||
if (geometry) geometry.dispose()
|
if (geometry) geometry.dispose()
|
||||||
if (material) material.dispose()
|
if (material) material.dispose()
|
||||||
|
|
@ -557,6 +735,7 @@ onBeforeUnmount(() => {
|
||||||
renderer.domElement.parentNode.removeChild(renderer.domElement)
|
renderer.domElement.parentNode.removeChild(renderer.domElement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
forceResizeHandler = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@
|
||||||
'glow-dot--label-above': labelAbove
|
'glow-dot--label-above': labelAbove
|
||||||
}"
|
}"
|
||||||
:style="dotStyle"
|
:style="dotStyle"
|
||||||
|
:role="isGhost ? undefined : 'button'"
|
||||||
|
:tabindex="isGhost ? -1 : 0"
|
||||||
|
:aria-label="dotAriaLabel"
|
||||||
@click.stop="onSelect"
|
@click.stop="onSelect"
|
||||||
|
@keydown.enter.prevent.stop="onSelect"
|
||||||
|
@keydown.space.prevent.stop="onSelect"
|
||||||
>
|
>
|
||||||
<div class="glow-dot__inner" :style="{ boxShadow: glowShadow }">
|
<div class="glow-dot__inner" :style="innerStyle">
|
||||||
<img
|
<img
|
||||||
v-if="imageSrc"
|
v-if="imageSrc"
|
||||||
:src="imageSrc"
|
:src="imageSrc"
|
||||||
|
|
@ -19,6 +24,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
|
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
|
||||||
|
<span class="glow-dot__connector" :style="connectorStyle"></span>
|
||||||
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
|
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
|
||||||
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
|
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,9 +49,9 @@ const eventsStore = useEventsStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
|
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
|
||||||
const { resolvedSrc: imageSrc } = props.event.image
|
const imageUrl = computed(() => props.event.image || null)
|
||||||
? useImageCache(props.event.image, props.event.id)
|
const eventId = computed(() => props.event.id)
|
||||||
: { resolvedSrc: computed(() => null) }
|
const { resolvedSrc: imageSrc } = useImageCache(imageUrl, eventId)
|
||||||
|
|
||||||
const fl = computed(() => settingsStore.floatingLines)
|
const fl = computed(() => settingsStore.floatingLines)
|
||||||
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
||||||
|
|
@ -67,19 +73,42 @@ const formattedDate = computed(() => {
|
||||||
const d = new Date(props.event.date)
|
const d = new Date(props.event.date)
|
||||||
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
|
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
|
||||||
})
|
})
|
||||||
|
const dotAriaLabel = computed(() => {
|
||||||
|
if (props.isGhost) return undefined
|
||||||
|
const title = props.event.title || 'Event'
|
||||||
|
return `${title}, ${formattedDate.value}`
|
||||||
|
})
|
||||||
|
|
||||||
// Label font sizes per setting
|
// Label font sizes per setting
|
||||||
const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } }
|
const LABEL_FONT = {
|
||||||
|
small: { title: 10, date: 9 },
|
||||||
|
medium: { title: 12, date: 11 },
|
||||||
|
large: { title: 14, date: 13 },
|
||||||
|
xlarge: { title: 18, date: 16 }
|
||||||
|
}
|
||||||
const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
|
const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
|
||||||
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
|
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
|
||||||
|
const labelOpacity = computed(() => Math.max(0.5, Math.min(1, fl.value.labelOpacity ?? 0.75)))
|
||||||
|
const connectorLengthScale = computed(() => Math.max(0, Math.min(1, fl.value.labelConnectorLength ?? 0.2)))
|
||||||
|
// 0 -> no connector, 1 -> ~5x current connector length (about 70px)
|
||||||
|
const connectorLengthPx = computed(() => connectorLengthScale.value * 70)
|
||||||
|
const labelGapPx = computed(() => 4 + connectorLengthPx.value)
|
||||||
|
|
||||||
const labelStyle = computed(() => ({
|
const labelStyle = computed(() => ({
|
||||||
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
|
maxWidth: labelFont.value.title >= 18 ? '150px' : labelFont.value.title >= 14 ? '120px' : '90px',
|
||||||
|
'--label-gap': `${labelGapPx.value}px`,
|
||||||
|
'--label-connector-len': `${connectorLengthPx.value}px`,
|
||||||
|
'--label-opacity': labelOpacity.value.toFixed(2),
|
||||||
|
'--label-connector-color': labelColor.value,
|
||||||
|
'--label-connector-opacity': Math.max(0.45, labelOpacity.value * 0.9).toFixed(2)
|
||||||
|
}))
|
||||||
|
const connectorStyle = computed(() => ({
|
||||||
|
display: connectorLengthPx.value <= 0.5 ? 'none' : 'block'
|
||||||
}))
|
}))
|
||||||
const titleStyle = computed(() => ({
|
const titleStyle = computed(() => ({
|
||||||
fontSize: `${labelFont.value.title}px`,
|
fontSize: `${labelFont.value.title}px`,
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
|
maxWidth: labelFont.value.title >= 18 ? '150px' : labelFont.value.title >= 14 ? '120px' : '90px'
|
||||||
}))
|
}))
|
||||||
const dateStyle = computed(() => ({
|
const dateStyle = computed(() => ({
|
||||||
fontSize: `${labelFont.value.date}px`,
|
fontSize: `${labelFont.value.date}px`,
|
||||||
|
|
@ -93,14 +122,18 @@ const dotStyle = computed(() => ({
|
||||||
height: `${dotSize.value}px`
|
height: `${dotSize.value}px`
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Two-layer box-shadow: tight bright core + wide soft halo
|
const innerStyle = computed(() => {
|
||||||
const glowShadow = computed(() => {
|
const size = fl.value.glowSize
|
||||||
const size = fl.value.glowSize
|
|
||||||
const strength = fl.value.glowStrength
|
const strength = fl.value.glowStrength
|
||||||
const color = glowColor.value
|
const color = glowColor.value
|
||||||
const core = alphaHex(Math.min(strength / 3, 1))
|
const core = alphaHex(Math.min(strength / 3, 1))
|
||||||
const halo = alphaHex(Math.min(strength / 7, 1))
|
const halo = alphaHex(Math.min(strength / 7, 1))
|
||||||
return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
|
const shadow = `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
|
||||||
|
const bw = fl.value.dotBorderWidth ?? 0
|
||||||
|
return {
|
||||||
|
boxShadow: shadow,
|
||||||
|
border: bw > 0 ? `${bw}px solid ${fl.value.dotBorderColor ?? '#ffffff'}` : 'none'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function alphaHex(a) {
|
function alphaHex(a) {
|
||||||
|
|
@ -163,25 +196,41 @@ function onSelect() {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + var(--label-gap, 18px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
max-width: 90px;
|
max-width: 90px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: var(--label-opacity, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When dot is in lower half, show label above */
|
/* When dot is in lower half, show label above */
|
||||||
.glow-dot--label-above .glow-dot__label {
|
.glow-dot--label-above .glow-dot__label {
|
||||||
top: auto;
|
top: auto;
|
||||||
bottom: calc(100% + 6px);
|
bottom: calc(100% + var(--label-gap, 18px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-dot__connector {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
height: var(--label-connector-len, 14px);
|
||||||
|
top: calc(-1 * var(--label-connector-len, 14px));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--label-connector-color, #ffffff);
|
||||||
|
opacity: var(--label-connector-opacity, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-dot--label-above .glow-dot__connector {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(-1 * var(--label-connector-len, 14px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.glow-dot__title {
|
.glow-dot__title {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.7;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -193,7 +242,6 @@ function onSelect() {
|
||||||
.glow-dot__date {
|
.glow-dot__date {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
opacity: 0.4;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,23 @@
|
||||||
<div class="lw-settings__scroll">
|
<div class="lw-settings__scroll">
|
||||||
<div class="lw-settings__title">Einstellungen</div>
|
<div class="lw-settings__title">Einstellungen</div>
|
||||||
|
|
||||||
|
<!-- Modus -->
|
||||||
|
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||||
|
<span class="lw-settings__card-label">Modus</span>
|
||||||
|
<div class="lw-settings__segmented">
|
||||||
|
<button
|
||||||
|
class="lw-settings__seg-btn"
|
||||||
|
:class="{ 'lw-settings__seg-btn--active': !isStatic }"
|
||||||
|
@click="update({ lineMode: 'glow' })"
|
||||||
|
>Glowing Lines</button>
|
||||||
|
<button
|
||||||
|
class="lw-settings__seg-btn"
|
||||||
|
:class="{ 'lw-settings__seg-btn--active': isStatic }"
|
||||||
|
@click="update({ lineMode: 'static' })"
|
||||||
|
>Color Lines</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Linien -->
|
<!-- Linien -->
|
||||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||||
<span class="lw-settings__card-label">Linien</span>
|
<span class="lw-settings__card-label">Linien</span>
|
||||||
|
|
@ -28,7 +45,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.speed"
|
:model-value="fl.speed"
|
||||||
@update:model-value="v => update({ speed: v })"
|
@update:model-value="v => update({ speed: v })"
|
||||||
:min="0.1" :max="3" :step="0.05"
|
:min="0" :max="2" :step="0.05"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -38,7 +55,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.lineCount"
|
:model-value="fl.lineCount"
|
||||||
@update:model-value="v => update({ lineCount: v })"
|
@update:model-value="v => update({ lineCount: v })"
|
||||||
:min="1" :max="40" :step="1"
|
:min="1" :max="10" :step="1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -48,7 +65,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.spread"
|
:model-value="fl.spread"
|
||||||
@update:model-value="v => update({ spread: v })"
|
@update:model-value="v => update({ spread: v })"
|
||||||
:min="0.01" :max="1" :step="0.01"
|
:min="0.01" :max="0.5" :step="0.01"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -58,7 +75,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.fanSpread"
|
:model-value="fl.fanSpread"
|
||||||
@update:model-value="v => update({ fanSpread: v })"
|
@update:model-value="v => update({ fanSpread: v })"
|
||||||
:min="0.01" :max="0.5" :step="0.005"
|
:min="0.01" :max="0.3" :step="0.005"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -68,7 +85,17 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.lineSharpness"
|
:model-value="fl.lineSharpness"
|
||||||
@update:model-value="v => update({ lineSharpness: v })"
|
@update:model-value="v => update({ lineSharpness: v })"
|
||||||
:min="0.3" :max="10" :step="0.1"
|
:min="5" :max="10" :step="0.1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Linienstärke</span>
|
||||||
|
<span class="lw-settings__value">{{ (fl.lineThickness ?? 1).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.lineThickness ?? 1"
|
||||||
|
@update:model-value="v => update({ lineThickness: v })"
|
||||||
|
:min="0.5" :max="4" :step="0.05"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -78,7 +105,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.waveFrequency"
|
:model-value="fl.waveFrequency"
|
||||||
@update:model-value="v => update({ waveFrequency: v })"
|
@update:model-value="v => update({ waveFrequency: v })"
|
||||||
:min="1" :max="30" :step="0.5"
|
:min="1" :max="10" :step="0.5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -88,7 +115,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.bezierCurvature"
|
:model-value="fl.bezierCurvature"
|
||||||
@update:model-value="v => update({ bezierCurvature: v })"
|
@update:model-value="v => update({ bezierCurvature: v })"
|
||||||
:min="-1" :max="1" :step="0.05"
|
:min="-0.5" :max="0.5" :step="0.05"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -98,7 +125,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.circleRadius"
|
:model-value="fl.circleRadius"
|
||||||
@update:model-value="v => update({ circleRadius: v })"
|
@update:model-value="v => update({ circleRadius: v })"
|
||||||
:min="10" :max="200" :step="5"
|
:min="50" :max="200" :step="5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -108,7 +135,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.glowSize"
|
:model-value="fl.glowSize"
|
||||||
@update:model-value="v => update({ glowSize: v })"
|
@update:model-value="v => update({ glowSize: v })"
|
||||||
:min="5" :max="100" :step="1"
|
:min="0" :max="50" :step="1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -118,7 +145,7 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.glowStrength"
|
:model-value="fl.glowStrength"
|
||||||
@update:model-value="v => update({ glowStrength: v })"
|
@update:model-value="v => update({ glowStrength: v })"
|
||||||
:min="0.5" :max="12" :step="0.5"
|
:min="0" :max="10" :step="0.5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
|
@ -128,7 +155,52 @@
|
||||||
<q-slider
|
<q-slider
|
||||||
:model-value="fl.lineBrightness ?? 1"
|
:model-value="fl.lineBrightness ?? 1"
|
||||||
@update:model-value="v => update({ lineBrightness: v })"
|
@update:model-value="v => update({ lineBrightness: v })"
|
||||||
:min="0.05" :max="2" :step="0.05"
|
:min="0.05" :max="1" :step="0.05"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Rahmen Stärke</span>
|
||||||
|
<span class="lw-settings__value">{{ (fl.dotBorderWidth ?? 0) === 0 ? 'aus' : `${fl.dotBorderWidth}px` }}</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.dotBorderWidth ?? 0"
|
||||||
|
@update:model-value="v => update({ dotBorderWidth: v })"
|
||||||
|
:min="0" :max="8" :step="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="lw-settings__row" style="margin-top: 4px;">
|
||||||
|
<span>Rahmen Farbe</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="fl.dotBorderColor ?? '#ffffff'"
|
||||||
|
@input="e => update({ dotBorderColor: e.target.value })"
|
||||||
|
class="lw-settings__color-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linienfarbe (Static Lines) -->
|
||||||
|
<div v-if="isStatic" class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||||
|
<span class="lw-settings__card-label">Linienfarbe</span>
|
||||||
|
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Farbe</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="fl.staticLineColor ?? '#2196F3'"
|
||||||
|
@input="e => update({ staticLineColor: e.target.value })"
|
||||||
|
class="lw-settings__color-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Schatten</span>
|
||||||
|
<span class="lw-settings__value">{{ (fl.staticLineShadowStrength ?? 0).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.staticLineShadowStrength ?? 0"
|
||||||
|
@update:model-value="v => update({ staticLineShadowStrength: v })"
|
||||||
|
:min="0" :max="1" :step="0.05"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -160,6 +232,26 @@
|
||||||
class="lw-settings__color-input"
|
class="lw-settings__color-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Transparenz</span>
|
||||||
|
<span class="lw-settings__value">{{ (fl.labelOpacity ?? 0.75).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.labelOpacity ?? 0.75"
|
||||||
|
@update:model-value="v => update({ labelOpacity: v })"
|
||||||
|
:min="0.5" :max="1" :step="0.05"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Strichlänge</span>
|
||||||
|
<span class="lw-settings__value">{{ (fl.labelConnectorLength ?? 0.2).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.labelConnectorLength ?? 0.2"
|
||||||
|
@update:model-value="v => update({ labelConnectorLength: v })"
|
||||||
|
:min="0" :max="1" :step="0.01"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hintergrundbild -->
|
<!-- Hintergrundbild -->
|
||||||
|
|
@ -174,6 +266,13 @@
|
||||||
>
|
>
|
||||||
Keins
|
Keins
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="lw-settings__img-btn"
|
||||||
|
:class="{ 'lw-settings__img-btn--active': isCustomBackground }"
|
||||||
|
@click="openBackgroundUpload"
|
||||||
|
>
|
||||||
|
Eigenes Bild
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="n in 10"
|
v-for="n in 10"
|
||||||
:key="'bg' + n"
|
:key="'bg' + n"
|
||||||
|
|
@ -184,6 +283,13 @@
|
||||||
{{ n }}
|
{{ n }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref="backgroundUploadRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="lw-settings__file-input"
|
||||||
|
@change="onBackgroundUpload"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hintergrundfarbe -->
|
<!-- Hintergrundfarbe -->
|
||||||
|
|
@ -191,7 +297,7 @@
|
||||||
<span class="lw-settings__card-label">Hintergrundfarbe</span>
|
<span class="lw-settings__card-label">Hintergrundfarbe</span>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
<span>BG Mitte</span>
|
<span>{{ isSplit ? 'Unten' : 'BG Mitte' }}</span>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
:value="fl.bgCenter"
|
:value="fl.bgCenter"
|
||||||
|
|
@ -201,7 +307,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
<span>BG Rand</span>
|
<span>{{ isSplit ? 'Oben' : 'BG Rand' }}</span>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
:value="fl.bgEdge"
|
:value="fl.bgEdge"
|
||||||
|
|
@ -211,24 +317,51 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Farbstopps -->
|
|
||||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
|
||||||
<span class="lw-settings__card-label">Farbverlauf (je Zeile ein Hex)</span>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
:value="fl.gradientStops"
|
|
||||||
@input="e => update({ gradientStops: e.target.value })"
|
|
||||||
class="lw-settings__gradient-input"
|
|
||||||
rows="4"
|
|
||||||
spellcheck="false"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Extras -->
|
<!-- Extras -->
|
||||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||||
<span class="lw-settings__card-label">Extras</span>
|
<span class="lw-settings__card-label">Extras</span>
|
||||||
|
|
||||||
<div class="lw-settings__row">
|
<div class="lw-settings__row">
|
||||||
|
<span>Horizont</span>
|
||||||
|
</div>
|
||||||
|
<div class="lw-settings__segmented" style="margin-top: 6px;">
|
||||||
|
<button
|
||||||
|
v-for="m in HORIZON_MODES"
|
||||||
|
:key="m.value"
|
||||||
|
class="lw-settings__seg-btn"
|
||||||
|
:class="{ 'lw-settings__seg-btn--active': (fl.horizonMode ?? 'off') === m.value }"
|
||||||
|
@click="update({ horizonMode: m.value })"
|
||||||
|
>{{ m.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isSplit">
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Übergang</span>
|
||||||
|
<span class="lw-settings__value lw-settings__value--hint">
|
||||||
|
{{ (fl.horizonBlend ?? 0.2) < 0.1 ? 'scharf' : (fl.horizonBlend ?? 0.2) > 0.6 ? 'weich' : '' }}
|
||||||
|
{{ (fl.horizonBlend ?? 0.2).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.horizonBlend ?? 0.2"
|
||||||
|
@update:model-value="v => update({ horizonBlend: v })"
|
||||||
|
:min="0" :max="1" :step="0.02"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="(fl.horizonMode ?? 'off') !== 'off'">
|
||||||
|
<div class="lw-settings__row">
|
||||||
|
<span>Deckkraft</span>
|
||||||
|
<span class="lw-settings__value">{{ (fl.horizonOpacity ?? 0.5).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
:model-value="fl.horizonOpacity ?? 0.5"
|
||||||
|
@update:model-value="v => update({ horizonOpacity: v })"
|
||||||
|
:min="0.05" :max="1" :step="0.05"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="lw-settings__row" style="margin-top: 12px;">
|
||||||
<span>{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}</span>
|
<span>{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}</span>
|
||||||
<q-toggle
|
<q-toggle
|
||||||
:model-value="isDark"
|
:model-value="isDark"
|
||||||
|
|
@ -238,6 +371,34 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Presets -->
|
||||||
|
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||||
|
<span class="lw-settings__card-label">Presets</span>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="selectedPresetId"
|
||||||
|
:options="presetOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
options-dense
|
||||||
|
label="Preset auswählen"
|
||||||
|
:dark="isDark"
|
||||||
|
:disable="presetOptions.length === 0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
class="lw-settings__save-preset"
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
icon="bookmark_add"
|
||||||
|
label="Speichern"
|
||||||
|
@click="openPresetDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reset -->
|
<!-- Reset -->
|
||||||
<div class="lw-settings__reset">
|
<div class="lw-settings__reset">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -249,19 +410,54 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="presetDialogOpen">
|
||||||
|
<q-card class="lw-settings__dialog" :class="{ 'lw-settings__dialog--dark': isDark }">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="lw-settings__dialog-title">Preset speichern</div>
|
||||||
|
<div class="lw-settings__dialog-copy">
|
||||||
|
Speichert alle aktuellen Life Wave Settings inklusive Hintergrundbild.
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
v-model="presetName"
|
||||||
|
autofocus
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
label="Name"
|
||||||
|
:dark="isDark"
|
||||||
|
@keyup.enter="confirmSavePreset"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat no-caps label="Abbrechen" v-close-popup />
|
||||||
|
<q-btn unelevated no-caps label="Bestätigen" @click="confirmSavePreset" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { useSettingsStore } from 'stores/settings'
|
import { useSettingsStore } from 'stores/settings'
|
||||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||||
|
|
||||||
const props = defineProps({ open: { type: Boolean, default: false } })
|
const props = defineProps({ open: { type: Boolean, default: false } })
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => emit('close'))
|
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(
|
||||||
|
() => emit('close'),
|
||||||
|
{
|
||||||
|
initialDvh: 50,
|
||||||
|
maxDvh: 50,
|
||||||
|
snapPoints: [50, 25]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(() => props.open, (open) => { if (open) resetHeight() })
|
watch(() => props.open, (open) => { if (open) resetHeight() })
|
||||||
|
|
||||||
|
|
@ -269,20 +465,130 @@ const $q = useQuasar()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const isDark = computed(() => $q.dark.isActive)
|
const isDark = computed(() => $q.dark.isActive)
|
||||||
const fl = computed(() => settingsStore.floatingLines)
|
const fl = computed(() => settingsStore.floatingLines)
|
||||||
|
const isStatic = computed(() => fl.value.lineMode === 'static')
|
||||||
|
const isSplit = computed(() => (fl.value.horizonMode ?? 'off') === 'split')
|
||||||
|
const backgroundUploadRef = ref(null)
|
||||||
|
const presetDialogOpen = ref(false)
|
||||||
|
const presetName = ref('')
|
||||||
|
const presetOptions = computed(() =>
|
||||||
|
settingsStore.presets.map(preset => ({
|
||||||
|
label: preset.name,
|
||||||
|
value: preset.id
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const selectedPresetId = computed({
|
||||||
|
get: () => settingsStore.activePresetId,
|
||||||
|
set: (presetId) => {
|
||||||
|
if (!presetId) return
|
||||||
|
settingsStore.applyPreset(presetId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const isCustomBackground = computed(() => {
|
||||||
|
const bg = fl.value.backgroundImage ?? ''
|
||||||
|
return bg.startsWith('data:image/')
|
||||||
|
})
|
||||||
|
|
||||||
|
const HORIZON_MODES = [
|
||||||
|
{ label: 'Aus', value: 'off' },
|
||||||
|
{ label: 'Nebel', value: 'fog' },
|
||||||
|
{ label: 'Trennung', value: 'split' },
|
||||||
|
{ label: 'Glow', value: 'glow' },
|
||||||
|
]
|
||||||
|
|
||||||
const LABEL_SIZES = [
|
const LABEL_SIZES = [
|
||||||
{ label: 'Klein', value: 'small' },
|
{ label: 'Klein', value: 'small' },
|
||||||
{ label: 'Mittel', value: 'medium' },
|
{ label: 'Mittel', value: 'medium' },
|
||||||
{ label: 'Groß', value: 'large' }
|
{ label: 'Groß', value: 'large' },
|
||||||
|
{ label: 'Extra groß', value: 'xlarge' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function update(changes) {
|
function update(changes) {
|
||||||
settingsStore.updateFloatingLines(changes)
|
settingsStore.updateFloatingLines(changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPresetDialog() {
|
||||||
|
presetName.value = ''
|
||||||
|
presetDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSavePreset() {
|
||||||
|
const saved = settingsStore.savePreset(presetName.value)
|
||||||
|
if (!saved) {
|
||||||
|
$q.notify?.({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Bitte gib einen Namen für das Preset ein.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
presetDialogOpen.value = false
|
||||||
|
$q.notify?.({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Preset "${saved.name}" gespeichert.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDark() {
|
function toggleDark() {
|
||||||
$q.dark.toggle()
|
$q.dark.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBackgroundUpload() {
|
||||||
|
backgroundUploadRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageAsDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result)
|
||||||
|
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => resolve(img)
|
||||||
|
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'))
|
||||||
|
img.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeImageDataUrl(dataUrl) {
|
||||||
|
const img = await loadImage(dataUrl)
|
||||||
|
const MAX_SIDE = 1600
|
||||||
|
const scale = Math.min(1, MAX_SIDE / Math.max(img.width, img.height))
|
||||||
|
const width = Math.max(1, Math.round(img.width * scale))
|
||||||
|
const height = Math.max(1, Math.round(img.height * scale))
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return dataUrl
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.86)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBackgroundUpload(event) {
|
||||||
|
const input = event.target
|
||||||
|
const file = input?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceDataUrl = await readImageAsDataUrl(file)
|
||||||
|
const optimized = await optimizeImageDataUrl(sourceDataUrl)
|
||||||
|
update({ backgroundImage: optimized })
|
||||||
|
} catch {
|
||||||
|
$q.notify?.({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Bild konnte nicht geladen werden.'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Allow selecting the same file again.
|
||||||
|
if (input) input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -292,7 +598,7 @@ function toggleDark() {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
height: 75dvh;
|
height: 50dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-radius: 20px 20px 0 0;
|
border-radius: 20px 20px 0 0;
|
||||||
|
|
@ -374,6 +680,11 @@ function toggleDark() {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lw-settings__value--hint {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Segmented control */
|
/* Segmented control */
|
||||||
.lw-settings__segmented {
|
.lw-settings__segmented {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -398,7 +709,7 @@ function toggleDark() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.lw-settings__seg-btn--active {
|
.lw-settings__seg-btn--active {
|
||||||
background: rgba(168, 85, 247, 0.25);
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.25);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
@ -424,15 +735,19 @@ function toggleDark() {
|
||||||
|
|
||||||
.lw-settings__img-btn:hover {
|
.lw-settings__img-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-color: #a855f7;
|
border-color: var(--tm-accent, #737373);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lw-settings__img-btn--active {
|
.lw-settings__img-btn--active {
|
||||||
border-color: #a855f7;
|
border-color: var(--tm-accent, #737373);
|
||||||
color: #a855f7;
|
color: var(--tm-accent, #737373);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lw-settings__file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.lw-settings__color-input {
|
.lw-settings__color-input {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|
@ -443,6 +758,38 @@ function toggleDark() {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lw-settings__save-preset {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.24);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lw-settings__dialog {
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lw-settings__dialog--dark {
|
||||||
|
background: rgba(30, 30, 30, 0.96);
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lw-settings__dialog-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lw-settings__dialog-copy {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.lw-settings__gradient-input {
|
.lw-settings__gradient-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
|
|
||||||
|
|
@ -142,11 +142,13 @@ const isDark = computed(() => $q.dark.isActive)
|
||||||
|
|
||||||
.modal-card__tab--active {
|
.modal-card__tab--active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(128, 128, 128, 0.18);
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.2);
|
||||||
|
color: var(--tm-accent, #737373);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-card--dark .modal-card__tab--active {
|
.modal-card--dark .modal-card__tab--active {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.25);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Body */
|
/* Body */
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,22 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useEventsStore } from 'stores/events'
|
import { useEventsStore } from 'stores/events'
|
||||||
|
import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings'
|
||||||
import GlowDot from 'components/GlowDot.vue'
|
import GlowDot from 'components/GlowDot.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['dotSelect', 'viewUpdate'])
|
const emit = defineEmits(['dotSelect', 'viewUpdate'])
|
||||||
const eventsStore = useEventsStore()
|
const eventsStore = useEventsStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
const timelineRef = ref(null)
|
const timelineRef = ref(null)
|
||||||
const scrollLeft = ref(0)
|
const scrollLeft = ref(0)
|
||||||
const viewportWidth = ref(400)
|
const viewportWidth = ref(400)
|
||||||
const containerHeight = ref(400)
|
const containerHeight = ref(400)
|
||||||
|
|
||||||
// Zoom: 1.0 = default, range 0.4–3.0
|
// Zoom: 1.0 = default, range 0.4–3.0
|
||||||
const zoomLevel = ref(1)
|
|
||||||
const MIN_ZOOM = 0.4
|
const MIN_ZOOM = 0.4
|
||||||
const MAX_ZOOM = 3.0
|
const MAX_ZOOM = 3.0
|
||||||
const ZOOM_STEP = 0.08
|
const ZOOM_STEP = 0.08
|
||||||
|
const zoomLevel = ref(clampZoom(settingsStore.timelineZoom ?? DEFAULT_TIMELINE_ZOOM))
|
||||||
|
|
||||||
// Spacing: ~4 events visible at a time, scaled by zoom
|
// Spacing: ~4 events visible at a time, scaled by zoom
|
||||||
const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
|
const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
|
||||||
|
|
@ -210,6 +212,8 @@ const stickyYearLabels = computed(() => {
|
||||||
|
|
||||||
// Virtualization: only render events near the viewport
|
// Virtualization: only render events near the viewport
|
||||||
const VIS_BUFFER = 2
|
const VIS_BUFFER = 2
|
||||||
|
const VIEW_EMIT_BUFFER = 3
|
||||||
|
const MAX_SHADER_POINTS = 16
|
||||||
|
|
||||||
const visibleRange = computed(() => {
|
const visibleRange = computed(() => {
|
||||||
const total = displayEvents.value.length
|
const total = displayEvents.value.length
|
||||||
|
|
@ -258,6 +262,8 @@ const activeLabel = computed(() => {
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (timelineRef.value) {
|
if (timelineRef.value) {
|
||||||
scrollLeft.value = timelineRef.value.scrollLeft
|
scrollLeft.value = timelineRef.value.scrollLeft
|
||||||
|
if (restoringScrollFromSettings) return
|
||||||
|
persistScrollPosition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,10 +294,9 @@ function scrollToYearCenter(year) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateViewportWidth() {
|
function updateViewportWidth() {
|
||||||
if (timelineRef.value) {
|
if (!timelineRef.value) return
|
||||||
viewportWidth.value = timelineRef.value.clientWidth || 400
|
viewportWidth.value = timelineRef.value.clientWidth || 400
|
||||||
containerHeight.value = timelineRef.value.clientHeight || 400
|
containerHeight.value = timelineRef.value.clientHeight || 400
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zoom while keeping the viewport center stable
|
// Zoom while keeping the viewport center stable
|
||||||
|
|
@ -316,6 +321,7 @@ function applyZoom(newZoom, centerClientX) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
el.scrollLeft = worldXBefore * ratio - cx
|
el.scrollLeft = worldXBefore * ratio - cx
|
||||||
scrollLeft.value = el.scrollLeft
|
scrollLeft.value = el.scrollLeft
|
||||||
|
persistScrollPosition()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,6 +340,7 @@ function onWheel(e) {
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollLeft += e.deltaX || e.deltaY
|
el.scrollLeft += e.deltaX || e.deltaY
|
||||||
scrollLeft.value = el.scrollLeft
|
scrollLeft.value = el.scrollLeft
|
||||||
|
persistScrollPosition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -369,51 +376,244 @@ function onTouchEnd() {
|
||||||
touchStartDist = 0
|
touchStartDist = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to center on the last event on mount
|
|
||||||
let resizeObserver = null
|
let resizeObserver = null
|
||||||
|
let resizeRafId = 0
|
||||||
|
let isInitialized = false
|
||||||
|
let viewUpdateRafId = 0
|
||||||
|
let restoringZoomFromSettings = false
|
||||||
|
let scrollPersistTimer = 0
|
||||||
|
let restoreScrollRafId = 0
|
||||||
|
let restoringScrollFromSettings = false
|
||||||
|
let initialScrollPositionApplied = false
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampZoom(value) {
|
||||||
|
return clamp(Number(value) || DEFAULT_TIMELINE_ZOOM, MIN_ZOOM, MAX_ZOOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistScrollPosition(immediate = false) {
|
||||||
|
if (!initialScrollPositionApplied || restoringScrollFromSettings) return
|
||||||
|
|
||||||
|
if (scrollPersistTimer) {
|
||||||
|
clearTimeout(scrollPersistTimer)
|
||||||
|
scrollPersistTimer = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
settingsStore.saveTimelineScrollLeft(scrollLeft.value, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPersistTimer = setTimeout(() => {
|
||||||
|
scrollPersistTimer = 0
|
||||||
|
settingsStore.saveTimelineScrollLeft(scrollLeft.value, true)
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSavedScrollPosition() {
|
||||||
|
const el = timelineRef.value
|
||||||
|
if (!el) return false
|
||||||
|
|
||||||
|
const hasSavedScrollLeft = settingsStore.timelineScrollLeft !== null && settingsStore.timelineScrollLeft !== undefined
|
||||||
|
const savedScrollLeft = Number(settingsStore.timelineScrollLeft)
|
||||||
|
if (!hasSavedScrollLeft || !Number.isFinite(savedScrollLeft)) return false
|
||||||
|
|
||||||
|
const maxScroll = Math.max(0, trackWidth.value - viewportWidth.value)
|
||||||
|
if (maxScroll <= 0 && savedScrollLeft > 0) return false
|
||||||
|
|
||||||
|
restoringScrollFromSettings = true
|
||||||
|
el.scrollLeft = clamp(savedScrollLeft, 0, maxScroll)
|
||||||
|
scrollLeft.value = el.scrollLeft
|
||||||
|
initialScrollPositionApplied = true
|
||||||
|
nextTick(() => {
|
||||||
|
restoringScrollFromSettings = false
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInitialScrollPosition() {
|
||||||
|
if (initialScrollPositionApplied || !timelineRef.value) return
|
||||||
|
updateViewportWidth()
|
||||||
|
|
||||||
|
if (restoreSavedScrollPosition()) return
|
||||||
|
|
||||||
|
const events = displayEvents.value
|
||||||
|
const maxScroll = Math.max(0, trackWidth.value - viewportWidth.value)
|
||||||
|
if (events.length === 0 || maxScroll <= 0) return
|
||||||
|
|
||||||
|
const lastX = getEventX(events.length - 1)
|
||||||
|
timelineRef.value.scrollLeft = clamp(lastX - viewportWidth.value / 2, 0, maxScroll)
|
||||||
|
scrollLeft.value = timelineRef.value.scrollLeft
|
||||||
|
initialScrollPositionApplied = true
|
||||||
|
persistScrollPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalculateLayoutAfterResize() {
|
||||||
|
const el = timelineRef.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
if (!initialScrollPositionApplied) {
|
||||||
|
updateViewportWidth()
|
||||||
|
applyInitialScrollPosition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve center in event-index space so dots/labels stay aligned after resize.
|
||||||
|
const oldViewport = viewportWidth.value || 1
|
||||||
|
const oldPadding = PADDING.value
|
||||||
|
const oldSpacing = EVENT_SPACING.value || 1
|
||||||
|
const oldCenterWorld = el.scrollLeft + oldViewport / 2
|
||||||
|
const centerIndex = (oldCenterWorld - oldPadding) / oldSpacing
|
||||||
|
|
||||||
|
updateViewportWidth()
|
||||||
|
|
||||||
|
const newPadding = PADDING.value
|
||||||
|
const newSpacing = EVENT_SPACING.value || 1
|
||||||
|
const newCenterWorld = newPadding + centerIndex * newSpacing
|
||||||
|
const maxScroll = Math.max(0, trackWidth.value - viewportWidth.value)
|
||||||
|
el.scrollLeft = clamp(newCenterWorld - viewportWidth.value / 2, 0, maxScroll)
|
||||||
|
scrollLeft.value = el.scrollLeft
|
||||||
|
persistScrollPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceReflow() {
|
||||||
|
recalculateLayoutAfterResize()
|
||||||
|
emitViewState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
persistScrollPosition(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShaderEvents() {
|
||||||
|
const total = displayEvents.value.length
|
||||||
|
if (total === 0) return []
|
||||||
|
|
||||||
|
const { start, end } = visibleRange.value
|
||||||
|
if (end < start) return []
|
||||||
|
|
||||||
|
const rangeStart = Math.max(0, start - VIEW_EMIT_BUFFER)
|
||||||
|
const rangeEnd = Math.min(total - 1, end + VIEW_EMIT_BUFFER)
|
||||||
|
let windowStart = rangeStart
|
||||||
|
let windowEnd = rangeEnd
|
||||||
|
|
||||||
|
if (windowEnd - windowStart + 1 > MAX_SHADER_POINTS) {
|
||||||
|
const centerIndex = Math.floor((start + end) / 2)
|
||||||
|
const maxStart = Math.max(rangeStart, rangeEnd - MAX_SHADER_POINTS + 1)
|
||||||
|
windowStart = Math.max(
|
||||||
|
rangeStart,
|
||||||
|
Math.min(maxStart, centerIndex - Math.floor(MAX_SHADER_POINTS / 2))
|
||||||
|
)
|
||||||
|
windowEnd = windowStart + MAX_SHADER_POINTS - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayEvents.value.slice(windowStart, windowEnd + 1).map((event, i) => ({
|
||||||
|
emotion: event.emotion,
|
||||||
|
x: getEventX(windowStart + i),
|
||||||
|
color: eventsStore.getGlowColor(event)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// Emit timeline state so the layout can position shader points
|
// Emit timeline state so the layout can position shader points
|
||||||
function emitViewState() {
|
function emitViewState() {
|
||||||
const { start, end } = visibleRange.value
|
const { start, end } = visibleRange.value
|
||||||
|
if (viewUpdateRafId) {
|
||||||
|
cancelAnimationFrame(viewUpdateRafId)
|
||||||
|
viewUpdateRafId = 0
|
||||||
|
}
|
||||||
emit('viewUpdate', {
|
emit('viewUpdate', {
|
||||||
scrollLeft: scrollLeft.value,
|
scrollLeft: scrollLeft.value,
|
||||||
viewportWidth: viewportWidth.value,
|
viewportWidth: viewportWidth.value,
|
||||||
containerHeight: containerHeight.value,
|
containerHeight: containerHeight.value,
|
||||||
visibleStart: start,
|
visibleStart: start,
|
||||||
visibleEnd: end,
|
visibleEnd: end,
|
||||||
events: displayEvents.value.map((e, i) => ({
|
events: getShaderEvents()
|
||||||
emotion: e.emotion,
|
|
||||||
x: getEventX(i),
|
|
||||||
color: eventsStore.getGlowColor(e)
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleViewStateEmit() {
|
||||||
|
if (viewUpdateRafId) return
|
||||||
|
viewUpdateRafId = requestAnimationFrame(emitViewState)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
|
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
|
||||||
emitViewState,
|
scheduleViewStateEmit
|
||||||
{ deep: true }
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[displayEvents, trackWidth, viewportWidth],
|
||||||
|
() => {
|
||||||
|
nextTick(applyInitialScrollPosition)
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(zoomLevel, (value) => {
|
||||||
|
if (restoringZoomFromSettings) return
|
||||||
|
settingsStore.timelineZoom = clampZoom(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => settingsStore.timelineZoom, (value) => {
|
||||||
|
const nextZoom = clampZoom(value)
|
||||||
|
if (nextZoom === zoomLevel.value) return
|
||||||
|
|
||||||
|
restoringZoomFromSettings = true
|
||||||
|
if (timelineRef.value) {
|
||||||
|
applyZoom(nextZoom)
|
||||||
|
} else {
|
||||||
|
zoomLevel.value = nextZoom
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
restoringZoomFromSettings = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => settingsStore.emotionGradientStart, () => settingsStore.emotionGradientEnd],
|
||||||
|
scheduleViewStateEmit
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!timelineRef.value) return
|
if (!timelineRef.value) return
|
||||||
updateViewportWidth()
|
updateViewportWidth()
|
||||||
|
applyInitialScrollPosition()
|
||||||
|
|
||||||
const events = displayEvents.value
|
// Resize observer runs once per frame and keeps timeline center stable.
|
||||||
if (events.length === 0) return
|
isInitialized = true
|
||||||
const lastX = getEventX(events.length - 1)
|
resizeObserver = new ResizeObserver(() => {
|
||||||
timelineRef.value.scrollLeft = lastX - viewportWidth.value / 2
|
if (!isInitialized) return
|
||||||
scrollLeft.value = timelineRef.value.scrollLeft
|
if (resizeRafId) cancelAnimationFrame(resizeRafId)
|
||||||
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
// Update viewport width on resize
|
resizeRafId = 0
|
||||||
resizeObserver = new ResizeObserver(updateViewportWidth)
|
recalculateLayoutAfterResize()
|
||||||
|
})
|
||||||
|
})
|
||||||
resizeObserver.observe(timelineRef.value)
|
resizeObserver.observe(timelineRef.value)
|
||||||
|
window.addEventListener('pagehide', onPageHide)
|
||||||
|
window.addEventListener('beforeunload', onPageHide)
|
||||||
|
|
||||||
|
// ResizeObserver can fire immediately after mount; restore once more after layout settles.
|
||||||
|
restoreScrollRafId = requestAnimationFrame(() => {
|
||||||
|
restoreScrollRafId = 0
|
||||||
|
applyInitialScrollPosition()
|
||||||
|
})
|
||||||
|
|
||||||
// Emit initial state
|
// Emit initial state
|
||||||
emitViewState()
|
emitViewState()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (resizeRafId) cancelAnimationFrame(resizeRafId)
|
||||||
|
if (viewUpdateRafId) cancelAnimationFrame(viewUpdateRafId)
|
||||||
|
if (restoreScrollRafId) cancelAnimationFrame(restoreScrollRafId)
|
||||||
|
window.removeEventListener('pagehide', onPageHide)
|
||||||
|
window.removeEventListener('beforeunload', onPageHide)
|
||||||
|
persistScrollPosition(true)
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -427,7 +627,12 @@ function zoomOut() {
|
||||||
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ timelineRef, zoomIn, zoomOut, zoomLevel, MIN_ZOOM, MAX_ZOOM })
|
function zoomTo(value) {
|
||||||
|
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value))
|
||||||
|
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZOOM, forceReflow })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
<!-- User header -->
|
<!-- User header -->
|
||||||
<div class="user-menu__header">
|
<div class="user-menu__header">
|
||||||
<div class="user-menu__avatar">
|
<div class="user-menu__avatar">
|
||||||
<span>K</span>
|
<span>{{ authStore.currentUser?.avatar ?? '?' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-menu__info">
|
<div class="user-menu__info">
|
||||||
<div class="user-menu__name">k-adam</div>
|
<div class="user-menu__name">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
|
||||||
<div class="user-menu__handle">@k-adam</div>
|
<div class="user-menu__handle">{{ authStore.currentUser?.email ?? '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -92,11 +92,11 @@
|
||||||
<!-- Footer: user + plan -->
|
<!-- Footer: user + plan -->
|
||||||
<div class="user-menu__footer">
|
<div class="user-menu__footer">
|
||||||
<div class="user-menu__avatar user-menu__avatar--sm">
|
<div class="user-menu__avatar user-menu__avatar--sm">
|
||||||
<span>K</span>
|
<span>{{ authStore.currentUser?.avatar ?? '?' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-menu__info">
|
<div class="user-menu__info">
|
||||||
<div class="user-menu__name user-menu__name--sm">Kevin Ada</div>
|
<div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
|
||||||
<div class="user-menu__plan">Free</div>
|
<div class="user-menu__plan">Demo Account</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,10 +105,12 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
defineProps({ open: { type: Boolean, default: false } })
|
defineProps({ open: { type: Boolean, default: false } })
|
||||||
defineEmits(['close', 'navigate'])
|
defineEmits(['close', 'navigate'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
const helpOpen = ref(false)
|
const helpOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -215,11 +217,11 @@ const helpOpen = ref(false)
|
||||||
|
|
||||||
.user-menu__item:hover,
|
.user-menu__item:hover,
|
||||||
.user-menu__item--active {
|
.user-menu__item--active {
|
||||||
background: rgba(128, 128, 128, 0.12);
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu__item:active {
|
.user-menu__item:active {
|
||||||
background: rgba(128, 128, 128, 0.2);
|
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu__item--sub {
|
.user-menu__item--sub {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ref } from 'vue'
|
import { ref, unref, watch } from 'vue'
|
||||||
import { db } from 'src/db'
|
import { db } from 'src/db'
|
||||||
|
|
||||||
const THUMB_SIZE = 200
|
const THUMB_SIZE = 200
|
||||||
|
|
@ -101,41 +101,52 @@ async function getCachedImage(imageUrl) {
|
||||||
export function useImageCache(imageUrl, eventId) {
|
export function useImageCache(imageUrl, eventId) {
|
||||||
const resolvedSrc = ref(null)
|
const resolvedSrc = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
let requestId = 0
|
||||||
|
|
||||||
async function resolve() {
|
async function resolve(nextUrl = unref(imageUrl)) {
|
||||||
if (!imageUrl) {
|
const currentRequestId = ++requestId
|
||||||
|
|
||||||
|
if (!nextUrl) {
|
||||||
resolvedSrc.value = null
|
resolvedSrc.value = null
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Memory cache (instant)
|
// 1. Memory cache (instant)
|
||||||
if (memoryCache.has(imageUrl)) {
|
if (memoryCache.has(nextUrl)) {
|
||||||
resolvedSrc.value = memoryCache.get(imageUrl)
|
resolvedSrc.value = memoryCache.get(nextUrl)
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. IndexedDB cache
|
// 2. IndexedDB cache
|
||||||
const cached = await getCachedImage(imageUrl)
|
const cached = await getCachedImage(nextUrl)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
if (currentRequestId !== requestId) return
|
||||||
resolvedSrc.value = cached
|
resolvedSrc.value = cached
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch, create thumbnail, cache
|
// 3. Fetch, create thumbnail, cache
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const blobUrl = await fetchAndCache(imageUrl, eventId)
|
const blobUrl = await fetchAndCache(nextUrl, unref(eventId))
|
||||||
|
if (currentRequestId !== requestId) return
|
||||||
resolvedSrc.value = blobUrl
|
resolvedSrc.value = blobUrl
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback: use original URL directly (works when online)
|
// Fallback: use original URL directly (works when online)
|
||||||
console.warn('Image cache failed, using direct URL:', e)
|
console.warn('Image cache failed, using direct URL:', e)
|
||||||
resolvedSrc.value = imageUrl
|
if (currentRequestId !== requestId) return
|
||||||
|
resolvedSrc.value = nextUrl
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (currentRequestId === requestId) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve()
|
watch(() => unref(imageUrl), (nextUrl) => {
|
||||||
|
resolve(nextUrl)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
return { resolvedSrc, loading }
|
return { resolvedSrc, loading }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,19 @@ import { ref, onBeforeUnmount } from 'vue'
|
||||||
/**
|
/**
|
||||||
* Composable for draggable bottom-sheet panels with snap points.
|
* Composable for draggable bottom-sheet panels with snap points.
|
||||||
*
|
*
|
||||||
* Snap stops (in dvh): 100, 75, 50
|
* Default snap stops (in dvh): 100, 75, 50
|
||||||
* Close threshold: below 25dvh
|
* Close threshold: below 25dvh
|
||||||
*
|
*
|
||||||
* @param {Function} onClose - called when panel is dragged below threshold
|
* @param {Function} onClose - called when panel is dragged below threshold
|
||||||
|
* @param {Object} options - drag/snap behavior overrides
|
||||||
* @returns {{ panelHeight, handleListeners, resetHeight }}
|
* @returns {{ panelHeight, handleListeners, resetHeight }}
|
||||||
*/
|
*/
|
||||||
export function usePanelDrag(onClose) {
|
export function usePanelDrag(onClose, options = {}) {
|
||||||
const SNAP_POINTS = [100, 75, 50, 25] // dvh values
|
const SNAP_POINTS = options.snapPoints ?? [100, 75, 50, 25] // dvh values
|
||||||
const CLOSE_THRESHOLD = 15 // below this → close
|
const CLOSE_THRESHOLD = options.closeThreshold ?? 15 // below this → close
|
||||||
|
const INITIAL_DVH = options.initialDvh ?? 75
|
||||||
|
const MIN_DVH = options.minDvh ?? 10
|
||||||
|
const MAX_DVH = options.maxDvh ?? 100
|
||||||
|
|
||||||
// Current panel height in dvh (null = use CSS default)
|
// Current panel height in dvh (null = use CSS default)
|
||||||
const panelHeight = ref(null)
|
const panelHeight = ref(null)
|
||||||
|
|
@ -52,7 +56,7 @@ export function usePanelDrag(onClose) {
|
||||||
startY = clientY
|
startY = clientY
|
||||||
|
|
||||||
// Current height: if panelHeight is set use it, else measure from CSS
|
// Current height: if panelHeight is set use it, else measure from CSS
|
||||||
const currentDvh = panelHeight.value ?? 75
|
const currentDvh = panelHeight.value ?? INITIAL_DVH
|
||||||
startHeight = currentDvh
|
startHeight = currentDvh
|
||||||
|
|
||||||
document.addEventListener('pointermove', onPointerMove, { passive: false })
|
document.addEventListener('pointermove', onPointerMove, { passive: false })
|
||||||
|
|
@ -80,7 +84,7 @@ export function usePanelDrag(onClose) {
|
||||||
function handleMove(clientY) {
|
function handleMove(clientY) {
|
||||||
const deltaY = clientY - startY
|
const deltaY = clientY - startY
|
||||||
const deltaDvh = pxToDvh(deltaY)
|
const deltaDvh = pxToDvh(deltaY)
|
||||||
const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh))
|
const newHeight = Math.max(MIN_DVH, Math.min(MAX_DVH, startHeight - deltaDvh))
|
||||||
panelHeight.value = newHeight
|
panelHeight.value = newHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +103,7 @@ export function usePanelDrag(onClose) {
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
||||||
const currentHeight = panelHeight.value ?? 75
|
const currentHeight = panelHeight.value ?? INITIAL_DVH
|
||||||
if (currentHeight < CLOSE_THRESHOLD) {
|
if (currentHeight < CLOSE_THRESHOLD) {
|
||||||
panelHeight.value = null
|
panelHeight.value = null
|
||||||
onClose()
|
onClose()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
|
:root {
|
||||||
|
--tm-accent: #737373;
|
||||||
|
--tm-accent-rgb: 115, 115, 115;
|
||||||
|
--q-primary: #737373;
|
||||||
|
--q-secondary: #737373;
|
||||||
|
--q-accent: #737373;
|
||||||
|
}
|
||||||
|
|
||||||
// Glass button style
|
// Glass button style
|
||||||
.glass--button {
|
.glass--button {
|
||||||
background: rgba(128, 128, 128, 0.1);
|
background: rgba(128, 128, 128, 0.1);
|
||||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
border: 1px solid rgba(var(--tm-accent-rgb), 0.24);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
|
|
@ -18,7 +26,7 @@
|
||||||
// Glass panel style — strong blur for slide-up panels
|
// Glass panel style — strong blur for slide-up panels
|
||||||
.glass--panel {
|
.glass--panel {
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
border-top: 1px solid rgba(var(--tm-accent-rgb), 0.24);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,19 @@ db.version(1).stores({
|
||||||
// Metadata: key-value pairs (lastSyncCursor, userId, etc.)
|
// Metadata: key-value pairs (lastSyncCursor, userId, etc.)
|
||||||
meta: 'key'
|
meta: 'key'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
db.version(2).stores({
|
||||||
|
// Events are locally namespaced by userId until the backend owns persistence.
|
||||||
|
events: 'id, userId, [userId+date], updatedAt, syncStatus',
|
||||||
|
syncQueue: '++queueId, userId, eventId, action, createdAt',
|
||||||
|
imageCache: 'url, eventId, type, cachedAt',
|
||||||
|
meta: 'key'
|
||||||
|
})
|
||||||
|
|
||||||
|
db.version(3).stores({
|
||||||
|
events: 'id, userId, [userId+date], updatedAt, syncStatus',
|
||||||
|
syncQueue: '++queueId, userId, eventId, action, createdAt',
|
||||||
|
imageCache: 'url, eventId, type, cachedAt',
|
||||||
|
eventMedia: 'id, eventId, userId, createdAt',
|
||||||
|
meta: 'key'
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
:line-spread="fl.spread"
|
:line-spread="fl.spread"
|
||||||
:fan-spread="fl.fanSpread"
|
:fan-spread="fl.fanSpread"
|
||||||
:line-sharpness="fl.lineSharpness"
|
:line-sharpness="fl.lineSharpness"
|
||||||
|
:line-thickness="fl.lineThickness ?? 1"
|
||||||
:wave-frequency="fl.waveFrequency"
|
:wave-frequency="fl.waveFrequency"
|
||||||
:bezier-curvature="fl.bezierCurvature"
|
:bezier-curvature="fl.bezierCurvature"
|
||||||
:circle-radius-px="fl.circleRadius"
|
:circle-radius-px="fl.circleRadius"
|
||||||
|
|
@ -25,7 +26,13 @@
|
||||||
:bg-color-center="fl.bgCenter"
|
:bg-color-center="fl.bgCenter"
|
||||||
:bg-color-edge="fl.bgEdge"
|
:bg-color-edge="fl.bgEdge"
|
||||||
:background-image="fl.backgroundImage"
|
:background-image="fl.backgroundImage"
|
||||||
:mix-blend-mode="'screen'"
|
:mix-blend-mode="fl.lineMode === 'static' ? 'normal' : 'screen'"
|
||||||
|
:horizon-mode="fl.horizonMode ?? 'off'"
|
||||||
|
:horizon-opacity="fl.horizonOpacity ?? 0.5"
|
||||||
|
:horizon-blend="fl.horizonBlend ?? 0.2"
|
||||||
|
:line-mode="fl.lineMode ?? 'glow'"
|
||||||
|
:static-line-color="fl.staticLineColor ?? '#2196F3'"
|
||||||
|
:static-line-shadow-strength="fl.staticLineShadowStrength ?? 0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Scrollable Timeline -->
|
<!-- Scrollable Timeline -->
|
||||||
|
|
@ -142,6 +149,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import AddEventButton from 'components/AddEventButton.vue'
|
import AddEventButton from 'components/AddEventButton.vue'
|
||||||
import EventPanel from 'components/EventPanel.vue'
|
import EventPanel from 'components/EventPanel.vue'
|
||||||
import FloatingLines from 'components/FloatingLines.vue'
|
import FloatingLines from 'components/FloatingLines.vue'
|
||||||
|
|
@ -150,10 +158,13 @@ import TimelineView from 'components/TimelineView.vue'
|
||||||
import AppSettingsModal from 'components/AppSettingsModal.vue'
|
import AppSettingsModal from 'components/AppSettingsModal.vue'
|
||||||
import UserMenu from 'components/UserMenu.vue'
|
import UserMenu from 'components/UserMenu.vue'
|
||||||
import ZoomControl from 'components/ZoomControl.vue'
|
import ZoomControl from 'components/ZoomControl.vue'
|
||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
import { useEventsStore } from 'stores/events'
|
import { useEventsStore } from 'stores/events'
|
||||||
import { useSettingsStore } from 'stores/settings'
|
import { useSettingsStore } from 'stores/settings'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const eventsStore = useEventsStore()
|
const eventsStore = useEventsStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const isDark = computed(() => $q.dark.isActive)
|
const isDark = computed(() => $q.dark.isActive)
|
||||||
|
|
@ -165,27 +176,61 @@ const fl = computed(() => settingsStore.floatingLines)
|
||||||
|
|
||||||
// Timeline view ref (for direct scroll access in render loop)
|
// Timeline view ref (for direct scroll access in render loop)
|
||||||
const timelineViewRef = ref(null)
|
const timelineViewRef = ref(null)
|
||||||
const scrollContainerEl = computed(() => timelineViewRef.value?.timelineRef ?? null)
|
const scrollContainerEl = computed(() => {
|
||||||
|
const exposed = timelineViewRef.value?.timelineRef
|
||||||
|
if (!exposed) return null
|
||||||
|
const el = exposed.value ?? exposed
|
||||||
|
return el && typeof el.addEventListener === 'function' ? el : null
|
||||||
|
})
|
||||||
|
|
||||||
// Layout dimensions (for screen→UV conversion)
|
// Layout dimensions (for screen→UV conversion)
|
||||||
const layoutRef = ref(null)
|
const layoutRef = ref(null)
|
||||||
const layoutWidth = ref(window.innerWidth)
|
const layoutWidth = ref(window.innerWidth)
|
||||||
const layoutHeight = ref(window.innerHeight)
|
const layoutHeight = ref(window.innerHeight)
|
||||||
let layoutResizeObserver = null
|
let layoutResizeObserver = null
|
||||||
|
let hardResizeTimer = null
|
||||||
|
let hardResizeRaf = 0
|
||||||
|
|
||||||
|
// After resize settles, force both timeline math and shader canvas to resync.
|
||||||
|
function runHardResizeSync() {
|
||||||
|
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
|
||||||
|
hardResizeRaf = requestAnimationFrame(() => {
|
||||||
|
hardResizeRaf = 0
|
||||||
|
if (layoutRef.value) {
|
||||||
|
layoutWidth.value = layoutRef.value.clientWidth
|
||||||
|
layoutHeight.value = layoutRef.value.clientHeight
|
||||||
|
}
|
||||||
|
timelineViewRef.value?.forceReflow?.()
|
||||||
|
floatingLinesRef.value?.forceResize?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowResize() {
|
||||||
|
if (hardResizeTimer) clearTimeout(hardResizeTimer)
|
||||||
|
hardResizeTimer = setTimeout(runHardResizeSync, 120)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (layoutRef.value) {
|
if (layoutRef.value) {
|
||||||
layoutWidth.value = layoutRef.value.clientWidth
|
layoutWidth.value = layoutRef.value.clientWidth
|
||||||
layoutHeight.value = layoutRef.value.clientHeight
|
layoutHeight.value = layoutRef.value.clientHeight
|
||||||
layoutResizeObserver = new ResizeObserver(() => {
|
layoutResizeObserver = new ResizeObserver(() => {
|
||||||
layoutWidth.value = layoutRef.value.clientWidth
|
// Read fresh dimensions in the next frame to avoid transient layout values.
|
||||||
layoutHeight.value = layoutRef.value.clientHeight
|
requestAnimationFrame(() => {
|
||||||
|
if (!layoutRef.value) return
|
||||||
|
layoutWidth.value = layoutRef.value.clientWidth
|
||||||
|
layoutHeight.value = layoutRef.value.clientHeight
|
||||||
|
})
|
||||||
})
|
})
|
||||||
layoutResizeObserver.observe(layoutRef.value)
|
layoutResizeObserver.observe(layoutRef.value)
|
||||||
}
|
}
|
||||||
|
window.addEventListener('resize', onWindowResize, { passive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', onWindowResize)
|
||||||
|
if (hardResizeTimer) clearTimeout(hardResizeTimer)
|
||||||
|
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
|
||||||
layoutResizeObserver?.disconnect()
|
layoutResizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -213,30 +258,13 @@ function screenToUV(sx, sy) {
|
||||||
// Compute shader point positions from event positions
|
// Compute shader point positions from event positions
|
||||||
const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px }
|
const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px }
|
||||||
|
|
||||||
// Select up to 8 points from visible window + boundary events for shader lines
|
// Select up to 16 points for shader lines (shader uniform limit).
|
||||||
|
// TimelineView already emits a small contiguous window around the viewport.
|
||||||
const shaderSelection = computed(() => {
|
const shaderSelection = computed(() => {
|
||||||
if (!timelineState.value) return []
|
if (!timelineState.value) return []
|
||||||
const { events, visibleStart, visibleEnd } = timelineState.value
|
const { events } = timelineState.value
|
||||||
if (events.length === 0) return []
|
if (events.length === 0) return []
|
||||||
|
return events.slice(0, 16)
|
||||||
// Include 3 events before and after visible range for smooth line continuity
|
|
||||||
const rangeStart = Math.max(0, (visibleStart ?? 0) - 3)
|
|
||||||
const rangeEnd = Math.min(events.length - 1, (visibleEnd ?? events.length - 1) + 3)
|
|
||||||
|
|
||||||
let candidates = events.slice(rangeStart, rangeEnd + 1)
|
|
||||||
|
|
||||||
// If more than 16, subsample evenly (keep first + last)
|
|
||||||
if (candidates.length > 16) {
|
|
||||||
const sampled = [candidates[0]]
|
|
||||||
const step = (candidates.length - 1) / 15
|
|
||||||
for (let i = 1; i < 15; i++) {
|
|
||||||
sampled.push(candidates[Math.round(i * step)])
|
|
||||||
}
|
|
||||||
sampled.push(candidates[candidates.length - 1])
|
|
||||||
candidates = sampled
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const shaderNumPoints = computed(() => shaderSelection.value.length)
|
const shaderNumPoints = computed(() => shaderSelection.value.length)
|
||||||
|
|
@ -290,19 +318,7 @@ const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0)
|
||||||
function onZoomTo(value) {
|
function onZoomTo(value) {
|
||||||
if (!timelineViewRef.value) return
|
if (!timelineViewRef.value) return
|
||||||
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
|
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
|
||||||
// Use applyZoom exposed or set directly — we use the internal method indirectly
|
timelineViewRef.value.zoomTo?.(clamped)
|
||||||
// by computing step from current to target
|
|
||||||
const tv = timelineViewRef.value
|
|
||||||
const el = tv.timelineRef
|
|
||||||
if (!el) return
|
|
||||||
const cx = el.clientWidth / 2
|
|
||||||
const worldX = el.scrollLeft + cx
|
|
||||||
const ratio = clamped / tv.zoomLevel
|
|
||||||
tv.zoomLevel = clamped
|
|
||||||
// Restore scroll position to keep center stable
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.scrollLeft = worldX * ratio - cx
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
|
|
@ -322,6 +338,9 @@ const onUserMenuNavigate = (target) => {
|
||||||
userMenuOpen.value = false
|
userMenuOpen.value = false
|
||||||
if (target === 'settings') {
|
if (target === 'settings') {
|
||||||
appSettingsOpen.value = true
|
appSettingsOpen.value = true
|
||||||
|
} else if (target === 'logout') {
|
||||||
|
authStore.logout()
|
||||||
|
router.replace('/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
5
frontend/src/legacy/README.md
Normal file
5
frontend/src/legacy/README.md
Normal 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.
|
||||||
|
|
@ -1,68 +1,216 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding class="flex flex-center">
|
<div class="login-page">
|
||||||
<q-card style="width: 350px">
|
<div class="login-page__bg" />
|
||||||
<q-card-section>
|
<form class="login-card" @submit.prevent="onSubmit">
|
||||||
<div class="text-h6">Login</div>
|
<div class="login-card__brand">That's Me</div>
|
||||||
</q-card-section>
|
<h1>Login</h1>
|
||||||
|
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
|
||||||
<q-card-section>
|
|
||||||
<q-form @submit.prevent="onSubmit" class="q-gutter-md">
|
<label class="login-field">
|
||||||
<q-input
|
<span>E-Mail</span>
|
||||||
v-model="email"
|
<select v-model="email">
|
||||||
type="email"
|
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
|
||||||
label="Email"
|
{{ user.email }}
|
||||||
filled
|
</option>
|
||||||
:rules="[val => !!val || 'Email is required']"
|
</select>
|
||||||
/>
|
<q-icon name="person_outline" size="22px" />
|
||||||
|
</label>
|
||||||
<q-input
|
|
||||||
v-model="password"
|
<label class="login-field">
|
||||||
type="password"
|
<span>Passwort</span>
|
||||||
label="Password"
|
<input
|
||||||
filled
|
v-model="password"
|
||||||
:rules="[val => !!val || 'Password is required']"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
/>
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
<div class="q-mt-md">
|
<button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
|
||||||
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
|
<q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
|
||||||
</div>
|
</button>
|
||||||
|
</label>
|
||||||
<div class="text-center q-mt-sm">
|
|
||||||
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
|
<label class="login-remember">
|
||||||
</div>
|
<input v-model="remember" type="checkbox">
|
||||||
</q-form>
|
<span>Remember me</span>
|
||||||
</q-card-section>
|
</label>
|
||||||
</q-card>
|
|
||||||
</q-page>
|
<div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
|
||||||
|
|
||||||
|
<button class="login-submit" type="submit">Login</button>
|
||||||
|
|
||||||
|
<div class="login-card__hint">
|
||||||
|
Demo-Accounts: <strong>user1-user5@thats-me.app</strong>, Passwort <strong>pass</strong>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
export default {
|
const authStore = useAuthStore()
|
||||||
name: 'LoginPage',
|
const router = useRouter()
|
||||||
setup() {
|
const route = useRoute()
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
const email = ref(authStore.users[0]?.email ?? '')
|
||||||
|
const password = ref('pass')
|
||||||
const onSubmit = () => {
|
const showPassword = ref(false)
|
||||||
console.log('Login attempt with:', email.value, password.value)
|
const remember = ref(true)
|
||||||
|
|
||||||
// Save login status
|
function onSubmit() {
|
||||||
localStorage.setItem('isLoggedIn', 'true')
|
if (!authStore.login(email.value, password.value)) return
|
||||||
window.dispatchEvent(new Event('storage'))
|
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
|
||||||
|
|
||||||
console.log('Redirecting to wave page...')
|
|
||||||
|
|
||||||
// Direct navigation
|
|
||||||
window.location.href = '/#/wave'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
onSubmit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #162914;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.35), transparent 28%),
|
||||||
|
linear-gradient(135deg, rgba(213, 218, 98, 0.9), rgba(33, 181, 113, 0.75) 45%, rgba(20, 68, 28, 0.95));
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
filter: saturate(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__bg::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)),
|
||||||
|
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
padding: 40px 34px 28px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(22, 29, 25, 0.32);
|
||||||
|
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__brand {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card p {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field span {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 6px 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field input,
|
||||||
|
.login-field select {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 52px 0 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.48);
|
||||||
|
border-radius: 14px;
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field select option {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field > .q-icon,
|
||||||
|
.login-field__icon-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field__icon-btn {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 0 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-remember input {
|
||||||
|
accent-color: #6ce36c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #ffd7d7;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit {
|
||||||
|
width: 100%;
|
||||||
|
height: 58px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(90deg, #cfdd34, #18c77a);
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 14px 35px rgba(16, 188, 102, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__hint {
|
||||||
|
margin-top: 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineRouter } from '#q-app/wrappers'
|
import { defineRouter } from '#q-app/wrappers'
|
||||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||||
import routes from './routes'
|
import routes from './routes'
|
||||||
|
import { AUTH_STORAGE_KEY, DEMO_USERS } from 'stores/auth'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If not building with SSR mode, you can
|
* If not building with SSR mode, you can
|
||||||
|
|
@ -26,5 +27,28 @@ export default defineRouter(function (/* { store, ssrContext } */) {
|
||||||
history: createHistory(process.env.VUE_ROUTER_BASE)
|
history: createHistory(process.env.VUE_ROUTER_BASE)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Router.beforeEach((to) => {
|
||||||
|
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||||
|
let userId = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
userId = stored ? JSON.parse(stored)?.userId ?? null : null
|
||||||
|
} catch {
|
||||||
|
userId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = DEMO_USERS.some(user => user.id === userId)
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path === '/login' && isAuthenticated) {
|
||||||
|
return { path: '/' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return Router
|
return Router
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
const routes = [
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
component: () => import('pages/LoginPage.vue'),
|
||||||
|
meta: { public: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('layouts/LifeWaveLayout.vue'),
|
component: () => import('layouts/LifeWaveLayout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: () => import('pages/LifeWavePage.vue') }
|
{ path: '', component: () => import('pages/LifeWavePage.vue') }
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,12 @@ async function pullRemoteChanges() {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
title: remote.title,
|
title: remote.title,
|
||||||
date: remote.date,
|
date: remote.date,
|
||||||
|
location: remote.location ?? '',
|
||||||
emotion: remote.emotion,
|
emotion: remote.emotion,
|
||||||
customColor: remote.customColor,
|
customColor: remote.customColor,
|
||||||
gradientPreset: remote.gradientPreset,
|
gradientPreset: remote.gradientPreset,
|
||||||
|
gradientStartColor: remote.gradientStartColor ?? null,
|
||||||
|
gradientEndColor: remote.gradientEndColor ?? null,
|
||||||
image: remote.image,
|
image: remote.image,
|
||||||
note: remote.note,
|
note: remote.note,
|
||||||
syncStatus: 'synced',
|
syncStatus: 'synced',
|
||||||
|
|
@ -181,9 +184,12 @@ async function pullRemoteChanges() {
|
||||||
await db.events.update(remote.id, {
|
await db.events.update(remote.id, {
|
||||||
title: remote.title,
|
title: remote.title,
|
||||||
date: remote.date,
|
date: remote.date,
|
||||||
|
location: remote.location ?? '',
|
||||||
emotion: remote.emotion,
|
emotion: remote.emotion,
|
||||||
customColor: remote.customColor,
|
customColor: remote.customColor,
|
||||||
gradientPreset: remote.gradientPreset,
|
gradientPreset: remote.gradientPreset,
|
||||||
|
gradientStartColor: remote.gradientStartColor ?? null,
|
||||||
|
gradientEndColor: remote.gradientEndColor ?? null,
|
||||||
image: remote.image,
|
image: remote.image,
|
||||||
note: remote.note,
|
note: remote.note,
|
||||||
syncStatus: 'synced',
|
syncStatus: 'synced',
|
||||||
|
|
|
||||||
67
frontend/src/stores/auth.js
Normal file
67
frontend/src/stores/auth.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import Dexie from 'dexie'
|
||||||
import { db } from 'src/db'
|
import { db } from 'src/db'
|
||||||
import { startAutoSync, getToken } from 'src/services/syncService'
|
import { startAutoSync, getToken } from 'src/services/syncService'
|
||||||
|
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
|
||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
// Color interpolation
|
// Color interpolation
|
||||||
function lerpColor(a, b, t) {
|
function lerpColor(a, b, t) {
|
||||||
|
|
@ -17,55 +20,24 @@ function lerpColor(a, b, t) {
|
||||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gradient presets: [negative, neutral, positive]
|
// Glow color logic: emotion value mapped on one continuous gradient.
|
||||||
const GRADIENT_PRESETS = [
|
function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = null) {
|
||||||
{ name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] },
|
const start = gradientStartColor || DEFAULT_EMOTION_GRADIENT_START
|
||||||
{ name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] },
|
const end = gradientEndColor || DEFAULT_EMOTION_GRADIENT_END
|
||||||
{ name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] },
|
const t = Math.max(0, Math.min(1, (emotion + 1) / 2))
|
||||||
{ name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] },
|
return lerpColor(start, end, t)
|
||||||
{ name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] },
|
|
||||||
{ name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] },
|
|
||||||
{ name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] },
|
|
||||||
{ name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] },
|
|
||||||
{ name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] },
|
|
||||||
{ name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Glow color logic: emotion value → color, with optional gradient preset
|
|
||||||
function emotionToColor(emotion, gradientIdx = null) {
|
|
||||||
const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null
|
|
||||||
if (preset) {
|
|
||||||
const [neg, mid, pos] = preset.colors
|
|
||||||
if (emotion >= 0) {
|
|
||||||
return lerpColor(mid, pos, emotion)
|
|
||||||
} else {
|
|
||||||
return lerpColor(mid, neg, Math.abs(emotion))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (emotion >= 0) {
|
|
||||||
if (emotion < 0.5) {
|
|
||||||
return lerpColor('#FF6B35', '#FFD700', emotion / 0.5)
|
|
||||||
}
|
|
||||||
return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5)
|
|
||||||
} else {
|
|
||||||
const abs = Math.abs(emotion)
|
|
||||||
if (abs < 0.5) {
|
|
||||||
return lerpColor('#2196F3', '#9C27B0', abs / 0.5)
|
|
||||||
}
|
|
||||||
return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo seed data
|
// Demo seed data
|
||||||
const demoEvents = [
|
const demoEvents = [
|
||||||
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', location: '', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', location: '', emotion: 0.85, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', location: '', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', location: '', emotion: 0.75, customColor: null, gradientPreset: null, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', location: '', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', location: '', emotion: 0.95, customColor: null, gradientPreset: null, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', location: '', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
|
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', location: '', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Generate realistic demo events for testing at scale
|
// Generate realistic demo events for testing at scale
|
||||||
|
|
@ -144,16 +116,14 @@ function generateManyEvents(count = 500) {
|
||||||
const note = hasNote ? pick(cat.notes) : ''
|
const note = hasNote ? pick(cat.notes) : ''
|
||||||
const hasImage = rand() < 0.15 // 15% chance
|
const hasImage = rand() < 0.15 // 15% chance
|
||||||
const image = hasImage ? pick(demoImages) : null
|
const image = hasImage ? pick(demoImages) : null
|
||||||
const hasPreset = rand() < 0.25 // 25% chance
|
|
||||||
const gradientPreset = hasPreset ? randInt(0, 9) : null
|
|
||||||
|
|
||||||
evts.push({
|
evts.push({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title,
|
title,
|
||||||
date,
|
date,
|
||||||
|
location: '',
|
||||||
emotion,
|
emotion,
|
||||||
customColor: null,
|
customColor: null,
|
||||||
gradientPreset,
|
gradientPreset: null,
|
||||||
image,
|
image,
|
||||||
note,
|
note,
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
|
|
@ -167,21 +137,44 @@ function generateManyEvents(count = 500) {
|
||||||
return evts
|
return evts
|
||||||
}
|
}
|
||||||
|
|
||||||
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
|
export {
|
||||||
|
emotionToColor,
|
||||||
|
demoEvents,
|
||||||
|
generateManyEvents
|
||||||
|
}
|
||||||
|
|
||||||
export const useEventsStore = defineStore('events', () => {
|
export const useEventsStore = defineStore('events', () => {
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const events = ref([])
|
const events = ref([])
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
const selectedEventId = ref(null)
|
const selectedEventId = ref(null)
|
||||||
const panelOpen = ref(false)
|
const panelOpen = ref(false)
|
||||||
const editingEventId = ref(null)
|
const editingEventId = ref(null)
|
||||||
|
const AUTOSAVE_DELAY_MS = 300
|
||||||
|
let persistTimer = null
|
||||||
|
let skipNextPersist = false
|
||||||
|
|
||||||
// Load events from IndexedDB; seed demo data on first launch
|
// Load events from IndexedDB; seed demo data on first launch
|
||||||
async function init() {
|
async function init() {
|
||||||
|
const userId = authStore.currentUserId
|
||||||
|
if (!userId) {
|
||||||
|
events.value = []
|
||||||
|
isLoaded.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded.value = false
|
||||||
try {
|
try {
|
||||||
let stored = await db.events.orderBy('date').toArray()
|
let stored = await db.events
|
||||||
|
.where('[userId+date]')
|
||||||
|
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
|
||||||
|
.toArray()
|
||||||
if (stored.length === 0) {
|
if (stored.length === 0) {
|
||||||
const seed = generateManyEvents(500)
|
const seed = generateManyEvents(500).map(event => ({
|
||||||
|
...event,
|
||||||
|
userId
|
||||||
|
}))
|
||||||
await db.events.bulkPut(seed)
|
await db.events.bulkPut(seed)
|
||||||
stored = seed
|
stored = seed
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +193,7 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
|
|
||||||
// Fire-and-forget DB write (UI already updated via ref)
|
// Fire-and-forget DB write (UI already updated via ref)
|
||||||
function dbPut(event) {
|
function dbPut(event) {
|
||||||
db.events.put(event).catch(e => console.warn('Dexie put failed:', e))
|
db.events.put({ ...event, userId: event.userId ?? authStore.currentUserId }).catch(e => console.warn('Dexie put failed:', e))
|
||||||
}
|
}
|
||||||
|
|
||||||
function dbDelete(id) {
|
function dbDelete(id) {
|
||||||
|
|
@ -208,27 +201,95 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function dbQueueSync(eventId, action, payload) {
|
function dbQueueSync(eventId, action, payload) {
|
||||||
db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() })
|
const userId = authStore.currentUserId
|
||||||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
if (!userId) return
|
||||||
|
|
||||||
|
const queue = async () => {
|
||||||
|
if (action === 'update') {
|
||||||
|
await db.syncQueue
|
||||||
|
.where('eventId')
|
||||||
|
.equals(eventId)
|
||||||
|
.and(item => item.userId === userId && item.action === 'update')
|
||||||
|
.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.syncQueue.add({ userId, eventId, action, payload, createdAt: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
queue().catch(e => console.warn('Dexie sync queue failed:', e))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMedia(media) {
|
||||||
|
return Array.isArray(media)
|
||||||
|
? media.map(item => ({ ...item }))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaMeta(media) {
|
||||||
|
return cloneMedia(media).map(({ id, type, name, createdAt }) => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
createdAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistEventMedia(eventId, userId, media) {
|
||||||
|
const mediaItems = cloneMedia(media)
|
||||||
|
const persist = async () => {
|
||||||
|
await db.eventMedia.where('eventId').equals(eventId).delete()
|
||||||
|
if (mediaItems.length === 0) return
|
||||||
|
await db.eventMedia.bulkPut(mediaItems.map(item => ({
|
||||||
|
...item,
|
||||||
|
eventId,
|
||||||
|
userId
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
persist().catch(e => console.warn('Dexie media persist failed:', e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEventMedia(event) {
|
||||||
|
const fallback = Array.isArray(event.media) ? cloneMedia(event.media) : []
|
||||||
|
ghostMedia.value = fallback
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = await db.eventMedia
|
||||||
|
.where('eventId')
|
||||||
|
.equals(event.id)
|
||||||
|
.toArray()
|
||||||
|
|
||||||
|
if (editingEventId.value !== event.id || stored.length === 0) return
|
||||||
|
|
||||||
|
ghostMedia.value = stored
|
||||||
|
.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
|
||||||
|
.map(({ eventId, userId, ...item }) => item)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Dexie media load failed:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ghost event for live preview while creating/editing
|
// Ghost event for live preview while creating/editing
|
||||||
const ghostEmotion = ref(0)
|
const ghostEmotion = ref(0)
|
||||||
const ghostCustomColor = ref(null)
|
const ghostCustomColor = ref(null)
|
||||||
const ghostGradientPreset = ref(null)
|
|
||||||
const ghostTitle = ref('')
|
const ghostTitle = ref('')
|
||||||
const ghostDate = ref(new Date().toISOString().slice(0, 10))
|
const ghostDate = ref(new Date().toISOString().slice(0, 10))
|
||||||
|
const ghostLocation = ref('')
|
||||||
const ghostNote = ref('')
|
const ghostNote = ref('')
|
||||||
const ghostImage = ref(null)
|
const ghostImage = ref(null)
|
||||||
|
const ghostKeyImageTitle = ref('')
|
||||||
|
const ghostMedia = ref([])
|
||||||
|
|
||||||
const ghostEvent = computed(() => ({
|
const ghostEvent = computed(() => ({
|
||||||
id: '__ghost__',
|
id: '__ghost__',
|
||||||
title: ghostTitle.value || 'New Event',
|
title: ghostTitle.value || 'New Event',
|
||||||
date: ghostDate.value,
|
date: ghostDate.value,
|
||||||
|
location: ghostLocation.value,
|
||||||
emotion: ghostEmotion.value,
|
emotion: ghostEmotion.value,
|
||||||
customColor: ghostCustomColor.value,
|
customColor: ghostCustomColor.value,
|
||||||
gradientPreset: ghostGradientPreset.value,
|
|
||||||
image: ghostImage.value,
|
image: ghostImage.value,
|
||||||
|
keyImageTitle: ghostKeyImageTitle.value,
|
||||||
|
media: ghostMedia.value,
|
||||||
note: ghostNote.value
|
note: ghostNote.value
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -245,22 +306,27 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
editingEventId.value = eventId
|
editingEventId.value = eventId
|
||||||
const event = events.value.find((e) => e.id === eventId)
|
const event = events.value.find((e) => e.id === eventId)
|
||||||
if (event) {
|
if (event) {
|
||||||
|
skipNextPersist = true
|
||||||
ghostTitle.value = event.title
|
ghostTitle.value = event.title
|
||||||
ghostDate.value = event.date
|
ghostDate.value = event.date
|
||||||
|
ghostLocation.value = event.location || ''
|
||||||
ghostEmotion.value = event.emotion
|
ghostEmotion.value = event.emotion
|
||||||
ghostCustomColor.value = event.customColor
|
ghostCustomColor.value = event.customColor
|
||||||
ghostGradientPreset.value = event.gradientPreset ?? null
|
|
||||||
ghostImage.value = event.image || null
|
ghostImage.value = event.image || null
|
||||||
|
ghostKeyImageTitle.value = event.keyImageTitle || ''
|
||||||
|
loadEventMedia(event)
|
||||||
ghostNote.value = event.note
|
ghostNote.value = event.note
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
editingEventId.value = null
|
editingEventId.value = null
|
||||||
ghostTitle.value = ''
|
ghostTitle.value = ''
|
||||||
ghostDate.value = new Date().toISOString().slice(0, 10)
|
ghostDate.value = new Date().toISOString().slice(0, 10)
|
||||||
|
ghostLocation.value = ''
|
||||||
ghostEmotion.value = 0
|
ghostEmotion.value = 0
|
||||||
ghostCustomColor.value = null
|
ghostCustomColor.value = null
|
||||||
ghostGradientPreset.value = null
|
|
||||||
ghostImage.value = null
|
ghostImage.value = null
|
||||||
|
ghostKeyImageTitle.value = ''
|
||||||
|
ghostMedia.value = []
|
||||||
ghostNote.value = ''
|
ghostNote.value = ''
|
||||||
}
|
}
|
||||||
panelOpen.value = true
|
panelOpen.value = true
|
||||||
|
|
@ -275,33 +341,75 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
...events.value[idx],
|
...events.value[idx],
|
||||||
title: ghostTitle.value,
|
title: ghostTitle.value,
|
||||||
date: ghostDate.value,
|
date: ghostDate.value,
|
||||||
|
location: ghostLocation.value,
|
||||||
emotion: ghostEmotion.value,
|
emotion: ghostEmotion.value,
|
||||||
customColor: ghostCustomColor.value,
|
customColor: ghostCustomColor.value,
|
||||||
gradientPreset: ghostGradientPreset.value,
|
gradientPreset: null,
|
||||||
image: ghostImage.value,
|
image: ghostImage.value,
|
||||||
|
keyImageTitle: ghostKeyImageTitle.value,
|
||||||
|
media: mediaMeta(ghostMedia.value),
|
||||||
note: ghostNote.value,
|
note: ghostNote.value,
|
||||||
syncStatus: 'modified',
|
syncStatus: 'modified',
|
||||||
|
userId: events.value[idx].userId ?? authStore.currentUserId,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
events.value[idx] = updated
|
events.value[idx] = updated
|
||||||
dbPut(updated)
|
dbPut(updated)
|
||||||
|
persistEventMedia(updated.id, updated.userId, ghostMedia.value)
|
||||||
|
dbQueueSync(updated.id, 'update', { ...updated })
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePersistToEvent() {
|
||||||
|
if (!editingEventId.value) return
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
persistTimer = null
|
||||||
|
persistToEvent()
|
||||||
|
}, AUTOSAVE_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPersistToEvent() {
|
||||||
|
if (!persistTimer) return
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
persistToEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGhostNow() {
|
||||||
|
if (persistTimer) {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
}
|
||||||
|
persistToEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote],
|
[ghostTitle, ghostDate, ghostLocation, ghostEmotion, ghostCustomColor, ghostImage, ghostKeyImageTitle, ghostMedia, ghostNote],
|
||||||
() => { persistToEvent() }
|
() => {
|
||||||
|
if (skipNextPersist) {
|
||||||
|
skipNextPersist = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
schedulePersistToEvent()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
|
flushPersistToEvent()
|
||||||
|
|
||||||
if (!editingEventId.value && ghostTitle.value.trim()) {
|
if (!editingEventId.value && ghostTitle.value.trim()) {
|
||||||
const newEvent = {
|
const newEvent = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
userId: authStore.currentUserId,
|
||||||
title: ghostTitle.value,
|
title: ghostTitle.value,
|
||||||
date: ghostDate.value,
|
date: ghostDate.value,
|
||||||
|
location: ghostLocation.value,
|
||||||
emotion: ghostEmotion.value,
|
emotion: ghostEmotion.value,
|
||||||
customColor: ghostCustomColor.value,
|
customColor: ghostCustomColor.value,
|
||||||
gradientPreset: ghostGradientPreset.value,
|
gradientPreset: null,
|
||||||
image: ghostImage.value,
|
image: ghostImage.value,
|
||||||
|
keyImageTitle: ghostKeyImageTitle.value,
|
||||||
|
media: mediaMeta(ghostMedia.value),
|
||||||
note: ghostNote.value,
|
note: ghostNote.value,
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
@ -309,6 +417,7 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
events.value.push(newEvent)
|
events.value.push(newEvent)
|
||||||
dbPut(newEvent)
|
dbPut(newEvent)
|
||||||
|
persistEventMedia(newEvent.id, newEvent.userId, ghostMedia.value)
|
||||||
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
||||||
}
|
}
|
||||||
panelOpen.value = false
|
panelOpen.value = false
|
||||||
|
|
@ -317,6 +426,10 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent(id) {
|
function deleteEvent(id) {
|
||||||
|
if (editingEventId.value === id) {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
}
|
||||||
events.value = events.value.filter((e) => e.id !== id)
|
events.value = events.value.filter((e) => e.id !== id)
|
||||||
dbDelete(id)
|
dbDelete(id)
|
||||||
dbQueueSync(id, 'delete', null)
|
dbQueueSync(id, 'delete', null)
|
||||||
|
|
@ -325,12 +438,27 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
|
|
||||||
function getGlowColor(event) {
|
function getGlowColor(event) {
|
||||||
if (event.customColor) return event.customColor
|
if (event.customColor) return event.customColor
|
||||||
return emotionToColor(event.emotion, event.gradientPreset ?? null)
|
return emotionToColor(
|
||||||
|
event.emotion,
|
||||||
|
settingsStore.emotionGradientStart || DEFAULT_EMOTION_GRADIENT_START,
|
||||||
|
settingsStore.emotionGradientEnd || DEFAULT_EMOTION_GRADIENT_END
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-init on store creation
|
// Auto-init on store creation
|
||||||
init()
|
init()
|
||||||
|
|
||||||
|
watch(() => authStore.currentUserId, () => {
|
||||||
|
panelOpen.value = false
|
||||||
|
editingEventId.value = null
|
||||||
|
selectedEventId.value = null
|
||||||
|
if (persistTimer) {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
|
|
@ -339,17 +467,20 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
editingEventId,
|
editingEventId,
|
||||||
ghostEmotion,
|
ghostEmotion,
|
||||||
ghostCustomColor,
|
ghostCustomColor,
|
||||||
ghostGradientPreset,
|
|
||||||
ghostTitle,
|
ghostTitle,
|
||||||
ghostDate,
|
ghostDate,
|
||||||
|
ghostLocation,
|
||||||
ghostNote,
|
ghostNote,
|
||||||
ghostImage,
|
ghostImage,
|
||||||
|
ghostKeyImageTitle,
|
||||||
|
ghostMedia,
|
||||||
ghostEvent,
|
ghostEvent,
|
||||||
sortedEvents,
|
sortedEvents,
|
||||||
selectEvent,
|
selectEvent,
|
||||||
openPanel,
|
openPanel,
|
||||||
closePanel,
|
closePanel,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
|
saveGhostNow,
|
||||||
getGlowColor
|
getGlowColor
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
const STORAGE_KEY = 'thatsme-settings'
|
const STORAGE_KEY_PREFIX = 'thatsme-settings'
|
||||||
|
const PERSIST_DELAY_MS = 250
|
||||||
|
export const DEFAULT_EMOTION_GRADIENT_START = '#2d2e83'
|
||||||
|
export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35'
|
||||||
|
export const DEFAULT_TIMELINE_ZOOM = 1
|
||||||
|
export const DEFAULT_TIMELINE_SCROLL_LEFT = null
|
||||||
|
|
||||||
export const ACCENT_COLORS = [
|
export const ACCENT_COLORS = [
|
||||||
{ label: 'Standard', value: 'default', hex: '#9e9e9e' },
|
{ label: 'Base', value: 'base', hex: '#737373' },
|
||||||
{ label: 'Blau', value: 'blue', hex: '#2196F3' },
|
{ label: 'Red', value: 'red', hex: '#ef4444' },
|
||||||
{ label: 'Grün', value: 'green', hex: '#4CAF50' },
|
{ label: 'Orange', value: 'orange', hex: '#f97316' },
|
||||||
{ label: 'Gelb', value: 'yellow', hex: '#FFC107' },
|
{ label: 'Amber', value: 'amber', hex: '#f59e0b' },
|
||||||
{ label: 'Rosa', value: 'pink', hex: '#E91E63' },
|
{ label: 'Yellow', value: 'yellow', hex: '#eab308' },
|
||||||
{ label: 'Orange', value: 'orange', hex: '#FF9800' }
|
{ label: 'Lime', value: 'lime', hex: '#84cc16' },
|
||||||
|
{ label: 'Green', value: 'green', hex: '#22c55e' },
|
||||||
|
{ label: 'Emerald', value: 'emerald', hex: '#10b981' },
|
||||||
|
{ label: 'Teal', value: 'teal', hex: '#14b8a6' },
|
||||||
|
{ label: 'Cyan', value: 'cyan', hex: '#06b6d4' },
|
||||||
|
{ label: 'Sky', value: 'sky', hex: '#0ea5e9' },
|
||||||
|
{ label: 'Blue', value: 'blue', hex: '#3b82f6' },
|
||||||
|
{ label: 'Indigo', value: 'indigo', hex: '#6366f1' },
|
||||||
|
{ label: 'Violet', value: 'violet', hex: '#8b5cf6' },
|
||||||
|
{ label: 'Purple', value: 'purple', hex: '#a855f7' },
|
||||||
|
{ label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' },
|
||||||
|
{ label: 'Pink', value: 'pink', hex: '#ec4899' },
|
||||||
|
{ label: 'Rose', value: 'rose', hex: '#f43f5e' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const LANGUAGES = [
|
export const LANGUAGES = [
|
||||||
|
|
@ -24,6 +42,7 @@ const FLOATING_LINES_DEFAULTS = {
|
||||||
spread: 0.05,
|
spread: 0.05,
|
||||||
fanSpread: 0.05,
|
fanSpread: 0.05,
|
||||||
lineSharpness: 8.0,
|
lineSharpness: 8.0,
|
||||||
|
lineThickness: 1.0,
|
||||||
waveFrequency: 7.0,
|
waveFrequency: 7.0,
|
||||||
bezierCurvature: 0.2,
|
bezierCurvature: 0.2,
|
||||||
circleRadius: 75,
|
circleRadius: 75,
|
||||||
|
|
@ -35,51 +54,177 @@ const FLOATING_LINES_DEFAULTS = {
|
||||||
bgEdge: '#000000',
|
bgEdge: '#000000',
|
||||||
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
|
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
|
||||||
backgroundImage: '',
|
backgroundImage: '',
|
||||||
|
// Horizont
|
||||||
|
horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow'
|
||||||
|
horizonOpacity: 0.5, // Nebel + Glow: Helligkeit
|
||||||
|
horizonBlend: 0.2, // Trennung: 0=scharf 1=weich
|
||||||
// Labels
|
// Labels
|
||||||
labelSize: 'small', // 'small' | 'medium' | 'large'
|
labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
|
||||||
labelColor: '#ffffff'
|
labelColor: '#ffffff',
|
||||||
|
labelOpacity: 0.75,
|
||||||
|
labelConnectorLength: 0.2,
|
||||||
|
// Dot-Rahmen
|
||||||
|
dotBorderWidth: 0, // 0 = kein Rahmen, in px
|
||||||
|
dotBorderColor: '#ffffff',
|
||||||
|
// Modus
|
||||||
|
lineMode: 'glow', // 'glow' | 'static'
|
||||||
|
staticLineColor: '#2196F3', // Linienfarbe im Static-Modus
|
||||||
|
staticLineShadowStrength: 0 // 0 = klar, 1 = ursprünglicher Schatten
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage() {
|
function getStorageKey(userId) {
|
||||||
|
return `${STORAGE_KEY_PREFIX}:${userId || 'guest'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromStorage(userId) {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(getStorageKey(userId))
|
||||||
return stored ? JSON.parse(stored) : null
|
return stored ? JSON.parse(stored) : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAccentColor(value) {
|
||||||
|
if (value === 'default') return 'base'
|
||||||
|
return ACCENT_COLORS.some(c => c.value === value) ? value : 'base'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const clean = hex.replace('#', '')
|
||||||
|
const full = clean.length === 3
|
||||||
|
? clean.split('').map(ch => ch + ch).join('')
|
||||||
|
: clean
|
||||||
|
const r = parseInt(full.slice(0, 2), 16)
|
||||||
|
const g = parseInt(full.slice(2, 4), 16)
|
||||||
|
const b = parseInt(full.slice(4, 6), 16)
|
||||||
|
return { r, g, b }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccentCssVariables(value) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const key = normalizeAccentColor(value)
|
||||||
|
const accent = ACCENT_COLORS.find(c => c.value === key) ?? ACCENT_COLORS[0]
|
||||||
|
const { r, g, b } = hexToRgb(accent.hex)
|
||||||
|
document.documentElement.style.setProperty('--tm-accent', accent.hex)
|
||||||
|
document.documentElement.style.setProperty('--tm-accent-rgb', `${r}, ${g}, ${b}`)
|
||||||
|
// Keep Quasar semantic color channels in sync with the active accent.
|
||||||
|
document.documentElement.style.setProperty('--q-primary', accent.hex)
|
||||||
|
document.documentElement.style.setProperty('--q-secondary', accent.hex)
|
||||||
|
document.documentElement.style.setProperty('--q-accent', accent.hex)
|
||||||
|
}
|
||||||
|
|
||||||
export { FLOATING_LINES_DEFAULTS }
|
export { FLOATING_LINES_DEFAULTS }
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const stored = loadFromStorage()
|
const authStore = useAuthStore()
|
||||||
|
const stored = loadFromStorage(authStore.currentUserId)
|
||||||
|
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
|
||||||
|
const initialSettings = initialActivePreset?.settings ?? stored
|
||||||
|
let persistTimer = null
|
||||||
|
|
||||||
const theme = ref(stored?.theme ?? 'light')
|
const theme = ref(initialSettings?.theme ?? 'light')
|
||||||
const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS })
|
const floatingLines = ref({
|
||||||
|
...FLOATING_LINES_DEFAULTS,
|
||||||
|
...(initialSettings?.floatingLines ?? {})
|
||||||
|
})
|
||||||
|
|
||||||
// App preferences
|
// App preferences
|
||||||
const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark'
|
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
|
||||||
const accentColor = ref(stored?.accentColor ?? 'default')
|
const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'base'))
|
||||||
const language = ref(stored?.language ?? 'de')
|
const language = ref(initialSettings?.language ?? 'de')
|
||||||
|
const emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START)
|
||||||
|
const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END)
|
||||||
|
const timelineZoom = ref(initialSettings?.timelineZoom ?? DEFAULT_TIMELINE_ZOOM)
|
||||||
|
const timelineScrollLeft = ref(stored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT)
|
||||||
|
const presets = ref(stored?.presets ?? [])
|
||||||
|
const activePresetId = ref(stored?.activePresetId ?? null)
|
||||||
|
|
||||||
// Developer / debug
|
// Developer / debug
|
||||||
const showFps = ref(stored?.showFps ?? false)
|
const showFps = ref(initialSettings?.showFps ?? false)
|
||||||
|
|
||||||
|
function createSnapshot() {
|
||||||
|
return {
|
||||||
|
theme: theme.value,
|
||||||
|
floatingLines: clone(floatingLines.value),
|
||||||
|
appearance: appearance.value,
|
||||||
|
accentColor: accentColor.value,
|
||||||
|
language: language.value,
|
||||||
|
emotionGradientStart: emotionGradientStart.value,
|
||||||
|
emotionGradientEnd: emotionGradientEnd.value,
|
||||||
|
timelineZoom: timelineZoom.value,
|
||||||
|
showFps: showFps.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySnapshot(snapshot) {
|
||||||
|
theme.value = snapshot?.theme ?? 'light'
|
||||||
|
floatingLines.value = {
|
||||||
|
...FLOATING_LINES_DEFAULTS,
|
||||||
|
...(snapshot?.floatingLines ?? {})
|
||||||
|
}
|
||||||
|
appearance.value = snapshot?.appearance ?? 'system'
|
||||||
|
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'base')
|
||||||
|
language.value = snapshot?.language ?? 'de'
|
||||||
|
emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START
|
||||||
|
emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END
|
||||||
|
timelineZoom.value = snapshot?.timelineZoom ?? DEFAULT_TIMELINE_ZOOM
|
||||||
|
showFps.value = snapshot?.showFps ?? false
|
||||||
|
}
|
||||||
|
|
||||||
function persist() {
|
function persist() {
|
||||||
|
if (persistTimer) {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authStore.currentUserId) return
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
getStorageKey(authStore.currentUserId),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
theme: theme.value,
|
...createSnapshot(),
|
||||||
floatingLines: floatingLines.value,
|
timelineScrollLeft: timelineScrollLeft.value,
|
||||||
appearance: appearance.value,
|
presets: presets.value,
|
||||||
accentColor: accentColor.value,
|
activePresetId: activePresetId.value
|
||||||
language: language.value,
|
|
||||||
showFps: showFps.value
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true })
|
function schedulePersist() {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
persistTimer = setTimeout(persist, PERSIST_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStoredSettingsForUser(userId) {
|
||||||
|
if (persistTimer) {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStored = loadFromStorage(userId)
|
||||||
|
presets.value = nextStored?.presets ?? []
|
||||||
|
activePresetId.value = nextStored?.activePresetId ?? null
|
||||||
|
|
||||||
|
const activePreset = presets.value.find(preset => preset.id === activePresetId.value)
|
||||||
|
applySnapshot(activePreset?.settings ?? nextStored)
|
||||||
|
timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true })
|
||||||
|
watch(() => authStore.currentUserId, applyStoredSettingsForUser)
|
||||||
|
watch(accentColor, (value) => {
|
||||||
|
const normalized = normalizeAccentColor(value)
|
||||||
|
if (accentColor.value !== normalized) {
|
||||||
|
accentColor.value = normalized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyAccentCssVariables(normalized)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||||
|
|
@ -93,15 +238,67 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetEmotionGradient() {
|
||||||
|
emotionGradientStart.value = DEFAULT_EMOTION_GRADIENT_START
|
||||||
|
emotionGradientEnd.value = DEFAULT_EMOTION_GRADIENT_END
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTimelineScrollLeft(value, immediate = false) {
|
||||||
|
timelineScrollLeft.value = Number.isFinite(value) ? value : DEFAULT_TIMELINE_SCROLL_LEFT
|
||||||
|
if (immediate) {
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreset(name) {
|
||||||
|
const trimmedName = String(name || '').trim()
|
||||||
|
if (!trimmedName) return null
|
||||||
|
|
||||||
|
const existing = presets.value.find(preset => preset.name.toLowerCase() === trimmedName.toLowerCase())
|
||||||
|
const savedPreset = {
|
||||||
|
id: existing?.id ?? crypto.randomUUID(),
|
||||||
|
name: trimmedName,
|
||||||
|
settings: createSnapshot(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
presets.value = existing
|
||||||
|
? presets.value.map(preset => preset.id === existing.id ? savedPreset : preset)
|
||||||
|
: [...presets.value, savedPreset]
|
||||||
|
activePresetId.value = savedPreset.id
|
||||||
|
persist()
|
||||||
|
return savedPreset
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(presetId) {
|
||||||
|
const preset = presets.value.find(candidate => candidate.id === presetId)
|
||||||
|
if (!preset) return false
|
||||||
|
|
||||||
|
activePresetId.value = preset.id
|
||||||
|
applySnapshot(preset.settings)
|
||||||
|
persist()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
floatingLines,
|
floatingLines,
|
||||||
appearance,
|
appearance,
|
||||||
accentColor,
|
accentColor,
|
||||||
language,
|
language,
|
||||||
|
emotionGradientStart,
|
||||||
|
emotionGradientEnd,
|
||||||
|
timelineZoom,
|
||||||
|
timelineScrollLeft,
|
||||||
|
presets,
|
||||||
|
activePresetId,
|
||||||
showFps,
|
showFps,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
updateFloatingLines,
|
updateFloatingLines,
|
||||||
resetFloatingLines
|
resetFloatingLines,
|
||||||
|
resetEmotionGradient,
|
||||||
|
saveTimelineScrollLeft,
|
||||||
|
savePreset,
|
||||||
|
applyPreset
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
30
workspace.code-workspace
Normal file
30
workspace.code-workspace
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue