Frontend: Offline-Warteschlange, PWA, Event-Panel und Markenauftritt

Offline
- Alle Schreibzugriffe von API-Nutzern laufen über eine Warteschlange in
  IndexedDB (services/syncQueue.js): FIFO, zusammengefasste Updates,
  Wiederholung bei Netz/Sichtbarkeit/Backoff, 4xx wird als Fehler gemeldet.
- Medien-Uploads liegen mit Bytes in der Queue, erscheinen sofort mit
  Marker und werden nach dem Upload durch das Server-Objekt ersetzt.
- PWA-Modus mit Service Worker für die App-Hülle; API nie gecacht.
  npm run build erzeugt dist/pwa.
- Speicherzustand im Panel: ausstehend / Fehler mit Wiederholen-Knopf,
  Verbindungsanzeige im Header.

Event
- Event entsteht erst mit dem ersten Eintrag; jede Löschung fragt nach.
- Key Image als 4:3-Rahmen, Galerie mit Video, mehrere Sprachnotizen mit
  Notiz, Live-Pegel beim Aufnehmen, WAV-Umwandlung für lokale Aufnahmen.
- Neue Felder: Zeitraum, Kategorie, Personen, Meilenstein; Stimmung als
  kompakte Zeile mit ausklappbarer Punktfarbe.

Oberfläche
- Untere Leiste: Darstellung und Zoom links, Jahres-Pille mittig, Plus rechts.
- Logos und Goldpalette, Login neu, zehn Wellen-Presets mit Vorschau,
  Hintergrundbilder als Kacheln, Kontomenü und Einstellungen bereinigt,
  Escape schließt Ebenen, größere Touch-Ziele.
- Alten Sync-Service und ungenutzte Legacy-Seiten entfernt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 07:00:35 +00:00
parent b262a6771f
commit 83c6c5813d
104 changed files with 13656 additions and 8584 deletions

View file

@ -60,6 +60,24 @@ export default [
} }
}, },
{
// Vitest runs with `globals: true`, so the test APIs are ambient.
files: [ 'test/vitest/**/*.js' ],
languageOptions: {
globals: {
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
vi: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly'
}
}
},
{ {
files: [ 'src-pwa/custom-service-worker.js' ], files: [ 'src-pwa/custom-service-worker.js' ],
languageOptions: { languageOptions: {

File diff suppressed because it is too large Load diff

View file

@ -7,18 +7,24 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\"", "lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\" test/vitest",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0", "test": "vitest run",
"dev": "npm install && quasar dev", "dev": "npm install && quasar dev",
"build": "quasar build", "dev:pwa": "npm install && quasar dev -m pwa",
"postinstall": "quasar prepare" "build": "quasar build -m pwa",
"build:spa": "quasar build",
"serve:pwa": "quasar serve dist/pwa --history --port 9100",
"postinstall": "quasar prepare",
"test:watch": "vitest",
"test:tz": "TZ=America/Los_Angeles vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js && TZ=Pacific/Kiritimati vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js && TZ=Europe/Berlin vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.16.4", "@quasar/extras": "^1.16.4",
"dexie": "^4.3.0", "dexie": "^4.3.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"register-service-worker": "^1.7.2",
"three": "^0.183.0", "three": "^0.183.0",
"vue": "^3.4.18", "vue": "^3.4.18",
"vue-router": "^4.0.0", "vue-router": "^4.0.0",
@ -32,18 +38,30 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@quasar/app-vite": "^2.1.0", "@quasar/app-vite": "^2.1.0",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/eslint-config-prettier": "^10.1.0", "@vue/eslint-config-prettier": "^10.1.0",
"@vue/test-utils": "^2.5.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0", "eslint-plugin-vue": "^9.30.0",
"fake-indexeddb": "^6.2.5",
"globals": "^15.12.0", "globals": "^15.12.0",
"happy-dom": "^20.14.5",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"vite-plugin-checker": "^0.9.0" "vite-plugin-checker": "^0.9.0",
"vitest": "^3.2.7",
"workbox-build": "^7.4.1",
"workbox-cacheable-response": "^7.4.1",
"workbox-core": "^7.4.1",
"workbox-expiration": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1"
}, },
"engines": { "engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "yarn": ">= 1.21.1"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 439 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1,009 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M40 330 C 140 250, 200 250, 256 290 S 380 330, 470 230" fill="none" stroke="#000" stroke-width="22" stroke-linecap="round"/>
<circle cx="352" cy="230" r="84" fill="#000"/>
<circle cx="72" cy="318" r="26" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 687 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -81,6 +81,20 @@ export default defineConfig((ctx) => {
// https: true, // https: true,
open: true, // opens browser window automatically open: true, // opens browser window automatically
allowedHosts: ['app.thats-me.test'], allowedHosts: ['app.thats-me.test'],
// When the app is opened via http://localhost:9000 the API client falls
// back to the relative base "/api" (see src/services/apiClient.js).
// Forward those requests to the Laravel container so the login works
// without VITE_API_BASE. "laravel.test" resolves inside the Docker
// network (dev container and quasar.app). When running the dev server
// on the host, point DEV_API_PROXY_TARGET at https://api.thats-me.test.
proxy: {
'/api': {
target: process.env.DEV_API_PROXY_TARGET || 'http://laravel.test',
changeOrigin: true,
// Traefik serves a self-signed certificate in local development.
secure: false,
},
},
}, },
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
@ -98,7 +112,9 @@ export default defineConfig((ctx) => {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: ['Dark'], // Notify is used by the event panel and settings for upload/save
// errors; without it registered those calls silently did nothing.
plugins: ['Dark', 'Notify'],
}, },
htmlVariables: { htmlVariables: {
@ -148,15 +164,37 @@ export default defineConfig((ctx) => {
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: { pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' workboxMode: 'GenerateSW',
// swFilename: 'sw.js', // What the service worker keeps for offline use is the app shell only.
// manifestFilename: 'manifest.json', // The API is never cached (the sync queue owns offline writes, the
// extendManifestJson (json) {}, // stores own offline reads), and the large bundled photos are fetched
// useCredentialsForManifestTag: true, // on first use rather than precached on install.
// injectPwaMetaTags: false, extendGenerateSWOptions (cfg) {
// extendPWACustomSWConf (esbuildConf) {}, cfg.skipWaiting = true
// extendGenerateSWOptions (cfg) {}, cfg.clientsClaim = true
// extendInjectManifestOptions (cfg) {} cfg.cleanupOutdatedCaches = true
cfg.globPatterns = [
'**/*.{js,css,html,ico,svg,woff,woff2,json,webmanifest}',
'icons/*.png',
'images/bg-thumbs/*.jpg'
]
cfg.globIgnores = [ ...(cfg.globIgnores || []), 'images/bg-image-*.jpg', 'images/gallery/**', 'videos/**', 'audio/**', 'demo/**', 'js/**' ]
cfg.navigateFallbackDenylist = [ ...(cfg.navigateFallbackDenylist || []), /^\/api\//, /^\/event-media\// ]
cfg.runtimeCaching = [
{
urlPattern: /\/images\/.*\.(?:jpg|jpeg|png|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'tm-backgrounds',
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 60 }
}
},
{
urlPattern: /\/api\//,
handler: 'NetworkOnly'
}
]
}
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova

View file

@ -0,0 +1,17 @@
{
"name": "That's Me",
"short_name": "That's Me",
"description": "Deine LifeWave: Ereignisse, Bilder und Sprachnotizen auf einer Zeitlinie.",
"lang": "de",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1b1c",
"theme_color": "#292a2b",
"icons": [
{ "src": "icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-256x256.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
]
}

View file

@ -0,0 +1,22 @@
import { register } from 'register-service-worker'
/**
* Registers the service worker that makes the app shell available without
* a connection. The shell is everything the build produced (scripts,
* styles, icons); the user's own data comes from IndexedDB and the sync
* queue, never from this cache.
*
* The worker takes over as soon as a new build is installed
* (skipWaiting/clientsClaim in quasar.config.js). The app is told so it can
* offer a reload; it never reloads on its own, that would throw away an
* open draft.
*/
register(process.env.SERVICE_WORKER_FILE, {
updated() {
window.dispatchEvent(new CustomEvent('tm:app-updated'))
},
error(err) {
console.warn('Service worker registration failed:', err)
}
})

View file

@ -3,5 +3,27 @@
</template> </template>
<script setup> <script setup>
// import { onBeforeUnmount, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings'
// The stored "appearance" setting is the single source of truth for dark
// mode. Every switch in the UI writes to it; this is the one place that
// applies it to Quasar, so it survives reloads and follows the OS in
// "system" mode.
const $q = useQuasar()
const settingsStore = useSettingsStore()
const systemDark = window.matchMedia('(prefers-color-scheme: dark)')
function applyAppearance() {
const mode = settingsStore.appearance
$q.dark.set(mode === 'system' ? systemDark.matches : mode === 'dark')
}
watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
systemDark.addEventListener('change', applyAppearance)
onBeforeUnmount(() => {
systemDark.removeEventListener('change', applyAppearance)
})
</script> </script>

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 30.8.1, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 595.3 149" style="enable-background:new 0 0 595.3 149;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F7F3E9;}
.st1{fill:none;stroke:#D59040;stroke-width:4;stroke-miterlimit:10;}
.st2{opacity:0.7;fill:url(#SVGID_1_);}
.st3{opacity:0.7;fill:url(#SVGID_00000010268479788305058960000017491844122260017031_);}
.st4{fill:#F5C26B;}
.st5{opacity:0.4;fill:#F5C26B;}
.st6{fill:none;stroke:#D59040;stroke-miterlimit:10;}
</style>
<path class="st0" d="M90.7,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4H75.2v-11c0-0.6-0.2-1.1-0.5-1.4C74.4,49.1,74,49,73.4,49c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C91,110.5,90.9,110.1,90.7,109.9z"/>
<path class="st0" d="M114.8,32.5v78.4c0,0.9,0.2,1.6,0.7,2.1c0.5,0.5,1.1,0.9,2,1l3.1,0.6c1.3,0.3,1.9,0.9,1.9,1.6
c0,1-0.7,1.6-2.2,1.6h-19.7c-0.7,0-1.2-0.1-1.6-0.4c-0.4-0.3-0.5-0.6-0.5-1.1c0-0.4,0.2-0.7,0.5-1c0.3-0.3,0.8-0.5,1.4-0.6l3.3-0.7
c0.9-0.2,1.5-0.5,2-1c0.5-0.5,0.7-1.2,0.7-2.2V39.4c0-0.8-0.1-1.4-0.5-1.7c-0.3-0.4-0.8-0.6-1.5-0.6l-5-0.1c-0.6,0-1.1-0.2-1.4-0.4
c-0.3-0.2-0.4-0.5-0.4-0.9c0-0.4,0.1-0.7,0.4-1c0.3-0.3,0.8-0.5,1.6-0.8l8.7-2.9c1-0.4,1.8-0.6,2.5-0.8c0.7-0.2,1.2-0.2,1.7-0.2
c0.8,0,1.4,0.2,1.8,0.6C114.7,31,114.8,31.7,114.8,32.5z"/>
<path class="st0" d="M258.1,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4h-13.2v-11c0-0.6-0.2-1.1-0.5-1.4c-0.3-0.3-0.7-0.5-1.3-0.5c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C258.4,110.5,258.3,110.1,258.1,109.9z"/>
<path class="st0" d="M407.3,76l-2-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,9.9-2.4c5,0,9,1.5,12,4.6
c3,3.1,4.5,7.3,4.5,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.3,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.9c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.3-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.6,5.8L407.3,76z"/>
<path class="st0" d="M511.4,100.7c-0.2-0.4-0.6-0.5-1.1-0.5c-0.4,0-0.7,0.1-0.9,0.3c-0.3,0.2-0.5,0.6-0.8,1.2c-1,3.3-3,6-6.2,8.2
s-6.9,3.2-11.3,3.2c-6.4,0-11.5-2.2-15.5-6.5c-4-4.3-6-10.5-6-18.6h36.3c1.9,0,3.3-0.4,4.3-1.2c1-0.8,1.4-2,1.4-3.5
c0-4.4-1-8.3-2.9-11.7c-1.9-3.3-4.6-6-8-7.9c-3.4-1.9-7.4-2.9-11.9-2.9c-5.6,0-10.4,1.3-14.5,4c-4.1,2.7-7.3,6.2-9.5,10.7
c-2.2,4.5-3.4,9.6-3.4,15.2c0,5.8,1.1,10.8,3.4,15c2.3,4.3,5.4,7.6,9.6,9.9s8.9,3.5,14.4,3.5c4.2,0,7.9-0.8,11.2-2.3s6-3.6,8.1-6.2
c2.1-2.6,3.2-5.5,3.6-8.6C511.7,101.5,511.6,101,511.4,100.7z M471.8,75.5c1.5-3.5,3.6-6.2,6.4-8.2c2.7-2,5.9-3,9.5-3
c4.5,0,8.1,1.6,10.8,4.9c2.7,3.3,4.1,7.6,4.1,13c0,1.7-1,2.5-2.9,2.5h-30C469.9,81.3,470.6,78.2,471.8,75.5z"/>
<path class="st1" d="M17.4,97.3"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="277.5667" y1="76.1994" x2="294.7258" y2="150.1156">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path class="st2" d="M532,80.7c-38.5,17.9-77.7,27-119.2,27.8c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9
c-0.3,0-0.6,0-0.8,0c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,91.2,116,80.6,162,80.5c0.3,0,0.6,0,0.8,0
c40.9,0,81.2,8.1,120.1,15.9c40,8,80.6,16.2,123.5,16.2c40.3,0,82.5-7.2,128-28.5C533.5,83,532.7,81.9,532,80.7z"/>
<linearGradient id="SVGID_00000079453371929638128150000013207070431988704682_" gradientUnits="userSpaceOnUse" x1="278.2577" y1="78.0403" x2="295.4174" y2="151.9591">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path style="opacity:0.7;fill:url(#SVGID_00000079453371929638128150000013207070431988704682_);" d="M533,82.2
c-38.8,18.2-78.3,27.4-120.2,28.2c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9c-0.3,0-0.6,0-0.8,0
c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,93.2,116,82.6,162,82.5c0.3,0,0.6,0,0.8,0c40.9,0,81.2,8.1,120.1,15.9
c40,8,80.6,16.2,123.5,16.2c40.7,0,83.3-7.4,129.3-29.1C534.7,84.4,533.8,83.4,533,82.2z"/>
<path class="st0" d="M113.2,76l-1.9-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,10-2.4c5,0,9,1.5,12.1,4.6
c3,3.1,4.6,7.3,4.6,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.2,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.8c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.4-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.7,5.8L113.2,76z"/>
<path class="st0" d="M220.9,111.7c-0.2-0.2-0.4-0.4-0.7-0.4c-0.3,0-0.6,0.1-0.8,0.3c-0.2,0.2-0.4,0.4-0.6,0.6
c-0.3,0.5-0.6,1-1.1,1.4c-0.4,0.5-1.2,0.7-2.3,0.7c-0.9,0-1.6-0.3-2.1-0.9c-0.5-0.6-0.8-1.7-0.8-3.1V76.6c0-4.8-1.5-8.6-4.6-11.5
c-3.1-2.9-7.7-4.3-14-4.3c-4.6,0-8.5,0.7-11.8,2.1c-3.3,1.4-5.8,3.2-7.6,5.5c-1.8,2.2-2.7,4.5-2.7,6.9c0,1.4,0.4,2.5,1.2,3.3
c0.8,0.8,1.9,1.3,3.3,1.3c1.6,0,2.8-0.4,3.8-1.1c0.9-0.8,1.4-1.9,1.4-3.4v-4.1c0-2.1,0.9-3.9,2.6-5.4c1.8-1.5,4.5-2.2,8.2-2.2
c3.8,0,6.7,1.1,8.8,3.2c2,2.1,3.1,5.3,3.1,9.4v15.5c-1.4-0.5-2.9-0.9-4.6-1.3c-2.4-0.6-5.1-0.8-7.9-0.8c-7.1,0-12.6,1.5-16.6,4.5
c-4,3-6,6.7-6,11.2c0,4.3,1.6,7.6,4.7,10.1c3.1,2.4,7.1,3.7,12,3.7c4.2,0,8.2-0.9,11.9-2.8c2.6-1.3,4.9-2.9,6.9-4.9
c0.1,2.5,0.8,4.4,2.2,5.6c1.5,1.4,3.6,2.1,6.3,2.1c1.8,0,3.2-0.3,4.4-1c1.2-0.7,2.1-1.5,2.7-2.5c0.6-1,0.9-2,0.9-2.9
C221.2,112.3,221.1,112,220.9,111.7z M188.9,114.8c-3.2,0-5.8-0.9-7.9-2.8c-2.1-1.8-3.1-4.5-3.1-7.9c0-3.6,1.3-6.4,3.9-8.5
c2.6-2.1,6.2-3.1,10.9-3.1c2.4,0,4.7,0.3,6.8,0.8c1.6,0.4,3.1,0.9,4.6,1.4v13.5c-1.8,1.8-3.8,3.2-6,4.3
C195.2,114.1,192.1,114.8,188.9,114.8z"/>
<path class="st0" d="M297.5,64.3c-3.2,0-5.8,0.9-7.7,2.6c-1.9,1.7-2.9,4-2.9,6.9c0,1.8,0.4,3.3,1.3,4.7c0.9,1.4,2.3,2.7,4.5,3.8
c2.1,1.1,5.1,2.2,8.9,3.3c4.9,1.2,8.8,2.8,11.7,4.6s5,3.8,6.2,6c1.2,2.2,1.8,4.6,1.8,7.3c0,4.6-1.7,8.4-5,11.3
c-3.4,2.9-8.1,4.4-14.2,4.4c-2.3,0-4.3-0.2-5.8-0.7c-1.5-0.4-2.8-0.9-3.9-1.3c-1.1-0.4-2-0.6-2.8-0.6c-0.8,0-1.3,0.2-1.7,0.7
c-0.4,0.4-0.7,0.9-1,1.3c-0.3,0.4-0.8,0.6-1.4,0.6c-0.4,0-0.7-0.1-1-0.4c-0.2-0.3-0.5-0.7-0.8-1.3l-4.8-13.2c-0.3-0.8-0.4-1.4-0.3-2
c0.1-0.6,0.5-1,1-1.2c0.6-0.2,1.1-0.2,1.5,0c0.4,0.3,0.8,0.7,1.1,1.2c1.8,3.5,3.7,6.2,5.7,8.2c2.1,2,4.3,3.3,6.6,4.1
c2.3,0.8,4.7,1.1,7.2,1.1c4.2,0,7.3-0.9,9.4-2.7c2.1-1.8,3.1-4.2,3.1-7.1c0-1.7-0.5-3.3-1.4-4.8c-0.9-1.5-2.5-2.9-4.7-4.1
c-2.3-1.3-5.4-2.5-9.5-3.5c-4.6-1.2-8.3-2.6-11-4.2c-2.7-1.6-4.7-3.5-5.9-5.7c-1.2-2.2-1.8-4.7-1.8-7.6s0.7-5.4,2.1-7.7
c1.4-2.3,3.4-4,6.1-5.3c2.6-1.3,5.8-2,9.4-2c2.3,0,4.2,0.2,5.7,0.7c1.5,0.5,2.7,1,3.7,1.4c1,0.5,1.8,0.7,2.5,0.7
c0.8,0,1.4-0.2,1.8-0.7c0.4-0.5,0.7-1,1-1.4c0.3-0.5,0.8-0.7,1.3-0.7c0.3,0,0.6,0.1,0.9,0.3c0.3,0.2,0.5,0.6,0.6,1.2l4,12.5
c0.3,0.9,0.4,1.7,0.4,2.4s-0.4,1.2-1,1.5c-0.6,0.2-1,0.1-1.5-0.2s-0.9-1-1.3-1.8c-2.1-4.4-4.8-7.6-8-9.5
C304.4,65.2,301,64.3,297.5,64.3z"/>
<path class="st0" d="M414.7,114.7l-2.9-0.6c-0.9-0.2-1.6-0.6-2.2-1.2c-0.5-0.6-0.8-1.3-0.8-2.3V78.1c0-5.4-1.5-9.6-4.5-12.7
c-3-3.1-7-4.6-12-4.6c-3.2,0-6.5,0.8-9.9,2.4c-3.2,1.5-7,4.2-11.2,7.8v-7.2c0-0.8-0.2-1.4-0.6-1.8s-1-0.6-1.8-0.6
c-0.4,0-1,0.1-1.7,0.2c-0.7,0.1-1.5,0.4-2.5,0.8l-8.7,2.9c-0.8,0.2-1.3,0.5-1.6,0.8c-0.3,0.3-0.4,0.6-0.4,1c0,0.4,0.1,0.7,0.4,0.9
c0.3,0.2,0.7,0.4,1.4,0.4l5,0.1c0.7,0,1.2,0.2,1.5,0.6c0.3,0.4,0.5,0.9,0.5,1.7v40.1c0,1-0.2,1.7-0.7,2.2c-0.5,0.5-1.1,0.8-2,1
l-3.3,0.7c-0.6,0.2-1.1,0.4-1.4,0.6c-0.3,0.3-0.5,0.6-0.5,1c0,0.4,0.2,0.8,0.5,1.1c0.4,0.3,0.9,0.4,1.6,0.4h19.7
c1.5,0,2.2-0.5,2.2-1.6c0-0.8-0.6-1.3-1.9-1.6l-3.1-0.6c-0.8-0.2-1.5-0.5-1.9-1c-0.5-0.5-0.7-1.2-0.7-2.1V74.6l1.2-1
c3.2-2.6,6-4.6,8.6-5.8c2.6-1.2,5-1.8,7.3-1.8c3.7,0,6.6,1.1,8.9,3.3s3.3,5.6,3.3,10v31.1c0,1-0.2,1.8-0.7,2.3
c-0.4,0.6-1.1,0.9-2.1,1.1l-3.3,0.6c-1.2,0.3-1.9,0.9-1.9,1.6c0,1,0.7,1.6,2.2,1.6h19.7c1.4,0,2.1-0.5,2.1-1.6
C416.5,115.6,415.9,115,414.7,114.7z"/>
<path class="st0" d="M269.5,42.7c2.2,0,3.8,0.6,5,1.7c1.1,1.1,1.7,2.6,1.7,4.3c0,1.9-0.7,3.4-2,4.6c-1.4,1.2-3,1.8-5,1.8
c-2.4,0-4.3-0.9-5.8-2.6c-1.5-1.7-2.3-4.6-2.3-8.5c0-3.8,0.8-7,2.3-9.6c1.5-2.6,3.3-4.6,5.4-5.9c2.1-1.4,3.8-2.2,5.4-2.4
c0.7-0.1,1.2-0.1,1.6,0.1c0.4,0.2,0.6,0.5,0.7,1c0,0.4-0.1,0.8-0.4,1.1c-0.3,0.4-1,0.8-2,1.2c-1.7,0.7-3.1,1.6-4.4,2.7
c-1.3,1.1-2.2,2.4-2.9,3.8c-0.7,1.4-1,2.7-1,4c0,0.9,0.2,1.6,0.7,2c0.4,0.5,1.2,0.7,2.4,0.7H269.5z"/>
<circle class="st4" cx="33.2" cy="111" r="5.2"/>
<g>
<circle class="st5" cx="553" cy="67.7" r="24.7"/>
<circle class="st5" cx="553" cy="67.7" r="17.7"/>
<circle class="st4" cx="553" cy="67.7" r="11.1"/>
</g>
<path class="st6" d="M-43.7-26.5"/>
<path class="st1" d="M46.8-46"/>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 30.8.1, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 595.3 149" style="enable-background:new 0 0 595.3 149;" xml:space="preserve">
<style type="text/css">
.st0{fill:#292A2B;}
.st1{fill:none;stroke:#D59040;stroke-width:4;stroke-miterlimit:10;}
.st2{opacity:0.7;fill:url(#SVGID_1_);}
.st3{opacity:0.7;fill:url(#SVGID_00000033328845987633311050000001729256314044772286_);}
.st4{fill:#F5C26B;}
.st5{opacity:0.4;fill:#D59040;}
.st6{fill:#D59040;}
</style>
<path class="st0" d="M90.7,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4H75.2v-11c0-0.6-0.2-1.1-0.5-1.4C74.4,49.1,74,49,73.4,49c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C91,110.5,90.9,110.1,90.7,109.9z"/>
<path class="st0" d="M114.8,32.5v78.4c0,0.9,0.2,1.6,0.7,2.1c0.5,0.5,1.1,0.9,2,1l3.1,0.6c1.3,0.3,1.9,0.9,1.9,1.6
c0,1-0.7,1.6-2.2,1.6h-19.7c-0.7,0-1.2-0.1-1.6-0.4c-0.4-0.3-0.5-0.6-0.5-1.1c0-0.4,0.2-0.7,0.5-1c0.3-0.3,0.8-0.5,1.4-0.6l3.3-0.7
c0.9-0.2,1.5-0.5,2-1c0.5-0.5,0.7-1.2,0.7-2.2V39.4c0-0.8-0.1-1.4-0.5-1.7c-0.3-0.4-0.8-0.6-1.5-0.6l-5-0.1c-0.6,0-1.1-0.2-1.4-0.4
c-0.3-0.2-0.4-0.5-0.4-0.9c0-0.4,0.1-0.7,0.4-1c0.3-0.3,0.8-0.5,1.6-0.8l8.7-2.9c1-0.4,1.8-0.6,2.5-0.8c0.7-0.2,1.2-0.2,1.7-0.2
c0.8,0,1.4,0.2,1.8,0.6C114.7,31,114.8,31.7,114.8,32.5z"/>
<path class="st0" d="M258.1,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4h-13.2v-11c0-0.6-0.2-1.1-0.5-1.4c-0.3-0.3-0.7-0.5-1.3-0.5c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C258.4,110.5,258.3,110.1,258.1,109.9z"/>
<path class="st0" d="M407.3,76l-2-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,9.9-2.4c5,0,9,1.5,12,4.6
c3,3.1,4.5,7.3,4.5,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.3,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.9c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.3-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.6,5.8L407.3,76z"/>
<path class="st0" d="M511.4,100.7c-0.2-0.4-0.6-0.5-1.1-0.5c-0.4,0-0.7,0.1-0.9,0.3c-0.3,0.2-0.5,0.6-0.8,1.2c-1,3.3-3,6-6.2,8.2
s-6.9,3.2-11.3,3.2c-6.4,0-11.5-2.2-15.5-6.5c-4-4.3-6-10.5-6-18.6h36.3c1.9,0,3.3-0.4,4.3-1.2c1-0.8,1.4-2,1.4-3.5
c0-4.4-1-8.3-2.9-11.7c-1.9-3.3-4.6-6-8-7.9c-3.4-1.9-7.4-2.9-11.9-2.9c-5.6,0-10.4,1.3-14.5,4c-4.1,2.7-7.3,6.2-9.5,10.7
c-2.2,4.5-3.4,9.6-3.4,15.2c0,5.8,1.1,10.8,3.4,15c2.3,4.3,5.4,7.6,9.6,9.9s8.9,3.5,14.4,3.5c4.2,0,7.9-0.8,11.2-2.3s6-3.6,8.1-6.2
c2.1-2.6,3.2-5.5,3.6-8.6C511.7,101.5,511.6,101,511.4,100.7z M471.8,75.5c1.5-3.5,3.6-6.2,6.4-8.2c2.7-2,5.9-3,9.5-3
c4.5,0,8.1,1.6,10.8,4.9c2.7,3.3,4.1,7.6,4.1,13c0,1.7-1,2.5-2.9,2.5h-30C469.9,81.3,470.6,78.2,471.8,75.5z"/>
<path class="st1" d="M17.4,97.3"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="277.5667" y1="76.1994" x2="294.7258" y2="150.1156">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path class="st2" d="M532,80.7c-38.5,17.9-77.7,27-119.2,27.8c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9
c-0.3,0-0.6,0-0.8,0c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,91.2,116,80.6,162,80.5c0.3,0,0.6,0,0.8,0
c40.9,0,81.2,8.1,120.1,15.9c40,8,80.6,16.2,123.5,16.2c40.3,0,82.5-7.2,128-28.5C533.5,83,532.7,81.9,532,80.7z"/>
<linearGradient id="SVGID_00000136391255565502544090000014781660229579327890_" gradientUnits="userSpaceOnUse" x1="278.2577" y1="78.0403" x2="295.4174" y2="151.9591">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path style="opacity:0.7;fill:url(#SVGID_00000136391255565502544090000014781660229579327890_);" d="M533,82.2
c-38.8,18.2-78.3,27.4-120.2,28.2c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9c-0.3,0-0.6,0-0.8,0
c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,93.2,116,82.6,162,82.5c0.3,0,0.6,0,0.8,0c40.9,0,81.2,8.1,120.1,15.9
c40,8,80.6,16.2,123.5,16.2c40.7,0,83.3-7.4,129.3-29.1C534.7,84.4,533.8,83.4,533,82.2z"/>
<path class="st0" d="M113.2,76l-1.9-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,10-2.4c5,0,9,1.5,12.1,4.6
c3,3.1,4.6,7.3,4.6,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.2,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.8c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.4-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.7,5.8L113.2,76z"/>
<path class="st0" d="M220.9,111.7c-0.2-0.2-0.4-0.4-0.7-0.4c-0.3,0-0.6,0.1-0.8,0.3c-0.2,0.2-0.4,0.4-0.6,0.6
c-0.3,0.5-0.6,1-1.1,1.4c-0.4,0.5-1.2,0.7-2.3,0.7c-0.9,0-1.6-0.3-2.1-0.9c-0.5-0.6-0.8-1.7-0.8-3.1V76.6c0-4.8-1.5-8.6-4.6-11.5
c-3.1-2.9-7.7-4.3-14-4.3c-4.6,0-8.5,0.7-11.8,2.1c-3.3,1.4-5.8,3.2-7.6,5.5c-1.8,2.2-2.7,4.5-2.7,6.9c0,1.4,0.4,2.5,1.2,3.3
c0.8,0.8,1.9,1.3,3.3,1.3c1.6,0,2.8-0.4,3.8-1.1c0.9-0.8,1.4-1.9,1.4-3.4v-4.1c0-2.1,0.9-3.9,2.6-5.4c1.8-1.5,4.5-2.2,8.2-2.2
c3.8,0,6.7,1.1,8.8,3.2c2,2.1,3.1,5.3,3.1,9.4v15.5c-1.4-0.5-2.9-0.9-4.6-1.3c-2.4-0.6-5.1-0.8-7.9-0.8c-7.1,0-12.6,1.5-16.6,4.5
c-4,3-6,6.7-6,11.2c0,4.3,1.6,7.6,4.7,10.1c3.1,2.4,7.1,3.7,12,3.7c4.2,0,8.2-0.9,11.9-2.8c2.6-1.3,4.9-2.9,6.9-4.9
c0.1,2.5,0.8,4.4,2.2,5.6c1.5,1.4,3.6,2.1,6.3,2.1c1.8,0,3.2-0.3,4.4-1c1.2-0.7,2.1-1.5,2.7-2.5c0.6-1,0.9-2,0.9-2.9
C221.2,112.3,221.1,112,220.9,111.7z M188.9,114.8c-3.2,0-5.8-0.9-7.9-2.8c-2.1-1.8-3.1-4.5-3.1-7.9c0-3.6,1.3-6.4,3.9-8.5
c2.6-2.1,6.2-3.1,10.9-3.1c2.4,0,4.7,0.3,6.8,0.8c1.6,0.4,3.1,0.9,4.6,1.4v13.5c-1.8,1.8-3.8,3.2-6,4.3
C195.2,114.1,192.1,114.8,188.9,114.8z"/>
<path class="st0" d="M297.5,64.3c-3.2,0-5.8,0.9-7.7,2.6c-1.9,1.7-2.9,4-2.9,6.9c0,1.8,0.4,3.3,1.3,4.7c0.9,1.4,2.3,2.7,4.5,3.8
c2.1,1.1,5.1,2.2,8.9,3.3c4.9,1.2,8.8,2.8,11.7,4.6s5,3.8,6.2,6c1.2,2.2,1.8,4.6,1.8,7.3c0,4.6-1.7,8.4-5,11.3
c-3.4,2.9-8.1,4.4-14.2,4.4c-2.3,0-4.3-0.2-5.8-0.7c-1.5-0.4-2.8-0.9-3.9-1.3c-1.1-0.4-2-0.6-2.8-0.6c-0.8,0-1.3,0.2-1.7,0.7
c-0.4,0.4-0.7,0.9-1,1.3c-0.3,0.4-0.8,0.6-1.4,0.6c-0.4,0-0.7-0.1-1-0.4c-0.2-0.3-0.5-0.7-0.8-1.3l-4.8-13.2c-0.3-0.8-0.4-1.4-0.3-2
c0.1-0.6,0.5-1,1-1.2c0.6-0.2,1.1-0.2,1.5,0c0.4,0.3,0.8,0.7,1.1,1.2c1.8,3.5,3.7,6.2,5.7,8.2c2.1,2,4.3,3.3,6.6,4.1
c2.3,0.8,4.7,1.1,7.2,1.1c4.2,0,7.3-0.9,9.4-2.7c2.1-1.8,3.1-4.2,3.1-7.1c0-1.7-0.5-3.3-1.4-4.8c-0.9-1.5-2.5-2.9-4.7-4.1
c-2.3-1.3-5.4-2.5-9.5-3.5c-4.6-1.2-8.3-2.6-11-4.2c-2.7-1.6-4.7-3.5-5.9-5.7c-1.2-2.2-1.8-4.7-1.8-7.6s0.7-5.4,2.1-7.7
c1.4-2.3,3.4-4,6.1-5.3c2.6-1.3,5.8-2,9.4-2c2.3,0,4.2,0.2,5.7,0.7c1.5,0.5,2.7,1,3.7,1.4c1,0.5,1.8,0.7,2.5,0.7
c0.8,0,1.4-0.2,1.8-0.7c0.4-0.5,0.7-1,1-1.4c0.3-0.5,0.8-0.7,1.3-0.7c0.3,0,0.6,0.1,0.9,0.3c0.3,0.2,0.5,0.6,0.6,1.2l4,12.5
c0.3,0.9,0.4,1.7,0.4,2.4s-0.4,1.2-1,1.5c-0.6,0.2-1,0.1-1.5-0.2s-0.9-1-1.3-1.8c-2.1-4.4-4.8-7.6-8-9.5
C304.4,65.2,301,64.3,297.5,64.3z"/>
<path class="st0" d="M414.7,114.7l-2.9-0.6c-0.9-0.2-1.6-0.6-2.2-1.2c-0.5-0.6-0.8-1.3-0.8-2.3V78.1c0-5.4-1.5-9.6-4.5-12.7
c-3-3.1-7-4.6-12-4.6c-3.2,0-6.5,0.8-9.9,2.4c-3.2,1.5-7,4.2-11.2,7.8v-7.2c0-0.8-0.2-1.4-0.6-1.8s-1-0.6-1.8-0.6
c-0.4,0-1,0.1-1.7,0.2c-0.7,0.1-1.5,0.4-2.5,0.8l-8.7,2.9c-0.8,0.2-1.3,0.5-1.6,0.8c-0.3,0.3-0.4,0.6-0.4,1c0,0.4,0.1,0.7,0.4,0.9
c0.3,0.2,0.7,0.4,1.4,0.4l5,0.1c0.7,0,1.2,0.2,1.5,0.6c0.3,0.4,0.5,0.9,0.5,1.7v40.1c0,1-0.2,1.7-0.7,2.2c-0.5,0.5-1.1,0.8-2,1
l-3.3,0.7c-0.6,0.2-1.1,0.4-1.4,0.6c-0.3,0.3-0.5,0.6-0.5,1c0,0.4,0.2,0.8,0.5,1.1c0.4,0.3,0.9,0.4,1.6,0.4h19.7
c1.5,0,2.2-0.5,2.2-1.6c0-0.8-0.6-1.3-1.9-1.6l-3.1-0.6c-0.8-0.2-1.5-0.5-1.9-1c-0.5-0.5-0.7-1.2-0.7-2.1V74.6l1.2-1
c3.2-2.6,6-4.6,8.6-5.8c2.6-1.2,5-1.8,7.3-1.8c3.7,0,6.6,1.1,8.9,3.3s3.3,5.6,3.3,10v31.1c0,1-0.2,1.8-0.7,2.3
c-0.4,0.6-1.1,0.9-2.1,1.1l-3.3,0.6c-1.2,0.3-1.9,0.9-1.9,1.6c0,1,0.7,1.6,2.2,1.6h19.7c1.4,0,2.1-0.5,2.1-1.6
C416.5,115.6,415.9,115,414.7,114.7z"/>
<path class="st0" d="M269.5,42.7c2.2,0,3.8,0.6,5,1.7c1.1,1.1,1.7,2.6,1.7,4.3c0,1.9-0.7,3.4-2,4.6c-1.4,1.2-3,1.8-5,1.8
c-2.4,0-4.3-0.9-5.8-2.6c-1.5-1.7-2.3-4.6-2.3-8.5c0-3.8,0.8-7,2.3-9.6c1.5-2.6,3.3-4.6,5.4-5.9c2.1-1.4,3.8-2.2,5.4-2.4
c0.7-0.1,1.2-0.1,1.6,0.1c0.4,0.2,0.6,0.5,0.7,1c0,0.4-0.1,0.8-0.4,1.1c-0.3,0.4-1,0.8-2,1.2c-1.7,0.7-3.1,1.6-4.4,2.7
c-1.3,1.1-2.2,2.4-2.9,3.8c-0.7,1.4-1,2.7-1,4c0,0.9,0.2,1.6,0.7,2c0.4,0.5,1.2,0.7,2.4,0.7H269.5z"/>
<circle class="st4" cx="33.2" cy="111" r="5.2"/>
<g>
<circle class="st5" cx="553" cy="67.7" r="24.7"/>
<circle class="st5" cx="553" cy="67.7" r="17.7"/>
<circle class="st6" cx="553" cy="67.7" r="11.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

@ -1,33 +1,42 @@
<template> <template>
<button class="add-event-btn glass--button" @click="$emit('click')"> <button class="add-event-btn" aria-label="Neues Event anlegen" @click="$emit('click')">
<q-icon name="add" size="22px" :color="isDark ? 'white' : 'grey-8'" /> <q-icon name="add" size="26px" />
</button> </button>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { useQuasar } from 'quasar'
defineEmits(['click']) defineEmits(['click'])
const $q = useQuasar()
const isDark = computed(() => $q.dark.isActive)
</script> </script>
<style scoped> <style scoped>
/*
* The one primary action on the screen: bottom right, where the thumb
* rests, larger than the utility buttons and tinted with the accent so it
* reads as the main call to action.
*/
.add-event-btn { .add-event-btn {
position: fixed; width: 52px;
bottom: 16px; height: 52px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%; border-radius: 50%;
border: 1px solid rgba(var(--tm-accent-rgb, 115, 115, 115), 0.45);
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.32);
color: inherit;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer;
padding: 0; padding: 0;
cursor: pointer;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
transition: background 0.2s ease, transform 0.1s ease;
}
.add-event-btn:hover {
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.45);
}
.add-event-btn:active {
transform: scale(0.94);
} }
</style> </style>

View file

@ -1,7 +1,7 @@
<template> <template>
<ModalCard <ModalCard
:open="open" :open="open"
title="Allgemein" title="Einstellungen"
:tabs="tabs" :tabs="tabs"
v-model="activeTab" v-model="activeTab"
@close="$emit('close')" @close="$emit('close')"
@ -110,61 +110,46 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Entwickler -->
<div v-else-if="activeTab === 'developer'">
<div class="settings-section__title">Entwickler</div>
<div class="settings-section__divider" /> <div class="settings-section__divider" />
<!-- Sprache -->
<div class="settings-row"> <div class="settings-row">
<span class="settings-row__label">Sprache</span> <div>
<div class="settings-row__control"> <div class="settings-row__label">FPS-Anzeige</div>
<select <div class="settings-row__hint">Bildrate der LifeWave oben rechts einblenden.</div>
:value="settingsStore.language"
@change="e => { settingsStore.language = e.target.value }"
class="settings-select"
:class="{ 'settings-select--dark': isDark }"
>
<option v-for="lang in LANGUAGES" :key="lang.value" :value="lang.value">
{{ lang.label }}
</option>
</select>
</div> </div>
</div>
<div class="settings-section__divider" />
<!-- FPS-Anzeige -->
<div class="settings-row">
<span class="settings-row__label">FPS-Anzeige</span>
<div class="settings-row__control"> <div class="settings-row__control">
<q-toggle <q-toggle
:model-value="settingsStore.showFps" :model-value="settingsStore.showFps"
@update:model-value="v => { settingsStore.showFps = v }" @update:model-value="v => { settingsStore.showFps = v }"
dense dense
color="primary" color="primary"
aria-label="FPS-Anzeige"
/> />
</div> </div>
</div> </div>
</div>
<!-- Benachrichtigungen (placeholder) -->
<div v-else-if="activeTab === 'notifications'">
<div class="settings-section__title">Benachrichtigungen</div>
<div class="settings-section__divider" /> <div class="settings-section__divider" />
<p class="settings-placeholder">Kommt bald.</p>
</div>
<!-- Personalisierung (placeholder) --> <div class="settings-row">
<div v-else-if="activeTab === 'personalize'"> <div>
<div class="settings-section__title">Personalisierung</div> <div class="settings-row__label">Version</div>
<div class="settings-section__divider" /> <div class="settings-row__hint">{{ APP_VERSION }}</div>
<p class="settings-placeholder">Kommt bald.</p> </div>
</div>
</div> </div>
</ModalCard> </ModalCard>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import ModalCard from 'components/ModalCard.vue' import ModalCard from 'components/ModalCard.vue'
import { useSettingsStore, ACCENT_COLORS, LANGUAGES } from 'stores/settings' import { useSettingsStore, ACCENT_COLORS } from 'stores/settings'
import { APP_VERSION } from 'src/config/appVersion'
defineProps({ open: { type: Boolean, default: false } }) defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close']) defineEmits(['close'])
@ -178,8 +163,7 @@ const accentDropdownOpen = ref(false)
const tabs = [ const tabs = [
{ value: 'general', label: 'Allgemein', icon: 'settings' }, { value: 'general', label: 'Allgemein', icon: 'settings' },
{ value: 'notifications', label: 'Benachrichtigungen', icon: 'notifications' }, { value: 'developer', label: 'Entwickler', icon: 'code' }
{ value: 'personalize', label: 'Personalisierung', icon: 'history' }
] ]
const currentAccentHex = computed(() => { const currentAccentHex = computed(() => {
@ -189,7 +173,7 @@ const currentAccentHex = computed(() => {
const currentAccentLabel = computed(() => { const currentAccentLabel = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor) const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
return found?.label ?? 'Base' return found?.label ?? 'Grau'
}) })
const emotionGradientCss = computed(() => const emotionGradientCss = computed(() =>
@ -201,23 +185,10 @@ function selectAccent(value) {
accentDropdownOpen.value = false accentDropdownOpen.value = false
} }
// Applying the mode to Quasar happens once, globally, in App.vue.
function onAppearanceChange(e) { function onAppearanceChange(e) {
const value = e.target.value settingsStore.appearance = e.target.value
settingsStore.appearance = value
applyAppearance(value)
} }
function applyAppearance(mode) {
if (mode === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
$q.dark.set(prefersDark)
} else {
$q.dark.set(mode === 'dark')
}
}
// Apply appearance on mount and when it changes externally
watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
</script> </script>
<style scoped> <style scoped>
@ -254,6 +225,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
position: relative; position: relative;
} }
.settings-row__hint {
font-size: 12px;
opacity: 0.55;
margin-top: 2px;
}
/* Native select styled */ /* Native select styled */
.settings-select { .settings-select {
appearance: none; appearance: none;
@ -392,12 +369,6 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
border-radius: 6px; border-radius: 6px;
} }
/* Placeholder text */
.settings-placeholder {
font-size: 14px;
opacity: 0.5;
}
/* Dropdown transition */ /* Dropdown transition */
.dropdown-enter-active, .dropdown-enter-active,
.dropdown-leave-active { .dropdown-leave-active {

View file

@ -0,0 +1,56 @@
<template>
<img
:src="src"
:height="height"
:style="{ height: `${height}px`, width: 'auto' }"
alt="That's Me"
class="brand-logo"
draggable="false"
/>
</template>
<script setup>
import { computed } from 'vue'
import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings'
import { logoVariantForBackground } from 'src/utils/brand'
import logoPositive from 'assets/logo-thatsme-positiv.svg'
import logoNegative from 'assets/logo-thatsme-negativ.svg'
/**
* The wordmark, in the variant that reads on the current ground.
* `variant="auto"` follows the LifeWave background colours; the login page
* and other fixed surfaces pass an explicit variant.
*/
const props = defineProps({
variant: {
type: String,
default: 'auto',
validator: value => ['auto', 'positive', 'negative'].includes(value)
},
height: { type: Number, default: 26 }
})
const $q = useQuasar()
const settingsStore = useSettingsStore()
const resolvedVariant = computed(() => {
if (props.variant !== 'auto') return props.variant
const fl = settingsStore.floatingLines
return logoVariantForBackground({
bgEdge: fl.bgEdge,
bgCenter: fl.bgCenter,
backgroundImage: fl.backgroundImage,
isDark: $q.dark.isActive
})
})
const src = computed(() => (resolvedVariant.value === 'positive' ? logoPositive : logoNegative))
</script>
<style scoped>
.brand-logo {
display: block;
transition: opacity 0.25s ease;
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="v => $emit('update:modelValue', v)">
<q-card class="confirm-dialog" :class="{ 'confirm-dialog--dark': isDark }">
<q-card-section>
<div class="confirm-dialog__title">{{ title }}</div>
<div v-if="message" class="confirm-dialog__copy">{{ message }}</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps :label="cancelLabel" @click="$emit('update:modelValue', false)" />
<q-btn
unelevated
no-caps
color="negative"
:label="confirmLabel"
@click="onConfirm"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { useQuasar } from 'quasar'
defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, required: true },
message: { type: String, default: '' },
confirmLabel: { type: String, default: 'Wirklich löschen' },
cancelLabel: { type: String, default: 'Abbrechen' }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const $q = useQuasar()
const isDark = computed(() => $q.dark.isActive)
function onConfirm() {
emit('confirm')
emit('update:modelValue', false)
}
</script>
<style scoped>
.confirm-dialog {
width: min(360px, calc(100vw - 32px));
border-radius: 18px;
background: rgba(255, 255, 255, 0.96);
color: #1a1a1a;
}
.confirm-dialog--dark {
background: rgba(30, 30, 30, 0.96);
color: #f5f5f5;
}
.confirm-dialog__title {
font-size: 18px;
font-weight: 700;
}
.confirm-dialog__copy {
margin-top: 6px;
font-size: 13px;
line-height: 1.4;
opacity: 0.72;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@
'glow-dot--ghost': isGhost, 'glow-dot--ghost': isGhost,
'glow-dot--selected': selected, 'glow-dot--selected': selected,
'glow-dot--dimmed': isDimmed, 'glow-dot--dimmed': isDimmed,
'glow-dot--milestone': Boolean(event.milestone) && !isGhost,
'glow-dot--label-above': labelAbove 'glow-dot--label-above': labelAbove
}" }"
:style="dotStyle" :style="dotStyle"
@ -23,10 +24,11 @@
alt="" alt=""
/> />
</div> </div>
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle"> <!-- An event without a title still needs a label; the date stands in -->
<div v-if="!isGhost" class="glow-dot__label" :style="labelStyle">
<span class="glow-dot__connector" :style="connectorStyle"></span> <span class="glow-dot__connector" :style="connectorStyle"></span>
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span> <span class="glow-dot__title" :style="titleStyle">{{ labelTitle }}</span>
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span> <span v-if="hasTitle" class="glow-dot__date" :style="dateStyle">{{ formattedSpan }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -36,6 +38,8 @@ import { computed } from 'vue'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { useImageCache } from 'composables/useImageCache' import { useImageCache } from 'composables/useImageCache'
import { emotionToYPercent, isLowerHalf } from 'src/utils/timelinePosition'
import { findEventCategory } from 'src/config/eventCategories'
const props = defineProps({ const props = defineProps({
event: { type: Object, required: true }, event: { type: Object, required: true },
@ -60,12 +64,11 @@ const glowColor = computed(() => eventsStore.getGlowColor(props.event))
const dpr = Math.min(window.devicePixelRatio || 1, 2) const dpr = Math.min(window.devicePixelRatio || 1, 2)
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr) const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
// Y position: emotion +1 → top (20%), 0 → middle (47%), -1 → bottom (74%) // Y position from emotion; shared with the shader points, see utils/timelinePosition
const yPercent = computed(() => emotionToYPercent(props.event.emotion))
const yPercent = computed(() => 48 - props.event.emotion * 30)
// Label above dot when dot is in lower half, below when in upper half // Label above dot when dot is in lower half, below when in upper half
const labelAbove = computed(() => yPercent.value > 48) const labelAbove = computed(() => isLowerHalf(yPercent.value))
// Format date as "12. Feb 2024" // Format date as "12. Feb 2024"
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'] const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
@ -73,10 +76,28 @@ const formattedDate = computed(() => {
const d = new Date(props.event.date) const d = new Date(props.event.date)
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}` return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
}) })
// A span reads "1. Apr – 14. Apr 2019", or with both years when they differ.
const formattedSpan = computed(() => {
const end = props.event.endDate
if (!end || end === props.event.date) return formattedDate.value
const a = new Date(props.event.date)
const b = new Date(end)
const from = a.getFullYear() === b.getFullYear()
? `${a.getDate()}. ${MONTH_SHORT[a.getMonth()]}`
: formattedDate.value
return `${from} – ${b.getDate()}. ${MONTH_SHORT[b.getMonth()]} ${b.getFullYear()}`
})
const hasTitle = computed(() => String(props.event.title ?? '').trim() !== '')
// Without a title the category names the event; without both, the date.
const labelTitle = computed(() => {
if (hasTitle.value) return props.event.title
return findEventCategory(props.event.category)?.label ?? formattedSpan.value
})
const dotAriaLabel = computed(() => { const dotAriaLabel = computed(() => {
if (props.isGhost) return undefined if (props.isGhost) return undefined
const title = props.event.title || 'Event' const title = props.event.title || findEventCategory(props.event.category)?.label || 'Event'
return `${title}, ${formattedDate.value}` const milestone = props.event.milestone ? ', Meilenstein' : ''
return `${title}, ${formattedSpan.value}${milestone}`
}) })
// Label font sizes per setting // Label font sizes per setting
@ -187,8 +208,16 @@ function onSelect() {
z-index: 15; z-index: 15;
} }
/* A milestone carries a thin gold ring outside the dot */
.glow-dot--milestone .glow-dot__inner {
outline: 2px solid var(--tm-gold, #f5c26b);
outline-offset: 3px;
}
/* While one event is open, the others step back a little so the selected
dot reads as selected. Not too far: the wave should stay legible. */
.glow-dot--dimmed { .glow-dot--dimmed {
opacity: 1; opacity: 0.6;
} }
/* Event label (title + date) */ /* Event label (title + date) */

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
<template>
<div class="timeline-nav glass--button" role="group" aria-label="Zeitnavigation">
<button
type="button"
class="timeline-nav__arrow"
:disabled="!canPrev"
aria-label="Ein Jahr zurück"
@click="$emit('prev')"
>
<q-icon name="chevron_left" size="22px" />
</button>
<!-- Current position; a tap opens the year list -->
<button
type="button"
class="timeline-nav__label"
:disabled="years.length === 0"
aria-haspopup="listbox"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
<span v-if="month" class="timeline-nav__month">{{ month }}</span>
<span class="timeline-nav__year">{{ year ?? '–' }}</span>
<q-menu
v-model="menuOpen"
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="timeline-nav__menu"
transition-show="jump-up"
transition-hide="jump-down"
>
<q-list dense role="listbox" class="timeline-nav__years">
<q-item
v-for="entry in years"
:key="entry"
clickable
role="option"
:active="entry === year"
:aria-selected="entry === year"
active-class="timeline-nav__year-item--active"
class="timeline-nav__year-item"
@click.stop="jumpTo(entry)"
>
<q-item-section>{{ entry }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</button>
<button
type="button"
class="timeline-nav__arrow"
:disabled="!canNext"
aria-label="Ein Jahr vor"
@click="$emit('next')"
>
<q-icon name="chevron_right" size="22px" />
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
/** Year of the event at the viewport centre. */
year: { type: Number, default: null },
/** Month name of that event, already formatted. */
month: { type: String, default: '' },
/** Every year that occurs in the data, ascending. */
years: { type: Array, default: () => [] },
canPrev: { type: Boolean, default: false },
canNext: { type: Boolean, default: false }
})
const emit = defineEmits(['prev', 'next', 'jump'])
const menuOpen = ref(false)
function jumpTo(year) {
menuOpen.value = false
emit('jump', year)
}
</script>
<style scoped>
.timeline-nav {
display: inline-flex;
align-items: center;
height: 44px;
padding: 0 4px;
border-radius: 22px;
color: inherit;
}
.timeline-nav__arrow,
.timeline-nav__label {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
border-radius: 18px;
-webkit-tap-highlight-color: transparent;
}
.timeline-nav__arrow {
width: 36px;
padding: 0;
opacity: 0.75;
}
.timeline-nav__arrow:disabled {
opacity: 0.25;
cursor: default;
}
.timeline-nav__arrow:not(:disabled):active {
background: rgba(128, 128, 128, 0.18);
}
.timeline-nav__label {
gap: 6px;
padding: 0 10px;
min-width: 112px;
font-variant-numeric: tabular-nums;
}
.timeline-nav__label:disabled {
cursor: default;
}
.timeline-nav__month {
font-size: 13px;
font-weight: 500;
opacity: 0.8;
}
.timeline-nav__year {
font-size: 16px;
font-weight: 700;
}
.timeline-nav__years {
max-height: 50dvh;
min-width: 120px;
padding: 4px;
}
.timeline-nav__year-item {
border-radius: 8px;
font-variant-numeric: tabular-nums;
text-align: center;
}
.timeline-nav__year-item--active {
font-weight: 700;
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.2);
}
</style>

View file

@ -20,52 +20,8 @@
:selected="eventsStore.selectedEventId === event.id" :selected="eventsStore.selectedEventId === event.id"
@select="$emit('dotSelect', event.id)" @select="$emit('dotSelect', event.id)"
/> />
<!-- Month labels — only visible -->
<div class="timeline__labels">
<div
v-for="{ label, globalIndex } in visibleLabels"
:key="label.key"
class="timeline__month"
:style="{ left: getEventX(globalIndex) + 'px' }"
:class="{ 'timeline__month--active': label.key === activeLabel }"
@click="scrollToIndex(globalIndex)"
>
{{ label.month }}
</div>
</div>
</div> </div>
</div> </div>
<!-- Sticky year labels with nav arrows (outside scroll container) -->
<div class="timeline__sticky-years">
<TransitionGroup name="year-fade">
<div
v-for="label in stickyYearLabels"
:key="label.year"
class="timeline__sticky-year-group"
:style="{ left: label.left + 'px' }"
>
<button
v-if="label.hasPrev"
class="timeline__year-arrow timeline__year-arrow--prev"
@click="scrollToYearCenter(label.year - 1)"
>
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M7 1L3 5l4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="timeline__sticky-year" @click="scrollToYearCenter(label.year)">
{{ label.year }}
</span>
<button
v-if="label.hasNext"
class="timeline__year-arrow timeline__year-arrow--next"
@click="scrollToYearCenter(label.year + 1)"
>
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</TransitionGroup>
</div>
</div> </div>
</template> </template>
@ -74,6 +30,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings' import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings'
import GlowDot from 'components/GlowDot.vue' import GlowDot from 'components/GlowDot.vue'
import { calendarMonthIndex, calendarYear, compareCalendarDates } from 'src/utils/calendarDate'
const emit = defineEmits(['dotSelect', 'viewUpdate']) const emit = defineEmits(['dotSelect', 'viewUpdate'])
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
@ -100,8 +57,7 @@ const displayEvents = computed(() => {
const sorted = [...eventsStore.sortedEvents] const sorted = [...eventsStore.sortedEvents]
if (!showGhost.value) return sorted if (!showGhost.value) return sorted
const ghost = eventsStore.ghostEvent const ghost = eventsStore.ghostEvent
const ghostDate = new Date(ghost.date) const insertIdx = sorted.findIndex((e) => compareCalendarDates(e.date, ghost.date) > 0)
const insertIdx = sorted.findIndex((e) => new Date(e.date) > ghostDate)
if (insertIdx === -1) { if (insertIdx === -1) {
sorted.push(ghost) sorted.push(ghost)
} else { } else {
@ -122,21 +78,7 @@ function getEventX(index) {
return PADDING.value + index * EVENT_SPACING.value return PADDING.value + index * EVENT_SPACING.value
} }
// Month names const MONTHS_LONG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
// One label per event showing its month
const eventLabels = computed(() => {
return displayEvents.value.map((event, index) => {
const d = new Date(event.date)
return {
key: `${event.id}-${index}`,
month: MONTHS[d.getMonth()],
year: d.getFullYear(),
fullMonth: `${d.getFullYear()}-${d.getMonth()}`
}
})
})
// Year ranges — for each year, the world-space X range of its events // Year ranges — for each year, the world-space X range of its events
const yearRanges = computed(() => { const yearRanges = computed(() => {
@ -144,11 +86,11 @@ const yearRanges = computed(() => {
const events = displayEvents.value const events = displayEvents.value
if (events.length === 0) return ranges if (events.length === 0) return ranges
let currentYear = new Date(events[0].date).getFullYear() let currentYear = calendarYear(events[0].date)
let startIdx = 0 let startIdx = 0
for (let i = 1; i < events.length; i++) { for (let i = 1; i < events.length; i++) {
const year = new Date(events[i].date).getFullYear() const year = calendarYear(events[i].date)
if (year !== currentYear) { if (year !== currentYear) {
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) }) ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
currentYear = year currentYear = year
@ -159,57 +101,9 @@ const yearRanges = computed(() => {
return ranges return ranges
}) })
// All years that exist in the data (for prev/next navigation) // All years that exist in the data, ascending (for the year navigation)
const allYears = computed(() => yearRanges.value.map(r => r.year)) const allYears = computed(() => yearRanges.value.map(r => r.year))
// Sticky year labels — positioned relative to viewport, clamped to edges
const YEAR_MARGIN = 24
const stickyYearLabels = computed(() => {
const sl = scrollLeft.value
const vw = viewportWidth.value
const viewLeft = sl
const viewRight = sl + vw
const years = allYears.value
// Find years whose event range overlaps the viewport
const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight)
if (visible.length === 0) return []
function makeLabel(year, left) {
const idx = years.indexOf(year)
return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 }
}
if (visible.length === 1) {
const r = visible[0]
const visStart = Math.max(r.startX, viewLeft)
const visEnd = Math.min(r.endX, viewRight)
const center = (visStart + visEnd) / 2 - sl
const clamped = Math.max(YEAR_MARGIN, Math.min(vw - YEAR_MARGIN, center))
return [makeLabel(r.year, clamped)]
}
// Multiple years: first pins left, last pins right, middles float naturally
const result = []
for (let i = 0; i < visible.length; i++) {
const r = visible[i]
const visStart = Math.max(r.startX, viewLeft)
const visEnd = Math.min(r.endX, viewRight)
const center = (visStart + visEnd) / 2 - sl
let pos
if (i === 0) {
pos = Math.max(YEAR_MARGIN, Math.min(vw / 2 - 30, center))
} else if (i === visible.length - 1) {
pos = Math.min(vw - YEAR_MARGIN, Math.max(vw / 2 + 30, center))
} else {
pos = Math.max(YEAR_MARGIN + 60, Math.min(vw - YEAR_MARGIN - 60, center))
}
result.push(makeLabel(r.year, pos))
}
return result
})
// Virtualization: only render events near the viewport // Virtualization: only render events near the viewport
const VIS_BUFFER = 2 const VIS_BUFFER = 2
const VIEW_EMIT_BUFFER = 3 const VIEW_EMIT_BUFFER = 3
@ -238,25 +132,27 @@ const visibleEvents = computed(() => {
})) }))
}) })
const visibleLabels = computed(() => { // The event closest to the viewport centre (O(1) via index)
const { start, end } = visibleRange.value const activeIndex = computed(() => {
if (end < start) return []
return eventLabels.value.slice(start, end + 1).map((label, i) => ({
label,
globalIndex: start + i
}))
})
// Active label — closest event to center of viewport (O(1) via index)
const activeLabel = computed(() => {
const total = displayEvents.value.length const total = displayEvents.value.length
if (total === 0) return null if (total === 0) return -1
const centerX = scrollLeft.value + viewportWidth.value / 2 const centerX = scrollLeft.value + viewportWidth.value / 2
const spacing = EVENT_SPACING.value const spacing = EVENT_SPACING.value
if (spacing <= 0) return null if (spacing <= 0) return 0
const index = Math.round((centerX - PADDING.value) / spacing) const index = Math.round((centerX - PADDING.value) / spacing)
const clamped = Math.max(0, Math.min(total - 1, index)) return Math.max(0, Math.min(total - 1, index))
return eventLabels.value[clamped]?.key ?? null })
// Month and year of the centred event, read from the calendar date so the
// navigation never drifts into the neighbouring month in western zones.
const activePosition = computed(() => {
const event = displayEvents.value[activeIndex.value]
if (!event) return null
const month = calendarMonthIndex(event.date)
return {
year: calendarYear(event.date),
month: month === null ? '' : MONTHS_LONG[month]
}
}) })
function onScroll() { function onScroll() {
@ -267,15 +163,6 @@ function onScroll() {
} }
} }
function scrollToIndex(index) {
if (!timelineRef.value) return
const x = getEventX(index)
timelineRef.value.scrollTo({
left: x - viewportWidth.value / 2,
behavior: 'smooth'
})
}
function scrollToYearCenter(year) { function scrollToYearCenter(year) {
if (!timelineRef.value) return if (!timelineRef.value) return
// Find exact year, or nearest in the requested direction // Find exact year, or nearest in the requested direction
@ -632,7 +519,19 @@ function zoomTo(value) {
if (newZoom !== zoomLevel.value) applyZoom(newZoom) if (newZoom !== zoomLevel.value) applyZoom(newZoom)
} }
defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZOOM, forceReflow }) defineExpose({
timelineRef,
zoomIn,
zoomOut,
zoomTo,
zoomLevel,
MIN_ZOOM,
MAX_ZOOM,
forceReflow,
activePosition,
years: allYears,
scrollToYearCenter
})
</script> </script>
<style scoped> <style scoped>
@ -641,7 +540,8 @@ defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZO
top: 40px; top: 40px;
left: 0; left: 0;
right: 0; right: 0;
bottom: 60px; /* Leaves room for the bottom bar only; the label rows are gone. */
bottom: 72px;
} }
.timeline { .timeline {
@ -662,91 +562,4 @@ defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZO
height: 100%; height: 100%;
} }
/* Month labels */
.timeline__labels {
position: absolute;
bottom: 44px;
left: 0;
right: 0;
height: 24px;
}
.timeline__month {
position: absolute;
transform: translateX(-50%);
font-size: 12px;
font-weight: 400;
opacity: 0.35;
transition: opacity 0.3s ease, font-size 0.3s ease, font-weight 0.3s ease;
white-space: nowrap;
cursor: pointer;
}
.timeline__month--active {
font-size: 15px;
font-weight: 700;
opacity: 1;
}
/* Sticky year labels — positioned outside the scroll container */
.timeline__sticky-years {
position: absolute;
bottom: 4px;
left: 0;
right: 0;
height: 32px;
pointer-events: none;
z-index: 2;
}
.timeline__sticky-year-group {
position: absolute;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
pointer-events: auto;
transition: left 0.15s ease-out;
}
.timeline__sticky-year {
font-size: 16px;
font-weight: 700;
opacity: 0.6;
white-space: nowrap;
cursor: pointer;
}
.timeline__year-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: inherit;
opacity: 0.45;
cursor: pointer;
padding: 0;
transition: opacity 0.2s ease, background 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.timeline__year-arrow:active {
opacity: 0.8;
background: rgba(255, 255, 255, 0.25);
}
/* TransitionGroup animations */
.year-fade-enter-active,
.year-fade-leave-active {
transition: opacity 0.3s ease;
}
.year-fade-enter-from,
.year-fade-leave-to {
opacity: 0;
}
</style> </style>

View file

@ -14,70 +14,28 @@
<div class="user-menu__divider" /> <div class="user-menu__divider" />
<!-- Menu items --> <!-- Menu items: only what exists. Placeholders for later features
were removed rather than left as dead buttons. -->
<div class="user-menu__items"> <div class="user-menu__items">
<button class="user-menu__item" @click="$emit('navigate', 'upgrade')"> <button class="user-menu__item" @click="$emit('navigate', 'appearance')">
<q-icon name="auto_awesome" size="20px" /> <q-icon name="tune" size="20px" />
<span>Plan upgraden</span> <span>Darstellung</span>
</button>
<button class="user-menu__item" @click="$emit('navigate', 'personalize')">
<q-icon name="palette" size="20px" />
<span>Personalisierung</span>
</button> </button>
<button class="user-menu__item" @click="$emit('navigate', 'settings')"> <button class="user-menu__item" @click="$emit('navigate', 'settings')">
<q-icon name="settings" size="20px" /> <q-icon name="settings" size="20px" />
<span>Einstellungen</span> <span>Einstellungen</span>
</button> </button>
</div>
<div class="user-menu__divider" />
<!-- Help with submenu -->
<div class="user-menu__items">
<button <button
class="user-menu__item" class="user-menu__item"
:class="{ 'user-menu__item--active': helpOpen }" role="switch"
@click="helpOpen = !helpOpen" :aria-checked="isDark"
@click="setDarkMode(!isDark)"
> >
<q-icon name="support_agent" size="20px" /> <q-icon :name="isDark ? 'dark_mode' : 'light_mode'" size="20px" />
<span>Hilfe</span> <span>Dunkelmodus</span>
<q-icon <!-- Visual only: the whole row is the switch -->
name="chevron_right" <q-toggle :model-value="isDark" dense class="user-menu__toggle" tabindex="-1" />
size="18px"
class="user-menu__chevron"
:class="{ 'user-menu__chevron--open': helpOpen }"
/>
</button> </button>
<!-- Help sub-items -->
<Transition name="expand">
<div v-if="helpOpen" class="user-menu__sub">
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'help-center')">
<q-icon name="help_outline" size="18px" />
<span>Hilfecenter</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'release-notes')">
<q-icon name="new_releases" size="18px" />
<span>Release-Hinweise</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'terms')">
<q-icon name="description" size="18px" />
<span>AGB und Richtlinien</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'report-bug')">
<q-icon name="bug_report" size="18px" />
<span>Fehler melden</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'download')">
<q-icon name="download" size="18px" />
<span>Apps herunterladen</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'shortcuts')">
<q-icon name="keyboard" size="18px" />
<span>Tastaturkuerzel</span>
</button>
</div>
</Transition>
</div> </div>
<div class="user-menu__divider" /> <div class="user-menu__divider" />
@ -96,7 +54,7 @@
</div> </div>
<div class="user-menu__info"> <div class="user-menu__info">
<div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div> <div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
<div class="user-menu__plan">Demo Account · v{{ APP_VERSION }}</div> <div class="user-menu__plan">{{ accountLabel }} · v{{ APP_VERSION }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -104,15 +62,28 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { computed } from 'vue'
import { useQuasar } from 'quasar'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { useSettingsStore } from 'stores/settings'
import { APP_VERSION } from 'src/config/appVersion' import { APP_VERSION } from 'src/config/appVersion'
defineProps({ open: { type: Boolean, default: false } }) defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close', 'navigate']) defineEmits(['close', 'navigate'])
const $q = useQuasar()
const authStore = useAuthStore() const authStore = useAuthStore()
const helpOpen = ref(false) const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive)
const accountLabel = computed(() =>
authStore.currentUser?.mode === 'remote' ? 'Konto' : 'Demo-Konto'
)
// Written to the persisted setting, not just toggled on Quasar: a toggle
// alone was undone by the stored "appearance" on the next load.
function setDarkMode(enabled) {
settingsStore.appearance = enabled ? 'dark' : 'light'
}
</script> </script>
<style scoped> <style scoped>
@ -145,13 +116,13 @@ const helpOpen = ref(false)
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background: #e05a33; background: var(--tm-accent, #f5c26b);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #fff; color: var(--tm-ink, #292a2b);
flex-shrink: 0; flex-shrink: 0;
} }
@ -230,18 +201,9 @@ const helpOpen = ref(false)
font-size: 13px; font-size: 13px;
} }
.user-menu__chevron { .user-menu__toggle {
margin-left: auto; margin-left: auto;
transition: transform 0.2s ease; pointer-events: none;
}
.user-menu__chevron--open {
transform: rotate(90deg);
}
/* Help sub-items */
.user-menu__sub {
overflow: hidden;
} }
/* Footer */ /* Footer */
@ -265,17 +227,4 @@ const helpOpen = ref(false)
transform: translateX(100%); transform: translateX(100%);
} }
/* Expand transition for help sub-menu */
.expand-enter-active,
.expand-leave-active {
transition: max-height 0.25s ease, opacity 0.2s ease;
max-height: 300px;
opacity: 1;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style> </style>

View file

@ -0,0 +1,473 @@
<template>
<div class="voice-notes" :class="{ 'voice-notes--dark': isDark }">
<div class="voice-notes__header">
<span class="voice-notes__label">Sprachnotizen</span>
<span v-if="notes.length > 0" class="voice-notes__count">{{ notes.length }}</span>
</div>
<!-- Recorded notes, oldest first -->
<ul v-if="notes.length > 0" class="voice-notes__list">
<li v-for="(note, index) in notes" :key="note.id" class="voice-note">
<div class="voice-note__row">
<span class="voice-note__index">{{ index + 1 }}</span>
<audio
v-if="srcById[note.id]"
:src="srcById[note.id]"
controls
preload="auto"
class="voice-note__audio"
></audio>
<span v-else class="voice-note__loading">
<q-spinner size="14px" />
<span>Wird geladen …</span>
</span>
<q-btn
flat
dense
round
type="button"
icon="delete_outline"
color="negative"
size="sm"
:aria-label="`Sprachnotiz ${index + 1} löschen`"
@click="askDelete(note)"
/>
</div>
<div class="voice-note__meta">
<span v-if="note.durationMs">{{ formatDuration(note.durationMs) }}</span>
<span v-if="note.createdAt">{{ formatRecordedAt(note.createdAt) }}</span>
<span v-if="note.pending" class="voice-note__pending">
<q-icon name="cloud_upload" size="12px" />
Wird übertragen
</span>
</div>
<q-input
:model-value="note.caption ?? ''"
borderless
dense
autogrow
maxlength="500"
placeholder="Kurze Notiz dazu, z. B. eine Ergänzung …"
class="voice-note__caption"
:dark="isDark"
:aria-label="`Notiz zu Sprachnotiz ${index + 1}`"
@update:model-value="value => eventsStore.setGhostVoiceNoteCaption(note.id, value)"
/>
</li>
</ul>
<!-- Recorder -->
<div v-if="!isSupported" class="voice-notes__hint">
Aufnahmen werden von diesem Browser nicht unterstützt. Die Seite muss über HTTPS oder localhost geöffnet sein.
</div>
<div v-else-if="isRecording" class="voice-notes__recording">
<span class="voice-notes__dot" aria-hidden="true"></span>
<VoiceWaveform :levels="levels" :level="level" class="voice-notes__wave" />
<span class="voice-notes__timer" role="timer">{{ formatDuration(elapsedMs) || '0:00' }}</span>
<q-btn
unelevated
no-caps
round
type="button"
color="negative"
icon="stop"
aria-label="Aufnahme beenden"
@click="stopRecording"
/>
</div>
<div v-else-if="saving" class="voice-notes__hint voice-notes__hint--row">
<q-spinner size="16px" />
<span>Sprachnotiz wird gespeichert …</span>
</div>
<div v-else class="voice-notes__record-row">
<q-btn
unelevated
no-caps
type="button"
icon="mic"
:label="notes.length > 0 ? 'Weitere aufnehmen' : 'Aufnehmen'"
class="voice-notes__record"
@click="startRecording"
/>
<span class="voice-notes__hint">
{{ notes.length > 0 ? 'Jede Aufnahme bekommt ein eigenes Notizfeld.' : `Erzähl, was an diesem Tag war. Bis zu ${maxMinutes} Minuten.` }}
</span>
</div>
<div v-if="recorderError" class="voice-notes__error" role="alert">{{ recorderError }}</div>
<ConfirmDialog
v-model="deleteDialogOpen"
title="Sprachnotiz wirklich löschen?"
:message="deleteMessage"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useEventsStore } from 'stores/events'
import { resolveFullRes } from 'composables/useImageCache'
import {
MAX_VOICE_NOTE_MS,
extensionForAudioType,
formatDuration,
useVoiceRecorder
} from 'composables/useVoiceRecorder'
import { blobToWav, needsWavForPlayback } from 'src/utils/audioWav'
import ConfirmDialog from 'components/ConfirmDialog.vue'
import VoiceWaveform from 'components/VoiceWaveform.vue'
const $q = useQuasar()
const eventsStore = useEventsStore()
const isDark = computed(() => $q.dark.isActive)
const notes = computed(() => eventsStore.ghostVoiceNotes)
const maxMinutes = Math.round(MAX_VOICE_NOTE_MS / 60000)
const {
isSupported,
isRecording,
elapsedMs,
error: recorderError,
level,
levels,
start,
stop
} = useVoiceRecorder()
const saving = ref(false)
// ---- Playback sources ----
// Local recordings (demo user) are blobs and get object URLs; uploaded ones
// are fetched through the authenticated image cache.
const srcById = ref({})
const objectUrls = new Map()
function objectUrlFor(note) {
if (!objectUrls.has(note.id)) objectUrls.set(note.id, URL.createObjectURL(note.blob))
return objectUrls.get(note.id)
}
function releaseObjectUrl(id) {
const url = objectUrls.get(id)
if (url) {
URL.revokeObjectURL(url)
objectUrls.delete(id)
}
}
let srcRequest = 0
watch(notes, async (list) => {
const requestId = ++srcRequest
const ids = new Set(list.map(note => note.id))
// Players of notes that are gone (another event, a deleted note) disappear
// at once, before anything is fetched for the new list.
for (const id of Object.keys(srcById.value)) {
if (!ids.has(id)) releaseObjectUrl(id)
}
srcById.value = Object.fromEntries(
Object.entries(srcById.value).filter(([id]) => ids.has(id))
)
for (const note of list) {
if (srcById.value[note.id]) continue
let src = null
if (note.blob) {
src = objectUrlFor(note)
} else if (note.originalUrl) {
src = await resolveFullRes(note.originalUrl)
}
if (requestId !== srcRequest) return
if (src) srcById.value = { ...srcById.value, [note.id]: src }
}
}, { immediate: true, deep: true })
onBeforeUnmount(() => {
for (const url of objectUrls.values()) URL.revokeObjectURL(url)
objectUrls.clear()
})
// ---- Recording ----
async function startRecording() {
await start()
}
async function stopRecording() {
const recording = await stop()
if (!recording) {
$q.notify({ type: 'warning', message: 'Es wurde nichts aufgenommen.' })
return
}
await saveRecording(recording)
}
async function saveRecording({ blob, mimeType, durationMs }) {
saving.value = true
const name = `sprachnotiz-${notes.value.length + 1}.${extensionForAudioType(mimeType)}`
try {
const file = new File([blob], name, { type: mimeType })
const uploaded = await eventsStore.uploadGhostVoiceNote(file, { durationMs })
if (uploaded) return
// Demo user: the recording stays in the browser. The server would turn
// it into AAC; here WebM/Ogg becomes WAV so Safari can play it too.
let localBlob = blob
let localType = mimeType
let localName = name
if (needsWavForPlayback(mimeType)) {
const wav = await blobToWav(blob)
if (wav) {
localBlob = wav
localType = 'audio/wav'
localName = name.replace(/\.[a-z0-9]+$/i, '.wav')
}
}
eventsStore.addGhostVoiceNote({
id: crypto.randomUUID(),
name: localName,
mimeType: localType,
blob: localBlob,
durationMs
})
eventsStore.saveGhostNow()
} catch (error) {
console.warn('Voice note save failed:', error)
$q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gespeichert werden.' })
} finally {
saving.value = false
}
}
// ---- Deleting ----
const deleteDialogOpen = ref(false)
const noteToDelete = ref(null)
const deleteMessage = computed(() => {
const note = noteToDelete.value
if (!note) return ''
const duration = note.durationMs ? ` (${formatDuration(note.durationMs)})` : ''
return `Die Aufnahme${duration} wird mit ihrer Notiz endgültig aus diesem Event entfernt.`
})
function askDelete(note) {
noteToDelete.value = note
deleteDialogOpen.value = true
}
async function confirmDelete() {
const note = noteToDelete.value
noteToDelete.value = null
if (!note) return
try {
await eventsStore.deleteGhostVoiceNote(note.id)
eventsStore.saveGhostNow()
} catch (error) {
console.warn('Voice note delete failed:', error)
$q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gelöscht werden.' })
}
}
function formatRecordedAt(timestamp) {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.voice-notes {
background: rgba(128, 128, 128, 0.06);
border: 1px solid rgba(128, 128, 128, 0.1);
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
}
.voice-notes--dark {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.06);
}
.voice-notes__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
min-height: 24px;
}
.voice-notes__label {
font-size: 13px;
font-weight: 600;
opacity: 0.7;
}
.voice-notes__count {
font-size: 11px;
font-weight: 700;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.22);
}
.voice-notes__list {
list-style: none;
margin: 0 0 14px;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.voice-note {
border-radius: 12px;
padding: 10px 12px;
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.12);
}
.voice-notes--dark .voice-note {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.08);
}
.voice-note__row {
display: flex;
align-items: center;
gap: 8px;
}
.voice-note__index {
width: 22px;
height: 22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.22);
flex-shrink: 0;
}
.voice-note__audio {
flex: 1;
min-width: 0;
height: 36px;
}
.voice-note__loading {
flex: 1;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
opacity: 0.6;
}
.voice-note__meta {
display: flex;
gap: 10px;
margin: 4px 0 0 30px;
font-size: 11px;
opacity: 0.55;
font-variant-numeric: tabular-nums;
}
.voice-note__pending {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--tm-accent, #f5c26b);
}
.voice-note__caption {
margin: 2px 0 0 22px;
font-size: 13px;
}
.voice-note__caption :deep(.q-field__native) {
font-size: 13px;
line-height: 1.4;
}
.voice-notes__hint {
font-size: 12px;
opacity: 0.6;
line-height: 1.4;
}
.voice-notes__hint--row {
display: flex;
align-items: center;
gap: 8px;
}
.voice-notes__record-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.voice-notes__record {
border-radius: 999px;
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.24);
color: inherit;
}
.voice-notes__recording {
display: flex;
align-items: center;
gap: 12px;
}
.voice-notes__wave {
flex: 1;
min-width: 0;
}
.voice-notes__dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff3b30;
flex-shrink: 0;
animation: voicePulse 1.2s ease-out infinite;
}
@keyframes voicePulse {
0% { box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.55); }
100% { box-shadow: 0 0 0 12px rgba(255, 59, 48, 0); }
}
.voice-notes__timer {
font-size: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 44px;
text-align: right;
}
.voice-notes__error {
margin-top: 8px;
font-size: 12px;
color: #ff5252;
}
@media (prefers-reduced-motion: reduce) {
.voice-notes__dot {
animation: none;
}
}
</style>

View file

@ -0,0 +1,105 @@
<template>
<canvas ref="canvasRef" class="voice-waveform" aria-hidden="true"></canvas>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
/**
* Scrolling bar waveform for a live recording. Each entry in `levels`
* (0..1, oldest first) becomes one bar, mirrored around the centre line,
* so speech visibly moves the line and silence leaves it flat.
*/
const props = defineProps({
levels: { type: Array, default: () => [] },
// The most recent value, drawn brighter at the right edge.
level: { type: Number, default: 0 }
})
const canvasRef = ref(null)
let resizeObserver = null
function accentColor(canvas) {
const styles = getComputedStyle(canvas)
const rgb = styles.getPropertyValue('--tm-accent-rgb').trim()
return rgb ? `rgba(${rgb}, 1)` : styles.color || '#737373'
}
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const width = canvas.clientWidth
const height = canvas.clientHeight
if (width === 0 || height === 0) return
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
}
const ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
const count = props.levels.length
if (count === 0) return
const gap = 2
const barWidth = Math.max(2, (width - gap * (count - 1)) / count)
const centre = height / 2
const minBar = 2
const color = accentColor(canvas)
for (let i = 0; i < count; i++) {
const value = Math.max(0, Math.min(1, props.levels[i] ?? 0))
const barHeight = Math.max(minBar, value * (height - 4))
const x = i * (barWidth + gap)
// Older bars fade out toward the left.
const age = i / count
ctx.globalAlpha = 0.25 + age * 0.75
ctx.fillStyle = color
roundedBar(ctx, x, centre - barHeight / 2, barWidth, barHeight)
}
ctx.globalAlpha = 1
}
function roundedBar(ctx, x, y, w, h) {
const r = Math.min(w / 2, 2)
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
ctx.fill()
}
watch(() => [props.levels, props.level], draw, { flush: 'post' })
onMounted(() => {
draw()
if (typeof ResizeObserver !== 'undefined' && canvasRef.value) {
resizeObserver = new ResizeObserver(draw)
resizeObserver.observe(canvasRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<style scoped>
.voice-waveform {
display: block;
width: 100%;
height: 40px;
}
</style>

View file

@ -1,14 +1,24 @@
<template> <template>
<div class="zoom-control glass--button"> <div class="zoom-control glass--button">
<button class="zoom-control__btn" @click="$emit('zoomIn')"> <button type="button" class="zoom-control__btn" aria-label="Vergrößern" @click="$emit('zoomIn')">
<q-icon name="add" size="18px" /> <q-icon name="add" size="20px" />
</button> </button>
<div class="zoom-control__track" ref="trackRef" @pointerdown="onTrackDown"> <!-- The visible line is thin; the element that takes the finger is not -->
<div
class="zoom-control__track"
ref="trackRef"
role="slider"
aria-label="Zoom"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="zoom"
@pointerdown="onTrackDown"
>
<div class="zoom-control__fill" :style="{ height: fillPercent + '%' }" /> <div class="zoom-control__fill" :style="{ height: fillPercent + '%' }" />
<div class="zoom-control__thumb" :style="{ bottom: fillPercent + '%' }" /> <div class="zoom-control__thumb" :style="{ bottom: fillPercent + '%' }" />
</div> </div>
<button class="zoom-control__btn" @click="$emit('zoomOut')"> <button type="button" class="zoom-control__btn" aria-label="Verkleinern" @click="$emit('zoomOut')">
<q-icon name="remove" size="18px" /> <q-icon name="remove" size="20px" />
</button> </button>
</div> </div>
</template> </template>
@ -57,18 +67,19 @@ function updateFromPointer(e) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 2px;
padding: 6px 4px; padding: 4px;
border-radius: 22px; border-radius: 24px;
width: 36px; width: 44px;
} }
/* 40px targets: about the minimum a thumb hits reliably */
.zoom-control__btn { .zoom-control__btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 28px; width: 36px;
height: 28px; height: 36px;
border: none; border: none;
background: none; background: none;
color: inherit; color: inherit;
@ -84,19 +95,31 @@ function updateFromPointer(e) {
.zoom-control__track { .zoom-control__track {
position: relative; position: relative;
width: 3px; width: 28px;
height: 80px; height: 96px;
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
cursor: pointer; cursor: pointer;
touch-action: none; touch-action: none;
} }
/* The line itself */
.zoom-control__track::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 3px;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
}
.zoom-control__fill { .zoom-control__fill {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 50%;
width: 100%; width: 3px;
transform: translateX(-50%);
border-radius: 2px; border-radius: 2px;
background: rgba(255, 255, 255, 0.35); background: rgba(255, 255, 255, 0.35);
pointer-events: none; pointer-events: none;
@ -105,8 +128,8 @@ function updateFromPointer(e) {
.zoom-control__thumb { .zoom-control__thumb {
position: absolute; position: absolute;
left: 50%; left: 50%;
width: 10px; width: 14px;
height: 10px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: #fff;
transform: translate(-50%, 50%); transform: translate(-50%, 50%);
@ -115,7 +138,7 @@ function updateFromPointer(e) {
} }
/* Light mode adjustments */ /* Light mode adjustments */
.body--light .zoom-control__track { .body--light .zoom-control__track::before {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
} }

View file

@ -1,13 +1,55 @@
import { ref, unref, watch } from 'vue' import { ref, unref, watch } from 'vue'
import { db } from 'src/db' import { db } from 'src/db'
import { apiFetch } from 'src/services/apiClient' import { API_BASE, apiFetch } from 'src/services/apiClient'
const THUMB_SIZE = 200 const THUMB_SIZE = 200
// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation // In-memory cache: avoids repeated IndexedDB reads and blob URL creation.
// Shared across all component instances // Shared across all component instances, therefore scoped by user — the URL
// alone is not a safe key, because two users' images can share a path and a
// cached blob would otherwise outlive the session that fetched it.
const memoryCache = new Map() const memoryCache = new Map()
// The user the cache currently holds data for.
let scopeUserId = null
/**
* Point the image cache at a user. Switching users (or logging out, with
* `null`) drops every cached blob so nothing carries over.
*/
export function setImageCacheScope(userId) {
const nextScope = userId ?? null
if (nextScope === scopeUserId) return
const previousScope = scopeUserId
scopeUserId = nextScope
purgeMemoryCache()
if (previousScope !== null) {
db.scopedImageCache
.where('userId')
.equals(previousScope)
.delete()
.catch(e => console.warn('Image cache purge failed:', e))
}
}
function purgeMemoryCache() {
for (const blobUrl of memoryCache.values()) {
URL.revokeObjectURL(blobUrl)
}
memoryCache.clear()
}
/**
* Cache key: API origin + owning user + image URL. All three matter — the
* same path means different bytes for a different user or a different
* backend.
*/
function cacheKey(imageUrl) {
return `${API_BASE}|${scopeUserId ?? 'anonymous'}|${imageUrl}`
}
function isProtectedApiImageUrl(imageUrl) { function isProtectedApiImageUrl(imageUrl) {
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/') return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/')
} }
@ -66,8 +108,11 @@ async function fetchAndCache(imageUrl, eventId) {
const thumbBlob = await createThumbnail(blob) const thumbBlob = await createThumbnail(blob)
// Store in IndexedDB // Store in IndexedDB
await db.imageCache.put({ const key = cacheKey(imageUrl)
await db.scopedImageCache.put({
key,
url: imageUrl, url: imageUrl,
userId: scopeUserId,
eventId, eventId,
type: 'thumbnail', type: 'thumbnail',
blob: thumbBlob, blob: thumbBlob,
@ -75,7 +120,7 @@ async function fetchAndCache(imageUrl, eventId) {
}) })
const blobUrl = URL.createObjectURL(thumbBlob) const blobUrl = URL.createObjectURL(thumbBlob)
memoryCache.set(imageUrl, blobUrl) memoryCache.set(key, blobUrl)
return blobUrl return blobUrl
} }
@ -84,14 +129,16 @@ async function fetchAndCache(imageUrl, eventId) {
* Returns null if not cached. * Returns null if not cached.
*/ */
async function getCachedImage(imageUrl) { async function getCachedImage(imageUrl) {
const key = cacheKey(imageUrl)
// Check memory first // Check memory first
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) if (memoryCache.has(key)) return memoryCache.get(key)
try { try {
const entry = await db.imageCache.get(imageUrl) const entry = await db.scopedImageCache.get(key)
if (entry?.blob) { if (entry?.blob) {
const blobUrl = URL.createObjectURL(entry.blob) const blobUrl = URL.createObjectURL(entry.blob)
memoryCache.set(imageUrl, blobUrl) memoryCache.set(key, blobUrl)
return blobUrl return blobUrl
} }
} catch (e) { } catch (e) {
@ -120,8 +167,9 @@ export function useImageCache(imageUrl, eventId) {
} }
// 1. Memory cache (instant) // 1. Memory cache (instant)
if (memoryCache.has(nextUrl)) { const key = cacheKey(nextUrl)
resolvedSrc.value = memoryCache.get(nextUrl) if (memoryCache.has(key)) {
resolvedSrc.value = memoryCache.get(key)
loading.value = false loading.value = false
return return
} }
@ -167,13 +215,14 @@ export async function resolveFullRes(imageUrl) {
if (!imageUrl) return null if (!imageUrl) return null
if (isProtectedApiImageUrl(imageUrl)) { if (isProtectedApiImageUrl(imageUrl)) {
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) const key = cacheKey(imageUrl)
if (memoryCache.has(key)) return memoryCache.get(key)
try { try {
const response = await apiFetch(imageUrl) const response = await apiFetch(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const blobUrl = URL.createObjectURL(await response.blob()) const blobUrl = URL.createObjectURL(await response.blob())
memoryCache.set(imageUrl, blobUrl) memoryCache.set(key, blobUrl)
return blobUrl return blobUrl
} catch (error) { } catch (error) {
console.warn('Full-res image load failed:', error) console.warn('Full-res image load failed:', error)
@ -193,14 +242,14 @@ export async function resolveFullRes(imageUrl) {
*/ */
export async function clearEventImages(eventId) { export async function clearEventImages(eventId) {
try { try {
const entries = await db.imageCache.where('eventId').equals(eventId).toArray() const entries = await db.scopedImageCache.where('eventId').equals(eventId).toArray()
for (const entry of entries) { for (const entry of entries) {
if (memoryCache.has(entry.url)) { if (memoryCache.has(entry.key)) {
URL.revokeObjectURL(memoryCache.get(entry.url)) URL.revokeObjectURL(memoryCache.get(entry.key))
memoryCache.delete(entry.url) memoryCache.delete(entry.key)
} }
} }
await db.imageCache.where('eventId').equals(eventId).delete() await db.scopedImageCache.where('eventId').equals(eventId).delete()
} catch (e) { } catch (e) {
console.warn('Clear event images failed:', e) console.warn('Clear event images failed:', e)
} }

View file

@ -0,0 +1,45 @@
import { watch } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
const NOTIFY_TIMEOUT_MS = 6000
/**
* Turns store-level failures into something the user can see, and ends the
* screen session when the auth store says it is over.
*
* Kept out of the layout so it can be exercised on its own: the layout also
* owns the WebGL scene and cannot be mounted in a DOM-only test environment.
*/
export function useStoreFeedback() {
const $q = useQuasar()
const router = useRouter()
const authStore = useAuthStore()
const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
// Each message is consumed after it is shown, so the same error occurring
// twice in a row still produces a second notification.
function notifyAndConsume(read, clear, type) {
watch(read, (message) => {
if (!message) return
$q.notify({ type, message, timeout: NOTIFY_TIMEOUT_MS })
clear()
})
}
notifyAndConsume(() => eventsStore.loadError, () => { eventsStore.loadError = null }, 'warning')
notifyAndConsume(() => eventsStore.lastError, () => { eventsStore.lastError = null }, 'negative')
notifyAndConsume(() => settingsStore.lastError, () => { settingsStore.lastError = null }, 'warning')
// An expired token ends the session from inside the API client. Without
// this the app stayed on screen in a state where nothing could be saved.
watch(() => authStore.isAuthenticated, (isAuthenticated) => {
if (!isAuthenticated && router.currentRoute.value.path !== '/login') {
router.replace('/login')
}
})
}

View file

@ -0,0 +1,264 @@
import { onBeforeUnmount, ref } from 'vue'
export const MAX_VOICE_NOTE_MS = 5 * 60 * 1000
// Number of level samples kept for the live waveform and how often a new
// one is taken. 64 samples at 50 ms show the last ~3 seconds.
export const LEVEL_HISTORY = 64
const LEVEL_SAMPLE_MS = 40
// Preferred containers in order. Chrome and Firefox record WebM/Opus,
// Safari records MP4/AAC; the first supported one wins.
const CANDIDATE_TYPES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
/**
* Records a voice note with the browser's MediaRecorder.
*
* `start()` asks for the microphone and begins recording; `stop()` resolves
* with `{ blob, mimeType, durationMs }` or null when nothing was captured.
* Recording ends on its own at `maxDurationMs`. The microphone is released
* on stop and when the owning component unmounts.
*
* While recording, `level` holds the current input loudness (0..1) and
* `levels` the recent history, newest last, for a live waveform.
*/
export function useVoiceRecorder({ maxDurationMs = MAX_VOICE_NOTE_MS } = {}) {
const isSupported =
typeof window !== 'undefined' &&
typeof MediaRecorder !== 'undefined' &&
typeof navigator !== 'undefined' &&
Boolean(navigator.mediaDevices?.getUserMedia)
const isRecording = ref(false)
const elapsedMs = ref(0)
const error = ref('')
const level = ref(0)
const levels = ref(Array(LEVEL_HISTORY).fill(0))
let recorder = null
let stream = null
let chunks = []
let startedAt = 0
let timer = 0
let mimeType = ''
let resolveStop = null
let audioContext = null
let analyser = null
let analyserBuffer = null
let meterFrame = 0
let lastSampleAt = 0
function pickMimeType() {
if (typeof MediaRecorder.isTypeSupported !== 'function') return ''
return CANDIDATE_TYPES.find(type => MediaRecorder.isTypeSupported(type)) ?? ''
}
async function start() {
if (!isSupported) {
error.value = 'Aufnahmen werden von diesem Browser nicht unterstützt.'
return false
}
if (isRecording.value) return true
error.value = ''
// Created inside the click, before the permission prompt: a context
// created after the (possibly long) prompt starts suspended and the
// waveform lags behind the recording.
prepareAudioContext()
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) {
stopMeter()
error.value = e?.name === 'NotAllowedError' || e?.name === 'SecurityError'
? 'Der Zugriff auf das Mikrofon wurde abgelehnt.'
: 'Das Mikrofon konnte nicht gestartet werden.'
return false
}
try {
mimeType = pickMimeType()
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
} catch {
stopMeter()
releaseStream()
error.value = 'Die Aufnahme konnte nicht gestartet werden.'
return false
}
chunks = []
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) chunks.push(event.data)
}
recorder.onstop = finish
recorder.onerror = () => {
error.value = 'Die Aufnahme ist fehlgeschlagen.'
}
recorder.start(250)
startedAt = Date.now()
elapsedMs.value = 0
isRecording.value = true
startMeter()
timer = setInterval(() => {
elapsedMs.value = Date.now() - startedAt
if (elapsedMs.value >= maxDurationMs) stop()
}, 200)
return true
}
function stop() {
return new Promise((resolve) => {
if (!recorder || recorder.state === 'inactive') {
resolve(null)
return
}
resolveStop = resolve
recorder.stop()
})
}
function finish() {
clearInterval(timer)
timer = 0
const durationMs = Math.max(0, Date.now() - startedAt)
const fullType = recorder?.mimeType || mimeType || 'audio/webm'
const blob = new Blob(chunks, { type: fullType })
stopMeter()
releaseStream()
isRecording.value = false
const result = blob.size > 0
? { blob, mimeType: fullType.split(';')[0], durationMs }
: null
const resolve = resolveStop
resolveStop = null
resolve?.(result)
}
function releaseStream() {
stream?.getTracks().forEach(track => track.stop())
stream = null
recorder = null
}
// ---- Input level meter (Web Audio analyser on the same stream) ----
function prepareAudioContext() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext
if (!AudioContextClass || audioContext) return
try {
audioContext = new AudioContextClass()
// Safari keeps a context suspended until it is resumed from a user
// gesture; we are inside the record button's click here.
audioContext.resume?.().catch?.(() => {})
} catch {
audioContext = null
}
}
function startMeter() {
if (!stream) return
prepareAudioContext()
if (!audioContext) return
try {
audioContext.resume?.().catch?.(() => {})
analyser = audioContext.createAnalyser()
analyser.fftSize = 1024
analyser.smoothingTimeConstant = 0
analyserBuffer = new Uint8Array(analyser.fftSize)
audioContext.createMediaStreamSource(stream).connect(analyser)
} catch {
// No meter, but the recording itself is unaffected.
analyser = null
return
}
levels.value = Array(LEVEL_HISTORY).fill(0)
level.value = 0
lastSampleAt = 0
meterFrame = requestAnimationFrame(sampleLevel)
}
function sampleLevel(now) {
if (!analyser) return
meterFrame = requestAnimationFrame(sampleLevel)
analyser.getByteTimeDomainData(analyserBuffer)
let sumSquares = 0
let peak = 0
for (let i = 0; i < analyserBuffer.length; i++) {
const centred = Math.abs(analyserBuffer[i] - 128) / 128
sumSquares += centred * centred
if (centred > peak) peak = centred
}
// Speech sits far below full scale. Peak and RMS are both lifted, the
// louder one wins, and the curve is compressed so quiet talking is
// clearly visible while shouting still tops out at 1.
const rms = Math.sqrt(sumSquares / analyserBuffer.length)
const raw = Math.max(peak * 1.6, rms * 4.5)
const next = raw < 0.02 ? 0 : Math.min(1, Math.pow(raw, 0.7))
level.value = next
if (now - lastSampleAt >= LEVEL_SAMPLE_MS) {
lastSampleAt = now
const history = levels.value.slice(1)
history.push(next)
levels.value = history
}
}
function stopMeter() {
if (meterFrame) cancelAnimationFrame(meterFrame)
meterFrame = 0
analyser = null
analyserBuffer = null
level.value = 0
audioContext?.close?.().catch?.(() => {})
audioContext = null
}
onBeforeUnmount(() => {
clearInterval(timer)
stopMeter()
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = null
try { recorder.stop() } catch { /* already stopped */ }
}
releaseStream()
isRecording.value = false
})
return { isSupported, isRecording, elapsedMs, error, level, levels, start, stop }
}
/** Format milliseconds as m:ss for durations shown next to media. */
export function formatDuration(ms) {
if (!Number.isFinite(ms) || ms <= 0) return ''
const totalSeconds = Math.round(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
/** File extension for a recorded MIME type. */
export function extensionForAudioType(mimeType) {
if (!mimeType) return 'webm'
if (mimeType.includes('mp4')) return 'm4a'
if (mimeType.includes('ogg')) return 'ogg'
if (mimeType.includes('mpeg')) return 'mp3'
if (mimeType.includes('wav')) return 'wav'
return 'webm'
}

View file

@ -0,0 +1,21 @@
/**
* Event categories. Stored by key (see backend EventFields::CATEGORIES); the
* labels and icons are presentation only and may change freely.
*/
export const EVENT_CATEGORIES = [
{ value: 'family', label: 'Familie', icon: 'family_restroom' },
{ value: 'friends', label: 'Freunde', icon: 'groups' },
{ value: 'love', label: 'Liebe', icon: 'favorite' },
{ value: 'work', label: 'Arbeit', icon: 'work' },
{ value: 'education', label: 'Ausbildung', icon: 'school' },
{ value: 'health', label: 'Gesundheit', icon: 'monitor_heart' },
{ value: 'travel', label: 'Reise', icon: 'flight' },
{ value: 'home', label: 'Wohnen', icon: 'home' },
{ value: 'hobby', label: 'Hobby', icon: 'sports_esports' },
{ value: 'loss', label: 'Verlust', icon: 'spa' },
{ value: 'other', label: 'Sonstiges', icon: 'more_horiz' }
]
export function findEventCategory(value) {
return EVENT_CATEGORIES.find(category => category.value === value) ?? null
}

View file

@ -0,0 +1,242 @@
/**
* Built-in LifeWave presets.
*
* Each preset is a partial `floatingLines` object; missing keys fall back to
* FLOATING_LINES_DEFAULTS. `preview` holds a few colours for the tile in the
* settings panel, so the panel never has to load the large background
* photos just to show a swatch.
*
* Glow mode adds light on top of the ground and only works on dark ground;
* light presets therefore use static lines.
*/
export const BRAND_PRESET_ID = 'builtin:gold'
const GOLD_LINES = '#f5c26b\n#d59040\n#3a2a14'
export const WAVE_PRESETS = [
{
id: BRAND_PRESET_ID,
name: 'Gold',
description: 'Die Marke: goldene Linien auf tiefem Anthrazit.',
preview: { ground: ['#2f3032', '#1a1b1c'], lines: ['#f5c26b', '#d59040'] },
floatingLines: {
lineMode: 'glow',
gradientStops: GOLD_LINES,
bgCenter: '#2f3032',
bgEdge: '#1a1b1c',
backgroundImage: '',
glowSize: 18,
glowStrength: 1.6,
lineBrightness: 0.9,
horizonMode: 'glow',
horizonOpacity: 0.25,
labelColor: '#f7f3ea',
dotBorderWidth: 0
}
},
{
id: 'builtin:paper',
name: 'Papier',
description: 'Hell und ruhig: dunkles Gold auf Papier, für Tageslicht.',
preview: { ground: ['#f7f3ea', '#e6ddca'], lines: ['#d59040', '#b9752f'] },
floatingLines: {
lineMode: 'static',
staticLineColor: '#d59040',
staticLineShadowStrength: 0.15,
lineThickness: 1.2,
lineBrightness: 1,
bgCenter: '#f7f3ea',
bgEdge: '#e6ddca',
backgroundImage: '',
glowSize: 10,
glowStrength: 0.8,
horizonMode: 'off',
labelColor: '#292a2b',
labelOpacity: 0.85,
dotBorderWidth: 1.5,
dotBorderColor: '#292a2b'
}
},
{
id: 'builtin:aurora',
name: 'Aurora',
description: 'Grünes und blaues Nordlicht mit weiten, langsamen Wellen.',
preview: { ground: ['#04121a', '#000000'], lines: ['#5cf2c2', '#2f8fff'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#5cf2c2\n#2f8fff\n#0a0a12',
bgCenter: '#04121a',
bgEdge: '#000000',
backgroundImage: '',
speed: 0.6,
spread: 0.09,
waveFrequency: 4,
bezierCurvature: 0.3,
glowSize: 24,
glowStrength: 2,
horizonMode: 'fog',
horizonOpacity: 0.45,
labelColor: '#ffffff'
}
},
{
id: 'builtin:dusk',
name: 'Abendrot',
description: 'Warmer Horizont unten, Nacht oben, Linien in Orange und Rosé.',
preview: { ground: ['#3a1a2b', '#120711'], lines: ['#ffb86b', '#ff5e7e'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#ffb86b\n#ff5e7e\n#2a0f1f',
bgCenter: '#3a1a2b',
bgEdge: '#120711',
backgroundImage: '',
speed: 0.8,
glowSize: 20,
glowStrength: 1.8,
horizonMode: 'split',
horizonBlend: 0.6,
labelColor: '#f7f3ea'
}
},
{
id: 'builtin:milkyway',
name: 'Milchstraße',
description: 'Sternenhimmel mit feinen, kühlen Linien.',
preview: { ground: ['#1b1a24', '#050408'], lines: ['#ffffff', '#9fb4ff'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#ffffff\n#9fb4ff\n#1a1f3a',
backgroundImage: '/images/bg-image-1.jpg',
bgCenter: '#1b1a24',
bgEdge: '#050408',
lineBrightness: 0.75,
lineThickness: 0.8,
glowSize: 16,
glowStrength: 1.4,
horizonMode: 'off',
labelColor: '#ffffff'
}
},
{
id: 'builtin:silk',
name: 'Seide',
description: 'Schwarzer Stoff, darauf klare goldene Linien.',
preview: { ground: ['#2a2a2a', '#0b0b0b'], lines: ['#f5c26b', '#f5c26b'] },
floatingLines: {
lineMode: 'static',
staticLineColor: '#f5c26b',
staticLineShadowStrength: 0.35,
lineThickness: 1.1,
lineCount: 8,
backgroundImage: '/images/bg-image-2.jpg',
bgCenter: '#2a2a2a',
bgEdge: '#0b0b0b',
glowSize: 14,
glowStrength: 1.2,
horizonMode: 'off',
labelColor: '#f7f3ea'
}
},
{
id: 'builtin:rain',
name: 'Regen',
description: 'Tropfen auf dunklem Glas, Linien in Türkis.',
preview: { ground: ['#1e2224', '#0a0c0d'], lines: ['#7fe3ff', '#1fa2c8'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#7fe3ff\n#1fa2c8\n#0a1a22',
backgroundImage: '/images/bg-image-3.jpg',
bgCenter: '#1e2224',
bgEdge: '#0a0c0d',
speed: 1.2,
glowSize: 22,
glowStrength: 1.8,
horizonMode: 'off',
labelColor: '#ffffff'
}
},
{
id: 'builtin:leaves',
name: 'Blattwerk',
description: 'Dunkles Grün mit Gold, wie Sonne durch Blätter.',
preview: { ground: ['#12301a', '#05110a'], lines: ['#f5c26b', '#8bc34a'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#f5c26b\n#8bc34a\n#0d1a0c',
backgroundImage: '/images/bg-image-4.jpg',
bgCenter: '#12301a',
bgEdge: '#05110a',
speed: 0.7,
glowSize: 18,
glowStrength: 1.5,
lineBrightness: 0.85,
horizonMode: 'off',
labelColor: '#f7f3ea'
}
},
{
id: 'builtin:moon',
name: 'Mondsichel',
description: 'Fast schwarz, wenige dünne helle Linien. Reduziert.',
preview: { ground: ['#161616', '#000000'], lines: ['#e8e8e8', '#8a8a8a'] },
floatingLines: {
lineMode: 'static',
staticLineColor: '#e8e8e8',
staticLineShadowStrength: 0,
lineCount: 5,
lineThickness: 0.6,
spread: 0.035,
speed: 0.5,
backgroundImage: '/images/bg-image-8.jpg',
bgCenter: '#161616',
bgEdge: '#000000',
glowSize: 10,
glowStrength: 1,
horizonMode: 'off',
labelColor: '#f7f3ea',
labelOpacity: 0.9
}
},
{
id: 'builtin:neon',
name: 'Farbrausch',
description: 'Magenta und Blau, Punkte mit weißem Rand. Laut und fröhlich.',
preview: { ground: ['#ff4fd8', '#2b3cff'], lines: ['#ffffff', '#ff6ad5'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#ffffff\n#ff6ad5\n#2b0a3a',
backgroundImage: '/images/bg-image-6.jpg',
bgCenter: '#3a0a5a',
bgEdge: '#0d0620',
speed: 1.3,
waveFrequency: 8,
glowSize: 20,
glowStrength: 2.2,
horizonMode: 'off',
labelColor: '#ffffff',
dotBorderWidth: 2,
dotBorderColor: '#ffffff'
}
}
]
export function findWavePreset(id) {
return WAVE_PRESETS.find(preset => preset.id === id) ?? null
}
export function isBuiltInPresetId(id) {
return typeof id === 'string' && id.startsWith('builtin:')
}
/** CSS for a preset tile: ground gradient with the line colours as a band. */
export function presetPreviewStyle(preset) {
const [g1, g2] = preset.preview.ground
const [l1, l2] = preset.preview.lines
return {
background: [
`linear-gradient(100deg, transparent 38%, ${l1} 46%, ${l2} 54%, transparent 62%)`,
`radial-gradient(circle at 50% 40%, ${g1}, ${g2})`
].join(', ')
}
}

View file

@ -1,9 +1,21 @@
:root { :root {
--tm-accent: #737373; // Brand palette (from the logo): gold accent, ink for text on light
--tm-accent-rgb: 115, 115, 115; // ground, paper for text on dark ground.
--q-primary: #737373; --tm-gold: #f5c26b;
--q-secondary: #737373; --tm-gold-dark: #d59040;
--q-accent: #737373; --tm-gold-rgb: 245, 194, 107;
--tm-gold-gradient: linear-gradient(135deg, #f5c26b, #d59040);
--tm-ink: #292a2b;
--tm-ink-rgb: 41, 42, 43;
--tm-paper: #f7f3ea;
--tm-paper-rgb: 247, 243, 234;
// Selected accent; the settings store overrides these at runtime.
--tm-accent: #f5c26b;
--tm-accent-rgb: 245, 194, 107;
--q-primary: #f5c26b;
--q-secondary: #d59040;
--q-accent: #f5c26b;
} }
// Glass button style // Glass button style
@ -25,16 +37,16 @@
// Glass panel style — strong blur for slide-up panels // Glass panel style — strong blur for slide-up panels
.glass--panel { .glass--panel {
background: rgba(255, 255, 255, 0.7); background: rgba(var(--tm-paper-rgb), 0.78);
border-top: 1px solid rgba(var(--tm-accent-rgb), 0.24); border-top: 1px solid rgba(var(--tm-accent-rgb), 0.24);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
color: #1a1a1a; color: var(--tm-ink);
.body--dark & { .body--dark & {
background: rgba(30, 30, 30, 0.7); background: rgba(var(--tm-ink-rgb), 0.74);
border-top-color: rgba(255, 255, 255, 0.08); border-top-color: rgba(var(--tm-paper-rgb), 0.1);
color: #f5f5f5; color: var(--tm-paper);
} }
} }

View file

@ -31,3 +31,17 @@ db.version(3).stores({
eventMedia: 'id, eventId, userId, createdAt', eventMedia: 'id, eventId, userId, createdAt',
meta: 'key' meta: 'key'
}) })
db.version(4).stores({
events: 'id, userId, [userId+date], updatedAt, syncStatus',
syncQueue: '++queueId, userId, eventId, action, createdAt',
// The image cache was keyed by URL alone, so a cached image stayed
// readable after a user switch and survived logout. The key now carries
// the API origin, the owning user and the variant. The primary key
// changes, so the old table is dropped — it is a pure cache and simply
// refills on the next load.
imageCache: null,
scopedImageCache: 'key, userId, eventId, type, cachedAt',
eventMedia: 'id, eventId, userId, createdAt',
meta: 'key'
})

View file

@ -45,12 +45,48 @@
<!-- Header --> <!-- Header -->
<header class="lifewave-header"> <header class="lifewave-header">
<span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span> <BrandLogo class="lifewave-header__logo" :height="26" />
<button class="lifewave-header__user glass--button" @click="userMenuOpen = !userMenuOpen"> <!-- Connection: only shown when something is not as it should be -->
<button
v-if="syncLabel"
type="button"
class="lifewave-header__sync glass--button"
:class="{ 'lifewave-header__sync--offline': !eventsStore.isOnline }"
role="status"
:title="syncTitle"
:disabled="!eventsStore.isOnline"
@click="eventsStore.syncNow()"
>
<q-icon :name="eventsStore.isOnline ? 'cloud_upload' : 'cloud_off'" size="16px" />
<span>{{ syncLabel }}</span>
</button>
<button
class="lifewave-header__user glass--button"
aria-label="Benutzermenü öffnen"
@click="userMenuOpen = !userMenuOpen"
>
<q-icon name="person" size="22px" :color="isDark ? 'white' : 'grey-8'" /> <q-icon name="person" size="22px" :color="isDark ? 'white' : 'grey-8'" />
</button> </button>
</header> </header>
<!-- Loading / empty state -->
<Transition name="fade">
<div v-if="!eventsStore.isLoaded" class="lifewave-state" role="status" aria-live="polite">
<q-spinner-dots size="36px" />
<span>Events werden geladen …</span>
</div>
<button
v-else-if="showEmptyState"
type="button"
class="lifewave-state lifewave-state--empty glass--panel"
@click="onAddEvent"
>
<q-icon name="add_circle_outline" size="40px" />
<strong>Noch keine Events</strong>
<span>Lege dein erstes Event an. Jeder Eintrag wird zu einem Punkt auf deiner LifeWave.</span>
</button>
</Transition>
<!-- FPS Overlay --> <!-- FPS Overlay -->
<div v-if="settingsStore.showFps" class="lifewave-fps"> <div v-if="settingsStore.showFps" class="lifewave-fps">
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS {{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS
@ -62,29 +98,57 @@
<router-view /> <router-view />
</main> </main>
<!-- Zoom Control (Left Side) — hidden when panel is open --> <!-- Bottom bar — hidden when panel is open.
<ZoomControl Left: everything about the surface (look, zoom). Centre: where am I.
v-if="!eventsStore.panelOpen" Right: the one primary action. -->
class="lifewave-layout__zoom" <div v-if="!eventsStore.panelOpen" class="lifewave-bar">
:zoom="currentZoom" <div class="lifewave-bar__group">
:min="zoomMin" <button
:max="zoomMax" class="lifewave-bar__btn glass--button"
@zoom-in="timelineViewRef?.zoomIn()" aria-label="Darstellung der LifeWave anpassen"
@zoom-out="timelineViewRef?.zoomOut()" @click="settingsOpen = !settingsOpen"
@zoom-to="onZoomTo" >
/> <q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" />
</button>
<button
class="lifewave-bar__btn glass--button"
:class="{ 'lifewave-bar__btn--active': zoomOpen }"
aria-label="Zoom anzeigen"
:aria-expanded="zoomOpen"
@click="zoomOpen = !zoomOpen"
>
<q-icon :name="zoomOpen ? 'close' : 'zoom_in'" size="22px" :color="isDark ? 'white' : 'grey-8'" />
</button>
</div>
<!-- Settings Button (Bottom-Left, below zoom) — hidden when panel is open --> <TimelineNav
<button class="lifewave-bar__nav"
v-if="!eventsStore.panelOpen" :year="activePosition?.year ?? null"
class="lifewave-settings-btn glass--button" :month="activePosition?.month ?? ''"
@click="settingsOpen = !settingsOpen" :years="timelineYears"
> :can-prev="canGoPrevYear"
<q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" /> :can-next="canGoNextYear"
</button> @prev="goToYear(activeYearIndex - 1)"
@next="goToYear(activeYearIndex + 1)"
@jump="year => timelineViewRef?.scrollToYearCenter(year)"
/>
<!-- Add Event Button (Bottom-Center) — hidden when panel is open --> <AddEventButton class="lifewave-bar__add" @click="onAddEvent" />
<AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" /> </div>
<!-- Zoom slider: folds out above its button -->
<Transition name="fade">
<ZoomControl
v-if="!eventsStore.panelOpen && zoomOpen"
class="lifewave-layout__zoom"
:zoom="currentZoom"
:min="zoomMin"
:max="zoomMax"
@zoom-in="timelineViewRef?.zoomIn()"
@zoom-out="timelineViewRef?.zoomOut()"
@zoom-to="onZoomTo"
/>
</Transition>
<!-- Backdrop blur when panel is open --> <!-- Backdrop blur when panel is open -->
<Transition name="fade"> <Transition name="fade">
@ -155,6 +219,8 @@ import EventPanel from 'components/EventPanel.vue'
import FloatingLines from 'components/FloatingLines.vue' import FloatingLines from 'components/FloatingLines.vue'
import LifeWaveSettings from 'components/LifeWaveSettings.vue' import LifeWaveSettings from 'components/LifeWaveSettings.vue'
import TimelineView from 'components/TimelineView.vue' import TimelineView from 'components/TimelineView.vue'
import TimelineNav from 'components/TimelineNav.vue'
import BrandLogo from 'components/BrandLogo.vue'
import AppSettingsModal from 'components/AppSettingsModal.vue' import AppSettingsModal from 'components/AppSettingsModal.vue'
import UserMenu from 'components/UserMenu.vue' import UserMenu from 'components/UserMenu.vue'
import ZoomControl from 'components/ZoomControl.vue' import ZoomControl from 'components/ZoomControl.vue'
@ -162,6 +228,8 @@ import { useAuthStore } from 'stores/auth'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { resolveFullRes } from 'composables/useImageCache' import { resolveFullRes } from 'composables/useImageCache'
import { useStoreFeedback } from 'composables/useStoreFeedback'
import { TIMELINE_TOP_PX, emotionToYPercent } from 'src/utils/timelinePosition'
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() const router = useRouter()
@ -170,6 +238,7 @@ const eventsStore = useEventsStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
const settingsOpen = ref(false) const settingsOpen = ref(false)
const zoomOpen = ref(false)
const userMenuOpen = ref(false) const userMenuOpen = ref(false)
const appSettingsOpen = ref(false) const appSettingsOpen = ref(false)
const floatingLinesRef = ref(null) const floatingLinesRef = ref(null)
@ -236,7 +305,46 @@ function onWindowResize() {
hardResizeTimer = setTimeout(runHardResizeSync, 120) hardResizeTimer = setTimeout(runHardResizeSync, 120)
} }
// Escape closes whatever is on top, one layer at a time. Quasar dialogs
// (the delete confirmations) handle Escape themselves; while one is open
// the key must not also close the panel underneath.
function onKeydown(event) {
if (event.key !== 'Escape' || event.defaultPrevented) return
if (document.querySelector('.q-dialog')) return
if (appSettingsOpen.value) {
appSettingsOpen.value = false
} else if (userMenuOpen.value) {
userMenuOpen.value = false
} else if (eventsStore.panelOpen) {
eventsStore.closePanel()
} else if (settingsOpen.value) {
settingsOpen.value = false
} else if (zoomOpen.value) {
zoomOpen.value = false
} else {
return
}
event.preventDefault()
}
// A new build was installed by the service worker. Offered, not forced: a
// reload in the middle of an edit would lose the draft.
function onAppUpdated() {
$q.notify({
type: 'info',
message: 'Eine neue Version der App ist bereit.',
timeout: 0,
actions: [
{ label: 'Neu laden', color: 'white', handler: () => window.location.reload() },
{ label: 'Später', color: 'white', handler: () => {} }
]
})
}
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', onKeydown)
window.addEventListener('tm:app-updated', onAppUpdated)
if (layoutRef.value) { if (layoutRef.value) {
layoutWidth.value = layoutRef.value.clientWidth layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight layoutHeight.value = layoutRef.value.clientHeight
@ -254,6 +362,8 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('tm:app-updated', onAppUpdated)
window.removeEventListener('resize', onWindowResize) window.removeEventListener('resize', onWindowResize)
if (hardResizeTimer) clearTimeout(hardResizeTimer) if (hardResizeTimer) clearTimeout(hardResizeTimer)
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf) if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
@ -282,7 +392,7 @@ function screenToUV(sx, sy) {
} }
// Compute shader point positions from event positions // Compute shader point positions from event positions
const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px } const TIMELINE_TOP = TIMELINE_TOP_PX
// Select up to 16 points for shader lines (shader uniform limit). // Select up to 16 points for shader lines (shader uniform limit).
// TimelineView already emits a small contiguous window around the viewport. // TimelineView already emits a small contiguous window around the viewport.
@ -317,7 +427,7 @@ const shaderPointY = computed(() => {
const sel = shaderSelection.value const sel = shaderSelection.value
const tlHeight = timelineState.value.containerHeight const tlHeight = timelineState.value.containerHeight
for (let i = 0; i < sel.length; i++) { for (let i = 0; i < sel.length; i++) {
const yPercent = 48 - sel[i].emotion * 30 const yPercent = emotionToYPercent(sel[i].emotion)
const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight
ys[i] = screenToUV(0, screenY).y ys[i] = screenToUV(0, screenY).y
} }
@ -336,6 +446,26 @@ const parsedGradient = computed(() => {
.filter(s => s.length > 0 && s.startsWith('#')) .filter(s => s.length > 0 && s.startsWith('#'))
}) })
// Position and year navigation, read from TimelineView
const activePosition = computed(() => timelineViewRef.value?.activePosition ?? null)
const timelineYears = computed(() => timelineViewRef.value?.years ?? [])
const activeYearIndex = computed(() => timelineYears.value.indexOf(activePosition.value?.year))
const canGoPrevYear = computed(() => activeYearIndex.value > 0)
const canGoNextYear = computed(() =>
activeYearIndex.value !== -1 && activeYearIndex.value < timelineYears.value.length - 1
)
function goToYear(index) {
const year = timelineYears.value[index]
if (year !== undefined) timelineViewRef.value?.scrollToYearCenter(year)
}
// The zoom slider is a transient control; the panel opening or the wave
// settings sliding up hide it again.
watch([() => eventsStore.panelOpen, settingsOpen], ([panelOpen, wave]) => {
if (panelOpen || wave) zoomOpen.value = false
})
// Zoom state from TimelineView // Zoom state from TimelineView
const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1) const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1)
const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4) const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4)
@ -347,9 +477,11 @@ function onZoomTo(value) {
timelineViewRef.value.zoomTo?.(clamped) timelineViewRef.value.zoomTo?.(clamped)
} }
const toggleDarkMode = () => { // Empty state only when the account really has no events and nothing is
$q.dark.toggle() // being created right now.
} const showEmptyState = computed(() =>
eventsStore.isLoaded && eventsStore.events.length === 0 && !eventsStore.panelOpen
)
const onAddEvent = () => { const onAddEvent = () => {
eventsStore.openPanel() eventsStore.openPanel()
@ -364,11 +496,32 @@ const onUserMenuNavigate = (target) => {
userMenuOpen.value = false userMenuOpen.value = false
if (target === 'settings') { if (target === 'settings') {
appSettingsOpen.value = true appSettingsOpen.value = true
} else if (target === 'appearance') {
// The wave settings sit behind the event panel; close it first so the
// choice is not swallowed.
if (eventsStore.panelOpen) eventsStore.closePanel()
settingsOpen.value = true
} else if (target === 'logout') { } else if (target === 'logout') {
authStore.logout() authStore.logout()
router.replace('/login') router.replace('/login')
} }
} }
// Connection state for remote users. Silent while everything is sent.
const syncLabel = computed(() => {
if (!eventsStore.isRemoteUser) return ''
const count = eventsStore.pendingSyncCount
if (!eventsStore.isOnline) return count > 0 ? `Offline · ${count} ausstehend` : 'Offline'
if (eventsStore.isSyncing) return 'Wird übertragen …'
if (count > 0) return `${count} ausstehend`
return ''
})
const syncTitle = computed(() => (eventsStore.isOnline
? 'Änderungen jetzt übertragen'
: 'Keine Verbindung. Änderungen werden lokal gespeichert und später übertragen.'))
// Error notifications and the redirect once the session has ended.
useStoreFeedback()
</script> </script>
<style scoped> <style scoped>
@ -378,14 +531,14 @@ const onUserMenuNavigate = (target) => {
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
color: #F5F5F5; color: var(--tm-paper);
transition: background 0.3s ease, color 0.3s ease; transition: background 0.3s ease, color 0.3s ease;
touch-action: pan-x pan-y; touch-action: pan-x pan-y;
} }
.lifewave-layout--dark { .lifewave-layout--dark {
background: #000; background: #000;
color: #F5F5F5; color: var(--tm-paper);
} }
/* FloatingLines Background */ /* FloatingLines Background */
@ -413,14 +566,79 @@ const onUserMenuNavigate = (target) => {
padding: 16px 20px; padding: 16px 20px;
} }
.lifewave-header__logo { .lifewave-header__sync {
font-size: 18px; display: inline-flex;
font-weight: 700; align-items: center;
letter-spacing: -0.3px; gap: 6px;
margin-left: auto;
margin-right: 12px;
padding: 0 12px;
height: 32px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--tm-paper, #f7f3ea);
cursor: pointer; cursor: pointer;
}
.lifewave-header__sync:disabled {
cursor: default;
opacity: 0.85;
}
.lifewave-header__sync--offline {
color: #ffb86b;
}
.lifewave-header__logo {
display: block;
user-select: none; user-select: none;
} }
/* Loading / empty state — above the timeline, below the controls */
.lifewave-state {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 8;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 20px;
font-size: 14px;
opacity: 0.85;
text-align: center;
pointer-events: none;
}
.lifewave-state--empty {
pointer-events: auto;
width: min(320px, calc(100vw - 48px));
padding: 24px 22px;
border-radius: 20px;
border: 1px solid rgba(var(--tm-accent-rgb, 115, 115, 115), 0.24);
font: inherit;
cursor: pointer;
opacity: 1;
transition: transform 0.15s ease;
}
.lifewave-state--empty:active {
transform: translate(-50%, -50%) scale(0.98);
}
.lifewave-state--empty strong {
font-size: 17px;
}
.lifewave-state--empty span {
font-size: 13px;
line-height: 1.45;
opacity: 0.75;
}
.lifewave-header__user { .lifewave-header__user {
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -432,21 +650,32 @@ const onUserMenuNavigate = (target) => {
padding: 0; padding: 0;
} }
/* Zoom Control */ /* Bottom bar */
.lifewave-layout__zoom { .lifewave-bar {
position: fixed; position: fixed;
left: 12px; left: 12px;
top: 50%; right: 12px;
transform: translateY(-50%); bottom: max(12px, env(safe-area-inset-bottom));
z-index: 10; z-index: 10;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
pointer-events: none;
} }
/* Settings Button — bottom-left, below zoom */ .lifewave-bar > * {
.lifewave-settings-btn { pointer-events: auto;
position: fixed; }
bottom: 16px;
left: 12px; .lifewave-bar__group {
z-index: 10; display: flex;
align-items: center;
gap: 8px;
justify-self: start;
}
.lifewave-bar__btn {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 50%;
@ -455,10 +684,33 @@ const onUserMenuNavigate = (target) => {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
color: #F5F5F5; color: inherit;
} }
.lifewave-settings-btn--dark {
color: #000; .lifewave-bar__btn--active {
background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.28);
}
.lifewave-bar__nav {
justify-self: center;
}
.lifewave-bar__add {
justify-self: end;
}
/* Zoom slider — above the zoom button (second button in the left group) */
.lifewave-layout__zoom {
position: fixed;
left: 64px;
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 56px);
z-index: 10;
}
@media (max-width: 380px) {
.lifewave-bar {
gap: 4px;
}
} }
/* Content */ /* Content */
.lifewave-content { .lifewave-content {

View file

@ -1,741 +0,0 @@
<template>
<q-layout view="lHh Lpr lFf" class="dynamic-gradient-bg">
<q-header elevated>
<q-toolbar>
<q-toolbar-title>
THATS ME
</q-toolbar-title>
<!-- Add login status indicator -->
<q-chip v-if="isLoggedIn" color="green" text-color="white" icon="check_circle">
Logged In
</q-chip>
<q-chip v-else color="grey" text-color="white" icon="person_off">
Not Logged In
</q-chip>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleDrawer" />
</q-toolbar>
</q-header>
<!-- Off-Canvas Drawer -->
<q-drawer v-model="drawer" show-if-above side="right" :width="280" :breakpoint="768" class="bg-grey-1"
style="overflow: hidden;">
<!-- Sliding Menu Container -->
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
<!-- Main Menu Panel -->
<div class="menu-panel">
<!-- Main Menu List -->
<q-list>
<q-item clickable v-ripple @click="navigateTo('login')" v-if="!isLoggedIn" to="/login">
<q-item-section avatar>
<q-icon name="login" />
</q-item-section>
<q-item-section>
<q-item-label>Login</q-item-label>
</q-item-section>
</q-item>
<!-- Move password reset to Account submenu and only show when logged in -->
<q-item clickable v-ripple @click="navigateTo('wave')" v-if="isLoggedIn" to="/wave">
<q-item-section avatar>
<q-icon name="line_axis" />
</q-item-section>
<q-item-section>
<q-item-label>Wave</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn" to="/edit">
<q-item-section avatar>
<q-icon name="add_circle" />
</q-item-section>
<q-item-section>
<q-item-label>New entry</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')" v-if="isLoggedIn">
<q-item-section avatar>
<q-icon name="people" />
</q-item-section>
<q-item-section>
<q-item-label>My People</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')" v-if="isLoggedIn">
<q-item-section avatar>
<q-icon name="mail" />
</q-item-section>
<q-item-section>
<q-item-label>Messages</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')" v-if="isLoggedIn">
<q-item-section avatar>
<q-icon name="person" />
</q-item-section>
<q-item-section>
<q-item-label>Profile</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')" v-if="isLoggedIn">
<q-item-section avatar>
<q-icon name="account_circle" />
</q-item-section>
<q-item-section>
<q-item-label>Account</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('sign-up')" to="/sign-up" v-if="!isLoggedIn">
<q-item-section avatar>
<q-icon name="person_add" />
</q-item-section>
<q-item-section>
<q-item-label>Sign Up</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
<q-item-section avatar>
<q-icon name="help" />
</q-item-section>
<q-item-section>
<q-item-label>Need Help?</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('legal', 'Legal Stuff')">
<q-item-section avatar>
<q-icon name="gavel" />
</q-item-section>
<q-item-section>
<q-item-label>Legal Stuff</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
</q-list>
</div>
<!-- Second Level Menu Panel -->
<div class="menu-panel">
<div class="q-pa-md bg-secondary text-white">
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
<span class="text-h6">{{ currentSubmenuTitle }}</span>
</div>
<!-- My People Submenu -->
<q-list v-if="currentSubmenu === 'my-people'">
<q-item clickable v-ripple @click="navigateTo('relatives')">
<q-item-section avatar>
<q-icon name="family_restroom" />
</q-item-section>
<q-item-section>
<q-item-label>Relatives</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('friends')">
<q-item-section avatar>
<q-icon name="group" />
</q-item-section>
<q-item-section>
<q-item-label>Friends</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Messages Submenu -->
<q-list v-if="currentSubmenu === 'messages'">
<q-item clickable v-ripple @click="navigateTo('notifications')">
<q-item-section avatar>
<q-icon name="notifications" />
</q-item-section>
<q-item-section>
<q-item-label>Notifications</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('message-center', 'Message Center')">
<q-item-section avatar>
<q-icon name="mail" />
</q-item-section>
<q-item-section>
<q-item-label>Messages</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('groups')">
<q-item-section avatar>
<q-icon name="group_work" />
</q-item-section>
<q-item-section>
<q-item-label>Groups</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
</q-list>
<!-- Profile Submenu -->
<q-list v-if="currentSubmenu === 'profile'">
<q-item clickable v-ripple @click="navigateTo('personal-data')">
<q-item-section avatar>
<q-icon name="badge" />
</q-item-section>
<q-item-section>
<q-item-label>Personal Data</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('portrait', 'Portrait')">
<q-item-section avatar>
<q-icon name="portrait" />
</q-item-section>
<q-item-section>
<q-item-label>Portrait</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('voice', 'Voice')">
<q-item-section avatar>
<q-icon name="record_voice_over" />
</q-item-section>
<q-item-section>
<q-item-label>Voice</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
</q-list>
<!-- Account Submenu -->
<q-list v-if="currentSubmenu === 'account'">
<q-item clickable v-ripple @click="openSubmenu('plan', 'Plan')">
<q-item-section avatar>
<q-icon name="subscriptions" />
</q-item-section>
<q-item-section>
<q-item-label>Plan</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('billing', 'Billing')">
<q-item-section avatar>
<q-icon name="payment" />
</q-item-section>
<q-item-section>
<q-item-label>Billing</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
<!-- Add a logout button that only appears when logged in -->
<q-item clickable v-ripple @click="logout" v-if="isLoggedIn">
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Login Submenu -->
<q-list v-if="currentSubmenu === 'login'">
<q-item clickable v-ripple @click="navigateTo('password-reset')">
<q-item-section avatar>
<q-icon name="lock_reset" />
</q-item-section>
<q-item-section>
<q-item-label>Password reset</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="logout">
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Support Submenu -->
<q-list v-if="currentSubmenu === 'support'">
<q-item clickable v-ripple @click="navigateTo('video-tutorials')">
<q-item-section avatar>
<q-icon name="play_circle" />
</q-item-section>
<q-item-section>
<q-item-label>Video tutorials</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('knowledge-base')">
<q-item-section avatar>
<q-icon name="library_books" />
</q-item-section>
<q-item-section>
<q-item-label>Knowledge base</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('faq')">
<q-item-section avatar>
<q-icon name="quiz" />
</q-item-section>
<q-item-section>
<q-item-label>FAQ</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('chatbot')">
<q-item-section avatar>
<q-icon name="smart_toy" />
</q-item-section>
<q-item-section>
<q-item-label>Chatbot</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Legal Submenu -->
<q-list v-if="currentSubmenu === 'legal'">
<q-item clickable v-ripple @click="navigateTo('legal-terms')">
<q-item-section avatar>
<q-icon name="article" />
</q-item-section>
<q-item-section>
<q-item-label>Legal Terms</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('privacy-policy')">
<q-item-section avatar>
<q-icon name="privacy_tip" />
</q-item-section>
<q-item-section>
<q-item-label>Privacy Policy</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<!-- Third Level Menu Panel -->
<div class="menu-panel">
<div class="q-pa-md bg-accent text-white">
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
<span class="text-h6">{{ currentSubmenuTitle }}</span>
</div>
<!-- Message Center Submenu -->
<q-list v-if="currentSubmenu === 'message-center'">
<q-item clickable v-ripple @click="navigateTo('new-message')">
<q-item-section avatar>
<q-icon name="add" />
</q-item-section>
<q-item-section>
<q-item-label>Chat</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('direct-messages')">
<q-item-section avatar>
<q-icon name="message" />
</q-item-section>
<q-item-section>
<q-item-label>Direct Messages</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('group-messages')">
<q-item-section avatar>
<q-icon name="group" />
</q-item-section>
<q-item-section>
<q-item-label>Group Messages</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('message-requests')">
<q-item-section avatar>
<q-icon name="request_page" />
</q-item-section>
<q-item-section>
<q-item-label>Message Requests</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
</q-item-section>
</q-item>
</q-list>
<!-- Portrait Submenu -->
<q-list v-if="currentSubmenu === 'portrait'">
<q-item clickable v-ripple @click="navigateTo('animated-avatar')">
<q-item-section avatar>
<q-icon name="face" />
</q-item-section>
<q-item-section>
<q-item-label>Animated Avatar</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Voice Submenu -->
<q-list v-if="currentSubmenu === 'voice'">
<q-item clickable v-ripple @click="navigateTo('voice-cloning')">
<q-item-section avatar>
<q-icon name="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Voice cloning</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Plan Submenu -->
<q-list v-if="currentSubmenu === 'plan'">
<q-item clickable v-ripple @click="navigateTo('upgrade')">
<q-item-section avatar>
<q-icon name="upgrade" />
</q-item-section>
<q-item-section>
<q-item-label>Upgrade</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Billing Submenu -->
<q-list v-if="currentSubmenu === 'billing'">
<q-item clickable v-ripple @click="navigateTo('billing-information')">
<q-item-section avatar>
<q-icon name="credit_card" />
</q-item-section>
<q-item-section>
<q-item-label>Billing Information</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('invoices')">
<q-item-section avatar>
<q-icon name="receipt" />
</q-item-section>
<q-item-section>
<q-item-label>Invoices</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
<q-footer
v-if="!$route.meta.hideFooter"
elevated
class="bg-white text-black font"
>
<q-tabs align="justify" dense>
<q-route-tab to="/" icon="home" />
<q-route-tab to="/people" icon="people" />
<q-route-tab to="/edit" icon="add_circle_outline" />
<q-route-tab to="/messages" icon="mail" />
<q-route-tab to="/profile" icon="person" />
</q-tabs>
</q-footer>
</q-layout>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'MultiLevelSlidingMenu',
setup() {
const router = useRouter()
const drawer = ref(false)
const currentRoute = ref('home')
const menuLevel = ref(0)
const currentSubmenu = ref('')
const currentSubmenuTitle = ref('')
const menuHistory = ref([])
// Make isLoggedIn reactive to localStorage changes
const isLoggedIn = ref(localStorage.getItem('isLoggedIn') === 'true')
// Listen for storage changes (when logging in from another tab/component)
const updateLoginStatus = () => {
isLoggedIn.value = localStorage.getItem('isLoggedIn') === 'true'
}
onMounted(() => {
window.addEventListener('storage', updateLoginStatus)
// Also check on route changes
router.afterEach(() => {
updateLoginStatus()
})
})
const slideOffset = computed(() => {
return menuLevel.value * -280 // Each level slides 280px (drawer width) to the left
})
const toggleDrawer = () => {
drawer.value = !drawer.value
// Reset menu to main level when drawer is opened
if (drawer.value) {
resetMenu()
}
}
const resetMenu = () => {
menuLevel.value = 0
currentSubmenu.value = ''
currentSubmenuTitle.value = ''
menuHistory.value = []
}
const openSubmenu = (submenuKey, title) => {
console.log(`Opening submenu: ${submenuKey} - ${title}`)
// Save current state to history
menuHistory.value.push({
level: menuLevel.value,
submenu: currentSubmenu.value,
title: currentSubmenuTitle.value
})
// Navigate to new submenu
menuLevel.value += 1
currentSubmenu.value = submenuKey
currentSubmenuTitle.value = title
console.log(`New menu level: ${menuLevel.value}, submenu: ${currentSubmenu.value}`)
}
const goBack = () => {
console.log('Going back, history length:', menuHistory.value.length)
if (menuHistory.value.length > 0) {
const previousState = menuHistory.value.pop()
menuLevel.value = previousState.level
currentSubmenu.value = previousState.submenu
currentSubmenuTitle.value = previousState.title
console.log(`Back to level: ${menuLevel.value}, submenu: ${currentSubmenu.value}`)
} else {
resetMenu()
}
}
const navigateTo = (route) => {
console.log(`Navigating to: ${route}`)
currentRoute.value = route
// In a real app, you would use Vue Router here
// this.$router.push('/' + route)
// Close drawer and reset menu on mobile after navigation
if (window.innerWidth < 768) {
drawer.value = false
resetMenu()
}
}
// Update the login function
const login = () => {
isLoggedIn.value = true
localStorage.setItem('isLoggedIn', 'true')
}
// Update the logout function
const logout = () => {
console.log('Logging out...')
isLoggedIn.value = false
localStorage.setItem('isLoggedIn', 'false')
currentRoute.value = 'login'
drawer.value = false
resetMenu()
router.push('/login')
}
return {
drawer,
currentRoute,
menuLevel,
currentSubmenu,
currentSubmenuTitle,
menuHistory,
slideOffset,
toggleDrawer,
openSubmenu,
goBack,
navigateTo,
logout,
isLoggedIn,
login
}
}
}
</script>
<style scoped>
.dynamic-gradient-bg {
/* background: linear-gradient(45deg, #541ba2, #d4890b, #fc1c98);
background: linear-gradient(45deg, #7317c3, #d2348b, #31e6cb); */
/*background: linear-gradient(-45deg, #a0197c, #fe4374, #541ba2, #a0197c);*/
background: linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);
background-size: 300% 300%;
animation: gradientAnimation 30s ease infinite;
}
@keyframes gradientAnimation {
0% {
background-position: 0% 0%;
/* Start Links Mitte */
}
25% {
background-position: 100% 0%;
/* Oben Rechts */
}
50% {
background-position: 100% 100%;
/* Unten Rechts */
}
75% {
background-position: 0% 100%;
/* Unten Links */
}
100% {
background-position: 0% 0%;
/* Zurück zum Start Links Mitte */
}
}
.q-footer .q-tab__label {
font-size: 0.7rem;
/* Passe diesen Wert nach Bedarf an */
}
/* Custom styles for the drawer */
/* .q-drawer {
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
} */
/* Menu container for sliding panels */
.menu-container {
display: flex;
width: calc(100% * 3);
/* Width for 3 levels */
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* Individual menu panel */
.menu-panel {
width: 280px;
flex-shrink: 0;
height: 100vh;
overflow-y: auto;
}
/* Hover effects for menu items */
.q-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
/* Chevron icon styling */
.q-item-section--side .q-icon {
color: rgba(0, 0, 0, 0.54);
}
/* Back button styling */
.q-btn[round] {
min-height: 32px;
min-width: 32px;
}
/* Header gradient for different levels */
.bg-secondary {
background: linear-gradient(135deg, #9c27b0, #673ab7) !important;
}
.bg-accent {
background: linear-gradient(135deg, #ff5722, #f44336) !important;
}
/* Smooth scrolling for menu panels */
.menu-panel {
scroll-behavior: smooth;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.menu-container {
width: calc(100vw * 3);
}
.menu-panel {
width: 100vw;
}
}
</style>

View file

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

View file

@ -1,369 +0,0 @@
<template>
<q-page padding class="category-selector-page">
<div class="row justify-center">
<div class="col-12 col-md-8 col-lg-6">
<q-card>
<q-card-section>
<div class="text-h5">Select Categories</div>
<div class="text-subtitle2 text-grey">Choose categories that best describe this life event</div>
</q-card-section>
<q-card-section>
<!-- Search bar -->
<q-input
v-model="searchQuery"
label="Search categories..."
filled
clearable
class="q-mb-md"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<!-- Category list -->
<div class="category-list q-gutter-md">
<div
v-for="category in filteredCategories"
:key="category.id"
class="category-item"
:class="{ 'selected': isCategorySelected(category.id) }"
@click="toggleCategorySelection(category.id)"
>
<div class="category-icon">
<q-icon :name="category.icon" size="32px" color="primary" />
</div>
<div class="category-info">
<div class="category-name">{{ category.label }}</div>
<div v-if="category.description" class="category-description text-grey">{{ category.description }}</div>
</div>
<!-- Selection indicator -->
<q-icon
v-if="isCategorySelected(category.id)"
name="check_circle"
color="positive"
size="24px"
class="selection-indicator"
/>
</div>
</div>
</q-card-section>
<!-- Selected categories section -->
<q-card-section v-if="selectedCategories.length > 0" class="selected-section">
<q-separator class="q-mb-md" />
<div class="text-subtitle2 q-mb-md">Selected Categories ({{ selectedCategories.length }})</div>
<div class="selected-categories-container">
<div class="selected-categories-list">
<div
v-for="category in selectedCategoriesData"
:key="category.id"
class="selected-category"
@mouseenter="hoveredCategoryId = category.id"
@mouseleave="hoveredCategoryId = null"
>
<q-chip
:removable="hoveredCategoryId === category.id"
@remove="removeCategorySelection(category.id)"
color="primary"
text-color="white"
:icon="category.icon"
class="selected-category-chip"
>
{{ category.label }}
</q-chip>
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Fixed Footer with Action Buttons -->
<q-footer elevated class="bg-white text-dark selector-footer">
<q-toolbar class="justify-between">
<q-btn
icon="arrow_back"
color="grey"
flat
round
@click="onCancel"
class="q-ml-sm"
>
<q-tooltip class="bg-grey">Cancel</q-tooltip>
</q-btn>
<q-btn
label="Add Categories"
color="primary"
unelevated
:disable="selectedCategories.length === 0"
@click="onAddCategories"
class="q-mr-sm"
/>
</q-toolbar>
</q-footer>
</q-page>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { categoryStructure } from '../utils/editFormOptions.js'
export default {
name: 'CategorySelector',
setup() {
const router = useRouter()
const route = useRoute()
// State
const searchQuery = ref('')
const selectedCategories = ref([])
const hoveredCategoryId = ref(null)
// Category icons mapping
const categoryIcons = {
'career': 'work',
'education': 'school',
'awards': 'emoji_events',
'personal-celebrations': 'celebration',
'relationships': 'favorite',
'parenthood': 'child_care',
'passing': 'sentiment_very_dissatisfied',
'festivities': 'party_mode',
'social-events': 'groups',
'community': 'volunteer_activism',
'health': 'health_and_safety',
'travel': 'flight',
'hobbies': 'palette',
'sports': 'sports_soccer',
'technology': 'computer',
'financial': 'attach_money',
'legal': 'gavel',
'spiritual': 'spa',
'creative': 'brush',
'learning': 'menu_book'
}
// Convert category structure to flat list without subcategories
const categories = ref(
categoryStructure.map((cat, index) => ({
id: index + 1,
label: cat.label,
value: cat.value,
icon: categoryIcons[cat.value] || 'category',
description: null // Could be added later
}))
)
// Computed
const filteredCategories = computed(() => {
if (!searchQuery.value) {
return categories.value
}
return categories.value.filter(category =>
category.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const selectedCategoriesData = computed(() => {
return categories.value.filter(category => selectedCategories.value.includes(category.id))
})
// Methods
const isCategorySelected = (categoryId) => {
return selectedCategories.value.includes(categoryId)
}
const toggleCategorySelection = (categoryId) => {
const index = selectedCategories.value.indexOf(categoryId)
if (index > -1) {
selectedCategories.value.splice(index, 1)
} else {
selectedCategories.value.push(categoryId)
}
}
const removeCategorySelection = (categoryId) => {
const index = selectedCategories.value.indexOf(categoryId)
if (index > -1) {
selectedCategories.value.splice(index, 1)
}
}
const onCancel = () => {
router.go(-1)
}
const onAddCategories = () => {
// Pass selected categories back to the edit page
const selectedCategoriesLabels = selectedCategoriesData.value.map(category => category.label)
// Navigate back with the selected categories data
router.push({
name: 'edit',
query: {
...route.query,
selectedCategories: JSON.stringify(selectedCategoriesLabels)
}
})
}
// Load previously selected categories from route query
onMounted(() => {
if (route.query.currentSelection) {
try {
const currentSelection = JSON.parse(route.query.currentSelection)
// Convert labels back to IDs
selectedCategories.value = categories.value
.filter(category => currentSelection.includes(category.label))
.map(category => category.id)
} catch (error) {
console.error('Error parsing current selection:', error)
}
}
})
return {
searchQuery,
selectedCategories,
hoveredCategoryId,
categories,
filteredCategories,
selectedCategoriesData,
isCategorySelected,
toggleCategorySelection,
removeCategorySelection,
onCancel,
onAddCategories
}
}
}
</script>
<style scoped>
.category-selector-page {
min-height: 100vh;
padding-bottom: 80px; /* Space for footer */
}
.category-list {
max-height: 400px;
overflow-y: auto;
}
.category-item {
display: flex;
align-items: center;
padding: 16px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
background: rgba(0, 0, 0, 0.02);
}
.category-item:hover {
background-color: rgba(25, 118, 210, 0.1);
transform: translateY(-1px);
}
.category-item.selected {
background-color: rgba(25, 118, 210, 0.15);
border-color: var(--q-primary);
}
.category-icon {
margin-right: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(25, 118, 210, 0.1);
}
.category-info {
flex: 1;
}
.category-name {
font-weight: 500;
font-size: 16px;
line-height: 1.2;
margin-bottom: 4px;
}
.category-description {
font-size: 14px;
}
.selection-indicator {
margin-left: 16px;
}
.selected-section {
background-color: rgba(0, 0, 0, 0.02);
}
.selected-categories-container {
max-height: 200px;
overflow-y: auto;
}
.selected-categories-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.selected-category {
position: relative;
}
.selected-category-chip {
font-size: 14px;
padding: 8px 16px;
min-height: 36px;
}
/* Responsive design */
@media (max-width: 600px) {
.category-item {
padding: 12px;
}
.category-icon {
margin-right: 12px;
width: 40px;
height: 40px;
}
.selected-categories-list {
gap: 6px;
}
}
/* Footer styling */
.selector-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
border-top: 1px solid #e0e0e0;
}
.selector-footer .q-toolbar {
min-height: 60px;
padding: 8px 16px;
}
</style>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,423 +0,0 @@
<template>
<q-page padding class="person-selector-page">
<div class="row justify-center">
<div class="col-12 col-md-8 col-lg-6">
<q-card>
<q-card-section>
<div class="text-h5">Select Related Persons</div>
<div class="text-subtitle2 text-grey">Choose people related to this life event</div>
</q-card-section>
<q-card-section>
<!-- Search bar -->
<q-input
v-model="searchQuery"
label="Search persons..."
filled
clearable
class="q-mb-md"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<!-- Person list -->
<div class="person-list q-gutter-md">
<div
v-for="person in filteredPersons"
:key="person.id"
class="person-item"
:class="{ 'selected': isPersonSelected(person.id) }"
@click="togglePersonSelection(person.id)"
>
<div class="person-avatar">
<q-avatar
v-if="person.avatar"
size="60px"
class="person-avatar-img"
>
<img :src="person.avatar" :alt="person.name" />
</q-avatar>
<q-avatar
v-else
size="60px"
color="primary"
text-color="white"
class="person-avatar-initials"
>
{{ getInitials(person.name) }}
</q-avatar>
<!-- Selection indicator -->
<q-icon
v-if="isPersonSelected(person.id)"
name="check_circle"
color="positive"
size="24px"
class="selection-indicator"
/>
</div>
<div class="person-info">
<div class="person-name">{{ person.name }}</div>
<div v-if="person.role" class="person-role text-grey">{{ person.role }}</div>
</div>
</div>
</div>
</q-card-section>
<!-- Selected persons section -->
<q-card-section v-if="selectedPersons.length > 0" class="selected-section">
<q-separator class="q-mb-md" />
<div class="text-subtitle2 q-mb-md">Selected Persons ({{ selectedPersons.length }})</div>
<div class="selected-persons-container">
<div class="selected-persons-list">
<div
v-for="person in selectedPersonsData"
:key="person.id"
class="selected-person"
@mouseenter="hoveredPersonId = person.id"
@mouseleave="hoveredPersonId = null"
>
<q-avatar
v-if="person.avatar"
size="50px"
class="selected-avatar"
>
<img :src="person.avatar" :alt="person.name" />
</q-avatar>
<q-avatar
v-else
size="50px"
color="primary"
text-color="white"
class="selected-avatar"
>
{{ getInitials(person.name) }}
</q-avatar>
<!-- Remove button on hover -->
<div
v-if="hoveredPersonId === person.id"
class="remove-person"
@click.stop="removePersonSelection(person.id)"
>
×
</div>
<div class="selected-person-name">{{ person.name }}</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Fixed Footer with Action Buttons -->
<q-footer elevated class="bg-white text-dark selector-footer">
<q-toolbar class="justify-between">
<q-btn
icon="arrow_back"
color="grey"
flat
round
@click="onCancel"
class="q-ml-sm"
>
<q-tooltip class="bg-grey">Cancel</q-tooltip>
</q-btn>
<q-btn
label="Add Persons"
color="primary"
unelevated
:disable="selectedPersons.length === 0"
@click="onAddPersons"
class="q-mr-sm"
/>
</q-toolbar>
</q-footer>
</q-page>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { personOptions } from '../utils/editFormOptions.js'
export default {
name: 'PersonSelector',
setup() {
const router = useRouter()
const route = useRoute()
// State
const searchQuery = ref('')
const selectedPersons = ref([])
const hoveredPersonId = ref(null)
// Convert simple person names to objects with IDs
const persons = ref(
personOptions.map((name, index) => ({
id: index + 1,
name: name,
avatar: null, // Could be loaded from API later
role: null // Could be added later
}))
)
// Computed
const filteredPersons = computed(() => {
if (!searchQuery.value) {
return persons.value
}
return persons.value.filter(person =>
person.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const selectedPersonsData = computed(() => {
return persons.value.filter(person => selectedPersons.value.includes(person.id))
})
// Methods
const getInitials = (name) => {
return name
.split(' ')
.map(word => word.charAt(0).toUpperCase())
.slice(0, 2)
.join('')
}
const isPersonSelected = (personId) => {
return selectedPersons.value.includes(personId)
}
const togglePersonSelection = (personId) => {
const index = selectedPersons.value.indexOf(personId)
if (index > -1) {
selectedPersons.value.splice(index, 1)
} else {
selectedPersons.value.push(personId)
}
}
const removePersonSelection = (personId) => {
const index = selectedPersons.value.indexOf(personId)
if (index > -1) {
selectedPersons.value.splice(index, 1)
}
}
const onCancel = () => {
router.go(-1)
}
const onAddPersons = () => {
// Pass selected persons back to the edit page
const selectedPersonsNames = selectedPersonsData.value.map(person => person.name)
// Navigate back with the selected persons data
// We'll use query parameters to pass the data
router.push({
name: 'edit',
query: {
...route.query,
selectedPersons: JSON.stringify(selectedPersonsNames)
}
})
}
// Load previously selected persons from route query
onMounted(() => {
if (route.query.currentSelection) {
try {
const currentSelection = JSON.parse(route.query.currentSelection)
// Convert names back to IDs
selectedPersons.value = persons.value
.filter(person => currentSelection.includes(person.name))
.map(person => person.id)
} catch (error) {
console.error('Error parsing current selection:', error)
}
}
})
return {
searchQuery,
selectedPersons,
hoveredPersonId,
persons,
filteredPersons,
selectedPersonsData,
getInitials,
isPersonSelected,
togglePersonSelection,
removePersonSelection,
onCancel,
onAddPersons
}
}
}
</script>
<style scoped>
.person-selector-page {
min-height: 100vh;
padding-bottom: 80px; /* Space for footer */
}
.person-list {
max-height: 400px;
overflow-y: auto;
}
.person-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.person-item:hover {
background-color: rgba(25, 118, 210, 0.1);
}
.person-item.selected {
background-color: rgba(25, 118, 210, 0.15);
border-color: var(--q-primary);
}
.person-avatar {
position: relative;
margin-right: 16px;
}
.selection-indicator {
position: absolute;
top: -4px;
right: -4px;
background: white;
border-radius: 50%;
}
.person-info {
flex: 1;
}
.person-name {
font-weight: 500;
font-size: 16px;
line-height: 1.2;
}
.person-role {
font-size: 14px;
margin-top: 2px;
}
.selected-section {
background-color: rgba(0, 0, 0, 0.02);
}
.selected-persons-container {
max-height: 200px;
overflow-y: auto;
}
.selected-persons-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.selected-person {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px;
border-radius: 8px;
transition: all 0.2s ease;
cursor: pointer;
min-width: 80px;
}
.selected-person:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.selected-avatar {
margin-bottom: 8px;
}
.selected-person-name {
font-size: 12px;
text-align: center;
line-height: 1.2;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-person {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background: rgba(244, 67, 54, 0.9);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
}
.remove-person:hover {
background: #f44336;
transform: scale(1.1);
}
/* Responsive design */
@media (max-width: 600px) {
.selected-persons-list {
justify-content: center;
}
.person-item {
padding: 8px;
}
.person-avatar {
margin-right: 12px;
}
}
/* Footer styling */
.selector-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
border-top: 1px solid #e0e0e0;
}
.selector-footer .q-toolbar {
min-height: 60px;
padding: 8px 16px;
}
</style>

View file

@ -1,445 +0,0 @@
<template>
<q-page padding class="tag-selector-page">
<div class="row justify-center">
<div class="col-12 col-md-8 col-lg-6">
<q-card>
<q-card-section>
<div class="text-h5">Select Tags</div>
<div class="text-subtitle2 text-grey">Add tags to describe the mood and nature of this event</div>
</q-card-section>
<q-card-section>
<!-- Search bar -->
<q-input
v-model="searchQuery"
label="Search tags..."
filled
clearable
class="q-mb-md"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<!-- Tag categories -->
<div class="tag-categories q-mb-md">
<q-btn-toggle
v-model="selectedTagCategory"
:options="tagCategoryOptions"
color="primary"
unelevated
class="full-width"
/>
</div>
<!-- Tag grid -->
<div class="tag-grid">
<div
v-for="tag in filteredTags"
:key="tag.id"
class="tag-item"
:class="{ 'selected': isTagSelected(tag.id) }"
@click="toggleTagSelection(tag.id)"
>
<div class="tag-content">
<q-icon :name="tag.icon" size="20px" class="tag-icon" />
<span class="tag-label">{{ tag.label }}</span>
<!-- Selection indicator -->
<q-icon
v-if="isTagSelected(tag.id)"
name="check_circle"
color="positive"
size="18px"
class="selection-indicator"
/>
</div>
</div>
</div>
</q-card-section>
<!-- Selected tags section -->
<q-card-section v-if="selectedTags.length > 0" class="selected-section">
<q-separator class="q-mb-md" />
<div class="text-subtitle2 q-mb-md">Selected Tags ({{ selectedTags.length }})</div>
<div class="selected-tags-container">
<div class="selected-tags-list">
<q-chip
v-for="tag in selectedTagsData"
:key="tag.id"
removable
@remove="removeTagSelection(tag.id)"
color="secondary"
text-color="white"
:icon="tag.icon"
class="selected-tag-chip"
>
{{ tag.label }}
</q-chip>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Fixed Footer with Action Buttons -->
<q-footer elevated class="bg-white text-dark selector-footer">
<q-toolbar class="justify-between">
<q-btn
icon="arrow_back"
color="grey"
flat
round
@click="onCancel"
class="q-ml-sm"
>
<q-tooltip class="bg-grey">Cancel</q-tooltip>
</q-btn>
<q-btn
label="Add Tags"
color="primary"
unelevated
:disable="selectedTags.length === 0"
@click="onAddTags"
class="q-mr-sm"
/>
</q-toolbar>
</q-footer>
</q-page>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { tagOptions } from '../utils/editFormOptions.js'
export default {
name: 'TagSelector',
setup() {
const router = useRouter()
const route = useRoute()
// State
const searchQuery = ref('')
const selectedTags = ref([])
const selectedTagCategory = ref('all')
// Tag category options
const tagCategoryOptions = [
{ label: 'All', value: 'all' },
{ label: 'Emotions', value: 'emotions' },
{ label: 'Significance', value: 'significance' },
{ label: 'Social', value: 'social' },
{ label: 'Intensity', value: 'intensity' },
{ label: 'Time', value: 'time' }
]
// Tag categorization and icons
const tagCategories = {
'emotions': ['happy', 'sad', 'exciting', 'stressful', 'memorable', 'important', 'fun', 'challenging', 'rewarding', 'disappointing', 'surprising', 'life-changing', 'routine', 'special', 'difficult', 'joyful', 'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful', 'emotional', 'touching', 'inspiring', 'motivating', 'healing'],
'significance': ['milestone', 'achievement', 'breakthrough', 'turning-point', 'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected', 'planned', 'spontaneous', 'tradition', 'new-experience'],
'social': ['family', 'friends', 'colleagues', 'community', 'solo', 'group', 'intimate', 'public', 'private', 'celebration'],
'intensity': ['intense', 'mild', 'dramatic', 'subtle', 'overwhelming', 'gradual', 'sudden', 'anticipated', 'shocking', 'gentle'],
'time': ['brief', 'extended', 'momentary', 'lasting', 'temporary', 'permanent', 'seasonal', 'annual', 'weekly', 'daily']
}
const tagIcons = {
// Emotions
'happy': 'sentiment_very_satisfied',
'sad': 'sentiment_very_dissatisfied',
'exciting': 'bolt',
'stressful': 'stress_management',
'memorable': 'star',
'important': 'priority_high',
'fun': 'sports_esports',
'challenging': 'fitness_center',
'rewarding': 'emoji_events',
'disappointing': 'thumb_down',
'surprising': 'surprise',
'life-changing': 'transform',
'routine': 'repeat',
'special': 'auto_awesome',
'difficult': 'warning',
'joyful': 'celebration',
'overwhelming': 'waves',
'peaceful': 'spa',
'anxious': 'psychology',
'proud': 'military_tech',
'grateful': 'volunteer_activism',
'emotional': 'favorite',
'touching': 'touch_app',
'inspiring': 'lightbulb',
'motivating': 'trending_up',
'healing': 'healing',
// Significance
'milestone': 'flag',
'achievement': 'workspace_premium',
'breakthrough': 'psychology_alt',
'turning-point': 'turn_right',
'first-time': 'new_releases',
'last-time': 'last_page',
'once-in-a-lifetime': 'diamond',
'unexpected': 'help_outline',
'planned': 'event_note',
'spontaneous': 'flash_on',
'tradition': 'history',
'new-experience': 'explore',
// Social
'family': 'family_restroom',
'friends': 'group',
'colleagues': 'business',
'community': 'diversity_3',
'solo': 'person',
'group': 'groups',
'intimate': 'favorite_border',
'public': 'public',
'private': 'lock',
'celebration': 'cake',
// Default for others
'default': 'tag'
}
// Convert tag options to objects with categories and icons
const tags = ref(
tagOptions.map((tag, index) => {
// Find which category this tag belongs to
let category = 'other'
for (const [cat, tagList] of Object.entries(tagCategories)) {
if (tagList.includes(tag)) {
category = cat
break
}
}
return {
id: index + 1,
label: tag,
value: tag,
category: category,
icon: tagIcons[tag] || tagIcons['default']
}
})
)
// Computed
const filteredTags = computed(() => {
let filtered = tags.value
// Filter by category
if (selectedTagCategory.value !== 'all') {
filtered = filtered.filter(tag => tag.category === selectedTagCategory.value)
}
// Filter by search query
if (searchQuery.value) {
filtered = filtered.filter(tag =>
tag.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
return filtered
})
const selectedTagsData = computed(() => {
return tags.value.filter(tag => selectedTags.value.includes(tag.id))
})
// Methods
const isTagSelected = (tagId) => {
return selectedTags.value.includes(tagId)
}
const toggleTagSelection = (tagId) => {
const index = selectedTags.value.indexOf(tagId)
if (index > -1) {
selectedTags.value.splice(index, 1)
} else {
selectedTags.value.push(tagId)
}
}
const removeTagSelection = (tagId) => {
const index = selectedTags.value.indexOf(tagId)
if (index > -1) {
selectedTags.value.splice(index, 1)
}
}
const onCancel = () => {
router.go(-1)
}
const onAddTags = () => {
// Pass selected tags back to the edit page
const selectedTagsLabels = selectedTagsData.value.map(tag => tag.label)
// Navigate back with the selected tags data
router.push({
name: 'edit',
query: {
...route.query,
selectedTags: JSON.stringify(selectedTagsLabels)
}
})
}
// Load previously selected tags from route query
onMounted(() => {
if (route.query.currentSelection) {
try {
const currentSelection = JSON.parse(route.query.currentSelection)
// Convert labels back to IDs
selectedTags.value = tags.value
.filter(tag => currentSelection.includes(tag.label))
.map(tag => tag.id)
} catch (error) {
console.error('Error parsing current selection:', error)
}
}
})
return {
searchQuery,
selectedTags,
selectedTagCategory,
tagCategoryOptions,
tags,
filteredTags,
selectedTagsData,
isTagSelected,
toggleTagSelection,
removeTagSelection,
onCancel,
onAddTags
}
}
}
</script>
<style scoped>
.tag-selector-page {
min-height: 100vh;
padding-bottom: 80px; /* Space for footer */
}
.tag-categories {
margin-bottom: 16px;
}
.tag-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
max-height: 400px;
overflow-y: auto;
padding: 4px;
}
.tag-item {
position: relative;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.02);
min-height: 44px;
}
.tag-item:hover {
background-color: rgba(25, 118, 210, 0.1);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-1px);
}
.tag-item.selected {
background-color: rgba(25, 118, 210, 0.15);
border-color: var(--q-primary);
}
.tag-content {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 8px;
}
.tag-icon {
flex-shrink: 0;
color: var(--q-primary);
}
.tag-label {
flex: 1;
font-size: 14px;
font-weight: 500;
text-transform: capitalize;
}
.selection-indicator {
flex-shrink: 0;
}
.selected-section {
background-color: rgba(0, 0, 0, 0.02);
}
.selected-tags-container {
max-height: 200px;
overflow-y: auto;
}
.selected-tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.selected-tag-chip {
font-size: 13px;
min-height: 32px;
}
/* Responsive design */
@media (max-width: 600px) {
.tag-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 6px;
}
.tag-content {
padding: 6px 10px;
gap: 6px;
}
.tag-label {
font-size: 13px;
}
.selected-tags-list {
gap: 6px;
}
}
/* Footer styling */
.selector-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
border-top: 1px solid #e0e0e0;
}
.selector-footer .q-toolbar {
min-height: 60px;
padding: 8px 16px;
}
</style>

View file

@ -1,488 +0,0 @@
<template>
<q-page>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- Entry Detail Lightbox -->
<q-dialog v-model="showEntryDetail" maximized transition-show="slide-up" transition-hide="slide-down">
<q-card class="entry-lightbox">
<q-card-section class="lightbox-header">
<q-btn icon="close" flat round dense v-close-popup color="white" />
</q-card-section>
<q-card-section class="lightbox-content">
<EntryDetailPage :entry-data="selectedEntryData" />
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script>
import { onMounted, onBeforeUnmount, defineComponent, ref } from 'vue'
import { ConnectedDotsVisualization } from "../utils/ConnectedDotsVisualization"
import EntryDetailPage from './EntryDetailPage.vue'
export default defineComponent({
name: 'WavePage',
components: {
EntryDetailPage
},
setup() {
let visualization = null;
let isDown = false;
let startX;
let scrollLeft;
// Lightbox state
const showEntryDetail = ref(false)
const selectedEntryData = ref(null)
// Sample detailed entry data
const sampleEntryData = {
// 1: {
// id: 1,
// title: "Beginn des neuen Abenteuers",
// subtitle: "Ein wichtiger Meilenstein in meinem Leben",
// date: "2024-10-01",
// time: "14:30",
// location: "München, Deutschland",
// level: 2,
// keyImage: "/images/familie2.png",
// description: "Dies war ein wirklich wichtiger Tag für mich. Nach langer Planung und Vorbereitung konnte ich endlich mein neues Abenteuer beginnen. Es war aufregend und gleichzeitig etwas beängstigend, aber ich wusste, dass es der richtige Schritt war. Die Erfahrungen, die ich an diesem Tag gemacht habe, werden mich noch lange begleiten.",
// additionalImages: [
// { url: "/images/feier.png", caption: "Familie beim Start" },
// { url: "/images/see.png", caption: "Der schöne Ort" }
// ],
// audioRecordings: [
// { name: "Gedanken zum Tag", url: "/audio/ferien_erlebnisbericht.mp3" }
// ],
// videoRecordings: [
// { name: "UHD Nature Video", url: "/videos/3191901-uhd_3840_2160_25fps.mp4" },
// { name: "HD Landscape Video", url: "/videos/3326928-hd_1920_1080_24fps.mp4" }
// ],
// relatedPersons: [
// { id: 1, name: "Maria Schmidt", relation: "Freundin", avatar: null },
// { id: 2, name: "Thomas Müller", relation: "Bruder", avatar: null }
// ],
// categories: [
// { id: 1, name: "Familie", icon: "family_restroom" },
// { id: 2, name: "Abenteuer", icon: "explore" }
// ],
// tags: [
// { id: 1, name: "Aufregend", icon: "emoji_emotions" },
// { id: 2, name: "Neuanfang", icon: "new_releases" }
// ]
// }
// Add more entries as needed
1:{
id: 1,
title: "Beginn des neuen Abenteuers",
subtitle: "Ein wichtiger Meilenstein in meinem Leben",
date: "2024-10-01",
time: "14:30",
location: "München, Deutschland",
level: 2,
keyImage: "/images/familie2.png",
description: "Dies war ein wirklich wichtiger Tag für mich. Nach langer Planung und Vorbereitung konnte ich endlich mein neues Abenteuer beginnen. Es war aufregend und gleichzeitig etwas beängstigenden, aber ich wusste, dass es der richtige Schritt war. Die Erfahrungen, die ich an diesem Tag gemacht habe, werden mich noch lange begleiten.",
additionalImages: [
{ url: "/images/see.png", caption: "Der schöne Ort" },
{ url: "/images/feier.png", caption: "Kleiner Umtrunk danach" }
],
audioRecordings: [
{ name: "Gedanken zum Tag", url: "/audio/ferien_erlebnisbericht.mp3" },
],
videoRecordings: [
{
name: "UHD Nature Video",
url: "/videos/3191901-uhd_3840_2160_25fps.mp4",
poster: "/videos/thumbs/3191901-uhd_3840_2160_25fps_thumb.jpg"
},
{
name: "HD Landscape Video",
url: "/videos/3326928-hd_1920_1080_24fps.mp4",
poster: "/videos/thumbs/3326928-hd_1920_1080_24fps_thumb.jpg"
}
],
relatedPersons: [
{ id: 1, name: "Maria Schmidt", relation: "Freundin", avatar: null },
{ id: 2, name: "Thomas Müller", relation: "Bruder", avatar: null }
],
categories: [
{ id: 1, name: "Familie", icon: "family_restroom" },
{ id: 2, name: "Abenteuer", icon: "explore" }
],
tags: [
{ id: 1, name: "Aufregend", icon: "emoji_emotions" },
{ id: 2, name: "Neuanfang", icon: "new_releases" },
{ id: 3, name: "Wichtig", icon: "star" }
]
}
}
// Method to open entry detail lightbox
const openEntryDetail = (entryId) => {
selectedEntryData.value = sampleEntryData[entryId] || sampleEntryData[1] // Fallback to first entry
showEntryDetail.value = true
}
// Function to handle cleanup of event listeners
const cleanupEventListeners = () => {
const scrollContainer = document.querySelector('.scroll-container');
if (scrollContainer) {
scrollContainer.removeEventListener('mousedown', handleMouseDown);
scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
scrollContainer.removeEventListener('mouseup', handleMouseUp);
scrollContainer.removeEventListener('mousemove', handleMouseMove);
scrollContainer.removeEventListener('touchstart', handleTouchStart);
scrollContainer.removeEventListener('touchend', handleTouchEnd);
scrollContainer.removeEventListener('touchmove', handleTouchMove);
}
window.removeEventListener('resize', handleResize);
};
// Event handlers
const handleResize = () => {
if (visualization) {
visualization.resize();
}
};
const handleMouseDown = (e) => {
const scrollContainer = e.currentTarget;
isDown = true;
scrollContainer.classList.add('active');
startX = e.pageX - scrollContainer.offsetLeft;
scrollLeft = scrollContainer.scrollLeft;
scrollContainer.classList.remove('smooth-scroll');
};
const handleMouseLeave = (e) => {
if (!isDown) return;
isDown = false;
e.currentTarget.classList.remove('active');
e.currentTarget.classList.add('smooth-scroll');
};
const handleMouseUp = (e) => {
if (!isDown) return;
isDown = false;
e.currentTarget.classList.remove('active');
e.currentTarget.classList.add('smooth-scroll');
};
const handleMouseMove = (e) => {
if (!isDown) return;
e.preventDefault();
const scrollContainer = e.currentTarget;
const x = e.pageX - scrollContainer.offsetLeft;
const walk = (x - startX) * 3;
scrollContainer.scrollLeft = scrollLeft - walk;
};
const handleTouchStart = (e) => {
const scrollContainer = e.currentTarget;
isDown = true;
scrollContainer.classList.add('active');
startX = e.touches[0].pageX - scrollContainer.offsetLeft;
scrollLeft = scrollContainer.scrollLeft;
scrollContainer.classList.remove('smooth-scroll');
};
const handleTouchEnd = (e) => {
if (!isDown) return;
isDown = false;
e.currentTarget.classList.remove('active');
e.currentTarget.classList.add('smooth-scroll');
};
const handleTouchMove = (e) => {
if (!isDown) return;
e.preventDefault();
const scrollContainer = e.currentTarget;
const x = e.touches[0].pageX - scrollContainer.offsetLeft;
const walk = (x - startX) * 3;
scrollContainer.scrollLeft = scrollLeft - walk;
};
onMounted(() => {
try {
console.log("Initializing Wave visualization...");
// Example sample dots data
const sampleDots = [
{
id: 1,
value: -1.8,
x: -2,
imageUrl: "/images/0_3.png",
title: "Beginn des neuen Abenteuers",
description: "01.10.2024",
onClick: () => openEntryDetail(1),
},
{
id: 2,
value: 1.2,
x: 0,
imageUrl: "/images/0_2.png",
title: "Omas Annis Geburtstag",
description: "02.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 3,
value: -0.6,
x: 2,
imageUrl: "/images/disco.png",
title: "Konzertbesuch mit Freunden",
description: "03.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 4,
value: 3,
x: 4,
imageUrl: "/images/pferd.png",
title: "Wanderreiten in den Bergen",
description: "04.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 5,
value: 1,
x: 6,
imageUrl: "/images/gpt.png",
title: "Ruhiger Tag zu Hause",
description: "05.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 6,
value: -3,
x: 8,
imageUrl: "/images/oma.png",
title: "Oma Erna verstorben",
description: "06.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 7,
value: 1.5,
x: 10,
imageUrl: "/images/see.png",
title: "Erholungsausflug zum See",
description: "07.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 8,
value: 0,
x: 12,
imageUrl: "/images/feier.png",
title: "Kleine Wochenendsfeier",
description: "08.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 9,
value: 3,
x: 14,
imageUrl: "/images/hochzeit.png",
title: "Hochzeit von Cousine Tatjana",
description: "09.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 10,
value: 1,
x: 16,
imageUrl: "/images/work.png",
title: "Erster Tag im neuen Job",
description: "10.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 11,
value: -1.2,
x: 18,
imageUrl: "/images/klasse.png",
title: "Klassentreffen nach vielen Jahren",
description: "11.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 12,
value: -0.6,
x: 20,
imageUrl: "/images/familie.png",
title: "Familienabendessen",
description: "12.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 13,
value: 2.7,
x: 22,
imageUrl:
"/images/kinobesuch.png",
title: "Kinobesuch mit der ganzen Familie",
description: "13.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 14,
value: 0,
x: 24,
imageUrl:
"/images/entspannung.png",
title: "Entspannung",
description: "14.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 15,
value: -2.9,
x: 26,
imageUrl: "/images/sonntag.png",
title: "Geruhsamer Sonntag",
description: "15.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 16,
value: 1.5,
x: 28,
imageUrl:
"/images/kindergeburtstag.png",
title: "Kindergeburtstag",
description: "16.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 17,
value: 0,
x: 30,
imageUrl:
"/images/familie2.png",
title: "Spaziergang mit der Familie",
description: "17.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 18,
value: 2.1,
x: 32,
imageUrl:
"/images/grosseltern.png",
title: "Familienfeier bei den Großeltern",
description: "18.10.2024",
onClick: () => openEntryDetail(1), // Using same entry for now
},
];
// Initialize the visualization with the sample dots
visualization = new ConnectedDotsVisualization('scroll-container', sampleDots, {
// Optional custom configuration
dotRadius: 6,
});
// Handle window resize
window.addEventListener('resize', handleResize);
// Set up scroll interactions
const scrollContainer = document.querySelector('.scroll-container');
if (scrollContainer) {
// Mouse events
scrollContainer.addEventListener('mousedown', handleMouseDown);
scrollContainer.addEventListener('mouseleave', handleMouseLeave);
scrollContainer.addEventListener('mouseup', handleMouseUp);
scrollContainer.addEventListener('mousemove', handleMouseMove);
// Touch events
scrollContainer.addEventListener('touchstart', handleTouchStart);
scrollContainer.addEventListener('touchend', handleTouchEnd);
scrollContainer.addEventListener('touchmove', handleTouchMove);
}
} catch (err) {
console.error("Error initializing visualization:", err);
}
});
// Clean up event listeners when component is unmounted
onBeforeUnmount(() => {
cleanupEventListeners();
});
return {
showEntryDetail,
selectedEntryData,
openEntryDetail
}
}
})
</script>
<style scoped>
.visualization-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
.scroll-container {
position: relative;
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
z-index: 2;
cursor: grab;
}
.scroll-container.active {
cursor: grabbing;
}
.scroll-container.smooth-scroll {
scroll-behavior: smooth;
}
/* Lightbox Styles */
.entry-lightbox {
background: rgba(0, 0, 0, 0.95);
backdrop-filter: blur(10px);
}
.lightbox-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 1000;
}
.lightbox-content {
padding: 0;
max-height: calc(100vh - 80px);
overflow-y: auto;
background: transparent;
}
</style>

View file

@ -1,550 +0,0 @@
// Define interfaces
export interface DotConfig {
id: number;
value: number;
x: number;
link?: string; // URL to navigate to when dot is clicked
onClick?: () => void; // Function to call when dot is clicked
imageUrl?: string; // Image to display in tooltip
title?: string; // Optional title for the tooltip
description?: string; // Optional description for the tooltip
}
export interface Config {
totalWidth: number;
height: number;
dotRadius: number;
xUnitSize: number;
tension: number;
showGrid: boolean;
tooltipWidth: number;
tooltipHeight: number;
}
interface ControlPoints {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface TooltipEdges {
leftmost: number;
rightmost: number;
}
export class ConnectedDotsVisualization {
private config: Config;
private dots: DotConfig[];
private preloadedImages: Map<string, HTMLImageElement> = new Map();
// DOM Elements
private scrollContainer: HTMLElement;
private svg: SVGElement;
private gridGroup: SVGGElement;
private curvePath: SVGPathElement;
private dotsGroup: SVGGElement;
private tooltipGroup: SVGGElement;
// Active tooltip
private activeTooltip: SVGElement | null = null;
constructor(
containerId: string,
dots: DotConfig[],
config?: Partial<Config>
) {
// Use the provided dots or empty array
this.dots = dots || [];
// Calculate the total width based on dots data
const xUnitSize = config?.xUnitSize || 100;
let calculatedWidth = 0;
if (this.dots.length > 0) {
// Find the minimum and maximum x values
const minX = Math.min(...this.dots.map((dot) => dot.x));
const maxX = Math.max(...this.dots.map((dot) => dot.x));
// Calculate width based on the range of x values
// Add padding on both sides (3 units on each side)
calculatedWidth = (maxX - minX + 6) * xUnitSize;
} else {
calculatedWidth = 6 * xUnitSize; // Default width if no dots
}
// Default configuration
this.config = {
totalWidth: calculatedWidth,
height: window.innerHeight,
dotRadius: 6,
xUnitSize: xUnitSize,
tension: 0.5,
showGrid: false,
tooltipWidth: 128,
tooltipHeight: 128,
...config,
};
// Initialize DOM elements
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
// Calculate the container height dynamically
const containerHeight =
this.scrollContainer.clientHeight ||
this.scrollContainer.offsetHeight ||
window.innerHeight;
// Default configuration
this.config = {
totalWidth: calculatedWidth,
height: containerHeight, // Use the calculated container height
dotRadius: 6,
xUnitSize: xUnitSize,
tension: 0.5,
showGrid: false,
tooltipWidth: 128,
tooltipHeight: 128,
...config,
};
// Create SVG elements
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this.gridGroup = document.createElementNS(
"http://www.w3.org/2000/svg",
"g"
);
this.curvePath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
);
this.dotsGroup = document.createElementNS(
"http://www.w3.org/2000/svg",
"g"
);
this.tooltipGroup = document.createElementNS(
"http://www.w3.org/2000/svg",
"g"
);
// Initialize the visualization
this.addStyles();
this.initializeSVG();
this.setupEventListeners();
this.preloadImages();
this.render();
}
private preloadImages(): void {
// Extract all unique image URLs from dots
const imageUrls: string[] = this.dots
.filter((dot) => dot.imageUrl)
// biome-ignore lint/style/noNonNullAssertion: <explanation>
.map((dot) => dot.imageUrl!)
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
// Create a loading indicator (optional)
const loadingCount = { current: 0, total: imageUrls.length };
if (imageUrls.length > 0) {
console.log(`Preloading ${imageUrls.length} images...`);
}
// Preload each image
for (const url of imageUrls) {
const img = new Image();
// Optional loading events
img.onload = () => {
loadingCount.current++;
if (loadingCount.current === loadingCount.total) {
console.log("All images preloaded successfully");
}
};
img.onerror = () => {
loadingCount.current++;
console.error(`Failed to preload image: ${url}`);
};
// Set src to start loading
img.src = url;
// Store in map for potential later use
this.preloadedImages.set(url, img);
}
}
private addStyles(): void {
// Add necessary styles for tooltips and interactions
const styleId = "connected-dots-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
// style.textContent = `
// `;
document.head.appendChild(style);
}
}
private initializeSVG(): void {
// Configure SVG
this.svg.setAttribute("width", `${this.config.totalWidth}`);
this.svg.setAttribute("height", `${this.config.height}`);
this.svg.style.overflow = "visible";
this.scrollContainer.appendChild(this.svg);
// Configure grid group
this.gridGroup.classList.add("grid");
this.svg.appendChild(this.gridGroup);
// Configure curve path
this.curvePath.setAttribute("fill", "none");
this.curvePath.setAttribute("stroke", "white");
this.curvePath.setAttribute("stroke-width", "2");
this.curvePath.setAttribute("stroke-linecap", "round");
this.curvePath.classList.add("curve-path");
this.svg.appendChild(this.curvePath);
// Configure dots group
this.svg.appendChild(this.dotsGroup);
// Configure tooltip group (always on top)
this.tooltipGroup.classList.add("tooltips");
this.svg.appendChild(this.tooltipGroup);
}
private setupEventListeners(): void {
// Event listeners removed as the controls were removed
}
private getDotX(x: number): number {
return (x + 3) * this.config.xUnitSize;
}
private getDotY(value: number): number {
const centerY = this.config.height / 1.95;
// Calculate raw Y position
// height of the amplitude
const rawY = centerY - (value / 3) * ((this.config.height / 2) * 0.6);
// Calculate minimum Y position to ensure tooltip fits
const minY = this.config.tooltipHeight + 40; // tooltip height + some padding
// Ensure Y is never less than minimum (never too high on screen)
return Math.max(rawY, minY);
}
private calculateBezierControlPoints(
dots: DotConfig[],
index: number
): ControlPoints {
const tension = this.config.tension * 500; // Scale tension for Bezier curve Rundung Kurve
// Get current point and its neighbors
const curr = dots[index];
const next = dots[index + 1];
// Calculate control points for a smooth bezier curve
const x1 = this.getDotX(curr.x) + tension;
const y1 = this.getDotY(curr.value);
const x2 = this.getDotX(next.x) - tension;
const y2 = this.getDotY(next.value);
return { x1, y1, x2, y2 };
}
private generateBezierPath(): string {
if (this.dots.length < 2) return "";
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(
this.dots[0].value
)}`;
for (let i = 0; i < this.dots.length - 1; i++) {
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(
this.dots,
i
);
const nextX = this.getDotX(this.dots[i + 1].x);
const nextY = this.getDotY(this.dots[i + 1].value);
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
}
return path;
}
private drawGrid(): void {
// Clear previous grid
while (this.gridGroup.firstChild) {
this.gridGroup.removeChild(this.gridGroup.firstChild);
}
if (!this.config.showGrid) return;
// Horizontal grid lines
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
const line = document.createElementNS(
"http://www.w3.org/2000/svg",
"line"
);
line.setAttribute("x1", "0");
line.setAttribute("y1", this.getDotY(value).toString());
line.setAttribute("x2", this.config.totalWidth.toString());
line.setAttribute("y2", this.getDotY(value).toString());
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
line.setAttribute("stroke-width", "1");
this.gridGroup.appendChild(line);
const text = document.createElementNS(
"http://www.w3.org/2000/svg",
"text"
);
text.setAttribute("x", "10");
text.setAttribute("y", (this.getDotY(value) + 4).toString());
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
text.setAttribute("font-size", "12");
text.textContent = value.toString();
this.gridGroup.appendChild(text);
}
// Vertical grid lines
const numVertLines = Math.ceil(
this.config.totalWidth / this.config.xUnitSize
);
for (let i = 0; i < numVertLines; i++) {
const x = i * this.config.xUnitSize;
const xValue = i - 3; // Starting from -3
const line = document.createElementNS(
"http://www.w3.org/2000/svg",
"line"
);
line.setAttribute("x1", x.toString());
line.setAttribute("y1", "0");
line.setAttribute("x2", x.toString());
line.setAttribute("y2", this.config.height.toString());
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
line.setAttribute("stroke-width", "1");
this.gridGroup.appendChild(line);
if (xValue !== 0) {
const text = document.createElementNS(
"http://www.w3.org/2000/svg",
"text"
);
text.setAttribute("x", x.toString());
text.setAttribute("y", (this.config.height / 2 + 20).toString());
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
text.setAttribute("font-size", "12");
text.setAttribute("text-anchor", "middle");
text.textContent = xValue.toString();
this.gridGroup.appendChild(text);
}
}
}
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
const tooltip = document.createElementNS("http://www.w3.org/2000/svg", "g");
tooltip.classList.add("dot-tooltip");
tooltip.setAttribute("data-dot-id", dot.id.toString());
// Calculate tooltip dimensions and position
const tooltipWidth = 128; // Base width for your tooltip
const tooltipHeight = (4 / 3) * tooltipWidth;
const tooltipX = x - tooltipWidth / 2;
let tooltipY = y - tooltipHeight - 10; // Positioned above the dot
tooltipY = Math.max(tooltipY, 10); // Ensure it doesn't go above the view
// Create background rectangle
const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
bg.setAttribute("x", tooltipX.toString());
bg.setAttribute("y", tooltipY.toString());
bg.setAttribute("width", tooltipWidth.toString());
bg.setAttribute("height", tooltipHeight.toString());
bg.setAttribute("rx", "0"); // Rounded corners
bg.classList.add("tooltip-background");
tooltip.appendChild(bg);
// Create foreignObject for the content
const contentContainer = document.createElementNS(
"http://www.w3.org/2000/svg",
"foreignObject"
);
contentContainer.setAttribute("x", tooltipX.toString());
contentContainer.setAttribute("y", tooltipY.toString());
contentContainer.setAttribute("width", tooltipWidth.toString());
contentContainer.setAttribute("height", tooltipHeight.toString());
// Create a div to contain the content
const div = document.createElement("div");
div.classList.add("tooltip-content");
// Add title if available
if (dot.title) {
const title = document.createElement("div");
title.textContent = dot.title;
title.classList.add("tooltip-title");
div.appendChild(title);
}
// Add description if available
if (dot.description) {
const desc = document.createElement("div");
desc.textContent = dot.description;
desc.classList.add("tooltip-description");
div.appendChild(desc);
}
// Add image if available
// Create a container div
const imageContainer = document.createElement("div");
imageContainer.classList.add("image_container"); // Add image_container class
// Define a variable for handling case with or without link
let imgWrapper: HTMLElement;
// if (dot.imageUrl) {
if (dot.link || dot.onClick) {
const link = document.createElement("a");
if (dot.link) {
link.href = dot.link;
} else {
link.href = "#"; // Prevent default href for onClick
}
link.target = "_self"; // Opens in the same window
const imgElement = document.createElement("img");
imgElement.src = dot.imageUrl;
imgElement.classList.add("tooltip-image");
// Append the image element to the link
link.appendChild(imgElement);
imgWrapper = link; // Use the link as the wrapper
// Add the event listener to the link
link.addEventListener("click", (e) => {
if (dot.onClick) {
e.preventDefault(); // Prevent default navigation
dot.onClick();
} else if (dot.link) {
window.location.href = dot.link;
} else {
console.error("Dot has no link or onClick handler");
throw new Error("Dot has no link or onClick handler");
}
});
} else {
const img = document.createElement("img");
img.src = dot.imageUrl;
img.classList.add("tooltip-image");
imgWrapper = img; // Use the image directly as the wrapper
}
// } else {
// console.error("Dot has no image URL");
// throw new Error("Dot has no image URL");
// }
// Append imageWrapper to the container
imageContainer.appendChild(imgWrapper);
// Append the image container to the main div
div.appendChild(imageContainer);
const arrow = document.createElement("div");
arrow.classList.add("tooltip-arrow");
div.appendChild(arrow); // Append the arrow to the tooltip-content div
contentContainer.appendChild(div);
tooltip.appendChild(contentContainer);
return tooltip;
}
private showTooltip(dot: DotConfig, x: number, y: number): void {
// Create tooltip
const tooltip = this.createTooltip(dot, x, y);
this.tooltipGroup.appendChild(tooltip);
this.activeTooltip = tooltip;
}
private hideTooltip(): void {
// This method is kept for compatibility but doesn't hide tooltips anymore
}
private drawCurve(): void {
const pathData = this.generateBezierPath();
this.curvePath.setAttribute("d", pathData);
}
private calculateTooltipEdges(): TooltipEdges {
let leftmost = 0;
let rightmost = 0;
let firstTooltipFound = false;
// If no dots with tooltips, return default values
if (this.dots.length === 0) {
return { leftmost: 0, rightmost: this.config.totalWidth };
}
// Calculate the leftmost and rightmost edges of all tooltips
for (const dot of this.dots) {
// Skip dots without tooltip content
if (!dot.imageUrl && !dot.title && !dot.description) {
continue;
}
const x = this.getDotX(dot.x);
const tooltipWidth = this.config.tooltipWidth;
const tooltipX = x - tooltipWidth / 2;
if (!firstTooltipFound) {
leftmost = tooltipX;
rightmost = tooltipX + tooltipWidth;
firstTooltipFound = true;
} else {
// Update leftmost and rightmost values
leftmost = Math.min(leftmost, tooltipX);
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
}
}
// If no dots with tooltips were found, use default values
if (!firstTooltipFound) {
return { leftmost: 0, rightmost: this.config.totalWidth };
}
return { leftmost, rightmost };
}
private drawDots(): void {
// Clear previous dots
while (this.dotsGroup.firstChild) {
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
}
// Clear previous tooltips
while (this.tooltipGroup.firstChild) {
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
}
for (const dot of this.dots) {
const x = this.getDotX(dot.x);
const y = this.getDotY(dot.value);
const circle = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle"
);
circle.setAttribute("cx", x.toString());
circle.setAttribute("cy", y.toString());
circle.setAttribute("r", this.config.dotRadius.toString());
circle.setAttribute("fill", "white");
circle.setAttribute("data-dot-id", dot.id.toString());
circle.classList.add("dot");
// Always show tooltip if it has content
if (dot.imageUrl || dot.title || dot.description) {
this.showTooltip(dot, x, y);
}
// Click event for navigation or custom function
if (dot.link || dot.onClick) {
circle.addEventListener("click", () => {
if (dot.onClick) {
dot.onClick();
} else if (dot.link) {
window.location.href = dot.link;
} else {
console.error("Dot has no link or onClick handler");
throw new Error("Dot has no link or onClick handler");
}
});
}
this.dotsGroup.appendChild(circle);
}
}
public render(): void {
this.drawGrid();
this.drawCurve();
this.drawDots();
// Calculate tooltip edges and set SVG width
const { leftmost, rightmost } = this.calculateTooltipEdges();
// Set the SVG width based on the rightmost tooltip edge
if (rightmost > 0) {
// Add some padding
const padding = 40;
this.config.totalWidth = rightmost + padding;
this.svg.setAttribute("width", `${this.config.totalWidth}`);
// Update grid width
this.drawGrid();
}
}
// Public API methods for external use
public updateDots(newDots: DotConfig[]): void {
this.dots = newDots;
// Initial width calculation based on dot positions (for grid)
if (this.dots.length > 0) {
// Find the minimum and maximum x values
const minX = Math.min(...this.dots.map((dot) => dot.x));
const maxX = Math.max(...this.dots.map((dot) => dot.x));
// Calculate width based on the range of x values
// Add padding on both sides (3 units on each side)
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
}
// Render will calculate the tooltip edges and update the SVG width
this.render();
}
public updateConfig(newConfig: Partial<Config>): void {
this.config = { ...this.config, ...newConfig };
this.render();
}
public resize(): void {
const containerHeight = this.scrollContainer.clientHeight || this.scrollContainer.offsetHeight || window.innerHeight;
this.config.height = containerHeight;
this.svg.setAttribute("height", `${this.config.height}`);
this.render();
}
}

View file

@ -1,238 +0,0 @@
// Form options and data for EditPage component
// Main categories with their subcategories
export const categoryStructure = [
{
label: 'Career',
value: 'career',
subcategories: [
{ label: 'Promotion', value: 'career-promotion' },
{ label: 'Retirement', value: 'career-retirement' },
{ label: 'Career Changes', value: 'career-changes' }
]
},
{
label: 'Education',
value: 'education',
subcategories: [
{ label: 'Graduation', value: 'education-graduation' },
{ label: 'Schooling', value: 'education-schooling' }
]
},
{
label: 'Awards',
value: 'awards',
subcategories: []
},
{
label: 'Personal Celebrations',
value: 'personal-celebrations',
subcategories: [
{ label: 'Birthday', value: 'birthday' },
{ label: 'Anniversary', value: 'anniversary' }
]
},
{
label: 'Relationships',
value: 'relationships',
subcategories: [
{ label: 'Engagement', value: 'relationships-engagement' },
{ label: 'Marriage', value: 'relationships-marriage' },
{ label: 'Divorce', value: 'relationships-divorce' }
]
},
{
label: 'Parenthood',
value: 'parenthood',
subcategories: [
{ label: 'Pregnancy', value: 'parenthood-pregnancy' },
{ label: 'Birth', value: 'parenthood-birth' },
{ label: 'Adoption', value: 'parenthood-adoption' }
]
},
{
label: 'Loss & Passing',
value: 'passing',
subcategories: [
{ label: 'Funeral', value: 'passing-funeral' }
]
},
{
label: 'Festivities',
value: 'festivities',
subcategories: [
{ label: 'Christmas', value: 'festivities-christmas' },
{ label: 'Thanksgiving', value: 'festivities-thanksgiving' },
{ label: 'New Year', value: 'festivities-new-year' },
{ label: 'Easter', value: 'festivities-easter' },
{ label: 'Holidays', value: 'festivities-holidays' }
]
},
{
label: 'Social Events',
value: 'social-events',
subcategories: [
{ label: 'Reunions', value: 'reunions' },
{ label: 'Concerts', value: 'concerts' },
{ label: 'Sports', value: 'sports' },
{ label: 'Festivals', value: 'festivals' }
]
},
{
label: 'Community',
value: 'community',
subcategories: [
{ label: 'Charity', value: 'charity' },
{ label: 'Community Service', value: 'community-service' }
]
},
{
label: 'Health',
value: 'health',
subcategories: [
{ label: 'Surgery', value: 'health-surgery' },
{ label: 'Illness', value: 'health-illness' },
{ label: 'Recovery', value: 'health-recovery' },
{ label: 'Transplants', value: 'health-transplants' },
{ label: 'Mental Health', value: 'health-mental-health' }
]
},
{
label: 'Religious & Spiritual',
value: 'religious',
subcategories: [
{ label: 'Baptism', value: 'religious-baptism' },
{ label: 'Bar/Bat Mitzvah', value: 'religious-bar-bat-mitzvah' },
{ label: 'Communion', value: 'religious-communion' },
{ label: 'Confirmation', value: 'religious-confirmation' },
{ label: 'Pilgrimage', value: 'religious-pilgrimage' }
]
},
{
label: 'Travel & Adventure',
value: 'travel',
subcategories: [
{ label: 'Travel', value: 'travel-general' },
{ label: 'Vacation', value: 'vacation' },
{ label: 'Adventure', value: 'adventure' }
]
},
{
label: 'Life Changes',
value: 'life-changes',
subcategories: [
{ label: 'Moving', value: 'moving' },
{ label: 'License', value: 'license' },
{ label: 'Voting', value: 'voting' },
{ label: 'Citizenship', value: 'citizenship' }
]
},
{
label: 'Milestones',
value: 'milestones',
subcategories: []
}
]
// Flattened category options for backward compatibility and simple select usage
export const categoryOptions = categoryStructure.reduce((acc, category) => {
// Add main category if it has no subcategories
if (category.subcategories.length === 0) {
acc.push({ label: category.label, value: category.value })
} else {
// Add subcategories with main category prefix
category.subcategories.forEach(sub => {
acc.push({
label: `${category.label} - ${sub.label}`,
value: sub.value,
mainCategory: category.value,
subcategory: sub.value
})
})
}
return acc
}, [])
// Helper functions for category management
export const getCategoryStructure = () => categoryStructure
export const getMainCategories = () => {
return categoryStructure.map(cat => ({
label: cat.label,
value: cat.value
}))
}
export const getSubcategories = (mainCategoryValue) => {
const mainCategory = categoryStructure.find(cat => cat.value === mainCategoryValue)
return mainCategory ? mainCategory.subcategories : []
}
export const getCategoryByValue = (value) => {
return categoryOptions.find(cat => cat.value === value)
}
export const getMainCategoryFromValue = (value) => {
const category = getCategoryByValue(value)
return category ? category.mainCategory : null
}
export const tagOptions = [
// Emotions
'happy', 'sad', 'exciting', 'stressful', 'memorable', 'important',
'fun', 'challenging', 'rewarding', 'disappointing', 'surprising',
'life-changing', 'routine', 'special', 'difficult', 'joyful',
'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful',
'emotional', 'touching', 'inspiring', 'motivating', 'healing',
// Significance
'milestone', 'achievement', 'breakthrough', 'turning-point',
'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected',
'planned', 'spontaneous', 'tradition', 'new-experience',
// Social
'family', 'friends', 'colleagues', 'community', 'solo',
'group', 'intimate', 'public', 'private', 'celebration',
// Intensity
'intense', 'mild', 'dramatic', 'subtle', 'overwhelming',
'gradual', 'sudden', 'anticipated', 'shocking', 'gentle',
// Time-related
'brief', 'extended', 'momentary', 'lasting', 'temporary',
'permanent', 'seasonal', 'annual', 'weekly', 'daily'
]
export const personOptions = [
'Anna Mueller',
'Max Schmidt',
'Sarah Johnson',
'Michael Weber',
'Lisa Anderson',
'Thomas Brown',
'Julia Martinez',
'David Wilson',
'Emma Garcia',
'Robert Davis'
]
export const defaultFormData = {
keyImage: null,
keyImageUrl: '',
additionalImages: [],
additionalImageUrls: [],
level: 0,
categories: [],
headline: '',
subheadline: '',
text: '',
tags: [],
location: '',
date: '',
time: '',
audioFiles: [],
audioRecordings: [],
videoFiles: [],
videoRecordings: [],
relatedPersons: []
}

View file

@ -1,71 +0,0 @@
<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>
<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']"
/>
<q-input
v-model="password"
type="password"
label="Password"
filled
:rules="[val => !!val || 'Password is required']"
/>
<div class="q-mt-md">
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
</div>
<div class="text-center q-mt-sm">
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
</div>
</q-form>
</q-card-section>
</q-card>
</q-page>
</template>
<script>
import { ref } from 'vue'
export default {
name: 'LoginPage',
setup() {
const email = ref('')
const password = ref('')
const onSubmit = () => {
console.log('Login attempt with:', email.value, password.value)
// 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
}
}
}
</script>

View file

@ -1,51 +1,56 @@
<template> <template>
<div class="login-page"> <div class="login-page">
<div class="login-page__bg" /> <div class="login-page__bg" aria-hidden="true" />
<form class="login-card" @submit.prevent="onSubmit">
<div class="login-card__brand">That&apos;s Me</div>
<h1>Login</h1>
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
<div class="login-user-row"> <form class="login-card" @submit.prevent="onSubmit">
<label class="login-field login-field--user"> <BrandLogo variant="negative" :height="34" class="login-card__logo" />
<span>E-Mail</span>
<select v-model="email"> <h1 class="login-card__title">Willkommen zurück</h1>
<option v-for="user in authStore.users" :key="user.id" :value="user.email"> <p class="login-card__lead">Melde dich an und arbeite mit deinen eigenen Events und Einstellungen.</p>
{{ user.mode === 'local' ? user.name : user.email }}
</option>
</select>
<q-icon name="person_outline" size="22px" />
</label>
</div>
<label class="login-field"> <label class="login-field">
<span>Passwort</span> <span class="login-field__label">Konto</span>
<select v-model="email" class="login-field__control" autocomplete="username">
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
{{ user.mode === 'local' ? `${user.name} (lokal im Browser)` : user.email }}
</option>
</select>
<q-icon name="expand_more" size="22px" class="login-field__icon" />
</label>
<label class="login-field">
<span class="login-field__label">Passwort</span>
<input <input
v-model="password" v-model="password"
class="login-field__control"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
autocomplete="current-password" autocomplete="current-password"
:disabled="isLocalDemo" :disabled="isLocalDemo"
:placeholder="isLocalDemo ? 'Für Demo nicht nötig' : ''" :placeholder="isLocalDemo ? 'Für die lokale Demo nicht nötig' : ''"
>
<button
class="login-field__icon login-field__icon-btn"
type="button"
:disabled="isLocalDemo"
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
@click="showPassword = !showPassword"
> >
<button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
<q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" /> <q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
</button> </button>
</label> </label>
<label class="login-remember"> <div v-if="authStore.lastError" class="login-error" role="alert">{{ authStore.lastError }}</div>
<input v-model="remember" type="checkbox">
<span>Remember me</span>
</label>
<div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
<button class="login-submit" type="submit" :disabled="isSubmitting"> <button class="login-submit" type="submit" :disabled="isSubmitting">
{{ submitLabel }} {{ submitLabel }}
</button> </button>
<div class="login-card__hint"> <p class="login-card__hint">
Demo bleibt lokal im Browser. API-Accounts: <strong>user1-user6@thats-me.app</strong>, Passwort <strong>pass</strong>. Die lokale Demo bleibt im Browser. API-Konten: <strong>jonas@</strong>, <strong>sophie@</strong>,
</div> <strong>nadja@</strong>, <strong>werner@</strong>, <strong>bruno@</strong>, <strong>benz@</strong>,
<strong>lukas@</strong>, <strong>nachtschicht@</strong> und <strong>user1</strong> bis <strong>user6@thats-me.app</strong>,
Passwort <strong>pass</strong>.
</p>
</form> </form>
</div> </div>
</template> </template>
@ -54,6 +59,7 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import BrandLogo from 'components/BrandLogo.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@ -62,13 +68,12 @@ const route = useRoute()
const email = ref(authStore.users[0]?.email ?? '') const email = ref(authStore.users[0]?.email ?? '')
const password = ref('pass') const password = ref('pass')
const showPassword = ref(false) const showPassword = ref(false)
const remember = ref(true)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const selectedUser = computed(() => authStore.users.find(user => user.email === email.value) ?? null) const selectedUser = computed(() => authStore.users.find(user => user.email === email.value) ?? null)
const isLocalDemo = computed(() => selectedUser.value?.mode === 'local') const isLocalDemo = computed(() => selectedUser.value?.mode === 'local')
const submitLabel = computed(() => { const submitLabel = computed(() => {
if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet...' : 'Login läuft...' if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet …' : 'Anmeldung läuft …'
return isLocalDemo.value ? 'Demo lokal starten' : 'Login' return isLocalDemo.value ? 'Demo lokal starten' : 'Anmelden'
}) })
watch(isLocalDemo, (localDemo) => { watch(isLocalDemo, (localDemo) => {
@ -89,6 +94,10 @@ async function onSubmit() {
</script> </script>
<style scoped> <style scoped>
/*
* Brand palette: ink ground, paper type, gold accent. The login has no
* user settings yet, so it is always the dark variant with the negative logo.
*/
.login-page { .login-page {
min-height: 100dvh; min-height: 100dvh;
display: flex; display: flex;
@ -96,155 +105,177 @@ async function onSubmit() {
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
overflow: hidden; overflow: hidden;
background: #162914; background: var(--tm-ink);
color: #fff; color: var(--tm-paper);
} }
.login-page__bg { .login-page__bg {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: background:
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.35), transparent 28%), radial-gradient(ellipse at 20% 15%, rgba(var(--tm-gold-rgb), 0.28), transparent 45%),
linear-gradient(135deg, rgba(213, 218, 98, 0.9), rgba(33, 181, 113, 0.75) 45%, rgba(20, 68, 28, 0.95)); radial-gradient(ellipse at 85% 90%, rgba(213, 144, 64, 0.22), transparent 50%),
background-size: cover; linear-gradient(180deg, #2f3032, var(--tm-ink) 60%, #1f2021);
background-position: center;
filter: saturate(1.08);
} }
/* A quiet wave, echoing the LifeWave, drawn with two soft gold lines */
.login-page__bg::after { .login-page__bg::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)), radial-gradient(120% 40% at 50% 62%, transparent 58%, rgba(var(--tm-gold-rgb), 0.16) 59%, transparent 61%),
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 60px); radial-gradient(120% 40% at 50% 66%, transparent 58%, rgba(var(--tm-gold-rgb), 0.08) 59%, transparent 61%);
opacity: 0.9;
} }
.login-card { .login-card {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: min(420px, 100%); width: min(420px, 100%);
padding: 40px 34px 28px; padding: 36px 32px 26px;
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(var(--tm-paper-rgb), 0.14);
border-radius: 24px; border-radius: 24px;
background: rgba(22, 29, 25, 0.32); background: rgba(var(--tm-paper-rgb), 0.06);
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.35); box-shadow: 0 26px 80px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
} }
.login-card__brand { .login-card__logo {
margin-bottom: 28px; margin-bottom: 28px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.72;
} }
.login-card h1 { .login-card__title {
margin: 0 0 6px; margin: 0 0 6px;
font-size: 34px; font-size: 28px;
line-height: 1; line-height: 1.1;
font-weight: 700;
letter-spacing: -0.01em;
} }
.login-card p { .login-card__lead {
margin: 0 0 28px; margin: 0 0 26px;
color: rgba(255, 255, 255, 0.82); color: rgba(var(--tm-paper-rgb), 0.72);
line-height: 1.45; line-height: 1.45;
font-size: 15px;
} }
.login-field { .login-field {
position: relative; position: relative;
display: block; display: block;
margin-bottom: 16px; margin-bottom: 14px;
} }
.login-user-row { .login-field__label {
display: flex;
align-items: flex-end;
gap: 10px;
}
.login-field--user {
flex: 1;
}
.login-field span {
display: block; display: block;
margin: 0 0 6px 2px; margin: 0 0 6px 2px;
font-size: 12px; font-size: 12px;
opacity: 0.75; letter-spacing: 0.02em;
color: rgba(var(--tm-paper-rgb), 0.7);
} }
.login-field input, .login-field__control {
.login-field select {
width: 100%; width: 100%;
height: 56px; height: 54px;
padding: 0 52px 0 20px; padding: 0 52px 0 18px;
border: 1px solid rgba(255, 255, 255, 0.48); border: 1px solid rgba(var(--tm-paper-rgb), 0.22);
border-radius: 14px; border-radius: 14px;
outline: none; outline: none;
background: rgba(255, 255, 255, 0.08); background: rgba(var(--tm-paper-rgb), 0.06);
color: #fff; color: var(--tm-paper);
font: inherit; font: inherit;
font-size: 16px;
appearance: none;
-webkit-appearance: none;
transition: border-color 0.15s ease, background 0.15s ease;
} }
.login-field select option { .login-field__control:focus-visible {
color: #1a1a1a; border-color: var(--tm-gold);
background: rgba(var(--tm-paper-rgb), 0.09);
} }
.login-field > .q-icon, .login-field__control:disabled {
.login-field__icon-btn { opacity: 0.55;
}
.login-field__control option {
color: var(--tm-ink);
}
.login-field__icon {
position: absolute; position: absolute;
right: 18px; right: 16px;
bottom: 16px; bottom: 15px;
color: rgba(255, 255, 255, 0.78); color: rgba(var(--tm-paper-rgb), 0.7);
pointer-events: none;
} }
.login-field__icon-btn { .login-field__icon-btn {
pointer-events: auto;
border: 0; border: 0;
padding: 0; padding: 0;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
} color: inherit;
.login-remember {
display: flex; display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 22px;
font-size: 14px;
} }
.login-remember input { .login-field__icon-btn:disabled {
accent-color: #6ce36c; opacity: 0.35;
cursor: default;
} }
.login-error { .login-error {
margin-bottom: 14px; margin: 4px 0 14px;
color: #ffd7d7; padding: 10px 12px;
border-radius: 10px;
background: rgba(255, 99, 88, 0.14);
border: 1px solid rgba(255, 99, 88, 0.35);
color: #ffd7d3;
font-size: 14px; font-size: 14px;
} }
.login-submit { .login-submit {
width: 100%; width: 100%;
height: 58px; height: 56px;
margin-top: 8px;
border: 0; border: 0;
border-radius: 14px; border-radius: 14px;
background: linear-gradient(90deg, #cfdd34, #18c77a); background: var(--tm-gold-gradient);
color: #fff; color: var(--tm-ink);
font: inherit; font: inherit;
font-size: 20px; font-size: 18px;
font-weight: 800; font-weight: 700;
cursor: pointer; cursor: pointer;
box-shadow: 0 14px 35px rgba(16, 188, 102, 0.28); box-shadow: 0 14px 35px rgba(var(--tm-gold-rgb), 0.25);
transition: transform 0.1s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.login-submit:hover {
box-shadow: 0 16px 40px rgba(var(--tm-gold-rgb), 0.35);
}
.login-submit:active {
transform: scale(0.99);
}
.login-submit:disabled {
opacity: 0.7;
cursor: default;
} }
.login-card__hint { .login-card__hint {
margin-top: 18px; margin: 18px 0 0;
text-align: center; text-align: center;
color: rgba(255, 255, 255, 0.78); color: rgba(var(--tm-paper-rgb), 0.6);
font-size: 13px; font-size: 12.5px;
line-height: 1.45; line-height: 1.5;
} }
</style>
.login-card__hint strong {
color: rgba(var(--tm-paper-rgb), 0.85);
font-weight: 600;
}
</style>

View file

@ -1,95 +0,0 @@
<template>
<q-page padding class="flex flex-center">
<q-card style="width: 350px">
<q-card-section>
<div class="text-h6">Password Reset</div>
</q-card-section>
<q-card-section>
<p class="text-body2 q-mb-md">
Enter your email address and we'll send you a link to reset your password.
</p>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input
v-model="email"
type="email"
label="Email"
filled
:rules="[val => !!val || 'Email is required']"
/>
<div class="q-mt-md">
<q-btn
label="Send Reset Link"
type="submit"
color="primary"
class="full-width"
:loading="loading"
/>
</div>
<div class="text-center q-mt-sm">
<router-link to="/login" class="text-primary">Back to login</router-link>
</div>
</q-form>
</q-card-section>
<q-dialog v-model="successDialog">
<q-card>
<q-card-section>
<div class="text-h6">Email Sent</div>
</q-card-section>
<q-card-section>
<p>If an account exists with that email, we've sent instructions to reset your password.</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" color="primary" v-close-popup @click="goToLogin" />
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</q-page>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'PasswordResetPage',
setup() {
const router = useRouter()
const email = ref('')
const loading = ref(false)
const successDialog = ref(false)
const onSubmit = () => {
loading.value = true
// Here you would call your API to request password reset
console.log('Password reset requested for:', email.value)
// Simulate API call
setTimeout(() => {
loading.value = false
successDialog.value = true
}, 1500)
}
const goToLogin = () => {
router.push('/login')
}
return {
email,
loading,
onSubmit,
successDialog,
goToLogin
}
}
}
</script>

View file

@ -1,138 +0,0 @@
<template>
<q-page padding class="flex flex-center">
<q-card style="width: 400px">
<q-card-section>
<div class="text-h6">Sign Up</div>
</q-card-section>
<q-card-section>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input v-model="firstName" label="First Name" filled :rules="[val => !!val || 'First name is required']" />
<q-input v-model="lastName" label="Last Name" filled :rules="[val => !!val || 'Last name is required']" />
<!-- Add gender selection -->
<q-select v-model="gender" :options="genderOptions" label="Gender" filled
:rules="[val => !!val || 'Please select a gender']" />
<q-input v-model="email" type="email" label="Email" filled :rules="[
val => !!val || 'Email is required',
val => /^[^@]+@[^@]+\.[^@]+$/.test(val) || 'Please enter a valid email'
]" />
<q-input v-model="password" type="password" label="Password" filled :rules="[
val => !!val || 'Password is required',
val => val.length >= 8 || 'Password must be at least 8 characters'
]" />
<q-input v-model="confirmPassword" type="password" label="Confirm Password" filled :rules="[
val => !!val || 'Please confirm your password',
val => val === password || 'Passwords do not match'
]" />
<q-checkbox v-model="termsAccepted" label="I agree to the Terms of Service">
<template v-slot:default>
I agree to the <a href="#" @click.prevent="showTerms = true" class="text-primary">Terms of Service</a>
</template>
</q-checkbox>
<div class="q-mt-md">
<q-btn label="Sign Up" type="submit" color="primary" class="full-width" :loading="loading"
:disable="!termsAccepted" />
</div>
<div class="text-center q-mt-sm">
Already have an account? <router-link to="/login" class="text-primary">Log in</router-link>
</div>
</q-form>
</q-card-section>
<q-dialog v-model="successDialog">
<q-card>
<q-card-section>
<div class="text-h6">Registration Successful</div>
</q-card-section>
<q-card-section>
<p>Your account has been created successfully. You can now log in.</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Go to Login" color="primary" v-close-popup @click="goToLogin" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showTerms">
<q-card style="width: 700px; max-width: 80vw">
<q-card-section>
<div class="text-h6">Terms of Service</div>
</q-card-section>
<q-card-section style="max-height: 70vh" class="scroll">
<p>These Terms of Service ("Terms") govern your use of our website and services.</p>
<p>By using our services, you agree to these Terms. Please read them carefully.</p>
<h6 class="text-subtitle1 q-mt-md">1. Your Account</h6>
<p>You are responsible for safeguarding your account and for any activities or actions under your account.
</p>
<h6 class="text-subtitle1 q-mt-md">2. Privacy</h6>
<p>Our Privacy Policy explains how we treat your personal data and protect your privacy.</p>
<!-- Add more terms as needed -->
<h6 class="text-subtitle1 q-mt-md">3. Termination</h6>
<p>We may terminate or suspend your account at any time for any reason.</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</q-page>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'SignUpPage',
setup() {
const router = useRouter()
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const termsAccepted = ref(false)
const loading = ref(false)
const successDialog = ref(false)
const showTerms = ref(false)
// Add gender field
const gender = ref('')
const genderOptions = [
'Male',
'Female',
'Non-binary',
'Prefer not to say'
]
const onSubmit = () => {
loading.value = true
// Here you would call your API to register the user
console.log('Sign up attempt with:', {
firstName: firstName.value,
lastName: lastName.value,
gender: gender.value,
email: email.value,
password: password.value
})
// Simulate API call
setTimeout(() => {
loading.value = false
successDialog.value = true
}, 1500)
}
const goToLogin = () => {
router.push('/login')
}
return {
firstName,
lastName,
gender,
genderOptions,
email,
password,
confirmPassword,
termsAccepted,
loading,
onSubmit,
successDialog,
showTerms,
goToLogin
}
}
}
</script>

View file

@ -63,6 +63,33 @@ export async function clearToken() {
await db.meta.delete('accessToken') await db.meta.delete('accessToken')
} }
// Listeners notified when the server rejects the stored token, so the auth
// store can end the session instead of leaving the UI logged in with a
// token that no longer works.
const unauthorizedHandlers = new Set()
export function onUnauthorized(handler) {
unauthorizedHandlers.add(handler)
return () => unauthorizedHandlers.delete(handler)
}
async function handleUnauthorized(usedToken) {
// A 401 from a request that belongs to an earlier session must not tear
// down the session that replaced it.
const storedToken = await getToken()
if (storedToken && storedToken !== usedToken) return
await clearToken()
for (const handler of unauthorizedHandlers) {
try {
handler()
} catch (e) {
console.warn('Unauthorized handler failed:', e)
}
}
}
async function readResponsePayload(response) { async function readResponsePayload(response) {
if (response.status === 204) return null if (response.status === 204) return null
@ -81,6 +108,10 @@ export async function apiFetch(path, options = {}) {
const token = auth ? await getToken() : null const token = auth ? await getToken() : null
if (auth && !token) { if (auth && !token) {
// The token is gone (logged out in another tab, storage cleared): end the
// session here too instead of leaving a logged-in UI whose requests all
// fail.
await handleUnauthorized(null)
throw new ApiError('Nicht angemeldet.', { status: 401 }) throw new ApiError('Nicht angemeldet.', { status: 401 })
} }
@ -103,7 +134,7 @@ export async function apiFetch(path, options = {}) {
}) })
if (response.status === 401 && auth) { if (response.status === 401 && auth) {
await clearToken() await handleUnauthorized(token)
} }
return response return response
@ -131,6 +162,21 @@ export async function loginRemote(email, password) {
}) })
} }
export async function logoutRemote() { /**
return apiJson('/logout', { method: 'POST' }) * Revoke a specific token. Used on logout so the revoke request carries the
* token being discarded, even if local state is cleared in parallel.
*/
export async function revokeToken(token) {
const response = await fetch(`${API_BASE}/logout`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
if (!response.ok && response.status !== 401) {
throw new ApiError('Der Token konnte nicht widerrufen werden.', { status: response.status })
}
} }

View file

@ -0,0 +1,414 @@
import { ref } from 'vue'
import { db } from 'src/db'
import { apiJson } from 'src/services/apiClient'
/**
* Outbound write queue for remote users.
*
* Every change a remote user makes is written to IndexedDB first and sent
* from there, strictly in order, one item at a time. A write that fails for
* a reason that may pass (no network, server down, rate limit) stays in the
* queue and is retried when the browser reports it is online again, when the
* tab becomes visible, or after a growing pause. A write the server rejects
* for good (validation, missing permission) is dropped and reported to the
* caller, which decides what to do with the local copy.
*
* Actions and their payloads:
* create { ...event fields }
* update { ...event fields }
* delete null
* upload_media { localId, collection, name, mimeType, bytes, durationMs, caption }
* delete_media { mediaId }
* update_media { mediaId, caption }
*
* Media that has not reached the server yet carries a local id (see
* isLocalMediaId). Queue operations on such an id are folded into the
* pending upload instead of being sent on their own.
*/
const LOCAL_ID_PREFIX = 'local-'
const RETRY_BASE_MS = 5000
const RETRY_MAX_MS = 60000
export const isOnline = ref(typeof navigator === 'undefined' ? true : navigator.onLine !== false)
export const isSyncing = ref(false)
/** Queue rows waiting for the current user. */
export const pendingCount = ref(0)
if (typeof window !== 'undefined') {
window.addEventListener('online', () => { isOnline.value = true })
window.addEventListener('offline', () => { isOnline.value = false })
}
export function isLocalMediaId(id) {
return typeof id === 'string' && id.startsWith(LOCAL_ID_PREFIX)
}
export function newLocalMediaId() {
return `${LOCAL_ID_PREFIX}${crypto.randomUUID()}`
}
/**
* Whether a failed request is worth retrying later. Anything without an HTTP
* status is a network failure; 408/429 and every 5xx are server-side and
* temporary by nature. Everything else is a verdict on the request itself.
*/
export function isTransientError(error) {
const status = error?.status
if (status === undefined || status === null || status === 0) return true
return status === 408 || status === 429 || status >= 500
}
function isNotFound(error) {
return error?.status === 404
}
/**
* Send one queue item. Resolves with the API payload (or null when there is
* none), rejects with the API error.
*/
async function send(item) {
const { action, eventId, payload } = item
switch (action) {
case 'create':
return apiJson('/events', { method: 'POST', body: payload })
case 'update':
try {
return await apiJson(`/events/${eventId}`, { method: 'PUT', body: payload })
} catch (error) {
// Gone on the server (another device, a failed earlier create): the
// local copy is what the user sees, so it is put back.
if (!isNotFound(error)) throw error
return apiJson('/events', { method: 'POST', body: { ...payload, id: eventId } })
}
case 'delete':
try {
await apiJson(`/events/${eventId}`, { method: 'DELETE' })
} catch (error) {
if (!isNotFound(error)) throw error
}
return null
case 'upload_media': {
const formData = new FormData()
formData.append('collection', payload.collection)
if (payload.caption) formData.append('caption', payload.caption)
const type = payload.mimeType || 'application/octet-stream'
const name = payload.name || 'upload'
// A File carries its name everywhere; the third argument of
// FormData.append is not honoured in every environment.
const file = typeof File !== 'undefined'
? new File([payload.bytes], name, { type })
: new Blob([payload.bytes], { type })
formData.append('file', file, name)
return apiJson(`/events/${eventId}/media`, { method: 'POST', body: formData })
}
case 'delete_media':
try {
await apiJson(`/events/${eventId}/media/${payload.mediaId}`, { method: 'DELETE' })
} catch (error) {
if (!isNotFound(error)) throw error
}
return null
case 'update_media':
try {
return await apiJson(`/events/${eventId}/media/${payload.mediaId}`, {
method: 'PATCH',
body: { caption: payload.caption }
})
} catch (error) {
if (!isNotFound(error)) throw error
return null
}
default:
throw Object.assign(new Error(`Unknown sync action: ${action}`), { status: 400 })
}
}
/**
* @param {object} options
* @param {() => string|null} options.getUserId user whose queue this is; null disables the queue
* @param {(item, payload) => (void|Promise<void>)} options.onApplied server accepted the item
* @param {(item, error) => (void|Promise<void>)} options.onRejected server refused the item for good
* @param {() => void} [options.onBlocked] processing stopped on a transient failure
*/
export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked }) {
let processing = null
let rerunRequested = false
let inFlightId = null
let retryTimer = null
let retryAttempt = 0
let started = false
function currentUserId() {
return getUserId() ?? null
}
async function rowsFor(userId) {
if (!userId) return []
const rows = await db.syncQueue.where('userId').equals(userId).toArray()
return rows.sort((a, b) => a.queueId - b.queueId)
}
async function refreshCount() {
try {
const userId = currentUserId()
pendingCount.value = userId ? await db.syncQueue.where('userId').equals(userId).count() : 0
} catch (error) {
console.warn('Sync queue count failed:', error)
}
}
/**
* Add a write. Related items already waiting are folded together so the
* queue never sends more than it has to: repeated updates collapse into
* one, a delete drops everything queued for the event, and changes to a
* not-yet-uploaded file go into the upload itself.
*/
async function enqueue({ eventId, action, payload = null }) {
const userId = currentUserId()
if (!userId) return false
const row = { userId, eventId, action, payload, createdAt: Date.now(), attempts: 0 }
let added = true
await db.transaction('rw', db.syncQueue, async () => {
const queued = (await rowsFor(userId)).filter(item => item.eventId === eventId && item.queueId !== inFlightId)
switch (action) {
case 'update': {
const target = queued.find(item => item.action === 'create' || item.action === 'update')
if (target) {
await db.syncQueue.update(target.queueId, { payload })
added = false
return
}
break
}
case 'delete': {
const hadCreate = queued.some(item => item.action === 'create')
await db.syncQueue.bulkDelete(queued.map(item => item.queueId))
// Never reached the server: there is nothing to delete there.
if (hadCreate) {
added = false
return
}
break
}
case 'delete_media': {
if (isLocalMediaId(payload.mediaId)) {
const upload = queued.find(item => item.action === 'upload_media' && item.payload?.localId === payload.mediaId)
if (upload) await db.syncQueue.delete(upload.queueId)
// Still uploading: the caller re-queues the delete with the
// server id once the upload has answered.
added = false
return
}
const captions = queued.filter(item => item.action === 'update_media' && item.payload?.mediaId === payload.mediaId)
await db.syncQueue.bulkDelete(captions.map(item => item.queueId))
break
}
case 'update_media': {
if (isLocalMediaId(payload.mediaId)) {
const upload = queued.find(item => item.action === 'upload_media' && item.payload?.localId === payload.mediaId)
if (upload) {
await db.syncQueue.update(upload.queueId, { payload: { ...upload.payload, caption: payload.caption } })
}
added = false
return
}
const previous = queued.find(item => item.action === 'update_media' && item.payload?.mediaId === payload.mediaId)
if (previous) {
await db.syncQueue.update(previous.queueId, { payload })
added = false
return
}
break
}
default:
break
}
await db.syncQueue.add(row)
})
await refreshCount()
return added
}
/** Whether an event still has a create or update waiting to be sent. */
async function hasQueuedWrites(eventId) {
const rows = await rowsFor(currentUserId())
return rows.some(item => item.eventId === eventId && (item.action === 'create' || item.action === 'update'))
}
/**
* What the queue currently holds, grouped for merging a server response
* with the local state.
*/
async function snapshot() {
const rows = await rowsFor(currentUserId())
const byEvent = new Map()
const deletedMediaIds = new Set()
for (const item of rows) {
if (!byEvent.has(item.eventId)) byEvent.set(item.eventId, new Set())
byEvent.get(item.eventId).add(item.action)
if (item.action === 'delete_media') deletedMediaIds.add(item.payload?.mediaId)
}
return { byEvent, deletedMediaIds, isEmpty: rows.length === 0 }
}
function clearRetry() {
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
function scheduleRetry() {
clearRetry()
// Offline: the 'online' event is the trigger, a timer would only spin.
if (!isOnline.value) return
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** retryAttempt)
retryAttempt += 1
retryTimer = setTimeout(() => {
retryTimer = null
process()
}, delay)
}
/**
* Send queued items in order until the queue is empty or a transient
* failure stops it. Safe to call at any time: a call during a run makes
* the run start over once it has finished, so a request to send now is
* never swallowed by a run that was already winding down.
*/
function process() {
if (processing) {
rerunRequested = true
return processing
}
processing = (async () => {
do {
rerunRequested = false
await runOnce()
} while (rerunRequested)
})().finally(() => {
processing = null
})
return processing
}
async function runOnce() {
isSyncing.value = true
try {
for (;;) {
const userId = currentUserId()
if (!userId || !isOnline.value) break
const item = await db.syncQueue.where('userId').equals(userId).first()
if (!item) {
retryAttempt = 0
clearRetry()
break
}
inFlightId = item.queueId
let payload
try {
payload = await send(item)
} catch (error) {
inFlightId = null
if (error?.status === 401) {
// The session is over; the API client is ending it. The item
// stays for the next login of this user.
break
}
if (isTransientError(error)) {
await db.syncQueue.update(item.queueId, {
attempts: (item.attempts ?? 0) + 1,
lastError: String(error?.message ?? error)
}).catch(() => {})
onBlocked?.(item, error)
scheduleRetry()
break
}
await db.syncQueue.delete(item.queueId)
if (userId === currentUserId()) {
try {
await onRejected(item, error)
} catch (handlerError) {
console.warn('Sync rejection handler failed:', handlerError)
}
}
continue
}
inFlightId = null
await db.syncQueue.delete(item.queueId)
retryAttempt = 0
// The response belongs to the user that queued the item. After a
// user switch it is still acknowledged, but not applied.
if (userId === currentUserId()) {
try {
await onApplied(item, payload)
} catch (handlerError) {
console.warn('Sync apply handler failed:', handlerError)
}
}
}
} catch (error) {
console.warn('Sync queue processing failed:', error)
inFlightId = null
scheduleRetry()
} finally {
await refreshCount()
isSyncing.value = false
}
}
function onOnline() {
retryAttempt = 0
process()
}
function onVisible() {
if (document.visibilityState === 'visible') process()
}
/** Attach the triggers that resume sending. */
function start() {
if (started || typeof window === 'undefined') return
started = true
window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisible)
}
function stop() {
if (!started) return
started = false
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
clearRetry()
}
return { enqueue, process, hasQueuedWrites, snapshot, refreshCount, start, stop }
}

View file

@ -1,212 +0,0 @@
import { ref } from 'vue'
import { db } from 'src/db'
import { apiFetch, getToken, setToken } from 'src/services/apiClient'
const isSyncing = ref(false)
const isOnline = ref(navigator.onLine)
const lastSyncAt = ref(null)
// Track online status
window.addEventListener('online', () => {
isOnline.value = true
processSyncQueue()
})
window.addEventListener('offline', () => {
isOnline.value = false
})
/**
* Process the outbound sync queue (FIFO).
* Called on app start, every 30s when online, and on reconnect.
*/
async function processSyncQueue() {
if (!isOnline.value || isSyncing.value) return
const token = await getToken()
if (!token) return
isSyncing.value = true
try {
const queue = await db.syncQueue.orderBy('queueId').toArray()
if (queue.length === 0) {
isSyncing.value = false
return
}
// Batch sync: send up to 100 mutations at once
const batch = queue.slice(0, 100)
const mutations = batch.map((item) => ({
action: item.action,
eventId: item.eventId,
payload: item.payload,
}))
const response = await apiFetch('/events/sync', {
method: 'POST',
body: JSON.stringify({ mutations }),
})
if (response.ok) {
const data = await response.json()
// Remove successfully processed items from queue
const processedIds = []
data.results.forEach((result, i) => {
if (result.status === 'ok') {
processedIds.push(batch[i].queueId)
}
})
if (processedIds.length > 0) {
await db.syncQueue.bulkDelete(processedIds)
}
// Update syncStatus on local events
for (const result of data.results) {
if (result.status === 'ok') {
const event = await db.events.get(result.eventId)
if (event && event.syncStatus !== 'local') {
await db.events.update(result.eventId, { syncStatus: 'synced' })
}
}
}
lastSyncAt.value = Date.now()
// If there are more items, process next batch
if (queue.length > 100) {
await processSyncQueue()
}
}
} catch (e) {
console.warn('Sync queue processing failed:', e)
} finally {
isSyncing.value = false
}
}
/**
* Pull remote changes since last sync cursor.
* Merges with local data using "last write wins" on updatedAt.
*/
async function pullRemoteChanges() {
if (!isOnline.value) return
const token = await getToken()
if (!token) return
try {
const lastSync = await db.meta.get('lastSyncCursor')
const since = lastSync?.value || null
let url = '/events?limit=200'
if (since) {
url += `&since=${since}`
}
const response = await apiFetch(url)
if (!response.ok) return
const data = await response.json()
const remoteEvents = data.data || []
for (const remote of remoteEvents) {
const local = await db.events.get(remote.id)
if (!local) {
// New event from server
await db.events.put({
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',
createdAt: remote.createdAt,
updatedAt: remote.updatedAt,
})
} else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') {
// Remote is newer and local hasn't been modified — update
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',
updatedAt: remote.updatedAt,
})
}
// If local is modified, skip — local changes will be pushed via sync queue
}
// Update sync cursor
await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() })
// Handle pagination (cursor-based)
if (data.next_cursor) {
// There are more pages — but for now we only pull one batch
// Future: iterate through pages
}
lastSyncAt.value = Date.now()
} catch (e) {
console.warn('Pull remote changes failed:', e)
}
}
/**
* Full sync: push local changes, then pull remote.
*/
async function fullSync() {
await processSyncQueue()
await pullRemoteChanges()
}
// Auto-sync interval (30s)
let syncInterval = null
function startAutoSync() {
if (syncInterval) return
syncInterval = setInterval(() => {
if (isOnline.value) {
fullSync()
}
}, 30000)
// Initial sync
fullSync()
}
function stopAutoSync() {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
export {
isOnline,
isSyncing,
lastSyncAt,
getToken,
setToken,
apiFetch,
processSyncQueue,
pullRemoteChanges,
fullSync,
startAutoSync,
stopAutoSync,
}

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { clearToken, loginRemote, logoutRemote, setToken } from 'src/services/apiClient' import { clearToken, getToken, loginRemote, onUnauthorized, revokeToken, setToken } from 'src/services/apiClient'
import { setImageCacheScope } from 'src/composables/useImageCache'
export const AUTH_STORAGE_KEY = 'thatsme-auth' export const AUTH_STORAGE_KEY = 'thatsme-auth'
@ -14,17 +15,39 @@ export const LOCAL_DEMO_USER = {
seedDemoEvents: false seedDemoEvents: false
} }
export const REMOTE_USERS = Array.from({ length: 6 }, (_, index) => { // Demo lifelines seeded by the Laravel LifelineSeeder (database/data/lifelines/*.json)
const number = index + 1 const LIFELINE_USERS = [
return { { handle: 'jonas', name: 'Jonas', avatar: 'JO' },
id: `remote-user-${number}`, { handle: 'sophie', name: 'Sophie', avatar: 'SO' },
email: `user${number}@thats-me.app`, { handle: 'nadja', name: 'Nadja', avatar: 'NA' },
name: `User ${number}`, { handle: 'werner', name: 'Werner', avatar: 'WE' },
avatar: `U${number}`, { handle: 'bruno', name: 'Bruno (Hund)', avatar: 'BR' },
{ handle: 'benz', name: 'Der Benz (Auto)', avatar: 'BZ' },
{ handle: 'lukas', name: 'Lukas', avatar: 'LU' },
{ handle: 'nachtschicht', name: 'Nachtschicht (Band)', avatar: 'NS' }
]
export const REMOTE_USERS = [
...LIFELINE_USERS.map(user => ({
id: `remote-${user.handle}`,
email: `${user.handle}@thats-me.app`,
name: user.name,
avatar: user.avatar,
mode: 'remote', mode: 'remote',
seedDemoEvents: false seedDemoEvents: false
} })),
}) ...Array.from({ length: 6 }, (_, index) => {
const number = index + 1
return {
id: `remote-user-${number}`,
email: `user${number}@thats-me.app`,
name: `User ${number}`,
avatar: `U${number}`,
mode: 'remote',
seedDemoEvents: false
}
})
]
function normalizeUser(user) { function normalizeUser(user) {
const mode = user.mode || 'local' const mode = user.mode || 'local'
@ -89,6 +112,25 @@ export const useAuthStore = defineStore('auth', () => {
) )
const isAuthenticated = computed(() => currentUser.value !== null) const isAuthenticated = computed(() => currentUser.value !== null)
setImageCacheScope(currentUserId.value)
function endSession() {
currentUserId.value = null
currentUserProfile.value = null
localStorage.removeItem(AUTH_STORAGE_KEY)
setImageCacheScope(null)
}
// The API rejected the stored token. Clearing only the token left the UI
// logged in and the router bouncing /login back into the app, with every
// write silently failing.
onUnauthorized(() => {
if (currentUser.value?.mode !== 'remote') return
endSession()
lastError.value = 'Die Sitzung ist abgelaufen. Bitte erneut anmelden.'
})
async function login(email, password) { async function login(email, password) {
const normalizedEmail = String(email).trim().toLowerCase() const normalizedEmail = String(email).trim().toLowerCase()
const user = users.value.find(candidate => const user = users.value.find(candidate =>
@ -110,13 +152,16 @@ export const useAuthStore = defineStore('auth', () => {
currentUserProfile.value = remoteUser currentUserProfile.value = remoteUser
lastError.value = '' lastError.value = ''
persistAuth(remoteUser) persistAuth(remoteUser)
setImageCacheScope(remoteUser.id)
return true return true
} catch (error) { } catch (error) {
console.warn('Remote login failed:', error) console.warn('Remote login failed:', error)
lastError.value = error?.status === 422 lastError.value = error?.status === 429
? 'E-Mail oder Passwort ist falsch.' ? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.'
: 'Login über die API ist gerade nicht möglich.' : error?.status === 422
? 'E-Mail oder Passwort ist falsch.'
: 'Login über die API ist gerade nicht möglich.'
return false return false
} }
} }
@ -126,6 +171,7 @@ export const useAuthStore = defineStore('auth', () => {
currentUserProfile.value = user currentUserProfile.value = user
lastError.value = '' lastError.value = ''
persistAuth(user) persistAuth(user)
setImageCacheScope(user.id)
return true return true
} }
@ -138,19 +184,36 @@ export const useAuthStore = defineStore('auth', () => {
currentUserProfile.value = user currentUserProfile.value = user
lastError.value = '' lastError.value = ''
persistAuth(user) persistAuth(user)
setImageCacheScope(user.id)
return true return true
} }
function logout() { function logout() {
if (currentUser.value?.mode === 'remote') { const wasRemote = currentUser.value?.mode === 'remote'
logoutRemote().catch(() => {})
}
currentUserId.value = null // Cleared synchronously: the caller navigates to /login straight after,
currentUserProfile.value = null // and the router guard reads localStorage. Doing this behind an await
// would let the guard still see a logged-in session and bounce back.
endSession()
lastError.value = '' lastError.value = ''
localStorage.removeItem(AUTH_STORAGE_KEY)
clearToken().catch(() => {}) return revokeRemoteSession(wasRemote)
}
async function revokeRemoteSession(wasRemote) {
// Read before the token is dropped, so the revoke request still carries
// the token it is meant to invalidate.
const token = wasRemote ? await getToken() : null
await clearToken().catch(() => {})
if (!token) return
try {
await revokeToken(token)
} catch (error) {
console.warn('Token revoke failed:', error)
}
} }
return { return {

File diff suppressed because it is too large Load diff

View file

@ -1,21 +0,0 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
counter: 0
}),
getters: {
doubleCount: (state) => state.counter * 2
},
actions: {
increment() {
this.counter++
}
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { apiJson } from 'src/services/apiClient' import { apiJson } from 'src/services/apiClient'
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
const STORAGE_KEY_PREFIX = 'thatsme-settings' const STORAGE_KEY_PREFIX = 'thatsme-settings'
@ -11,24 +12,25 @@ export const DEFAULT_TIMELINE_ZOOM = 1
export const DEFAULT_TIMELINE_SCROLL_LEFT = null export const DEFAULT_TIMELINE_SCROLL_LEFT = null
export const ACCENT_COLORS = [ export const ACCENT_COLORS = [
{ label: 'Base', value: 'base', hex: '#737373' }, { label: 'Gold', value: 'gold', hex: '#f5c26b' },
{ label: 'Red', value: 'red', hex: '#ef4444' }, { label: 'Grau', value: 'grey', hex: '#737373' },
{ label: 'Rot', value: 'red', hex: '#ef4444' },
{ label: 'Orange', value: 'orange', hex: '#f97316' }, { label: 'Orange', value: 'orange', hex: '#f97316' },
{ label: 'Amber', value: 'amber', hex: '#f59e0b' }, { label: 'Bernstein', value: 'amber', hex: '#f59e0b' },
{ label: 'Yellow', value: 'yellow', hex: '#eab308' }, { label: 'Gelb', value: 'yellow', hex: '#eab308' },
{ label: 'Lime', value: 'lime', hex: '#84cc16' }, { label: 'Limette', value: 'lime', hex: '#84cc16' },
{ label: 'Green', value: 'green', hex: '#22c55e' }, { label: 'Grün', value: 'green', hex: '#22c55e' },
{ label: 'Emerald', value: 'emerald', hex: '#10b981' }, { label: 'Smaragd', value: 'emerald', hex: '#10b981' },
{ label: 'Teal', value: 'teal', hex: '#14b8a6' }, { label: 'Petrol', value: 'teal', hex: '#14b8a6' },
{ label: 'Cyan', value: 'cyan', hex: '#06b6d4' }, { label: 'Cyan', value: 'cyan', hex: '#06b6d4' },
{ label: 'Sky', value: 'sky', hex: '#0ea5e9' }, { label: 'Himmelblau', value: 'sky', hex: '#0ea5e9' },
{ label: 'Blue', value: 'blue', hex: '#3b82f6' }, { label: 'Blau', value: 'blue', hex: '#3b82f6' },
{ label: 'Indigo', value: 'indigo', hex: '#6366f1' }, { label: 'Indigo', value: 'indigo', hex: '#6366f1' },
{ label: 'Violet', value: 'violet', hex: '#8b5cf6' }, { label: 'Violett', value: 'violet', hex: '#8b5cf6' },
{ label: 'Purple', value: 'purple', hex: '#a855f7' }, { label: 'Purpur', value: 'purple', hex: '#a855f7' },
{ label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' }, { label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' },
{ label: 'Pink', value: 'pink', hex: '#ec4899' }, { label: 'Pink', value: 'pink', hex: '#ec4899' },
{ label: 'Rose', value: 'rose', hex: '#f43f5e' } { label: 'Rosé', value: 'rose', hex: '#f43f5e' }
] ]
export const LANGUAGES = [ export const LANGUAGES = [
@ -36,6 +38,7 @@ export const LANGUAGES = [
{ label: 'English', value: 'en' } { label: 'English', value: 'en' }
] ]
// The defaults are the brand look ("Gold" preset): gold lines on ink.
const FLOATING_LINES_DEFAULTS = { const FLOATING_LINES_DEFAULTS = {
// Linien // Linien
speed: 1.0, speed: 1.0,
@ -48,20 +51,20 @@ const FLOATING_LINES_DEFAULTS = {
bezierCurvature: 0.2, bezierCurvature: 0.2,
circleRadius: 75, circleRadius: 75,
glowSize: 18, glowSize: 18,
glowStrength: 1.5, glowStrength: 1.6,
lineBrightness: 1.0, lineBrightness: 0.9,
// Hintergrund // Hintergrund
bgCenter: '#0a0514', bgCenter: '#2f3032',
bgEdge: '#000000', bgEdge: '#1a1b1c',
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', gradientStops: '#f5c26b\n#d59040\n#3a2a14',
backgroundImage: '', backgroundImage: '',
// Horizont // Horizont
horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow' horizonMode: 'glow', // 'off' | 'fog' | 'split' | 'glow'
horizonOpacity: 0.5, // Nebel + Glow: Helligkeit horizonOpacity: 0.25, // Nebel + Glow: Helligkeit
horizonBlend: 0.2, // Trennung: 0=scharf 1=weich horizonBlend: 0.2, // Trennung: 0=scharf 1=weich
// Labels // Labels
labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge' labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
labelColor: '#ffffff', labelColor: '#f7f3ea',
labelOpacity: 0.75, labelOpacity: 0.75,
labelConnectorLength: 0.2, labelConnectorLength: 0.2,
// Dot-Rahmen // Dot-Rahmen
@ -90,9 +93,12 @@ function clone(value) {
return JSON.parse(JSON.stringify(value)) return JSON.parse(JSON.stringify(value))
} }
// 'default' and 'base' are what accounts from before the brand palette hold:
// nobody chose that grey, it was simply the old default. Both become gold;
// a deliberate grey is stored as 'grey' since 2026-09-25.
function normalizeAccentColor(value) { function normalizeAccentColor(value) {
if (value === 'default') return 'base' if (value === 'default' || value === 'base') return 'gold'
return ACCENT_COLORS.some(c => c.value === value) ? value : 'base' return ACCENT_COLORS.some(c => c.value === value) ? value : 'gold'
} }
function hexToRgb(hex) { function hexToRgb(hex) {
@ -128,6 +134,10 @@ export const useSettingsStore = defineStore('settings', () => {
const initialSettings = initialActivePreset?.settings ?? stored const initialSettings = initialActivePreset?.settings ?? stored
let persistTimer = null let persistTimer = null
let isApplyingSettings = false let isApplyingSettings = false
// Incremented on every user switch so a late settings response can be
// recognised as belonging to a session that has already ended.
let settingsGeneration = 0
const lastError = ref(null)
const theme = ref(initialSettings?.theme ?? 'light') const theme = ref(initialSettings?.theme ?? 'light')
const floatingLines = ref({ const floatingLines = ref({
@ -137,7 +147,7 @@ export const useSettingsStore = defineStore('settings', () => {
// App preferences // App preferences
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark' const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'base')) const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'gold'))
const language = ref(initialSettings?.language ?? 'de') const language = ref(initialSettings?.language ?? 'de')
const emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START) const emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START)
const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END) const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END)
@ -183,7 +193,7 @@ export const useSettingsStore = defineStore('settings', () => {
...(snapshot?.floatingLines ?? {}) ...(snapshot?.floatingLines ?? {})
} }
appearance.value = snapshot?.appearance ?? 'system' appearance.value = snapshot?.appearance ?? 'system'
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'base') accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold')
language.value = snapshot?.language ?? 'de' language.value = snapshot?.language ?? 'de'
emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START
emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END
@ -201,13 +211,26 @@ export const useSettingsStore = defineStore('settings', () => {
const storedSettings = createStoredSettings() const storedSettings = createStoredSettings()
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings)) try {
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings))
} catch (error) {
// Private mode, full quota, or a torn-down environment: the remote
// copy below still happens, the local mirror is best effort.
console.warn('Settings could not be written to localStorage:', error)
}
if (isRemoteUser()) { if (isRemoteUser()) {
const generation = settingsGeneration
apiJson('/settings', { apiJson('/settings', {
method: 'PUT', method: 'PUT',
body: { settings: storedSettings } body: { settings: storedSettings }
}).catch(error => console.warn('Remote settings persist failed:', error)) }).catch((error) => {
console.warn('Remote settings persist failed:', error)
if (generation !== settingsGeneration) return
lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.'
})
} }
} }
@ -228,28 +251,47 @@ export const useSettingsStore = defineStore('settings', () => {
persistTimer = null persistTimer = null
} }
// Everything below belongs to this user. A response arriving after the
// next switch must not overwrite the settings of whoever is active then.
settingsGeneration += 1
const generation = settingsGeneration
isApplyingSettings = true isApplyingSettings = true
lastError.value = null
let nextStored = loadFromStorage(userId) try {
if (userId && isRemoteUser()) { let nextStored = loadFromStorage(userId)
try {
nextStored = await loadRemoteSettings() if (userId && isRemoteUser()) {
if (nextStored) { try {
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored)) const remoteSettings = await loadRemoteSettings()
if (generation !== settingsGeneration) return
nextStored = remoteSettings
if (nextStored) {
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
}
} catch (error) {
console.warn('Remote settings load failed:', error)
if (generation !== settingsGeneration) return
// The locally cached settings from `loadFromStorage` stay in
// effect; the user is told the server copy could not be read.
lastError.value = 'Die Einstellungen konnten nicht geladen werden. Angezeigt wird der lokale Stand.'
} }
} catch (error) {
console.warn('Remote settings load failed:', error)
} }
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
} finally {
// Must run on every exit path: leaving this true would silently
// disable persisting for the rest of the session.
isApplyingSettings = false
} }
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
isApplyingSettings = false
} }
watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true }) watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true })
@ -304,6 +346,7 @@ export const useSettingsStore = defineStore('settings', () => {
function resetFloatingLines() { function resetFloatingLines() {
floatingLines.value = { ...FLOATING_LINES_DEFAULTS } floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
activePresetId.value = null
} }
function resetEmotionGradient() { function resetEmotionGradient() {
@ -338,7 +381,21 @@ export const useSettingsStore = defineStore('settings', () => {
return savedPreset return savedPreset
} }
/**
* Apply a built-in or a user preset. Built-in presets change the wave
* only; a user preset restores the full snapshot it was saved with.
*/
function applyPreset(presetId) { function applyPreset(presetId) {
if (isBuiltInPresetId(presetId)) {
const builtIn = findWavePreset(presetId)
if (!builtIn) return false
activePresetId.value = builtIn.id
floatingLines.value = { ...FLOATING_LINES_DEFAULTS, ...builtIn.floatingLines }
persist()
return true
}
const preset = presets.value.find(candidate => candidate.id === presetId) const preset = presets.value.find(candidate => candidate.id === presetId)
if (!preset) return false if (!preset) return false
@ -348,9 +405,20 @@ export const useSettingsStore = defineStore('settings', () => {
return true return true
} }
function deletePreset(presetId) {
if (isBuiltInPresetId(presetId)) return false
const before = presets.value.length
presets.value = presets.value.filter(preset => preset.id !== presetId)
if (presets.value.length === before) return false
if (activePresetId.value === presetId) activePresetId.value = null
persist()
return true
}
applyStoredSettingsForUser(authStore.currentUserId) applyStoredSettingsForUser(authStore.currentUserId)
return { return {
lastError,
theme, theme,
floatingLines, floatingLines,
appearance, appearance,
@ -361,6 +429,7 @@ export const useSettingsStore = defineStore('settings', () => {
timelineZoom, timelineZoom,
timelineScrollLeft, timelineScrollLeft,
presets, presets,
builtInPresets: WAVE_PRESETS,
activePresetId, activePresetId,
showFps, showFps,
toggleTheme, toggleTheme,
@ -371,6 +440,7 @@ export const useSettingsStore = defineStore('settings', () => {
uploadBackgroundImage, uploadBackgroundImage,
clearBackgroundImage, clearBackgroundImage,
savePreset, savePreset,
applyPreset applyPreset,
deletePreset
} }
}) })

View file

@ -0,0 +1,101 @@
/**
* Re-encode a recording as 16-bit PCM WAV.
*
* Browsers record into different containers: Chrome and Firefox produce
* WebM/Opus, which Safari cannot play at all. Recordings the server keeps
* are transcoded to AAC there; recordings the demo user keeps in the browser
* have no server, so they are turned into WAV here. WAV plays everywhere.
*
* Mono at 24 kHz: voice needs no more, and a five-minute note stays under
* 15 MB.
*/
export const WAV_SAMPLE_RATE = 24000
/**
* Decode `blob` and return a WAV blob, or null when the browser cannot
* decode the input (then the original is kept).
*/
export async function blobToWav(blob, { sampleRate = WAV_SAMPLE_RATE } = {}) {
if (typeof window === 'undefined') return null
const AudioContextCtor = window.AudioContext || window.webkitAudioContext
const OfflineCtor = window.OfflineAudioContext || window.webkitOfflineAudioContext
if (!AudioContextCtor) return null
let context
try {
context = new AudioContextCtor()
const decoded = await context.decodeAudioData(await blob.arrayBuffer())
const mono = await downmix(decoded, sampleRate, OfflineCtor)
return new Blob([encodeWav(mono, sampleRate)], { type: 'audio/wav' })
} catch (error) {
console.warn('WAV conversion failed:', error)
return null
} finally {
context?.close?.().catch(() => {})
}
}
// Mixes to one channel and resamples through an OfflineAudioContext.
async function downmix(buffer, sampleRate, OfflineCtor) {
const length = Math.ceil(buffer.duration * sampleRate)
if (!OfflineCtor || length === 0) {
return mixChannels(buffer)
}
const offline = new OfflineCtor(1, length, sampleRate)
const source = offline.createBufferSource()
source.buffer = buffer
source.connect(offline.destination)
source.start(0)
const rendered = await offline.startRendering()
return rendered.getChannelData(0)
}
function mixChannels(buffer) {
const channels = buffer.numberOfChannels
const out = new Float32Array(buffer.length)
for (let c = 0; c < channels; c++) {
const data = buffer.getChannelData(c)
for (let i = 0; i < data.length; i++) out[i] += data[i] / channels
}
return out
}
/** Float samples (-1..1) to a complete RIFF/WAVE file, 16-bit mono. */
export function encodeWav(samples, sampleRate) {
const bytesPerSample = 2
const dataSize = samples.length * bytesPerSample
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
writeAscii(view, 0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeAscii(view, 8, 'WAVE')
writeAscii(view, 12, 'fmt ')
view.setUint32(16, 16, true) // fmt chunk size
view.setUint16(20, 1, true) // PCM
view.setUint16(22, 1, true) // mono
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * bytesPerSample, true) // byte rate
view.setUint16(32, bytesPerSample, true) // block align
view.setUint16(34, 16, true) // bits per sample
writeAscii(view, 36, 'data')
view.setUint32(40, dataSize, true)
let offset = 44
for (let i = 0; i < samples.length; i++, offset += bytesPerSample) {
const clamped = Math.max(-1, Math.min(1, samples[i]))
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true)
}
return buffer
}
function writeAscii(view, offset, text) {
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i))
}
/** Whether Safari would refuse this recording as it is. */
export function needsWavForPlayback(mimeType) {
const type = String(mimeType || '').toLowerCase()
return type.includes('webm') || type.includes('ogg')
}

View file

@ -0,0 +1,58 @@
/**
* Brand helpers: which logo variant reads well on the current background.
*
* The logo exists as "positiv" (ink lettering, for light ground) and
* "negativ" (paper lettering, for dark ground). The LifeWave background is
* user-configured, so the choice is made from the actual colours rather
* than from the UI theme alone.
*/
/** Parse #rgb or #rrggbb into [r, g, b] (0..255); null when unparseable. */
export function hexToRgb(hex) {
if (typeof hex !== 'string') return null
const value = hex.trim().replace(/^#/, '')
const full = value.length === 3
? value.split('').map(c => c + c).join('')
: value
if (!/^[0-9a-f]{6}$/i.test(full)) return null
return [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16))
}
/** WCAG relative luminance, 0 (black) .. 1 (white). */
export function relativeLuminance(hex) {
const rgb = hexToRgb(hex)
if (!rgb) return null
const [r, g, b] = rgb.map((channel) => {
const c = channel / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Pick the logo variant for the header.
*
* With a background photo the colours are unknown, so the UI theme decides.
* Otherwise the wave's edge colour counts most: it is what sits behind the
* header (in "split" horizon mode it is literally the top half), with the
* centre colour as a smaller influence.
*
* @param {{ bgEdge?: string, bgCenter?: string, backgroundImage?: string, isDark?: boolean }} input
* @returns {'positive' | 'negative'}
*/
export function logoVariantForBackground({ bgEdge, bgCenter, backgroundImage, isDark = true } = {}) {
if (backgroundImage) return isDark ? 'negative' : 'positive'
const edge = relativeLuminance(bgEdge)
const centre = relativeLuminance(bgCenter)
if (edge === null && centre === null) return isDark ? 'negative' : 'positive'
const luminance = edge === null
? centre
: centre === null
? edge
: edge * 0.8 + centre * 0.2
// Mid-grey is ~0.21 in linear luminance; anything brighter is "light".
return luminance > 0.25 ? 'positive' : 'negative'
}

View file

@ -0,0 +1,62 @@
/**
* Helpers for pure calendar dates in `YYYY-MM-DD` form.
*
* An event date carries no time and no timezone. `new Date('2026-01-01')` is
* nevertheless parsed as *UTC* midnight, so reading it back with the local
* getters shifts the day backwards everywhere west of UTC — in
* America/Los_Angeles that date renders as 31.12.2025, which also moves the
* event into the wrong month and year band on the timeline.
*
* Building the Date from its parts makes the constructor interpret them in
* local time, so day, month and year always match the stored string.
*/
const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})/
/**
* @param {string} value `YYYY-MM-DD` (a longer ISO string is accepted; the
* time portion is ignored on purpose)
* @returns {Date|null} local-midnight Date, or null when unparseable
*/
export function parseCalendarDate (value) {
const match = CALENDAR_DATE.exec(String(value ?? ''))
if (!match) return null
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
return Number.isNaN(date.getTime()) ? null : date
}
/**
* Sort comparator for calendar date strings. Lexical order equals
* chronological order for `YYYY-MM-DD`, so no Date is allocated at all.
*/
export function compareCalendarDates (a, b) {
return String(a ?? '').localeCompare(String(b ?? ''))
}
/**
* @returns {number|null} the calendar year, e.g. 2026
*/
export function calendarYear (value) {
return parseCalendarDate(value)?.getFullYear() ?? null
}
/**
* @returns {number|null} the zero-based calendar month, e.g. 0 for January
*/
export function calendarMonthIndex (value) {
const date = parseCalendarDate(value)
return date ? date.getMonth() : null
}
/**
* @returns {string} localised date, or the input unchanged when unparseable
*/
export function formatCalendarDate (value, locale = 'de-DE', options = {
day: '2-digit',
month: 'long',
year: 'numeric'
}) {
const date = parseCalendarDate(value)
return date ? date.toLocaleDateString(locale, options) : String(value ?? '')
}

View file

@ -0,0 +1,35 @@
/**
* Vertical placement of events on the LifeWave.
*
* Emotion runs from -1 (bottom) to +1 (top). The dots' container starts
* TIMELINE_TOP_PX below the viewport top and ends above the bottom bar, so
* the centre and the amplitude below are chosen to leave the same margin
* above the highest and below the lowest dot: the header above is taller
* than the bar below, which is why the centre sits a little under 50 %.
*
* GlowDot (DOM) and the shader points (LifeWaveLayout) both use this, so a
* change here moves both together.
*/
/** CSS: .timeline-container { top } */
export const TIMELINE_TOP_PX = 40
/** Percent of the container height where emotion 0 sits. */
export const EMOTION_CENTER_PERCENT = 52.5
/** Percent of the container height that emotion ±1 moves away from the centre. */
export const EMOTION_AMPLITUDE_PERCENT = 32
/**
* @param {number} emotion -1..1
* @returns {number} percent of the container height, measured from the top
*/
export function emotionToYPercent(emotion) {
const clamped = Math.max(-1, Math.min(1, Number(emotion) || 0))
return EMOTION_CENTER_PERCENT - clamped * EMOTION_AMPLITUDE_PERCENT
}
/** True when a dot at this height should carry its label above itself. */
export function isLowerHalf(yPercent) {
return yPercent > EMOTION_CENTER_PERCENT
}

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { WAV_SAMPLE_RATE, encodeWav, needsWavForPlayback } from 'src/utils/audioWav'
function ascii(view, offset, length) {
return String.fromCharCode(...Array.from({ length }, (_, i) => view.getUint8(offset + i)))
}
describe('audioWav — WAV for recordings kept in the browser', () => {
it('writes a valid 16-bit mono RIFF header', () => {
const samples = new Float32Array([0, 0.5, -0.5, 1, -1])
const view = new DataView(encodeWav(samples, WAV_SAMPLE_RATE))
expect(ascii(view, 0, 4)).toBe('RIFF')
expect(ascii(view, 8, 4)).toBe('WAVE')
expect(ascii(view, 12, 4)).toBe('fmt ')
expect(view.getUint16(20, true)).toBe(1) // PCM
expect(view.getUint16(22, true)).toBe(1) // mono
expect(view.getUint32(24, true)).toBe(WAV_SAMPLE_RATE)
expect(view.getUint32(28, true)).toBe(WAV_SAMPLE_RATE * 2)
expect(view.getUint16(34, true)).toBe(16)
expect(ascii(view, 36, 4)).toBe('data')
expect(view.getUint32(40, true)).toBe(samples.length * 2)
expect(view.byteLength).toBe(44 + samples.length * 2)
expect(view.getUint32(4, true)).toBe(view.byteLength - 8)
})
it('scales and clamps samples to the 16-bit range', () => {
const view = new DataView(encodeWav(new Float32Array([0, 1, -1, 2, -2]), 8000))
expect(view.getInt16(44, true)).toBe(0)
expect(view.getInt16(46, true)).toBe(32767)
expect(view.getInt16(48, true)).toBe(-32768)
expect(view.getInt16(50, true)).toBe(32767)
expect(view.getInt16(52, true)).toBe(-32768)
})
it('converts only the containers Safari refuses', () => {
expect(needsWavForPlayback('audio/webm')).toBe(true)
expect(needsWavForPlayback('audio/ogg;codecs=opus')).toBe(true)
expect(needsWavForPlayback('audio/mp4')).toBe(false)
expect(needsWavForPlayback('audio/wav')).toBe(false)
expect(needsWavForPlayback('')).toBe(false)
})
})

View file

@ -0,0 +1,239 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { flush } from './helpers'
// This suite runs against the *real* apiClient — the 401 handling and the
// token revoke are exactly what is under test — so only `fetch` is faked.
function jsonResponse(status, body = null) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => (body === null ? '' : JSON.stringify(body))
}
}
describe('session handling — an expired token ends the session (A11)', () => {
let apiClient
let useAuthStore
let fetchMock
beforeEach(async () => {
setActivePinia(createPinia())
vi.resetModules()
localStorage.clear()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
apiClient = await import('src/services/apiClient')
useAuthStore = (await import('stores/auth')).useAuthStore
const { db } = await import('src/db')
await db.meta.clear()
})
it('clears token, profile and stored auth when the API answers 401', async () => {
const authStore = useAuthStore()
await apiClient.setToken('token-a')
authStore.currentUserId = 'remote-user-1'
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' }))
fetchMock.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthenticated.' }))
await apiClient.apiFetch('/events')
await flush(2)
expect(await apiClient.getToken()).toBeNull()
expect(authStore.currentUserId).toBeNull()
expect(authStore.isAuthenticated).toBe(false)
// The router guard reads this key; leaving it behind sent the user
// straight back into an app that could no longer save.
expect(localStorage.getItem('thatsme-auth')).toBeNull()
expect(authStore.lastError).toBeTruthy()
})
it('ends the session when no token is stored any more (logged out in another tab)', async () => {
const authStore = useAuthStore()
// Remote user restored from localStorage, but the shared token is gone.
authStore.currentUserId = 'remote-user-1'
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' }))
await expect(apiClient.apiFetch('/events')).rejects.toMatchObject({ status: 401 })
await flush(2)
expect(fetchMock).not.toHaveBeenCalled()
expect(authStore.currentUserId).toBeNull()
expect(localStorage.getItem('thatsme-auth')).toBeNull()
expect(authStore.lastError).toBeTruthy()
})
it('does not tear down a newer session because of a late 401 from the old one', async () => {
const authStore = useAuthStore()
await apiClient.setToken('token-old')
// The old session's request is answered late; by then a new session has
// already stored its own token.
let resolveRequest
fetchMock.mockImplementationOnce(() => new Promise((resolve) => { resolveRequest = resolve }))
const pending = apiClient.apiFetch('/events')
await flush(1)
await apiClient.setToken('token-new')
authStore.currentUserId = 'remote-user-2'
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-2' }))
resolveRequest(jsonResponse(401, { message: 'Unauthenticated.' }))
await pending
await flush(2)
expect(await apiClient.getToken()).toBe('token-new')
expect(authStore.currentUserId).toBe('remote-user-2')
expect(localStorage.getItem('thatsme-auth')).not.toBeNull()
})
it('leaves a local demo session alone when a stray 401 arrives', async () => {
const authStore = useAuthStore()
await apiClient.setToken('token-a')
authStore.currentUserId = 'local-demo'
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'local-demo' }))
fetchMock.mockResolvedValueOnce(jsonResponse(401))
await apiClient.apiFetch('/events')
await flush(2)
expect(authStore.currentUserId).toBe('local-demo')
})
})
describe('session handling — logout (A11)', () => {
let apiClient
let useAuthStore
let fetchMock
beforeEach(async () => {
setActivePinia(createPinia())
vi.resetModules()
localStorage.clear()
fetchMock = vi.fn().mockResolvedValue(jsonResponse(204))
vi.stubGlobal('fetch', fetchMock)
apiClient = await import('src/services/apiClient')
useAuthStore = (await import('stores/auth')).useAuthStore
const { db } = await import('src/db')
await db.meta.clear()
})
it('clears the local session synchronously so the router guard sees it', async () => {
const authStore = useAuthStore()
await apiClient.setToken('token-a')
authStore.currentUserId = 'remote-user-1'
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' }))
const done = authStore.logout()
// No await yet: the caller navigates to /login right after this line.
expect(authStore.currentUserId).toBeNull()
expect(localStorage.getItem('thatsme-auth')).toBeNull()
await done
})
it('revokes the token that is being discarded', async () => {
const authStore = useAuthStore()
await apiClient.setToken('token-a')
authStore.currentUserId = 'remote-user-1'
await authStore.logout()
await flush(2)
const revoke = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/logout'))
expect(revoke).toBeDefined()
expect(revoke[1].headers.Authorization).toBe('Bearer token-a')
expect(await apiClient.getToken()).toBeNull()
})
it('still ends the session when the revoke request fails', async () => {
const authStore = useAuthStore()
await apiClient.setToken('token-a')
authStore.currentUserId = 'remote-user-1'
fetchMock.mockRejectedValue(new Error('offline'))
await authStore.logout()
await flush(2)
expect(authStore.currentUserId).toBeNull()
expect(await apiClient.getToken()).toBeNull()
})
it('does not call the API when a local demo user logs out', async () => {
const authStore = useAuthStore()
authStore.currentUserId = 'local-demo'
await authStore.logout()
await flush(2)
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/logout'))).toHaveLength(0)
})
})
describe('image cache is scoped per user (A10)', () => {
let useImageCache
let db
beforeEach(async () => {
setActivePinia(createPinia())
vi.resetModules()
vi.stubGlobal('fetch', vi.fn())
useImageCache = await import('composables/useImageCache')
db = (await import('src/db')).db
await db.scopedImageCache.clear()
})
it('drops the previous user\'s cached images when the scope changes', async () => {
useImageCache.setImageCacheScope('remote-user-1')
await db.scopedImageCache.put({
key: 'http://api.test|remote-user-1|/event-media/1/thumb',
url: '/event-media/1/thumb',
userId: 'remote-user-1',
eventId: 'e1',
type: 'thumbnail',
blob: new Blob(['x']),
cachedAt: Date.now()
})
expect(await db.scopedImageCache.count()).toBe(1)
useImageCache.setImageCacheScope('remote-user-2')
await flush(2)
expect(await db.scopedImageCache.count()).toBe(0)
})
it('clears the cache on logout', async () => {
useImageCache.setImageCacheScope('remote-user-1')
await db.scopedImageCache.put({
key: 'http://api.test|remote-user-1|/event-media/2/thumb',
url: '/event-media/2/thumb',
userId: 'remote-user-1',
eventId: 'e2',
type: 'thumbnail',
blob: new Blob(['x']),
cachedAt: Date.now()
})
useImageCache.setImageCacheScope(null)
await flush(2)
expect(await db.scopedImageCache.count()).toBe(0)
})
})

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { hexToRgb, logoVariantForBackground, relativeLuminance } from 'src/utils/brand'
describe('brand — logo variant follows the LifeWave background', () => {
it('parses short and long hex colours', () => {
expect(hexToRgb('#fff')).toEqual([255, 255, 255])
expect(hexToRgb('#292a2b')).toEqual([41, 42, 43])
expect(hexToRgb('nope')).toBeNull()
})
it('ranks the brand colours by luminance', () => {
expect(relativeLuminance('#f7f3ea')).toBeGreaterThan(relativeLuminance('#f5c26b'))
expect(relativeLuminance('#f5c26b')).toBeGreaterThan(relativeLuminance('#292a2b'))
})
it('uses the negative logo on the default dark wave', () => {
expect(logoVariantForBackground({ bgEdge: '#000000', bgCenter: '#0a0514' })).toBe('negative')
})
it('uses the positive logo on a light wave', () => {
expect(logoVariantForBackground({ bgEdge: '#f7f3ea', bgCenter: '#ffffff' })).toBe('positive')
})
it('weights the edge colour, which sits behind the header', () => {
// Light edge, dark centre: the header area is light.
expect(logoVariantForBackground({ bgEdge: '#f7f3ea', bgCenter: '#000000' })).toBe('positive')
// Dark edge, light centre: the header area is dark.
expect(logoVariantForBackground({ bgEdge: '#000000', bgCenter: '#f7f3ea' })).toBe('negative')
})
it('falls back to the UI theme when a background photo hides the colours', () => {
expect(logoVariantForBackground({ backgroundImage: '/images/bg-image-1.jpg', isDark: true })).toBe('negative')
expect(logoVariantForBackground({ backgroundImage: '/images/bg-image-1.jpg', isDark: false })).toBe('positive')
})
it('falls back to the UI theme when no colour can be read', () => {
expect(logoVariantForBackground({ isDark: false })).toBe('positive')
expect(logoVariantForBackground({ bgEdge: 'transparent', isDark: true })).toBe('negative')
})
})

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
calendarMonthIndex,
calendarYear,
compareCalendarDates,
formatCalendarDate,
parseCalendarDate
} from 'src/utils/calendarDate'
/**
* The process timezone is fixed at start-up, so a single run cannot cover
* several zones. Instead these tests state the invariant directly: whatever
* zone the runner is in, the parsed day, month and year must equal the
* digits in the string. `npm run test:tz` runs the suite under the zones
* that used to shift the date.
*/
describe('calendar dates are parsed as calendar dates, not as UTC instants (A18)', () => {
it.each([
['2026-01-01', 2026, 0, 1],
['2025-12-31', 2025, 11, 31],
['2014-08-12', 2014, 7, 12],
['2000-02-29', 2000, 1, 29]
])('%s keeps its year, month and day in %s', (value, year, month, day) => {
const date = parseCalendarDate(value)
expect(date.getFullYear()).toBe(year)
expect(date.getMonth()).toBe(month)
expect(date.getDate()).toBe(day)
})
it('exposes the same year and month index used by the timeline labels', () => {
expect(calendarYear('2026-01-01')).toBe(2026)
expect(calendarMonthIndex('2026-01-01')).toBe(0)
})
it('formats without shifting into the previous day', () => {
expect(formatCalendarDate('2026-01-01')).toBe('01. Januar 2026')
})
it('ignores a trailing time portion instead of letting it move the day', () => {
expect(calendarYear('2026-01-01T00:00:00.000Z')).toBe(2026)
expect(calendarMonthIndex('2026-01-01T00:00:00.000Z')).toBe(0)
})
it('returns null for values that are not calendar dates', () => {
expect(parseCalendarDate('')).toBeNull()
expect(parseCalendarDate(null)).toBeNull()
expect(parseCalendarDate('gestern')).toBeNull()
expect(calendarYear(undefined)).toBeNull()
})
it('leaves unparseable input untouched when formatting', () => {
expect(formatCalendarDate('gestern')).toBe('gestern')
})
it('sorts chronologically without allocating a Date', () => {
const dates = ['2026-01-01', '2014-08-12', '2025-12-31', '2025-12-30']
expect([...dates].sort(compareCalendarDates)).toEqual([
'2014-08-12', '2025-12-30', '2025-12-31', '2026-01-01'
])
expect(compareCalendarDates('2025-12-31', '2026-01-01')).toBeLessThan(0)
expect(compareCalendarDates('2026-01-01', '2026-01-01')).toBe(0)
})
it('documents the old behaviour it replaces', () => {
// This is the bug: in any zone west of UTC, the local getters of a
// UTC-parsed instant point at the day before.
const offsetMinutes = new Date('2026-01-01').getTimezoneOffset()
const oldDay = new Date('2026-01-01').getDate()
if (offsetMinutes > 0) {
expect(oldDay).toBe(31)
}
expect(parseCalendarDate('2026-01-01').getDate()).toBe(1)
})
})

View file

@ -0,0 +1,270 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
import { routerKey } from 'vue-router'
import { createApiRouter, flush, page, remoteEvent } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
// Static imports on purpose: Quasar installs `$q` per mounted app, and a
// module reset between tests would leave the stores and the components with
// different copies of the same singletons. A fresh Pinia per test isolates
// the store state.
import { db } from 'src/db'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
import { useStoreFeedback } from 'composables/useStoreFeedback'
import EventPanel from 'components/EventPanel.vue'
const EVENTS_LIST = /^\/events\?/
function fakeRouter(path = '/') {
return {
currentRoute: { value: { path } },
replace: vi.fn()
}
}
/**
* A component that does nothing but run the composable. It stands in for the
* layout, which owns a WebGL scene and cannot be mounted in a DOM-only
* environment.
*/
const FeedbackHost = defineComponent({
setup() {
useStoreFeedback()
return () => h('div')
}
})
describe('useStoreFeedback — store failures become notifications', () => {
let notify
let eventsStore
let settingsStore
let authStore
let router
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
await db.events.clear()
eventsStore = useEventsStore()
settingsStore = useSettingsStore()
authStore = useAuthStore()
router = fakeRouter('/')
const wrapper = mount(FeedbackHost, {
global: { provide: { [routerKey]: router } }
})
// `$q.notify` is looked up on each call, so spying on the mounted app's
// `$q` intercepts what the composable sends.
notify = vi.spyOn(wrapper.vm.$q, 'notify').mockImplementation(() => () => {})
})
it('shows a write failure as a negative notification', async () => {
eventsStore.lastError = 'Das Event konnte nicht gespeichert werden.'
await flush(1)
expect(notify).toHaveBeenCalledTimes(1)
expect(notify.mock.calls[0][0]).toMatchObject({
type: 'negative',
message: 'Das Event konnte nicht gespeichert werden.'
})
})
it('shows a load failure as a warning', async () => {
eventsStore.loadError = 'Die Events konnten nicht geladen werden.'
await flush(1)
expect(notify.mock.calls[0][0]).toMatchObject({ type: 'warning' })
})
it('shows a settings failure as a warning', async () => {
settingsStore.lastError = 'Die Einstellungen konnten nicht gespeichert werden.'
await flush(1)
expect(notify.mock.calls[0][0]).toMatchObject({
type: 'warning',
message: 'Die Einstellungen konnten nicht gespeichert werden.'
})
})
it('consumes the message so the identical error notifies again', async () => {
eventsStore.lastError = 'Gleiche Meldung'
await flush(1)
expect(eventsStore.lastError).toBeNull()
eventsStore.lastError = 'Gleiche Meldung'
await flush(1)
expect(notify).toHaveBeenCalledTimes(2)
})
it('does not notify when the error is cleared', async () => {
eventsStore.lastError = null
await flush(1)
expect(notify).not.toHaveBeenCalled()
})
it('redirects to /login once the session has ended', async () => {
authStore.currentUserId = 'remote-user-1'
await flush(1)
expect(router.replace).not.toHaveBeenCalled()
authStore.currentUserId = null
await flush(1)
expect(router.replace).toHaveBeenCalledWith('/login')
})
it('does not redirect when already on /login', async () => {
router.currentRoute.value.path = '/login'
authStore.currentUserId = 'remote-user-1'
await flush(1)
authStore.currentUserId = null
await flush(1)
expect(router.replace).not.toHaveBeenCalled()
})
})
describe('EventPanel — the save state is visible', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
await db.events.clear()
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Event A' })]))
api.always(/^\/events\/e1$/, (url, options) => Promise.resolve({
data: remoteEvent({ id: 'e1', ...(options.body ?? {}) })
}))
eventsStore = useEventsStore()
useAuthStore().currentUserId = 'remote-user-1'
await flush()
})
function mountPanel() {
return mount(EventPanel, {
global: {
stubs: {
// Not under test, and they pull in portals and carousels.
QDialog: true,
QCarousel: true,
QCarouselSlide: true,
QPopupProxy: true,
QDate: true
}
}
})
}
it('renders nothing about saving on a freshly opened panel', async () => {
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
expect(wrapper.find('.event-panel__save-state').exists()).toBe(false)
})
it('shows "Gespeichert" after a successful autosave', async () => {
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'geändert'
await flush(1)
eventsStore.saveGhostNow()
await flush()
const state = wrapper.find('.event-panel__save-state')
expect(state.exists()).toBe(true)
expect(state.text()).toBe('Gespeichert')
expect(state.classes()).toContain('event-panel__save-state--saved')
})
it('shows "Nicht gespeichert" when the server rejects the write', async () => {
api = createApiRouter()
api.always(/^\/events\/e1$/, () => Promise.reject(
Object.assign(new Error('unprocessable'), { status: 422 })
))
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'abgelehnt'
await flush(1)
eventsStore.saveGhostNow()
await flush()
const state = wrapper.find('.event-panel__save-state')
expect(state.find('span').text()).toBe('Nicht gespeichert')
expect(state.classes()).toContain('event-panel__save-state--error')
expect(state.attributes('role')).toBe('status')
// A failed write offers a way out.
expect(state.find('.event-panel__save-retry').text()).toBe('Erneut versuchen')
})
it('shows "Wird gespeichert …" while the request is in flight', async () => {
let resolveWrite
api = createApiRouter()
api.always(/^\/events\/e1$/, () => new Promise((resolve) => { resolveWrite = resolve }))
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'unterwegs'
await flush(1)
eventsStore.saveGhostNow()
// The write leaves through the queue, which takes a few IndexedDB turns.
await flush()
expect(wrapper.find('.event-panel__save-state').text()).toBe('Wird gespeichert …')
expect(resolveWrite).toBeTypeOf('function')
resolveWrite({ data: remoteEvent({ id: 'e1', title: 'unterwegs' }) })
await flush()
expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert')
})
it('starts clean again when another event is opened', async () => {
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'geändert'
await flush(1)
eventsStore.saveGhostNow()
await flush()
expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert')
eventsStore.openPanel('e1')
await flush(1)
expect(wrapper.find('.event-panel__save-state').exists()).toBe(false)
})
})

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import TimelineNav from 'components/TimelineNav.vue'
// The menu is a portal; rendering it inline keeps the year list testable.
const inlineMenu = {
QMenu: {
props: ['modelValue'],
template: '<div v-if="modelValue" class="menu-stub"><slot /></div>'
}
}
function mountNav(props = {}) {
return mount(TimelineNav, {
props: {
year: 2026,
month: 'Januar',
years: [2025, 2026, 2027],
canPrev: true,
canNext: true,
...props
},
global: { stubs: inlineMenu }
})
}
describe('TimelineNav — the position pill', () => {
it('shows month and year of the centred event', () => {
const wrapper = mountNav()
expect(wrapper.find('.timeline-nav__month').text()).toBe('Januar')
expect(wrapper.find('.timeline-nav__year').text()).toBe('2026')
})
it('emits prev and next from the arrows', async () => {
const wrapper = mountNav()
const arrows = wrapper.findAll('.timeline-nav__arrow')
await arrows[0].trigger('click')
await arrows[1].trigger('click')
expect(wrapper.emitted('prev')).toHaveLength(1)
expect(wrapper.emitted('next')).toHaveLength(1)
})
it('disables the arrows at either end of the data', () => {
const wrapper = mountNav({ canPrev: false, canNext: false })
const arrows = wrapper.findAll('.timeline-nav__arrow')
expect(arrows[0].attributes('disabled')).toBeDefined()
expect(arrows[1].attributes('disabled')).toBeDefined()
})
it('opens the year list on tap and emits the chosen year', async () => {
const wrapper = mountNav()
expect(wrapper.find('.menu-stub').exists()).toBe(false)
await wrapper.find('.timeline-nav__label').trigger('click')
expect(wrapper.find('.menu-stub').exists()).toBe(true)
const items = wrapper.findAll('.timeline-nav__year-item')
expect(items.map(item => item.text())).toEqual(['2025', '2026', '2027'])
await items[2].trigger('click')
expect(wrapper.emitted('jump')).toEqual([[2027]])
// Choosing closes the list.
expect(wrapper.find('.menu-stub').exists()).toBe(false)
})
it('shows a dash and no month while there is no event', () => {
const wrapper = mountNav({ year: null, month: '', years: [], canPrev: false, canNext: false })
expect(wrapper.find('.timeline-nav__year').text()).toBe('–')
expect(wrapper.find('.timeline-nav__month').exists()).toBe(false)
expect(wrapper.find('.timeline-nav__label').attributes('disabled')).toBeDefined()
})
})

View file

@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { createApiRouter, flush, page, remoteEvent } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
import { db } from 'src/db'
import { useEventsStore } from 'stores/events'
import { useAuthStore } from 'stores/auth'
import TimelineView from 'components/TimelineView.vue'
const EVENTS_LIST = /^\/events\?/
/**
* Dates chosen so that a UTC-parsed instant read with local getters would
* put the January events into December of the previous year in any zone
* west of UTC — the failure the audit reproduced (A18). `npm run test:tz`
* runs this file under such a zone.
*/
const EVENTS = [
remoteEvent({ id: 'e-dec', title: 'Silvester', date: '2025-12-31' }),
remoteEvent({ id: 'e-jan', title: 'Neujahr', date: '2026-01-01' }),
remoteEvent({ id: 'e-feb', title: 'Februar', date: '2026-02-15' }),
remoteEvent({ id: 'e-next', title: 'Nächstes Jahr', date: '2027-01-01' })
]
const MONTHS_LONG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
/** Month and year read from the date string itself, never via Date parsing. */
function positionFromDateString(date) {
return { year: Number(date.slice(0, 4)), month: MONTHS_LONG[Number(date.slice(5, 7)) - 1] }
}
describe('TimelineView — the reported position follows the calendar date (A18)', () => {
let wrapper
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
await db.events.clear()
api.once(EVENTS_LIST, page(EVENTS))
useEventsStore()
useAuthStore().currentUserId = 'remote-user-1'
await flush()
// happy-dom reports no layout, so the component falls back to a 400 px
// viewport; the first five events are inside its virtualisation window.
wrapper = mount(TimelineView)
await flush(2)
})
it('lists exactly the years written in the dates, ascending', () => {
// A UTC-shifted parse would move both New Year events into the previous
// year and 2027 would go missing.
expect(wrapper.vm.years).toEqual([2025, 2026, 2027])
})
it('reports month and year of the centred event as written in its date', () => {
const position = wrapper.vm.activePosition
expect(position).not.toBeNull()
// Whichever event the timeline centres on mount, month and year must be
// the ones spelled out in that event's date string.
const matching = EVENTS.map(event => positionFromDateString(event.date))
expect(matching).toContainEqual(position)
})
it('names months in full for the navigation pill', () => {
expect(MONTHS_LONG).toContain(wrapper.vm.activePosition.month)
})
it('no longer renders month or year rows inside the timeline', () => {
expect(wrapper.find('.timeline__month').exists()).toBe(false)
expect(wrapper.find('.timeline__sticky-year').exists()).toBe(false)
})
it('exposes a year jump for the navigation', () => {
expect(typeof wrapper.vm.scrollToYearCenter).toBe('function')
// Must not throw for a year that exists or for one that does not.
expect(() => wrapper.vm.scrollToYearCenter(2026)).not.toThrow()
expect(() => wrapper.vm.scrollToYearCenter(1999)).not.toThrow()
})
})

View file

@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const EVENTS_LIST = /^\/events\?/
describe('events store — switching events does not lose the pending edit (A06)', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([
remoteEvent({ id: 'e1', title: 'original A', date: '2025-01-01' }),
remoteEvent({ id: 'e2', title: 'original B', date: '2025-02-01' })
]))
// Echo the request, like the real API does: a stub with fixed values
// would mask whether the right fields were sent.
api.always(/^\/events\/e\d$/, (url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
}))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
// A queue still sending when the test ends would hit the next test's API
// mock; let it finish first.
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('keeps the edit when the next event is opened inside the debounce window', async () => {
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'bearbeitet A'
await flush(1)
// Straight to the next event, well inside the 300 ms autosave delay.
eventsStore.openPanel('e2')
await flush(2)
const eventA = eventsStore.events.find(event => event.id === 'e1')
expect(eventA.title).toBe('bearbeitet A')
})
it('sends the edit to the API under the event it belongs to', async () => {
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'bearbeitet A'
await flush(1)
eventsStore.openPanel('e2')
// The write leaves through the queue; give it its IndexedDB turns.
await waitFor(() => api.callsTo('/events/e1').some(call => call.options?.method === 'PUT'), { label: 'PUT to e1' })
const writes = api.callsTo(/^\/events\/e\d$/).filter(call => call.options?.method === 'PUT')
const writeToA = writes.find(call => call.url === '/events/e1')
expect(writeToA).toBeDefined()
expect(writeToA.options.body.title).toBe('bearbeitet A')
// The unrelated event must not be rewritten with A's title.
for (const write of writes.filter(call => call.url === '/events/e2')) {
expect(write.options.body.title).not.toBe('bearbeitet A')
}
})
it('leaves the other event untouched', async () => {
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'bearbeitet A'
await flush(1)
eventsStore.openPanel('e2')
await flush(2)
const eventB = eventsStore.events.find(event => event.id === 'e2')
expect(eventB.title).toBe('original B')
})
it('does not reset gradientPreset on every autosave', async () => {
const eventA = eventsStore.events.find(event => event.id === 'e1')
eventA.gradientPreset = 3
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'neuer Titel'
await flush(1)
eventsStore.saveGhostNow()
await flush(2)
expect(eventsStore.events.find(event => event.id === 'e1').gradientPreset).toBe(3)
})
})
describe('events store — failed writes stay visible (A07)', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
await db.syncQueue.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'bleibt' })]))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
// A queue still sending when the test ends would hit the next test's API
// mock; let it finish first.
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('keeps a delete the server could not process in the queue and retries it', async () => {
let failing = true
api.always(/^\/events\/e1$/, () => (failing
? Promise.reject(Object.assign(new Error('server error'), { status: 500 }))
: Promise.resolve(null)))
expect(eventsStore.events).toHaveLength(1)
eventsStore.deleteEvent('e1')
await waitFor(
() => api.callsTo(/^\/events\/e1$/).length === 1 && eventsStore.pendingSyncCount === 1,
{ label: `first attempt (calls=${api.callsTo(/^\/events\/e1$/).length}, pending=${eventsStore.pendingSyncCount})` }
)
// Gone locally, waiting in the queue, and not reported as a failure:
// the server will get the delete as soon as it answers again.
expect(eventsStore.events.find(event => event.id === 'e1')).toBeUndefined()
expect(eventsStore.lastError).toBeNull()
const { db } = await import('src/db')
expect(await db.events.get('e1')).toBeUndefined()
failing = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0, { label: 'queue drained after retry' })
expect(api.callsTo(/^\/events\/e1$/)).toHaveLength(2)
})
it('treats a 404 on delete as done instead of resurrecting the event', async () => {
api.always(/^\/events\/e1$/, () => Promise.reject(
Object.assign(new Error('not found'), { status: 404 })
))
eventsStore.deleteEvent('e1')
await flush()
expect(eventsStore.events.find(event => event.id === 'e1')).toBeUndefined()
expect(eventsStore.lastError).toBeNull()
const { db } = await import('src/db')
expect(await db.events.get('e1')).toBeUndefined()
})
it('does not autosave just because the panel was opened on an event with stored media', async () => {
const { db } = await import('src/db')
await db.eventMedia.put({
id: 'm1', eventId: 'e1', userId: 'remote-user-1', uuid: 'u1', type: 'image',
collection: 'gallery', name: 'a.jpg', createdAt: 1, updatedAt: 1
})
api.always(/^\/events\/e1$/, (url, options) => Promise.resolve({ data: remoteEvent({ id: 'e1', ...(options.body ?? {}) }) }))
const before = eventsStore.events.find(event => event.id === 'e1').updatedAt
eventsStore.openPanel('e1')
await flush(4)
// Past the autosave debounce: a scheduled persist would have fired by now.
await new Promise(resolve => setTimeout(resolve, 400))
await flush(2)
expect(api.callsTo(/^\/events\/e1$/)).toHaveLength(0)
expect(eventsStore.saveState).toBe('idle')
expect(eventsStore.events.find(event => event.id === 'e1').updatedAt).toBe(before)
expect(eventsStore.ghostMedia).toHaveLength(1)
})
it('flags an event whose update the server refused instead of showing it as saved', async () => {
api.always(/^\/events\/e1$/, () => Promise.reject(
Object.assign(new Error('unprocessable'), { status: 422 })
))
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'abgelehnt'
await flush(1)
eventsStore.saveGhostNow()
await flush()
expect(eventsStore.saveState).toBe('error')
expect(eventsStore.lastError).toBeTruthy()
// The local draft survives so the user can retry.
expect(eventsStore.events.find(event => event.id === 'e1').title).toBe('abgelehnt')
})
it('falls back to the cached events when the initial load fails', async () => {
const { db } = await import('src/db')
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
await db.events.put({
...remoteEvent({ id: 'cached', title: 'aus dem Cache' }),
userId: 'remote-user-3'
})
setActivePinia(createPinia())
api = createApiRouter()
api.always(EVENTS_LIST, () => Promise.reject(new Error('network down')))
const authStore = useAuthStore()
const store = useEventsStore()
authStore.currentUserId = 'remote-user-3'
await flush()
expect(store.events.map(event => event.id)).toEqual(['cached'])
expect(store.loadError).toBeTruthy()
})
})

View file

@ -0,0 +1,312 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, drainQueue, flush, page, remoteEvent } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const EVENTS_LIST = /^\/events\?/
const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST'
const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT'
// Past the 300 ms autosave debounce.
async function settle() {
await flush(2)
await new Promise(resolve => setTimeout(resolve, 400))
await flush(2)
}
describe('events store — a new event is stored with its first entry', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden' })]))
api.always(EVENTS_CREATE, (url, options) => Promise.resolve({
data: remoteEvent({ ...(options.body ?? {}) })
}))
api.always(EVENT_UPDATE, (url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
}))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
// A queue still sending when the test ends would hit the next test's API
// mock; let it finish first.
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('opens the panel as a draft without creating an event', async () => {
eventsStore.openPanel()
await settle()
expect(eventsStore.panelOpen).toBe(true)
expect(eventsStore.editingEventId).toBeNull()
expect(eventsStore.events).toHaveLength(1)
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
})
it('leaves nothing behind when the empty draft is closed', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.closePanel()
await settle()
expect(eventsStore.events).toHaveLength(1)
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
const { db } = await import('src/db')
expect(await db.events.count()).toBe(1)
})
it('does not treat date or emotion alone as an entry', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostDate = '2024-06-01'
eventsStore.ghostEmotion = 0.8
await settle()
expect(eventsStore.editingEventId).toBeNull()
expect(eventsStore.events).toHaveLength(1)
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
})
it('creates exactly one event with the first title and updates it afterwards', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostTitle = 'Erster Tag'
await flush(2)
expect(eventsStore.editingEventId).not.toBeNull()
const createdId = eventsStore.editingEventId
const created = eventsStore.events.find(event => event.id === createdId)
expect(created).toBeDefined()
expect(created.title).toBe('Erster Tag')
expect(eventsStore.selectedEventId).toBe(createdId)
eventsStore.ghostTitle = 'Erster Tag am Meer'
eventsStore.ghostNote = 'Sonne'
await settle()
const creates = api.callsTo(EVENTS_CREATE)
expect(creates).toHaveLength(1)
expect(creates[0].options.body.title).toBe('Erster Tag')
const updates = api.callsTo(EVENT_UPDATE).filter(call => call.url === `/events/${createdId}`)
expect(updates.length).toBeGreaterThan(0)
expect(updates.at(-1).options.body.title).toBe('Erster Tag am Meer')
expect(updates.at(-1).options.body.note).toBe('Sonne')
expect(eventsStore.events).toHaveLength(2)
})
it('keeps the created event when the panel is closed', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostNote = 'nur eine Notiz'
await flush(2)
const createdId = eventsStore.editingEventId
eventsStore.closePanel()
await settle()
expect(eventsStore.events.find(event => event.id === createdId)?.note).toBe('nur eine Notiz')
const { db } = await import('src/db')
expect(await db.events.get(createdId)).toBeDefined()
})
it('starts each new draft clean, even after editing another event', async () => {
eventsStore.openPanel('e1')
await flush(2)
expect(eventsStore.ghostTitle).toBe('vorhanden')
eventsStore.openPanel()
await flush(2)
expect(eventsStore.ghostTitle).toBe('')
expect(eventsStore.ghostNote).toBe('')
expect(eventsStore.ghostMedia).toEqual([])
expect(eventsStore.ghostHasContent).toBe(false)
expect(eventsStore.editingEventId).toBeNull()
})
it('does not resurrect the existing event as a "Neues Event" copy', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.closePanel()
await settle()
expect(eventsStore.events.map(event => event.title)).toEqual(['vorhanden'])
})
})
describe('events store — drafts for the local demo user', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'local-demo'
await flush()
})
// A queue still sending when the test ends would hit the next test's API
// mock; let it finish first.
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('stores the draft locally with its first entry and never calls the API', async () => {
eventsStore.openPanel()
await flush(2)
expect(eventsStore.events).toHaveLength(0)
eventsStore.ghostLocation = 'Berlin'
await settle()
expect(eventsStore.events).toHaveLength(1)
expect(eventsStore.events[0].location).toBe('Berlin')
expect(eventsStore.saveState).toBe('saved')
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
const { db } = await import('src/db')
expect(await db.events.count()).toBe(1)
})
})
describe('events store — category, people, span and milestone', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
await db.syncQueue.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({
id: 'e1', title: 'Umzug', endDate: '2019-04-14', category: 'home', people: ['Lena'], milestone: true
})]))
api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) }))
api.always(EVENT_UPDATE, (url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
}))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('loads the fields from the API and puts them into the draft', async () => {
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.endDate).toBe('2019-04-14')
expect(event.category).toBe('home')
expect(event.people).toEqual(['Lena'])
expect(event.milestone).toBe(true)
eventsStore.openPanel('e1')
await flush(2)
expect(eventsStore.ghostEndDate).toBe('2019-04-14')
expect(eventsStore.ghostCategory).toBe('home')
expect(eventsStore.ghostPeople).toEqual(['Lena'])
expect(eventsStore.ghostMilestone).toBe(true)
})
it('a category or a person is an entry that stores the draft', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostMilestone = true
await settle()
// A flag alone is no content.
expect(eventsStore.editingEventId).toBeNull()
eventsStore.ghostCategory = 'travel'
await settle()
expect(eventsStore.editingEventId).not.toBeNull()
const creates = api.callsTo(EVENTS_CREATE)
expect(creates).toHaveLength(1)
expect(creates[0].options.body.category).toBe('travel')
expect(creates[0].options.body.milestone).toBe(true)
expect(creates[0].options.body.people).toEqual([])
expect(creates[0].options.body.endDate).toBeNull()
})
it('sends people, span and milestone on update and keeps them on the event', async () => {
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostPeople = ['Lena', 'Papa']
eventsStore.ghostEndDate = '2019-04-20'
eventsStore.ghostMilestone = false
await settle()
await drainQueue(eventsStore)
const puts = api.callsTo(EVENT_UPDATE)
const last = puts[puts.length - 1].options.body
expect(last.people).toEqual(['Lena', 'Papa'])
expect(last.endDate).toBe('2019-04-20')
expect(last.milestone).toBe(false)
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.people).toEqual(['Lena', 'Papa'])
expect(event.endDate).toBe('2019-04-20')
expect(event.milestone).toBe(false)
const { db } = await import('src/db')
expect((await db.events.get('e1')).people).toEqual(['Lena', 'Papa'])
})
})

View file

@ -0,0 +1,361 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const EVENTS_LIST = /^\/events\?/
const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST'
const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT'
const MEDIA_UPLOAD = (url, options) => /^\/events\/[^/]+\/media$/.test(url) && options?.method === 'POST'
const MEDIA_DELETE = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'DELETE'
const MEDIA_PATCH = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'PATCH'
function mediaItem(overrides = {}) {
const id = overrides.id ?? 'm1'
return {
id,
uuid: `uuid-${id}`,
type: 'image',
collection: 'gallery',
name: `${id}.jpg`,
mimeType: 'image/jpeg',
durationMs: null,
src: `/event-media/${id}/thumb`,
thumbnailUrl: `/event-media/${id}/thumb`,
previewUrl: `/event-media/${id}/preview`,
originalUrl: `/event-media/${id}/original`,
createdAt: 1,
...overrides
}
}
async function settle() {
await flush(2)
await new Promise(resolve => setTimeout(resolve, 400))
await flush(2)
}
describe('events store — media types and the voice note (remote user)', () => {
let eventsStore
let uploadCounter
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
uploadCounter = 0
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
await db.eventMedia.clear()
await db.syncQueue.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([
remoteEvent({
id: 'e1',
title: 'mit Medien',
media: [
mediaItem({ id: 'k1', collection: 'key_image' }),
mediaItem({ id: 'g1' }),
mediaItem({ id: 'g2', type: 'video', name: 'clip.mp4', mimeType: 'video/mp4', durationMs: 4200, src: null, thumbnailUrl: null, previewUrl: null }),
mediaItem({ id: 'v1', type: 'audio', collection: 'voice_note', name: 'sprachnotiz.webm', mimeType: 'audio/webm', durationMs: 9000, src: null, thumbnailUrl: null, previewUrl: null })
]
})
]))
api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) }))
// Like the real API, an update echoes the event with its media.
api.always(EVENT_UPDATE, (url, options) => {
const id = url.split('/').pop()
const media = eventsStore.events.find(entry => entry.id === id)?.media ?? []
return Promise.resolve({ data: remoteEvent({ id, ...(options.body ?? {}), media }) })
})
api.always(MEDIA_UPLOAD, (url, options) => {
const collection = options.body.get('collection')
const id = `up${++uploadCounter}`
return Promise.resolve({
data: mediaItem({
id,
collection,
type: collection === 'voice_note' ? 'audio' : 'image',
name: options.body.get('file')?.name,
src: null,
thumbnailUrl: null,
previewUrl: null,
durationMs: collection === 'voice_note' ? 1500 : null
})
})
})
api.always(MEDIA_DELETE, () => Promise.resolve(null))
api.always(MEDIA_PATCH, (url, options) => Promise.resolve({
data: mediaItem({ id: url.split('/').pop(), collection: 'voice_note', type: 'audio', caption: options.body.caption })
}))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
// The 300 ms autosave of one test must not fire into the next test's API.
afterEach(async () => {
eventsStore.closePanel()
await flush(2)
// A queue still sending when the test ends would hit the next test's
// API mock; let it finish first.
await drainQueue(eventsStore)
})
it('splits stored media into gallery items and the voice note', async () => {
eventsStore.openPanel('e1')
await flush(3)
expect(eventsStore.ghostMedia.map(item => item.id)).toEqual(['g1', 'g2'])
expect(eventsStore.ghostMedia[1].type).toBe('video')
expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['v1'])
expect(eventsStore.ghostVoiceNotes[0].type).toBe('audio')
})
it('keeps the key image and the voice note on the event when saving', async () => {
eventsStore.openPanel('e1')
await flush(3)
eventsStore.ghostTitle = 'bearbeitet'
await settle()
const event = eventsStore.events.find(entry => entry.id === 'e1')
const collections = event.media.map(item => `${item.collection}:${item.id}`)
expect(collections).toContain('key_image:k1')
expect(collections).toContain('voice_note:v1')
expect(collections).toContain('gallery:g1')
expect(collections).toContain('gallery:g2')
expect(event.media).toHaveLength(4)
})
it('uploads a recording as the voice note of a fresh draft', async () => {
eventsStore.openPanel()
await flush(2)
const file = new File(['audio'], 'sprachnotiz.webm', { type: 'audio/webm' })
const media = await eventsStore.uploadGhostVoiceNote(file, { durationMs: 2100 })
// The note is there at once, marked as still uploading.
expect(media.type).toBe('audio')
expect(media.pending).toBe(true)
expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual([media.id])
await waitFor(() => eventsStore.ghostVoiceNotes.some(note => note.id === 'up1'), { label: 'upload applied' })
// The draft became an event first, then the upload went to it.
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(1)
const eventId = eventsStore.editingEventId
expect(eventId).not.toBeNull()
const uploads = api.callsTo(MEDIA_UPLOAD)
expect(uploads).toHaveLength(1)
expect(uploads[0].url).toBe(`/events/${eventId}/media`)
expect(uploads[0].options.body.get('collection')).toBe('voice_note')
// Once the server has answered, the local item is the server's item.
expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['up1'])
expect(eventsStore.ghostVoiceNotes[0].pending).toBeUndefined()
const event = eventsStore.events.find(entry => entry.id === eventId)
expect(event.media.filter(item => item.collection === 'voice_note').map(item => item.id)).toEqual(['up1'])
})
it('adds a second voice note and keeps the first', async () => {
eventsStore.openPanel('e1')
await flush(3)
await eventsStore.uploadGhostVoiceNote(new File(['a'], 'neu.webm', { type: 'audio/webm' }))
await waitFor(() => eventsStore.ghostVoiceNotes.some(note => note.id === 'up1'), { label: 'upload applied' })
expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['v1', 'up1'])
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.media.filter(item => item.collection === 'voice_note').map(item => item.id)).toEqual(['v1', 'up1'])
expect(api.callsTo(MEDIA_DELETE)).toHaveLength(0)
})
it('saves a caption for one voice note, debounced, and keeps it on the event', async () => {
eventsStore.openPanel('e1')
await flush(3)
eventsStore.setGhostVoiceNoteCaption('v1', 'Nachtrag: die')
eventsStore.setGhostVoiceNoteCaption('v1', 'Nachtrag: die Adresse')
await flush(1)
expect(api.callsTo(MEDIA_PATCH)).toHaveLength(0)
await new Promise(resolve => setTimeout(resolve, 700))
await flush(2)
const patches = api.callsTo(MEDIA_PATCH)
expect(patches).toHaveLength(1)
expect(patches[0].url).toBe('/events/e1/media/v1')
expect(patches[0].options.body).toEqual({ caption: 'Nachtrag: die Adresse' })
expect(eventsStore.ghostVoiceNotes[0].caption).toBe('Nachtrag: die Adresse')
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.media.find(item => item.id === 'v1').caption).toBe('Nachtrag: die Adresse')
})
it('flushes a pending caption when the panel closes', async () => {
eventsStore.openPanel('e1')
await flush(3)
eventsStore.setGhostVoiceNoteCaption('v1', 'schnell getippt')
eventsStore.closePanel()
await flush(2)
const patches = api.callsTo(MEDIA_PATCH)
expect(patches).toHaveLength(1)
expect(patches[0].options.body.caption).toBe('schnell getippt')
})
it('deletes the voice note on the server and on the event', async () => {
eventsStore.openPanel('e1')
await flush(3)
await eventsStore.deleteGhostVoiceNote('v1')
await flush(2)
const deletes = api.callsTo(MEDIA_DELETE)
expect(deletes).toHaveLength(1)
expect(deletes[0].url).toBe('/events/e1/media/v1')
expect(eventsStore.ghostVoiceNotes).toEqual([])
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.media.some(item => item.collection === 'voice_note')).toBe(false)
// The rest stays.
expect(event.media.map(item => item.id).sort()).toEqual(['g1', 'g2', 'k1'])
})
it('does not autosave just because a stored voice note was loaded', async () => {
const before = eventsStore.events.find(entry => entry.id === 'e1').updatedAt
eventsStore.openPanel('e1')
await settle()
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(0)
expect(eventsStore.events.find(entry => entry.id === 'e1').updatedAt).toBe(before)
})
})
describe('events store — local media for the demo user', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
await db.eventMedia.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'local-demo'
await flush()
})
afterEach(async () => {
eventsStore.closePanel()
await flush(2)
// A queue still sending when the test ends would hit the next test's
// API mock; let it finish first.
await drainQueue(eventsStore)
})
it('a recorded voice note counts as the first entry and survives reopening', async () => {
eventsStore.openPanel()
await flush(2)
expect(eventsStore.events).toHaveLength(0)
eventsStore.addGhostVoiceNote({
id: 'local-voice',
name: 'sprachnotiz.webm',
mimeType: 'audio/webm',
// fake-indexeddb cannot clone a real Blob; a plain stand-in exercises
// the same storage path.
blob: { bytes: 'audio' },
durationMs: 2500
})
eventsStore.setGhostVoiceNoteCaption('local-voice', 'lokal beschriftet')
await settle()
expect(eventsStore.events).toHaveLength(1)
const eventId = eventsStore.events[0].id
// The event row keeps metadata only; the blob lives in the media table.
expect(eventsStore.events[0].media).toEqual([
expect.objectContaining({ id: 'local-voice', collection: 'voice_note', type: 'audio', durationMs: 2500, caption: 'lokal beschriftet' })
])
expect(eventsStore.events[0].media[0]).not.toHaveProperty('blob')
const { db } = await import('src/db')
const stored = await db.eventMedia.get('local-voice')
expect(stored).toBeDefined()
expect(stored.eventId).toBe(eventId)
expect(api.callsTo(() => true)).toHaveLength(0)
eventsStore.closePanel()
await flush(2)
eventsStore.openPanel(eventId)
await flush(4)
expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['local-voice'])
expect(eventsStore.ghostVoiceNotes[0].caption).toBe('lokal beschriftet')
expect(eventsStore.ghostVoiceNotes[0].blob).toEqual({ bytes: 'audio' })
expect(eventsStore.ghostMedia).toEqual([])
})
it('keeps a local video in the gallery list with its type', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostMedia = [{
id: 'local-video',
type: 'video',
collection: 'gallery',
name: 'clip.mp4',
mimeType: 'video/mp4',
blob: { bytes: 'video' },
durationMs: 8000,
createdAt: 1
}]
await settle()
const event = eventsStore.events[0]
expect(event.media).toEqual([
expect.objectContaining({ id: 'local-video', type: 'video', collection: 'gallery', durationMs: 8000 })
])
eventsStore.closePanel()
await flush(2)
eventsStore.openPanel(event.id)
await flush(4)
expect(eventsStore.ghostMedia).toHaveLength(1)
expect(eventsStore.ghostMedia[0].type).toBe('video')
expect(eventsStore.ghostVoiceNotes).toEqual([])
})
})

View file

@ -0,0 +1,307 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const EVENTS_LIST = /^\/events\?/
const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST'
const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT'
const EVENT_DELETE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'DELETE'
const MEDIA_UPLOAD = (url, options) => /^\/events\/[^/]+\/media$/.test(url) && options?.method === 'POST'
const MEDIA_PATCH = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'PATCH'
const MEDIA_DELETE = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'DELETE'
// What fetch throws when there is no network: an error without a status.
const networkDown = () => Promise.reject(new TypeError('Failed to fetch'))
// Past the 300 ms autosave debounce.
async function settle() {
await flush(2)
await new Promise(resolve => setTimeout(resolve, 400))
await flush(2)
}
/**
* A server that can be switched off. While down, every write fails like a
* lost connection; the reads keep working so the start-up load succeeds.
*/
function serverWithSwitch() {
const state = { down: false, uploads: 0 }
const guard = respond => (url, options) => (state.down ? networkDown() : respond(url, options))
api.always(EVENTS_CREATE, guard((url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) })))
api.always(EVENT_UPDATE, guard((url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
})))
api.always(EVENT_DELETE, guard(() => Promise.resolve(null)))
api.always(MEDIA_UPLOAD, guard((url, options) => Promise.resolve({
data: {
id: `up${++state.uploads}`,
uuid: `uuid-${state.uploads}`,
type: options.body.get('collection') === 'voice_note' ? 'audio' : 'image',
collection: options.body.get('collection'),
name: options.body.get('file')?.name,
caption: options.body.get('caption') ?? '',
mimeType: 'audio/mp4',
durationMs: 1500,
src: null,
thumbnailUrl: null,
previewUrl: null,
originalUrl: `/event-media/up${state.uploads}/original`,
createdAt: 5
}
})))
api.always(MEDIA_PATCH, guard((url, options) => Promise.resolve({
data: { id: url.split('/').pop(), caption: options.body.caption }
})))
api.always(MEDIA_DELETE, guard(() => Promise.resolve(null)))
return state
}
describe('events store — changes made without a connection are kept and sent later', () => {
let eventsStore
let server
let db
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
db = (await import('src/db')).db
await db.events.clear()
await db.eventMedia.clear()
await db.syncQueue.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden', note: '' })]))
server = serverWithSwitch()
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
afterEach(async () => {
eventsStore.closePanel()
// A queue still sending when the test ends would hit the next test's
// API mock; let it finish first.
server.down = false
await drainQueue(eventsStore)
})
it('keeps an edit made while the server is unreachable and sends it once it is back', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'im Zug geändert'
await settle()
await waitFor(() => eventsStore.saveState === 'pending' && eventsStore.pendingSyncCount === 1)
// Stored locally, flagged as waiting, not as failed.
expect(eventsStore.saveState).toBe('pending')
expect(eventsStore.lastError).toBeNull()
expect(eventsStore.pendingSyncCount).toBe(1)
expect(eventsStore.events.find(event => event.id === 'e1').title).toBe('im Zug geändert')
expect((await db.events.get('e1')).title).toBe('im Zug geändert')
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(1)
server.down = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0 && eventsStore.saveState === 'saved')
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(2)
expect(api.callsTo(EVENT_UPDATE)[1].options.body.title).toBe('im Zug geändert')
expect(eventsStore.pendingSyncCount).toBe(0)
expect(eventsStore.saveState).toBe('saved')
expect(eventsStore.events.find(event => event.id === 'e1').syncStatus).toBe('synced')
})
it('collapses several offline edits of one event into a single request', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'eins'
await settle()
eventsStore.ghostTitle = 'zwei'
await settle()
eventsStore.ghostNote = 'und eine Notiz'
await settle()
await waitFor(() => eventsStore.pendingSyncCount === 1)
server.down = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0)
const sent = api.callsTo(EVENT_UPDATE).filter(call => !call.failed)
const last = sent[sent.length - 1].options.body
expect(last.title).toBe('zwei')
expect(last.note).toBe('und eine Notiz')
expect(eventsStore.pendingSyncCount).toBe(0)
})
it('never sends an event that was created and deleted again while offline', async () => {
server.down = true
eventsStore.openPanel()
await flush(2)
eventsStore.ghostTitle = 'kurzlebig'
await settle()
const id = eventsStore.editingEventId
expect(id).not.toBeNull()
await waitFor(() => eventsStore.pendingSyncCount === 1 && api.callsTo(EVENTS_CREATE).length === 1)
eventsStore.deleteEvent(id)
await waitFor(() => eventsStore.pendingSyncCount === 0)
expect(eventsStore.events.find(event => event.id === id)).toBeUndefined()
server.down = false
await eventsStore.syncNow()
await flush()
// The failed attempt while offline is the only one there ever was.
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(1)
expect(api.callsTo(EVENT_DELETE)).toHaveLength(0)
})
it('keeps a recording made offline, uploads it later with the caption typed meanwhile', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
const file = new File(['audio'], 'sprachnotiz.webm', { type: 'audio/webm' })
const note = await eventsStore.uploadGhostVoiceNote(file, { durationMs: 2100 })
await waitFor(() => eventsStore.pendingSyncCount === 1)
expect(note.pending).toBe(true)
expect(eventsStore.ghostVoiceNotes.map(entry => entry.id)).toEqual([note.id])
// The recording itself is in the queue, so it survives a reload.
const rows = await db.syncQueue.toArray()
expect(rows.map(row => row.action)).toEqual(['upload_media'])
expect(rows[0].payload.bytes).toBeInstanceOf(ArrayBuffer)
eventsStore.setGhostVoiceNoteCaption(note.id, 'ohne Netz beschriftet')
await new Promise(resolve => setTimeout(resolve, 700))
await flush()
// The caption went into the pending upload, not into a request of its own.
expect(api.callsTo(MEDIA_PATCH)).toHaveLength(0)
expect((await db.syncQueue.toArray())[0].payload.caption).toBe('ohne Netz beschriftet')
server.down = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0 && eventsStore.ghostVoiceNotes.some(entry => entry.id === 'up1'))
const uploads = api.callsTo(MEDIA_UPLOAD)
const sent = uploads[uploads.length - 1].options.body
expect(sent.get('collection')).toBe('voice_note')
expect(sent.get('caption')).toBe('ohne Netz beschriftet')
expect(sent.get('file')?.name).toBe('sprachnotiz.webm')
// The local item became the server's item, caption included.
expect(eventsStore.ghostVoiceNotes.map(entry => entry.id)).toEqual(['up1'])
expect(eventsStore.ghostVoiceNotes[0].caption).toBe('ohne Netz beschriftet')
expect(eventsStore.pendingSyncCount).toBe(0)
expect(await db.eventMedia.get(note.id)).toBeUndefined()
expect((await db.eventMedia.get('up1'))?.eventId).toBe('e1')
})
it('drops a queued upload when the file is removed before it was sent', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
const [item] = await eventsStore.uploadGhostMedia([{ file: new File(['img'], 'foto.jpg', { type: 'image/jpeg' }) }])
await waitFor(() => eventsStore.pendingSyncCount === 1 && api.callsTo(MEDIA_UPLOAD).length === 1)
await eventsStore.deleteGhostMedia(item.id)
await waitFor(() => eventsStore.pendingSyncCount === 0)
// Only the attempt that failed while the server was down was ever made.
const attemptsWhileDown = api.callsTo(MEDIA_UPLOAD).length
server.down = false
await eventsStore.syncNow()
await flush()
expect(api.callsTo(MEDIA_UPLOAD)).toHaveLength(attemptsWhileDown)
expect(api.callsTo(MEDIA_DELETE)).toHaveLength(0)
})
it('does not lose queued changes when the app starts with a working connection', async () => {
// Left behind by an earlier, offline session: the edit in the cache and
// its write in the queue.
await db.events.put({ ...remoteEvent({ id: 'e2', title: 'offline bearbeitet', note: 'lokal' }), userId: 'remote-user-2', syncStatus: 'pending' })
await db.syncQueue.add({
userId: 'remote-user-2',
eventId: 'e2',
action: 'update',
payload: { id: 'e2', title: 'offline bearbeitet', note: 'lokal', date: '2025-01-01', emotion: 0 },
createdAt: 1,
attempts: 1
})
setActivePinia(createPinia())
api = createApiRouter()
// The list reflects what the server holds at the time it is asked, so
// an update that arrives first shows up in it.
const serverCopy = remoteEvent({ id: 'e2', title: 'Serverstand', note: '' })
api.always(EVENT_UPDATE, (url, options) => {
Object.assign(serverCopy, options.body ?? {})
return Promise.resolve({ data: { ...serverCopy } })
})
api.once(EVENTS_LIST, () => Promise.resolve(page([{ ...serverCopy }])))
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
const authStore = useAuthStore()
const store = useEventsStore()
authStore.currentUserId = 'remote-user-2'
// isLoaded is already true from the userless start; wait for the load
// that belongs to this user.
await waitFor(() => api.callsTo(EVENTS_LIST).length === 1 && store.isLoaded && store.pendingSyncCount === 0)
// The queue went out before the list was read, so the server already
// knows the edit and the timeline shows it.
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(1)
expect(api.callsTo(EVENT_UPDATE)[0].options.body.title).toBe('offline bearbeitet')
expect(store.pendingSyncCount).toBe(0)
expect(store.events).toHaveLength(1)
expect(store.events[0].title).toBe('offline bearbeitet')
})
it('a rejected edit is reported as an error and leaves the queue', async () => {
api = createApiRouter()
api.always(EVENT_UPDATE, () => Promise.reject(Object.assign(new Error('unprocessable'), { status: 422 })))
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'abgelehnt'
await settle()
expect(eventsStore.saveState).toBe('error')
expect(eventsStore.lastError).toBeTruthy()
expect(eventsStore.pendingSyncCount).toBe(0)
expect(eventsStore.events.find(event => event.id === 'e1').syncStatus).toBe('error')
})
})

View file

@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, deferred, flush, page, remoteEvent } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const EVENTS_LIST = /^\/events\?/
describe('events store — a response belongs to the user that asked for it (A04)', () => {
let useEventsStore
let useAuthStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
useEventsStore = (await import('stores/events')).useEventsStore
useAuthStore = (await import('stores/auth')).useAuthStore
})
it('discards a late response from the previous user', async () => {
const authStore = useAuthStore()
const eventsStore = useEventsStore()
const loadA = deferred()
const loadB = deferred()
api.once(EVENTS_LIST, () => loadA.promise)
api.once(EVENTS_LIST, () => loadB.promise)
// User A starts loading.
authStore.currentUserId = 'remote-user-1'
await flush()
// Switch to B while A's request is still open.
authStore.currentUserId = 'remote-user-2'
await flush()
// B answers first; A's stale response arrives afterwards.
loadB.resolve(page([remoteEvent({ id: 'b1', title: 'Event von B' })]))
await flush()
loadA.resolve(page([remoteEvent({ id: 'a1', title: 'privat A' })]))
await flush()
expect(eventsStore.events.map(event => event.id)).toEqual(['b1'])
expect(eventsStore.events.every(event => event.userId === 'remote-user-2')).toBe(true)
})
it('does not write the previous user\'s events into the new user\'s cache', async () => {
const authStore = useAuthStore()
useEventsStore()
const { db } = await import('src/db')
const loadA = deferred()
api.once(EVENTS_LIST, () => loadA.promise)
api.once(EVENTS_LIST, () => Promise.resolve(page([remoteEvent({ id: 'b1' })])))
authStore.currentUserId = 'remote-user-1'
await flush()
authStore.currentUserId = 'remote-user-2'
await flush()
loadA.resolve(page([remoteEvent({ id: 'a1', title: 'privat A' })]))
await flush()
const cached = await db.events.toArray()
expect(cached.map(event => event.id)).toEqual(['b1'])
expect(cached.every(event => event.userId === 'remote-user-2')).toBe(true)
})
it('stamps loaded events with the user the request was started for', async () => {
const authStore = useAuthStore()
const eventsStore = useEventsStore()
api.once(EVENTS_LIST, page([remoteEvent({ id: 'a1' })]))
authStore.currentUserId = 'remote-user-1'
await flush()
expect(eventsStore.events[0].userId).toBe('remote-user-1')
})
it('keeps the timeline empty for the new user until their own data arrives', async () => {
const authStore = useAuthStore()
const eventsStore = useEventsStore()
api.once(EVENTS_LIST, page([remoteEvent({ id: 'a1', title: 'privat A' })]))
authStore.currentUserId = 'remote-user-1'
await flush()
expect(eventsStore.events).toHaveLength(1)
const loadB = deferred()
api.once(EVENTS_LIST, () => loadB.promise)
authStore.currentUserId = 'remote-user-2'
await flush()
// A's data must be gone the moment the user switches, not only once B
// has answered.
expect(eventsStore.events).toEqual([])
loadB.resolve(page([]))
await flush()
expect(eventsStore.events).toEqual([])
})
})
describe('events store — the API payload carries every edited field (A05)', () => {
let useEventsStore
let useAuthStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
useEventsStore = (await import('stores/events')).useEventsStore
useAuthStore = (await import('stores/auth')).useAuthStore
})
it('sends location and keyImageTitle when creating an event', async () => {
const authStore = useAuthStore()
const eventsStore = useEventsStore()
api.once(EVENTS_LIST, page([]))
api.always('/events', () => Promise.resolve({ data: remoteEvent() }))
authStore.currentUserId = 'remote-user-1'
await flush()
const created = eventsStore.createEvent()
await flush()
const post = api.callsTo('/events').find(call => call.options?.method === 'POST')
expect(post).toBeDefined()
expect(post.options.body.id).toBe(created.id)
expect(post.options.body).toHaveProperty('location')
expect(post.options.body).toHaveProperty('keyImageTitle')
})
it('sends the edited location and key image title on update', async () => {
const authStore = useAuthStore()
const eventsStore = useEventsStore()
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Original' })]))
api.always(/^\/events\/e1$/, () => Promise.resolve({ data: remoteEvent({ id: 'e1' }) }))
authStore.currentUserId = 'remote-user-1'
await flush()
eventsStore.openPanel('e1')
await flush()
eventsStore.ghostLocation = 'Zugspitze'
eventsStore.ghostKeyImageTitle = 'Gipfelfoto'
await flush()
eventsStore.saveGhostNow()
await flush()
const put = api.callsTo(/^\/events\/e1$/).find(call => call.options?.method === 'PUT')
expect(put).toBeDefined()
expect(put.options.body.location).toBe('Zugspitze')
expect(put.options.body.keyImageTitle).toBe('Gipfelfoto')
})
})

Some files were not shown because too many files have changed in this diff Show more