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>
|
|
@ -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' ],
|
||||
languageOptions: {
|
||||
|
|
|
|||
5165
frontend/package-lock.json
generated
|
|
@ -7,18 +7,24 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"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",
|
||||
"test": "echo \"No test specified\" && exit 0",
|
||||
"test": "vitest run",
|
||||
"dev": "npm install && quasar dev",
|
||||
"build": "quasar build",
|
||||
"postinstall": "quasar prepare"
|
||||
"dev:pwa": "npm install && quasar dev -m pwa",
|
||||
"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": {
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"dexie": "^4.3.0",
|
||||
"pinia": "^3.0.1",
|
||||
"quasar": "^2.16.0",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"three": "^0.183.0",
|
||||
"vue": "^3.4.18",
|
||||
"vue-router": "^4.0.0",
|
||||
|
|
@ -32,18 +38,30 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@quasar/app-vite": "^2.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^15.12.0",
|
||||
"happy-dom": "^20.14.5",
|
||||
"postcss": "^8.4.14",
|
||||
"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": {
|
||||
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
frontend/public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
frontend/public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1,009 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
frontend/public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
5
frontend/public/icons/safari-pinned-tab.svg
Normal 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 |
|
Before Width: | Height: | Size: 7.8 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 64 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-1.jpg
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-10.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-2.jpg
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-3.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-4.jpg
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-5.jpg
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-6.jpg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-7.jpg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-8.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/images/bg-thumbs/bg-image-9.jpg
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
|
|
@ -81,6 +81,20 @@ export default defineConfig((ctx) => {
|
|||
// https: true,
|
||||
open: true, // opens browser window automatically
|
||||
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
|
||||
|
|
@ -98,7 +112,9 @@ export default defineConfig((ctx) => {
|
|||
// directives: [],
|
||||
|
||||
// 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: {
|
||||
|
|
@ -148,15 +164,37 @@ export default defineConfig((ctx) => {
|
|||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
|
||||
pwa: {
|
||||
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
|
||||
// swFilename: 'sw.js',
|
||||
// manifestFilename: 'manifest.json',
|
||||
// extendManifestJson (json) {},
|
||||
// useCredentialsForManifestTag: true,
|
||||
// injectPwaMetaTags: false,
|
||||
// extendPWACustomSWConf (esbuildConf) {},
|
||||
// extendGenerateSWOptions (cfg) {},
|
||||
// extendInjectManifestOptions (cfg) {}
|
||||
workboxMode: 'GenerateSW',
|
||||
// What the service worker keeps for offline use is the app shell only.
|
||||
// The API is never cached (the sync queue owns offline writes, the
|
||||
// stores own offline reads), and the large bundled photos are fetched
|
||||
// on first use rather than precached on install.
|
||||
extendGenerateSWOptions (cfg) {
|
||||
cfg.skipWaiting = true
|
||||
cfg.clientsClaim = true
|
||||
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
|
||||
|
|
|
|||
17
frontend/src-pwa/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
22
frontend/src-pwa/register-service-worker.js
Normal 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)
|
||||
}
|
||||
})
|
||||
|
|
@ -3,5 +3,27 @@
|
|||
</template>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
107
frontend/src/assets/logo-thatsme-negativ.svg
Normal 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 |
105
frontend/src/assets/logo-thatsme-positiv.svg
Normal 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 |
|
|
@ -1,33 +1,42 @@
|
|||
<template>
|
||||
<button class="add-event-btn glass--button" @click="$emit('click')">
|
||||
<q-icon name="add" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
<button class="add-event-btn" aria-label="Neues Event anlegen" @click="$emit('click')">
|
||||
<q-icon name="add" size="26px" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<ModalCard
|
||||
:open="open"
|
||||
title="Allgemein"
|
||||
title="Einstellungen"
|
||||
:tabs="tabs"
|
||||
v-model="activeTab"
|
||||
@close="$emit('close')"
|
||||
|
|
@ -110,61 +110,46 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entwickler -->
|
||||
<div v-else-if="activeTab === 'developer'">
|
||||
<div class="settings-section__title">Entwickler</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Sprache -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Sprache</span>
|
||||
<div class="settings-row__control">
|
||||
<select
|
||||
: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 class="settings-row__label">FPS-Anzeige</div>
|
||||
<div class="settings-row__hint">Bildrate der LifeWave oben rechts einblenden.</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">
|
||||
<q-toggle
|
||||
:model-value="settingsStore.showFps"
|
||||
@update:model-value="v => { settingsStore.showFps = v }"
|
||||
dense
|
||||
color="primary"
|
||||
aria-label="FPS-Anzeige"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benachrichtigungen (placeholder) -->
|
||||
<div v-else-if="activeTab === 'notifications'">
|
||||
<div class="settings-section__title">Benachrichtigungen</div>
|
||||
<div class="settings-section__divider" />
|
||||
<p class="settings-placeholder">Kommt bald.</p>
|
||||
</div>
|
||||
|
||||
<!-- Personalisierung (placeholder) -->
|
||||
<div v-else-if="activeTab === 'personalize'">
|
||||
<div class="settings-section__title">Personalisierung</div>
|
||||
<div class="settings-section__divider" />
|
||||
<p class="settings-placeholder">Kommt bald.</p>
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-row__label">Version</div>
|
||||
<div class="settings-row__hint">{{ APP_VERSION }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
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 } })
|
||||
defineEmits(['close'])
|
||||
|
|
@ -178,8 +163,7 @@ const accentDropdownOpen = ref(false)
|
|||
|
||||
const tabs = [
|
||||
{ value: 'general', label: 'Allgemein', icon: 'settings' },
|
||||
{ value: 'notifications', label: 'Benachrichtigungen', icon: 'notifications' },
|
||||
{ value: 'personalize', label: 'Personalisierung', icon: 'history' }
|
||||
{ value: 'developer', label: 'Entwickler', icon: 'code' }
|
||||
]
|
||||
|
||||
const currentAccentHex = computed(() => {
|
||||
|
|
@ -189,7 +173,7 @@ const currentAccentHex = computed(() => {
|
|||
|
||||
const currentAccentLabel = computed(() => {
|
||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||
return found?.label ?? 'Base'
|
||||
return found?.label ?? 'Grau'
|
||||
})
|
||||
|
||||
const emotionGradientCss = computed(() =>
|
||||
|
|
@ -201,23 +185,10 @@ function selectAccent(value) {
|
|||
accentDropdownOpen.value = false
|
||||
}
|
||||
|
||||
// Applying the mode to Quasar happens once, globally, in App.vue.
|
||||
function onAppearanceChange(e) {
|
||||
const value = e.target.value
|
||||
settingsStore.appearance = value
|
||||
applyAppearance(value)
|
||||
settingsStore.appearance = e.target.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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -254,6 +225,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.settings-row__hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Native select styled */
|
||||
.settings-select {
|
||||
appearance: none;
|
||||
|
|
@ -392,12 +369,6 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
|||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Placeholder text */
|
||||
.settings-placeholder {
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
|
|
|
|||
56
frontend/src/components/BrandLogo.vue
Normal 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>
|
||||
70
frontend/src/components/ConfirmDialog.vue
Normal 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>
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
'glow-dot--ghost': isGhost,
|
||||
'glow-dot--selected': selected,
|
||||
'glow-dot--dimmed': isDimmed,
|
||||
'glow-dot--milestone': Boolean(event.milestone) && !isGhost,
|
||||
'glow-dot--label-above': labelAbove
|
||||
}"
|
||||
:style="dotStyle"
|
||||
|
|
@ -23,10 +24,11 @@
|
|||
alt=""
|
||||
/>
|
||||
</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__title" :style="titleStyle">{{ event.title }}</span>
|
||||
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
|
||||
<span class="glow-dot__title" :style="titleStyle">{{ labelTitle }}</span>
|
||||
<span v-if="hasTitle" class="glow-dot__date" :style="dateStyle">{{ formattedSpan }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -36,6 +38,8 @@ import { computed } from 'vue'
|
|||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { useImageCache } from 'composables/useImageCache'
|
||||
import { emotionToYPercent, isLowerHalf } from 'src/utils/timelinePosition'
|
||||
import { findEventCategory } from 'src/config/eventCategories'
|
||||
|
||||
const props = defineProps({
|
||||
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 dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
|
||||
|
||||
// Y position: emotion +1 → top (20%), 0 → middle (47%), -1 → bottom (74%)
|
||||
|
||||
const yPercent = computed(() => 48 - props.event.emotion * 30)
|
||||
// Y position from emotion; shared with the shader points, see utils/timelinePosition
|
||||
const yPercent = computed(() => emotionToYPercent(props.event.emotion))
|
||||
|
||||
// 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"
|
||||
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)
|
||||
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(() => {
|
||||
if (props.isGhost) return undefined
|
||||
const title = props.event.title || 'Event'
|
||||
return `${title}, ${formattedDate.value}`
|
||||
const title = props.event.title || findEventCategory(props.event.category)?.label || 'Event'
|
||||
const milestone = props.event.milestone ? ', Meilenstein' : ''
|
||||
return `${title}, ${formattedSpan.value}${milestone}`
|
||||
})
|
||||
|
||||
// Label font sizes per setting
|
||||
|
|
@ -187,8 +208,16 @@ function onSelect() {
|
|||
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 {
|
||||
opacity: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Event label (title + date) */
|
||||
|
|
|
|||
164
frontend/src/components/TimelineNav.vue
Normal 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>
|
||||
|
|
@ -20,52 +20,8 @@
|
|||
:selected="eventsStore.selectedEventId === 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>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
|
|
@ -74,6 +30,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|||
import { useEventsStore } from 'stores/events'
|
||||
import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings'
|
||||
import GlowDot from 'components/GlowDot.vue'
|
||||
import { calendarMonthIndex, calendarYear, compareCalendarDates } from 'src/utils/calendarDate'
|
||||
|
||||
const emit = defineEmits(['dotSelect', 'viewUpdate'])
|
||||
const eventsStore = useEventsStore()
|
||||
|
|
@ -100,8 +57,7 @@ const displayEvents = computed(() => {
|
|||
const sorted = [...eventsStore.sortedEvents]
|
||||
if (!showGhost.value) return sorted
|
||||
const ghost = eventsStore.ghostEvent
|
||||
const ghostDate = new Date(ghost.date)
|
||||
const insertIdx = sorted.findIndex((e) => new Date(e.date) > ghostDate)
|
||||
const insertIdx = sorted.findIndex((e) => compareCalendarDates(e.date, ghost.date) > 0)
|
||||
if (insertIdx === -1) {
|
||||
sorted.push(ghost)
|
||||
} else {
|
||||
|
|
@ -122,21 +78,7 @@ function getEventX(index) {
|
|||
return PADDING.value + index * EVENT_SPACING.value
|
||||
}
|
||||
|
||||
// Month names
|
||||
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()}`
|
||||
}
|
||||
})
|
||||
})
|
||||
const MONTHS_LONG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
|
||||
// Year ranges — for each year, the world-space X range of its events
|
||||
const yearRanges = computed(() => {
|
||||
|
|
@ -144,11 +86,11 @@ const yearRanges = computed(() => {
|
|||
const events = displayEvents.value
|
||||
if (events.length === 0) return ranges
|
||||
|
||||
let currentYear = new Date(events[0].date).getFullYear()
|
||||
let currentYear = calendarYear(events[0].date)
|
||||
let startIdx = 0
|
||||
|
||||
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) {
|
||||
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
|
||||
currentYear = year
|
||||
|
|
@ -159,57 +101,9 @@ const yearRanges = computed(() => {
|
|||
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))
|
||||
|
||||
// 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
|
||||
const VIS_BUFFER = 2
|
||||
const VIEW_EMIT_BUFFER = 3
|
||||
|
|
@ -238,25 +132,27 @@ const visibleEvents = computed(() => {
|
|||
}))
|
||||
})
|
||||
|
||||
const visibleLabels = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
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(() => {
|
||||
// The event closest to the viewport centre (O(1) via index)
|
||||
const activeIndex = computed(() => {
|
||||
const total = displayEvents.value.length
|
||||
if (total === 0) return null
|
||||
if (total === 0) return -1
|
||||
const centerX = scrollLeft.value + viewportWidth.value / 2
|
||||
const spacing = EVENT_SPACING.value
|
||||
if (spacing <= 0) return null
|
||||
if (spacing <= 0) return 0
|
||||
const index = Math.round((centerX - PADDING.value) / spacing)
|
||||
const clamped = Math.max(0, Math.min(total - 1, index))
|
||||
return eventLabels.value[clamped]?.key ?? null
|
||||
return Math.max(0, Math.min(total - 1, index))
|
||||
})
|
||||
|
||||
// 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() {
|
||||
|
|
@ -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) {
|
||||
if (!timelineRef.value) return
|
||||
// Find exact year, or nearest in the requested direction
|
||||
|
|
@ -632,7 +519,19 @@ function zoomTo(value) {
|
|||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -641,7 +540,8 @@ defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZO
|
|||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 60px;
|
||||
/* Leaves room for the bottom bar only; the label rows are gone. */
|
||||
bottom: 72px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
|
|
@ -662,91 +562,4 @@ defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZO
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -14,70 +14,28 @@
|
|||
|
||||
<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">
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'upgrade')">
|
||||
<q-icon name="auto_awesome" size="20px" />
|
||||
<span>Plan upgraden</span>
|
||||
</button>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'personalize')">
|
||||
<q-icon name="palette" size="20px" />
|
||||
<span>Personalisierung</span>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'appearance')">
|
||||
<q-icon name="tune" size="20px" />
|
||||
<span>Darstellung</span>
|
||||
</button>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'settings')">
|
||||
<q-icon name="settings" size="20px" />
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<!-- Help with submenu -->
|
||||
<div class="user-menu__items">
|
||||
<button
|
||||
class="user-menu__item"
|
||||
:class="{ 'user-menu__item--active': helpOpen }"
|
||||
@click="helpOpen = !helpOpen"
|
||||
role="switch"
|
||||
:aria-checked="isDark"
|
||||
@click="setDarkMode(!isDark)"
|
||||
>
|
||||
<q-icon name="support_agent" size="20px" />
|
||||
<span>Hilfe</span>
|
||||
<q-icon
|
||||
name="chevron_right"
|
||||
size="18px"
|
||||
class="user-menu__chevron"
|
||||
:class="{ 'user-menu__chevron--open': helpOpen }"
|
||||
/>
|
||||
<q-icon :name="isDark ? 'dark_mode' : 'light_mode'" size="20px" />
|
||||
<span>Dunkelmodus</span>
|
||||
<!-- Visual only: the whole row is the switch -->
|
||||
<q-toggle :model-value="isDark" dense class="user-menu__toggle" tabindex="-1" />
|
||||
</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 class="user-menu__divider" />
|
||||
|
|
@ -96,7 +54,7 @@
|
|||
</div>
|
||||
<div class="user-menu__info">
|
||||
<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>
|
||||
|
|
@ -104,15 +62,28 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { APP_VERSION } from 'src/config/appVersion'
|
||||
|
||||
defineProps({ open: { type: Boolean, default: false } })
|
||||
defineEmits(['close', 'navigate'])
|
||||
|
||||
const $q = useQuasar()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -145,13 +116,13 @@ const helpOpen = ref(false)
|
|||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e05a33;
|
||||
background: var(--tm-accent, #f5c26b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: var(--tm-ink, #292a2b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -230,18 +201,9 @@ const helpOpen = ref(false)
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__chevron {
|
||||
.user-menu__toggle {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Help sub-items */
|
||||
.user-menu__sub {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
|
@ -265,17 +227,4 @@ const helpOpen = ref(false)
|
|||
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>
|
||||
|
|
|
|||
473
frontend/src/components/VoiceNotesCard.vue
Normal 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>
|
||||
105
frontend/src/components/VoiceWaveform.vue
Normal 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>
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
<template>
|
||||
<div class="zoom-control glass--button">
|
||||
<button class="zoom-control__btn" @click="$emit('zoomIn')">
|
||||
<q-icon name="add" size="18px" />
|
||||
<button type="button" class="zoom-control__btn" aria-label="Vergrößern" @click="$emit('zoomIn')">
|
||||
<q-icon name="add" size="20px" />
|
||||
</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__thumb" :style="{ bottom: fillPercent + '%' }" />
|
||||
</div>
|
||||
<button class="zoom-control__btn" @click="$emit('zoomOut')">
|
||||
<q-icon name="remove" size="18px" />
|
||||
<button type="button" class="zoom-control__btn" aria-label="Verkleinern" @click="$emit('zoomOut')">
|
||||
<q-icon name="remove" size="20px" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -57,18 +67,19 @@ function updateFromPointer(e) {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 22px;
|
||||
width: 36px;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 24px;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
/* 40px targets: about the minimum a thumb hits reliably */
|
||||
.zoom-control__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
|
|
@ -84,19 +95,31 @@ function updateFromPointer(e) {
|
|||
|
||||
.zoom-control__track {
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
width: 28px;
|
||||
height: 96px;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
left: 50%;
|
||||
width: 3px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
pointer-events: none;
|
||||
|
|
@ -105,8 +128,8 @@ function updateFromPointer(e) {
|
|||
.zoom-control__thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: translate(-50%, 50%);
|
||||
|
|
@ -115,7 +138,7 @@ function updateFromPointer(e) {
|
|||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
.body--light .zoom-control__track {
|
||||
.body--light .zoom-control__track::before {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,55 @@
|
|||
import { ref, unref, watch } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
import { apiFetch } from 'src/services/apiClient'
|
||||
import { API_BASE, apiFetch } from 'src/services/apiClient'
|
||||
|
||||
const THUMB_SIZE = 200
|
||||
|
||||
// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation
|
||||
// Shared across all component instances
|
||||
// In-memory cache: avoids repeated IndexedDB reads and blob URL creation.
|
||||
// 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()
|
||||
|
||||
// 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) {
|
||||
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/')
|
||||
}
|
||||
|
|
@ -66,8 +108,11 @@ async function fetchAndCache(imageUrl, eventId) {
|
|||
const thumbBlob = await createThumbnail(blob)
|
||||
|
||||
// Store in IndexedDB
|
||||
await db.imageCache.put({
|
||||
const key = cacheKey(imageUrl)
|
||||
await db.scopedImageCache.put({
|
||||
key,
|
||||
url: imageUrl,
|
||||
userId: scopeUserId,
|
||||
eventId,
|
||||
type: 'thumbnail',
|
||||
blob: thumbBlob,
|
||||
|
|
@ -75,7 +120,7 @@ async function fetchAndCache(imageUrl, eventId) {
|
|||
})
|
||||
|
||||
const blobUrl = URL.createObjectURL(thumbBlob)
|
||||
memoryCache.set(imageUrl, blobUrl)
|
||||
memoryCache.set(key, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
|
||||
|
|
@ -84,14 +129,16 @@ async function fetchAndCache(imageUrl, eventId) {
|
|||
* Returns null if not cached.
|
||||
*/
|
||||
async function getCachedImage(imageUrl) {
|
||||
const key = cacheKey(imageUrl)
|
||||
|
||||
// Check memory first
|
||||
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl)
|
||||
if (memoryCache.has(key)) return memoryCache.get(key)
|
||||
|
||||
try {
|
||||
const entry = await db.imageCache.get(imageUrl)
|
||||
const entry = await db.scopedImageCache.get(key)
|
||||
if (entry?.blob) {
|
||||
const blobUrl = URL.createObjectURL(entry.blob)
|
||||
memoryCache.set(imageUrl, blobUrl)
|
||||
memoryCache.set(key, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -120,8 +167,9 @@ export function useImageCache(imageUrl, eventId) {
|
|||
}
|
||||
|
||||
// 1. Memory cache (instant)
|
||||
if (memoryCache.has(nextUrl)) {
|
||||
resolvedSrc.value = memoryCache.get(nextUrl)
|
||||
const key = cacheKey(nextUrl)
|
||||
if (memoryCache.has(key)) {
|
||||
resolvedSrc.value = memoryCache.get(key)
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
|
@ -167,13 +215,14 @@ export async function resolveFullRes(imageUrl) {
|
|||
if (!imageUrl) return null
|
||||
|
||||
if (isProtectedApiImageUrl(imageUrl)) {
|
||||
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl)
|
||||
const key = cacheKey(imageUrl)
|
||||
if (memoryCache.has(key)) return memoryCache.get(key)
|
||||
|
||||
try {
|
||||
const response = await apiFetch(imageUrl)
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
memoryCache.set(imageUrl, blobUrl)
|
||||
memoryCache.set(key, blobUrl)
|
||||
return blobUrl
|
||||
} catch (error) {
|
||||
console.warn('Full-res image load failed:', error)
|
||||
|
|
@ -193,14 +242,14 @@ export async function resolveFullRes(imageUrl) {
|
|||
*/
|
||||
export async function clearEventImages(eventId) {
|
||||
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) {
|
||||
if (memoryCache.has(entry.url)) {
|
||||
URL.revokeObjectURL(memoryCache.get(entry.url))
|
||||
memoryCache.delete(entry.url)
|
||||
if (memoryCache.has(entry.key)) {
|
||||
URL.revokeObjectURL(memoryCache.get(entry.key))
|
||||
memoryCache.delete(entry.key)
|
||||
}
|
||||
}
|
||||
await db.imageCache.where('eventId').equals(eventId).delete()
|
||||
await db.scopedImageCache.where('eventId').equals(eventId).delete()
|
||||
} catch (e) {
|
||||
console.warn('Clear event images failed:', e)
|
||||
}
|
||||
|
|
|
|||
45
frontend/src/composables/useStoreFeedback.js
Normal 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')
|
||||
}
|
||||
})
|
||||
}
|
||||
264
frontend/src/composables/useVoiceRecorder.js
Normal 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'
|
||||
}
|
||||
21
frontend/src/config/eventCategories.js
Normal 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
|
||||
}
|
||||
242
frontend/src/config/wavePresets.js
Normal 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(', ')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,21 @@
|
|||
:root {
|
||||
--tm-accent: #737373;
|
||||
--tm-accent-rgb: 115, 115, 115;
|
||||
--q-primary: #737373;
|
||||
--q-secondary: #737373;
|
||||
--q-accent: #737373;
|
||||
// Brand palette (from the logo): gold accent, ink for text on light
|
||||
// ground, paper for text on dark ground.
|
||||
--tm-gold: #f5c26b;
|
||||
--tm-gold-dark: #d59040;
|
||||
--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
|
||||
|
|
@ -25,16 +37,16 @@
|
|||
|
||||
// Glass panel style — strong blur for slide-up panels
|
||||
.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);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: #1a1a1a;
|
||||
color: var(--tm-ink);
|
||||
|
||||
.body--dark & {
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
color: #f5f5f5;
|
||||
background: rgba(var(--tm-ink-rgb), 0.74);
|
||||
border-top-color: rgba(var(--tm-paper-rgb), 0.1);
|
||||
color: var(--tm-paper);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,3 +31,17 @@ db.version(3).stores({
|
|||
eventMedia: 'id, eventId, userId, createdAt',
|
||||
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'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,12 +45,48 @@
|
|||
|
||||
<!-- Header -->
|
||||
<header class="lifewave-header">
|
||||
<span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span>
|
||||
<button class="lifewave-header__user glass--button" @click="userMenuOpen = !userMenuOpen">
|
||||
<BrandLogo class="lifewave-header__logo" :height="26" />
|
||||
<!-- 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'" />
|
||||
</button>
|
||||
</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 -->
|
||||
<div v-if="settingsStore.showFps" class="lifewave-fps">
|
||||
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS
|
||||
|
|
@ -62,29 +98,57 @@
|
|||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Zoom Control (Left Side) — hidden when panel is open -->
|
||||
<ZoomControl
|
||||
v-if="!eventsStore.panelOpen"
|
||||
class="lifewave-layout__zoom"
|
||||
:zoom="currentZoom"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
@zoom-in="timelineViewRef?.zoomIn()"
|
||||
@zoom-out="timelineViewRef?.zoomOut()"
|
||||
@zoom-to="onZoomTo"
|
||||
/>
|
||||
<!-- Bottom bar — hidden when panel is open.
|
||||
Left: everything about the surface (look, zoom). Centre: where am I.
|
||||
Right: the one primary action. -->
|
||||
<div v-if="!eventsStore.panelOpen" class="lifewave-bar">
|
||||
<div class="lifewave-bar__group">
|
||||
<button
|
||||
class="lifewave-bar__btn glass--button"
|
||||
aria-label="Darstellung der LifeWave anpassen"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<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 -->
|
||||
<button
|
||||
v-if="!eventsStore.panelOpen"
|
||||
class="lifewave-settings-btn glass--button"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
<TimelineNav
|
||||
class="lifewave-bar__nav"
|
||||
:year="activePosition?.year ?? null"
|
||||
:month="activePosition?.month ?? ''"
|
||||
:years="timelineYears"
|
||||
:can-prev="canGoPrevYear"
|
||||
:can-next="canGoNextYear"
|
||||
@prev="goToYear(activeYearIndex - 1)"
|
||||
@next="goToYear(activeYearIndex + 1)"
|
||||
@jump="year => timelineViewRef?.scrollToYearCenter(year)"
|
||||
/>
|
||||
|
||||
<!-- Add Event Button (Bottom-Center) — hidden when panel is open -->
|
||||
<AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" />
|
||||
<AddEventButton class="lifewave-bar__add" @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 -->
|
||||
<Transition name="fade">
|
||||
|
|
@ -155,6 +219,8 @@ import EventPanel from 'components/EventPanel.vue'
|
|||
import FloatingLines from 'components/FloatingLines.vue'
|
||||
import LifeWaveSettings from 'components/LifeWaveSettings.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 UserMenu from 'components/UserMenu.vue'
|
||||
import ZoomControl from 'components/ZoomControl.vue'
|
||||
|
|
@ -162,6 +228,8 @@ import { useAuthStore } from 'stores/auth'
|
|||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { resolveFullRes } from 'composables/useImageCache'
|
||||
import { useStoreFeedback } from 'composables/useStoreFeedback'
|
||||
import { TIMELINE_TOP_PX, emotionToYPercent } from 'src/utils/timelinePosition'
|
||||
|
||||
const $q = useQuasar()
|
||||
const router = useRouter()
|
||||
|
|
@ -170,6 +238,7 @@ const eventsStore = useEventsStore()
|
|||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const settingsOpen = ref(false)
|
||||
const zoomOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
const appSettingsOpen = ref(false)
|
||||
const floatingLinesRef = ref(null)
|
||||
|
|
@ -236,7 +305,46 @@ function onWindowResize() {
|
|||
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(() => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('tm:app-updated', onAppUpdated)
|
||||
if (layoutRef.value) {
|
||||
layoutWidth.value = layoutRef.value.clientWidth
|
||||
layoutHeight.value = layoutRef.value.clientHeight
|
||||
|
|
@ -254,6 +362,8 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('tm:app-updated', onAppUpdated)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (hardResizeTimer) clearTimeout(hardResizeTimer)
|
||||
if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf)
|
||||
|
|
@ -282,7 +392,7 @@ function screenToUV(sx, sy) {
|
|||
}
|
||||
|
||||
// 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).
|
||||
// TimelineView already emits a small contiguous window around the viewport.
|
||||
|
|
@ -317,7 +427,7 @@ const shaderPointY = computed(() => {
|
|||
const sel = shaderSelection.value
|
||||
const tlHeight = timelineState.value.containerHeight
|
||||
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
|
||||
ys[i] = screenToUV(0, screenY).y
|
||||
}
|
||||
|
|
@ -336,6 +446,26 @@ const parsedGradient = computed(() => {
|
|||
.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
|
||||
const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1)
|
||||
const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4)
|
||||
|
|
@ -347,9 +477,11 @@ function onZoomTo(value) {
|
|||
timelineViewRef.value.zoomTo?.(clamped)
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
$q.dark.toggle()
|
||||
}
|
||||
// Empty state only when the account really has no events and nothing is
|
||||
// being created right now.
|
||||
const showEmptyState = computed(() =>
|
||||
eventsStore.isLoaded && eventsStore.events.length === 0 && !eventsStore.panelOpen
|
||||
)
|
||||
|
||||
const onAddEvent = () => {
|
||||
eventsStore.openPanel()
|
||||
|
|
@ -364,11 +496,32 @@ const onUserMenuNavigate = (target) => {
|
|||
userMenuOpen.value = false
|
||||
if (target === 'settings') {
|
||||
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') {
|
||||
authStore.logout()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -378,14 +531,14 @@ const onUserMenuNavigate = (target) => {
|
|||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #F5F5F5;
|
||||
color: var(--tm-paper);
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.lifewave-layout--dark {
|
||||
background: #000;
|
||||
color: #F5F5F5;
|
||||
color: var(--tm-paper);
|
||||
}
|
||||
|
||||
/* FloatingLines Background */
|
||||
|
|
@ -413,14 +566,79 @@ const onUserMenuNavigate = (target) => {
|
|||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.lifewave-header__logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
.lifewave-header__sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.lifewave-header__sync:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.lifewave-header__sync--offline {
|
||||
color: #ffb86b;
|
||||
}
|
||||
|
||||
.lifewave-header__logo {
|
||||
display: block;
|
||||
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 {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
|
@ -432,21 +650,32 @@ const onUserMenuNavigate = (target) => {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
/* Zoom Control */
|
||||
.lifewave-layout__zoom {
|
||||
/* Bottom bar */
|
||||
.lifewave-bar {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
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-settings-btn {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
.lifewave-bar > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lifewave-bar__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.lifewave-bar__btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
|
|
@ -455,10 +684,33 @@ const onUserMenuNavigate = (target) => {
|
|||
justify-content: center;
|
||||
cursor: pointer;
|
||||
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 */
|
||||
.lifewave-content {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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: []
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,51 +1,56 @@
|
|||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-page__bg" />
|
||||
<form class="login-card" @submit.prevent="onSubmit">
|
||||
<div class="login-card__brand">That's Me</div>
|
||||
<h1>Login</h1>
|
||||
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
|
||||
<div class="login-page__bg" aria-hidden="true" />
|
||||
|
||||
<div class="login-user-row">
|
||||
<label class="login-field login-field--user">
|
||||
<span>E-Mail</span>
|
||||
<select v-model="email">
|
||||
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
|
||||
{{ user.mode === 'local' ? user.name : user.email }}
|
||||
</option>
|
||||
</select>
|
||||
<q-icon name="person_outline" size="22px" />
|
||||
</label>
|
||||
</div>
|
||||
<form class="login-card" @submit.prevent="onSubmit">
|
||||
<BrandLogo variant="negative" :height="34" class="login-card__logo" />
|
||||
|
||||
<h1 class="login-card__title">Willkommen zurück</h1>
|
||||
<p class="login-card__lead">Melde dich an und arbeite mit deinen eigenen Events und Einstellungen.</p>
|
||||
|
||||
<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
|
||||
v-model="password"
|
||||
class="login-field__control"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
: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" />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label class="login-remember">
|
||||
<input v-model="remember" type="checkbox">
|
||||
<span>Remember me</span>
|
||||
</label>
|
||||
|
||||
<div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
|
||||
<div v-if="authStore.lastError" class="login-error" role="alert">{{ authStore.lastError }}</div>
|
||||
|
||||
<button class="login-submit" type="submit" :disabled="isSubmitting">
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
|
||||
<div class="login-card__hint">
|
||||
Demo bleibt lokal im Browser. API-Accounts: <strong>user1-user6@thats-me.app</strong>, Passwort <strong>pass</strong>.
|
||||
</div>
|
||||
<p class="login-card__hint">
|
||||
Die lokale Demo bleibt im Browser. API-Konten: <strong>jonas@</strong>, <strong>sophie@</strong>,
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -54,6 +59,7 @@
|
|||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
import BrandLogo from 'components/BrandLogo.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
|
@ -62,13 +68,12 @@ const route = useRoute()
|
|||
const email = ref(authStore.users[0]?.email ?? '')
|
||||
const password = ref('pass')
|
||||
const showPassword = ref(false)
|
||||
const remember = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
const selectedUser = computed(() => authStore.users.find(user => user.email === email.value) ?? null)
|
||||
const isLocalDemo = computed(() => selectedUser.value?.mode === 'local')
|
||||
const submitLabel = computed(() => {
|
||||
if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet...' : 'Login läuft...'
|
||||
return isLocalDemo.value ? 'Demo lokal starten' : 'Login'
|
||||
if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet …' : 'Anmeldung läuft …'
|
||||
return isLocalDemo.value ? 'Demo lokal starten' : 'Anmelden'
|
||||
})
|
||||
|
||||
watch(isLocalDemo, (localDemo) => {
|
||||
|
|
@ -89,6 +94,10 @@ async function onSubmit() {
|
|||
</script>
|
||||
|
||||
<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 {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
|
|
@ -96,155 +105,177 @@ async function onSubmit() {
|
|||
justify-content: center;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
background: #162914;
|
||||
color: #fff;
|
||||
background: var(--tm-ink);
|
||||
color: var(--tm-paper);
|
||||
}
|
||||
|
||||
.login-page__bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.35), transparent 28%),
|
||||
linear-gradient(135deg, rgba(213, 218, 98, 0.9), rgba(33, 181, 113, 0.75) 45%, rgba(20, 68, 28, 0.95));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: saturate(1.08);
|
||||
radial-gradient(ellipse at 20% 15%, rgba(var(--tm-gold-rgb), 0.28), transparent 45%),
|
||||
radial-gradient(ellipse at 85% 90%, rgba(213, 144, 64, 0.22), transparent 50%),
|
||||
linear-gradient(180deg, #2f3032, var(--tm-ink) 60%, #1f2021);
|
||||
}
|
||||
|
||||
/* A quiet wave, echoing the LifeWave, drawn with two soft gold lines */
|
||||
.login-page__bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)),
|
||||
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 60px);
|
||||
radial-gradient(120% 40% at 50% 62%, transparent 58%, rgba(var(--tm-gold-rgb), 0.16) 59%, transparent 61%),
|
||||
radial-gradient(120% 40% at 50% 66%, transparent 58%, rgba(var(--tm-gold-rgb), 0.08) 59%, transparent 61%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(420px, 100%);
|
||||
padding: 40px 34px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
padding: 36px 32px 26px;
|
||||
border: 1px solid rgba(var(--tm-paper-rgb), 0.14);
|
||||
border-radius: 24px;
|
||||
background: rgba(22, 29, 25, 0.32);
|
||||
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.35);
|
||||
background: rgba(var(--tm-paper-rgb), 0.06);
|
||||
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.login-card__brand {
|
||||
.login-card__logo {
|
||||
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;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin: 0 0 28px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
.login-card__lead {
|
||||
margin: 0 0 26px;
|
||||
color: rgba(var(--tm-paper-rgb), 0.72);
|
||||
line-height: 1.45;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-field {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-user-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-field--user {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-field span {
|
||||
.login-field__label {
|
||||
display: block;
|
||||
margin: 0 0 6px 2px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(var(--tm-paper-rgb), 0.7);
|
||||
}
|
||||
|
||||
.login-field input,
|
||||
.login-field select {
|
||||
.login-field__control {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 52px 0 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.48);
|
||||
height: 54px;
|
||||
padding: 0 52px 0 18px;
|
||||
border: 1px solid rgba(var(--tm-paper-rgb), 0.22);
|
||||
border-radius: 14px;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
background: rgba(var(--tm-paper-rgb), 0.06);
|
||||
color: var(--tm-paper);
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.login-field select option {
|
||||
color: #1a1a1a;
|
||||
.login-field__control:focus-visible {
|
||||
border-color: var(--tm-gold);
|
||||
background: rgba(var(--tm-paper-rgb), 0.09);
|
||||
}
|
||||
|
||||
.login-field > .q-icon,
|
||||
.login-field__icon-btn {
|
||||
.login-field__control:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.login-field__control option {
|
||||
color: var(--tm-ink);
|
||||
}
|
||||
|
||||
.login-field__icon {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 16px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
right: 16px;
|
||||
bottom: 15px;
|
||||
color: rgba(var(--tm-paper-rgb), 0.7);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-field__icon-btn {
|
||||
pointer-events: auto;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-remember {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-remember input {
|
||||
accent-color: #6ce36c;
|
||||
.login-field__icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-bottom: 14px;
|
||||
color: #ffd7d7;
|
||||
margin: 4px 0 14px;
|
||||
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;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
height: 58px;
|
||||
height: 56px;
|
||||
margin-top: 8px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(90deg, #cfdd34, #18c77a);
|
||||
color: #fff;
|
||||
background: var(--tm-gold-gradient);
|
||||
color: var(--tm-ink);
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
margin-top: 18px;
|
||||
margin: 18px 0 0;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: rgba(var(--tm-paper-rgb), 0.6);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
.login-card__hint strong {
|
||||
color: rgba(var(--tm-paper-rgb), 0.85);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -63,6 +63,33 @@ export async function clearToken() {
|
|||
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) {
|
||||
if (response.status === 204) return null
|
||||
|
||||
|
|
@ -81,6 +108,10 @@ export async function apiFetch(path, options = {}) {
|
|||
const token = auth ? await getToken() : null
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +134,7 @@ export async function apiFetch(path, options = {}) {
|
|||
})
|
||||
|
||||
if (response.status === 401 && auth) {
|
||||
await clearToken()
|
||||
await handleUnauthorized(token)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
414
frontend/src/services/syncQueue.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
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'
|
||||
|
||||
|
|
@ -14,17 +15,39 @@ export const LOCAL_DEMO_USER = {
|
|||
seedDemoEvents: false
|
||||
}
|
||||
|
||||
export const REMOTE_USERS = 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}`,
|
||||
// Demo lifelines seeded by the Laravel LifelineSeeder (database/data/lifelines/*.json)
|
||||
const LIFELINE_USERS = [
|
||||
{ handle: 'jonas', name: 'Jonas', avatar: 'JO' },
|
||||
{ handle: 'sophie', name: 'Sophie', avatar: 'SO' },
|
||||
{ handle: 'nadja', name: 'Nadja', avatar: 'NA' },
|
||||
{ handle: 'werner', name: 'Werner', avatar: 'WE' },
|
||||
{ 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',
|
||||
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) {
|
||||
const mode = user.mode || 'local'
|
||||
|
|
@ -89,6 +112,25 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
)
|
||||
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) {
|
||||
const normalizedEmail = String(email).trim().toLowerCase()
|
||||
const user = users.value.find(candidate =>
|
||||
|
|
@ -110,13 +152,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
currentUserProfile.value = remoteUser
|
||||
lastError.value = ''
|
||||
persistAuth(remoteUser)
|
||||
setImageCacheScope(remoteUser.id)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Remote login failed:', error)
|
||||
lastError.value = error?.status === 422
|
||||
? 'E-Mail oder Passwort ist falsch.'
|
||||
: 'Login über die API ist gerade nicht möglich.'
|
||||
lastError.value = error?.status === 429
|
||||
? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.'
|
||||
: error?.status === 422
|
||||
? 'E-Mail oder Passwort ist falsch.'
|
||||
: 'Login über die API ist gerade nicht möglich.'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +171,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
currentUserProfile.value = user
|
||||
lastError.value = ''
|
||||
persistAuth(user)
|
||||
setImageCacheScope(user.id)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -138,19 +184,36 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
currentUserProfile.value = user
|
||||
lastError.value = ''
|
||||
persistAuth(user)
|
||||
setImageCacheScope(user.id)
|
||||
return true
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (currentUser.value?.mode === 'remote') {
|
||||
logoutRemote().catch(() => {})
|
||||
}
|
||||
const wasRemote = currentUser.value?.mode === 'remote'
|
||||
|
||||
currentUserId.value = null
|
||||
currentUserProfile.value = null
|
||||
// Cleared synchronously: the caller navigates to /login straight after,
|
||||
// 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 = ''
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
|
@ -1,6 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiJson } from 'src/services/apiClient'
|
||||
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
|
||||
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 ACCENT_COLORS = [
|
||||
{ label: 'Base', value: 'base', hex: '#737373' },
|
||||
{ label: 'Red', value: 'red', hex: '#ef4444' },
|
||||
{ label: 'Gold', value: 'gold', hex: '#f5c26b' },
|
||||
{ label: 'Grau', value: 'grey', hex: '#737373' },
|
||||
{ label: 'Rot', value: 'red', hex: '#ef4444' },
|
||||
{ label: 'Orange', value: 'orange', hex: '#f97316' },
|
||||
{ label: 'Amber', value: 'amber', hex: '#f59e0b' },
|
||||
{ label: 'Yellow', value: 'yellow', hex: '#eab308' },
|
||||
{ label: 'Lime', value: 'lime', hex: '#84cc16' },
|
||||
{ label: 'Green', value: 'green', hex: '#22c55e' },
|
||||
{ label: 'Emerald', value: 'emerald', hex: '#10b981' },
|
||||
{ label: 'Teal', value: 'teal', hex: '#14b8a6' },
|
||||
{ label: 'Bernstein', value: 'amber', hex: '#f59e0b' },
|
||||
{ label: 'Gelb', value: 'yellow', hex: '#eab308' },
|
||||
{ label: 'Limette', value: 'lime', hex: '#84cc16' },
|
||||
{ label: 'Grün', value: 'green', hex: '#22c55e' },
|
||||
{ label: 'Smaragd', value: 'emerald', hex: '#10b981' },
|
||||
{ label: 'Petrol', value: 'teal', hex: '#14b8a6' },
|
||||
{ label: 'Cyan', value: 'cyan', hex: '#06b6d4' },
|
||||
{ label: 'Sky', value: 'sky', hex: '#0ea5e9' },
|
||||
{ label: 'Blue', value: 'blue', hex: '#3b82f6' },
|
||||
{ label: 'Himmelblau', value: 'sky', hex: '#0ea5e9' },
|
||||
{ label: 'Blau', value: 'blue', hex: '#3b82f6' },
|
||||
{ label: 'Indigo', value: 'indigo', hex: '#6366f1' },
|
||||
{ label: 'Violet', value: 'violet', hex: '#8b5cf6' },
|
||||
{ label: 'Purple', value: 'purple', hex: '#a855f7' },
|
||||
{ label: 'Violett', value: 'violet', hex: '#8b5cf6' },
|
||||
{ label: 'Purpur', value: 'purple', hex: '#a855f7' },
|
||||
{ label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' },
|
||||
{ label: 'Pink', value: 'pink', hex: '#ec4899' },
|
||||
{ label: 'Rose', value: 'rose', hex: '#f43f5e' }
|
||||
{ label: 'Rosé', value: 'rose', hex: '#f43f5e' }
|
||||
]
|
||||
|
||||
export const LANGUAGES = [
|
||||
|
|
@ -36,6 +38,7 @@ export const LANGUAGES = [
|
|||
{ label: 'English', value: 'en' }
|
||||
]
|
||||
|
||||
// The defaults are the brand look ("Gold" preset): gold lines on ink.
|
||||
const FLOATING_LINES_DEFAULTS = {
|
||||
// Linien
|
||||
speed: 1.0,
|
||||
|
|
@ -48,20 +51,20 @@ const FLOATING_LINES_DEFAULTS = {
|
|||
bezierCurvature: 0.2,
|
||||
circleRadius: 75,
|
||||
glowSize: 18,
|
||||
glowStrength: 1.5,
|
||||
lineBrightness: 1.0,
|
||||
glowStrength: 1.6,
|
||||
lineBrightness: 0.9,
|
||||
// Hintergrund
|
||||
bgCenter: '#0a0514',
|
||||
bgEdge: '#000000',
|
||||
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
|
||||
bgCenter: '#2f3032',
|
||||
bgEdge: '#1a1b1c',
|
||||
gradientStops: '#f5c26b\n#d59040\n#3a2a14',
|
||||
backgroundImage: '',
|
||||
// Horizont
|
||||
horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow'
|
||||
horizonOpacity: 0.5, // Nebel + Glow: Helligkeit
|
||||
horizonMode: 'glow', // 'off' | 'fog' | 'split' | 'glow'
|
||||
horizonOpacity: 0.25, // Nebel + Glow: Helligkeit
|
||||
horizonBlend: 0.2, // Trennung: 0=scharf 1=weich
|
||||
// Labels
|
||||
labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
|
||||
labelColor: '#ffffff',
|
||||
labelColor: '#f7f3ea',
|
||||
labelOpacity: 0.75,
|
||||
labelConnectorLength: 0.2,
|
||||
// Dot-Rahmen
|
||||
|
|
@ -90,9 +93,12 @@ function clone(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) {
|
||||
if (value === 'default') return 'base'
|
||||
return ACCENT_COLORS.some(c => c.value === value) ? value : 'base'
|
||||
if (value === 'default' || value === 'base') return 'gold'
|
||||
return ACCENT_COLORS.some(c => c.value === value) ? value : 'gold'
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
|
|
@ -128,6 +134,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
const initialSettings = initialActivePreset?.settings ?? stored
|
||||
let persistTimer = null
|
||||
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 floatingLines = ref({
|
||||
|
|
@ -137,7 +147,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
|
||||
// App preferences
|
||||
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 emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START)
|
||||
const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END)
|
||||
|
|
@ -183,7 +193,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
...(snapshot?.floatingLines ?? {})
|
||||
}
|
||||
appearance.value = snapshot?.appearance ?? 'system'
|
||||
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'base')
|
||||
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold')
|
||||
language.value = snapshot?.language ?? 'de'
|
||||
emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START
|
||||
emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END
|
||||
|
|
@ -201,13 +211,26 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
|
||||
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()) {
|
||||
const generation = settingsGeneration
|
||||
|
||||
apiJson('/settings', {
|
||||
method: 'PUT',
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
lastError.value = null
|
||||
|
||||
let nextStored = loadFromStorage(userId)
|
||||
if (userId && isRemoteUser()) {
|
||||
try {
|
||||
nextStored = await loadRemoteSettings()
|
||||
if (nextStored) {
|
||||
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
|
||||
try {
|
||||
let nextStored = loadFromStorage(userId)
|
||||
|
||||
if (userId && isRemoteUser()) {
|
||||
try {
|
||||
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 })
|
||||
|
|
@ -304,6 +346,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
|
||||
function resetFloatingLines() {
|
||||
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
||||
activePresetId.value = null
|
||||
}
|
||||
|
||||
function resetEmotionGradient() {
|
||||
|
|
@ -338,7 +381,21 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
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) {
|
||||
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)
|
||||
if (!preset) return false
|
||||
|
||||
|
|
@ -348,9 +405,20 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
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)
|
||||
|
||||
return {
|
||||
lastError,
|
||||
theme,
|
||||
floatingLines,
|
||||
appearance,
|
||||
|
|
@ -361,6 +429,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
timelineZoom,
|
||||
timelineScrollLeft,
|
||||
presets,
|
||||
builtInPresets: WAVE_PRESETS,
|
||||
activePresetId,
|
||||
showFps,
|
||||
toggleTheme,
|
||||
|
|
@ -371,6 +440,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
uploadBackgroundImage,
|
||||
clearBackgroundImage,
|
||||
savePreset,
|
||||
applyPreset
|
||||
applyPreset,
|
||||
deletePreset
|
||||
}
|
||||
})
|
||||
|
|
|
|||
101
frontend/src/utils/audioWav.js
Normal 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')
|
||||
}
|
||||
58
frontend/src/utils/brand.js
Normal 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'
|
||||
}
|
||||
62
frontend/src/utils/calendarDate.js
Normal 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 ?? '')
|
||||
}
|
||||
35
frontend/src/utils/timelinePosition.js
Normal 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
|
||||
}
|
||||
43
frontend/test/vitest/audio-wav.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
239
frontend/test/vitest/auth-session.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
40
frontend/test/vitest/brand.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
78
frontend/test/vitest/calendar-date.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
270
frontend/test/vitest/components-feedback.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
77
frontend/test/vitest/components-timeline-nav.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
100
frontend/test/vitest/components-timeline.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
260
frontend/test/vitest/events-autosave.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
312
frontend/test/vitest/events-draft.spec.js
Normal 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'])
|
||||
})
|
||||
})
|
||||
361
frontend/test/vitest/events-media.spec.js
Normal 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([])
|
||||
})
|
||||
})
|
||||
307
frontend/test/vitest/events-offline.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
184
frontend/test/vitest/events-user-context.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||