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