diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dee6d20..4a8d873 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,24 +1,12 @@ { "name": "Thats-Me (Dev Container)", - // 1. DIES IST DER WICHTIGSTE TEIL: - // Wir verwenden Docker Compose für alle Services "dockerComposeFile": [ "../docker-compose.yml" ], "service": "laravel.test", - // 3. WIR DEFINIEREN DEN ARBEITSBEREICH: - // Wir mounten das gesamte Projekt, damit Sie Backend UND Frontend sehen "workspaceFolder": "/workspace", - // 4. WIR LEGEN DEN BENUTZER FEST: - // Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden. "remoteUser": "sail", - // 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES): - // Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden "features": {}, - // 6. BEFEHLE NACH DEM ERSTELLEN: - // Installiert nur die Tools die ohne Root-Rechte funktionieren - //"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader", - // 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen): "customizations": { "vscode": { "extensions": [ @@ -34,65 +22,29 @@ ] } }, - // 8. ZU STARTENDE DIENSTE: - // Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen. "runServices": [ "laravel.test", - "quasar.app", - "mysql", - "redis", - "mailpit" + "quasar.app" ], - // 9. ZUSÄTZLICHE KONFIGURATION: - // Umgebungsvariablen für den DevContainer "containerEnv": { "WWWUSER": "501", "WWWGROUP": "20", "LARAVEL_SAIL": "1" }, - // 9b. MOUNTS: - // Mountet das gesamte Projekt (Root) nach /workspace, damit Sie Backend UND Frontend sehen "mounts": [ "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached" ], - // 10. FORWARD PORTS: - // Ports die automatisch weitergeleitet werden sollen (Container-Ports) "forwardPorts": [ - 80, 5173, - 3306, - 6379, - 1025, - 8025, 9000 ], "portsAttributes": { - "80": { - "label": "Laravel App (HTTP)", - "onAutoForward": "notify" - }, "5173": { - "label": "Vite Dev Server", + "label": "Vite Dev Server (Backend)", "onAutoForward": "notify" }, - "3306": { - "label": "MySQL", - "onAutoForward": "silent" - }, - "6379": { - "label": "Redis", - "onAutoForward": "silent" - }, - "8025": { - "label": "Mailpit Dashboard", - "onAutoForward": "notify" - }, - "1025": { - "label": "Mailpit SMTP", - "onAutoForward": "silent" - }, "9000": { - "label": "Quasar App", + "label": "Quasar App (Frontend)", "onAutoForward": "notify" } } diff --git a/docker-compose.yml b/docker-compose.yml index 2def612..ae33b91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,52 +19,40 @@ services: XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' IGNITION_LOCAL_SITES_PATH: '${PWD}' + + # --- Anbindung an das Mutterschiff --- DB_CONNECTION: mysql - DB_HOST: mysql + DB_HOST: global-mysql DB_PORT: 3306 DB_DATABASE: thats-me - DB_USERNAME: sail + DB_USERNAME: root DB_PASSWORD: password - MAIL_HOST: mailpit + MAIL_HOST: global-mailpit MAIL_PORT: 1025 - REDIS_HOST: redis + REDIS_HOST: global-redis volumes: - './backend:/var/www/html' networks: - sail - proxy - depends_on: - - mysql - - mailpit - - redis labels: - "traefik.enable=true" - - # Domain 1: Hauptdomain thats-me.test (Webseite/Landingpage) - "traefik.http.routers.thatsme-main.rule=Host(`thats-me.test`)" - "traefik.http.routers.thatsme-main.entrypoints=websecure" - "traefik.http.routers.thatsme-main.tls=true" - "traefik.http.routers.thatsme-main.service=thatsme-service" - - # Domain 2: portal.thats-me.test (Admin Panel) - "traefik.http.routers.thatsme-portal.rule=Host(`portal.thats-me.test`)" - "traefik.http.routers.thatsme-portal.entrypoints=websecure" - "traefik.http.routers.thatsme-portal.tls=true" - "traefik.http.routers.thatsme-portal.service=thatsme-service" - - # Domain 3: api.thats-me.test (API für Quasar App) - "traefik.http.routers.thatsme-api.rule=Host(`api.thats-me.test`)" - "traefik.http.routers.thatsme-api.entrypoints=websecure" - "traefik.http.routers.thatsme-api.tls=true" - "traefik.http.routers.thatsme-api.service=thatsme-service" - - # Vite Asset Domain für Backend Development - "traefik.http.routers.thatsme-assets.rule=Host(`assets.thats-me.test`)" - "traefik.http.routers.thatsme-assets.entrypoints=websecure" - "traefik.http.routers.thatsme-assets.tls=true" - "traefik.http.routers.thatsme-assets.service=thatsme-assets-service" - - # Service Definitions - "traefik.http.services.thatsme-service.loadbalancer.server.port=80" - "traefik.http.services.thatsme-assets-service.loadbalancer.server.port=5173" - "traefik.http.services.thatsme-assets-service.loadbalancer.server.scheme=http" @@ -87,79 +75,20 @@ services: - proxy labels: - "traefik.enable=true" - - # Domain 4: app.thats-me.test (Quasar Frontend App) - "traefik.http.routers.thatsme-app.rule=Host(`app.thats-me.test`)" - "traefik.http.routers.thatsme-app.entrypoints=websecure" - "traefik.http.routers.thatsme-app.tls=true" - "traefik.http.routers.thatsme-app.service=thatsme-app-service" - - # Service Definition - "traefik.http.services.thatsme-app-service.loadbalancer.server.port=9000" - "traefik.http.services.thatsme-app-service.loadbalancer.server.scheme=http" - "traefik.docker.network=proxy" - # MySQL Database - mysql: - image: 'mysql/mysql-server:8.0' - ports: - - '${FORWARD_DB_PORT:-33070}:3306' - environment: - MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-password}' - MYSQL_ROOT_HOST: '%' - MYSQL_DATABASE: '${DB_DATABASE:-thats-me}' - MYSQL_USER: '${DB_USERNAME:-sail}' - MYSQL_PASSWORD: '${DB_PASSWORD:-password}' - MYSQL_ALLOW_EMPTY_PASSWORD: 1 - volumes: - - 'sail-mysql:/var/lib/mysql' - - './backend/vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' - networks: - - sail - healthcheck: - test: - - CMD - - mysqladmin - - ping - - '-p${DB_PASSWORD:-password}' - retries: 3 - timeout: 5s - - # Mailpit für E-Mail Testing - mailpit: - image: 'axllent/mailpit:latest' - ports: - - '${FORWARD_MAILPIT_PORT:-1028}:1025' - - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8028}:8025' - networks: - - sail - - # Redis Cache/Queue - redis: - image: 'redis:alpine' - ports: - - '${FORWARD_REDIS_PORT:-6383}:6379' - volumes: - - 'sail-redis:/data' - networks: - - sail - healthcheck: - test: - - CMD - - redis-cli - - ping - retries: 3 - timeout: 5s - networks: sail: driver: bridge proxy: external: true + volumes: - sail-mysql: - driver: local - sail-redis: - driver: local quasar-node-modules: driver: local \ No newline at end of file diff --git a/frontend/_src/App.vue b/frontend/_src/App.vue new file mode 100644 index 0000000..b22f395 --- /dev/null +++ b/frontend/_src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/_src/assets/quasar-logo-vertical.svg b/frontend/_src/assets/quasar-logo-vertical.svg new file mode 100644 index 0000000..8210831 --- /dev/null +++ b/frontend/_src/assets/quasar-logo-vertical.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/_src/boot/.gitkeep b/frontend/_src/boot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/_src/components/AddEventButton.vue b/frontend/_src/components/AddEventButton.vue new file mode 100644 index 0000000..2a58325 --- /dev/null +++ b/frontend/_src/components/AddEventButton.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/frontend/_src/components/AppSettingsModal.vue b/frontend/_src/components/AppSettingsModal.vue new file mode 100644 index 0000000..09ab13b --- /dev/null +++ b/frontend/_src/components/AppSettingsModal.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/_src/components/EssentialLink.vue b/frontend/_src/components/EssentialLink.vue new file mode 100644 index 0000000..54afb06 --- /dev/null +++ b/frontend/_src/components/EssentialLink.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/_src/components/EventPanel.vue b/frontend/_src/components/EventPanel.vue new file mode 100644 index 0000000..ea0d2de --- /dev/null +++ b/frontend/_src/components/EventPanel.vue @@ -0,0 +1,546 @@ + + + + + diff --git a/frontend/_src/components/FloatingLines.vue b/frontend/_src/components/FloatingLines.vue new file mode 100644 index 0000000..054bdd3 --- /dev/null +++ b/frontend/_src/components/FloatingLines.vue @@ -0,0 +1,571 @@ + + + + + diff --git a/frontend/_src/components/GlowDot.vue b/frontend/_src/components/GlowDot.vue new file mode 100644 index 0000000..627de28 --- /dev/null +++ b/frontend/_src/components/GlowDot.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/_src/components/LifeWaveSettings.vue b/frontend/_src/components/LifeWaveSettings.vue new file mode 100644 index 0000000..c36579a --- /dev/null +++ b/frontend/_src/components/LifeWaveSettings.vue @@ -0,0 +1,482 @@ + + + + + diff --git a/frontend/_src/components/ModalCard.vue b/frontend/_src/components/ModalCard.vue new file mode 100644 index 0000000..d722160 --- /dev/null +++ b/frontend/_src/components/ModalCard.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/frontend/_src/components/TimelineView.vue b/frontend/_src/components/TimelineView.vue new file mode 100644 index 0000000..1101565 --- /dev/null +++ b/frontend/_src/components/TimelineView.vue @@ -0,0 +1,547 @@ + + + + + diff --git a/frontend/_src/components/UserMenu.vue b/frontend/_src/components/UserMenu.vue new file mode 100644 index 0000000..d78d130 --- /dev/null +++ b/frontend/_src/components/UserMenu.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/_src/components/UserMenuButton.vue b/frontend/_src/components/UserMenuButton.vue new file mode 100644 index 0000000..da96753 --- /dev/null +++ b/frontend/_src/components/UserMenuButton.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/_src/components/ZoomControl.vue b/frontend/_src/components/ZoomControl.vue new file mode 100644 index 0000000..c53bb45 --- /dev/null +++ b/frontend/_src/components/ZoomControl.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/_src/composables/useImageCache.js b/frontend/_src/composables/useImageCache.js new file mode 100644 index 0000000..8685a28 --- /dev/null +++ b/frontend/_src/composables/useImageCache.js @@ -0,0 +1,175 @@ +import { ref } from 'vue' +import { db } from 'src/db' + +const THUMB_SIZE = 200 + +// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation +// Shared across all component instances +const memoryCache = new Map() + +/** + * Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob. + * Returns a new Blob (JPEG, quality 0.8). + */ +function createThumbnail(blob) { + return new Promise((resolve, reject) => { + const img = new Image() + const url = URL.createObjectURL(blob) + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = THUMB_SIZE + canvas.height = THUMB_SIZE + + const ctx = canvas.getContext('2d') + // Cover crop: center the image + const scale = Math.max(THUMB_SIZE / img.width, THUMB_SIZE / img.height) + const w = img.width * scale + const h = img.height * scale + const x = (THUMB_SIZE - w) / 2 + const y = (THUMB_SIZE - h) / 2 + ctx.drawImage(img, x, y, w, h) + + canvas.toBlob( + (thumbBlob) => { + URL.revokeObjectURL(url) + if (thumbBlob) resolve(thumbBlob) + else reject(new Error('Canvas toBlob failed')) + }, + 'image/jpeg', + 0.8 + ) + } + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('Image load failed')) + } + img.src = url + }) +} + +/** + * Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL. + */ +async function fetchAndCache(imageUrl, eventId) { + const response = await fetch(imageUrl) + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) + const blob = await response.blob() + + // Create thumbnail + const thumbBlob = await createThumbnail(blob) + + // Store in IndexedDB + await db.imageCache.put({ + url: imageUrl, + eventId, + type: 'thumbnail', + blob: thumbBlob, + cachedAt: Date.now() + }) + + const blobUrl = URL.createObjectURL(thumbBlob) + memoryCache.set(imageUrl, blobUrl) + return blobUrl +} + +/** + * Get a cached thumbnail blob URL from IndexedDB. + * Returns null if not cached. + */ +async function getCachedImage(imageUrl) { + // Check memory first + if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) + + try { + const entry = await db.imageCache.get(imageUrl) + if (entry?.blob) { + const blobUrl = URL.createObjectURL(entry.blob) + memoryCache.set(imageUrl, blobUrl) + return blobUrl + } + } catch (e) { + console.warn('Image cache read failed:', e) + } + return null +} + +/** + * Composable: resolves an event's image to a displayable src. + * - Checks memory cache → IndexedDB cache → fetches & caches thumbnail. + * - Returns reactive `resolvedSrc` ref. + */ +export function useImageCache(imageUrl, eventId) { + const resolvedSrc = ref(null) + const loading = ref(false) + + async function resolve() { + if (!imageUrl) { + resolvedSrc.value = null + return + } + + // 1. Memory cache (instant) + if (memoryCache.has(imageUrl)) { + resolvedSrc.value = memoryCache.get(imageUrl) + return + } + + // 2. IndexedDB cache + const cached = await getCachedImage(imageUrl) + if (cached) { + resolvedSrc.value = cached + return + } + + // 3. Fetch, create thumbnail, cache + loading.value = true + try { + const blobUrl = await fetchAndCache(imageUrl, eventId) + resolvedSrc.value = blobUrl + } catch (e) { + // Fallback: use original URL directly (works when online) + console.warn('Image cache failed, using direct URL:', e) + resolvedSrc.value = imageUrl + } finally { + loading.value = false + } + } + + resolve() + + return { resolvedSrc, loading } +} + +/** + * Resolve full-res image for EventPanel (no thumbnail, just cache check). + * Returns the original URL — browser Cache-Control handles caching. + * When offline, falls back to cached thumbnail. + */ +export async function resolveFullRes(imageUrl) { + if (!imageUrl) return null + + // If online, return original URL (browser caches via HTTP headers) + if (navigator.onLine) return imageUrl + + // Offline: try cached thumbnail as fallback + const cached = await getCachedImage(imageUrl) + return cached || imageUrl +} + +/** + * Clear all cached images for a specific event. + */ +export async function clearEventImages(eventId) { + try { + const entries = await db.imageCache.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) + } + } + await db.imageCache.where('eventId').equals(eventId).delete() + } catch (e) { + console.warn('Clear event images failed:', e) + } +} diff --git a/frontend/_src/composables/usePanelDrag.js b/frontend/_src/composables/usePanelDrag.js new file mode 100644 index 0000000..98a6896 --- /dev/null +++ b/frontend/_src/composables/usePanelDrag.js @@ -0,0 +1,137 @@ +import { ref, onBeforeUnmount } from 'vue' + +/** + * Composable for draggable bottom-sheet panels with snap points. + * + * Snap stops (in dvh): 100, 75, 50 + * Close threshold: below 25dvh + * + * @param {Function} onClose - called when panel is dragged below threshold + * @returns {{ panelHeight, handleListeners, resetHeight }} + */ +export function usePanelDrag(onClose) { + const SNAP_POINTS = [100, 75, 50, 25] // dvh values + const CLOSE_THRESHOLD = 15 // below this → close + + // Current panel height in dvh (null = use CSS default) + const panelHeight = ref(null) + const isDragging = ref(false) + + let dragging = false + let startY = 0 + let startHeight = 0 + + function getViewportHeight() { + return window.innerHeight + } + + function pxToDvh(px) { + return (px / getViewportHeight()) * 100 + } + + function findNearestSnap(dvh) { + let nearest = SNAP_POINTS[0] + let minDist = Infinity + for (const snap of SNAP_POINTS) { + const dist = Math.abs(dvh - snap) + if (dist < minDist) { + minDist = dist + nearest = snap + } + } + return nearest + } + + function onPointerDown(e) { + // Only primary button / single touch + if (e.button && e.button !== 0) return + dragging = true + isDragging.value = true + + const clientY = e.touches ? e.touches[0].clientY : e.clientY + startY = clientY + + // Current height: if panelHeight is set use it, else measure from CSS + const currentDvh = panelHeight.value ?? 75 + startHeight = currentDvh + + document.addEventListener('pointermove', onPointerMove, { passive: false }) + document.addEventListener('pointerup', onPointerUp) + document.addEventListener('touchmove', onTouchMove, { passive: false }) + document.addEventListener('touchend', onTouchEnd) + + // Prevent text selection + e.preventDefault() + } + + function onPointerMove(e) { + if (!dragging) return + const clientY = e.clientY + handleMove(clientY) + } + + function onTouchMove(e) { + if (!dragging) return + if (e.touches.length !== 1) return + handleMove(e.touches[0].clientY) + e.preventDefault() + } + + function handleMove(clientY) { + const deltaY = clientY - startY + const deltaDvh = pxToDvh(deltaY) + const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh)) + panelHeight.value = newHeight + } + + function onPointerUp() { + finishDrag() + } + + function onTouchEnd() { + finishDrag() + } + + function finishDrag() { + if (!dragging) return + dragging = false + isDragging.value = false + + cleanup() + + const currentHeight = panelHeight.value ?? 75 + if (currentHeight < CLOSE_THRESHOLD) { + panelHeight.value = null + onClose() + } else { + // Snap to nearest point + panelHeight.value = findNearestSnap(currentHeight) + } + } + + function cleanup() { + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', onPointerUp) + document.removeEventListener('touchmove', onTouchMove) + document.removeEventListener('touchend', onTouchEnd) + } + + function resetHeight() { + panelHeight.value = null + } + + onBeforeUnmount(cleanup) + + // Event listeners to bind on the handle element + const handleListeners = { + pointerdown: onPointerDown, + touchstart: onPointerDown, + } + + return { + panelHeight, + isDragging, + handleListeners, + resetHeight, + } +} diff --git a/frontend/_src/css/app.css b/frontend/_src/css/app.css new file mode 100644 index 0000000..1d8d514 --- /dev/null +++ b/frontend/_src/css/app.css @@ -0,0 +1 @@ +.controls{display:flex;justify-content:space-between;width:100%;max-width:500px;margin-bottom:10px}.button{padding:6px 12px;background-color:#4f46e5;color:#fff;border:none;border-radius:4px;cursor:pointer}.visualization-container{position:relative;width:100%;height:calc(100vh - 86px);overflow:hidden}.gradient-bg{position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;background:linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);background-size:200% 200%;animation:gradientAnimation 20s ease infinite}@keyframes gradientAnimation{0%{background-position:0% 0%}25%{background-position:100% 0%}50%{background-position:100% 100%}75%{background-position:0% 100%}100%{background-position:0% 0%}}.median{position:absolute;top:51.2%;left:0;right:0;height:1px;background-color:rgba(255,255,255,.3);z-index:1}.scroll-container{position:relative;width:100%;height:100%;overflow-x:auto;overflow-y:hidden;min-height:400px;z-index:2;-ms-overflow-style:none;scrollbar-width:none}.scroll-container::-webkit-scrollbar{display:none}.smooth-scroll{scroll-behavior:smooth}.active{cursor:grabbing}.spacer{height:100vh}.dot-tooltip{pointer-events:none;opacity:1}.dot-tooltip .tooltip-background{fill:rgba(0,0,0,0)}.dot-tooltip .tooltip-content{display:flex;justify-content:center;align-items:center;flex-direction:column;width:100%;height:100%;color:#fff}.dot-tooltip .image_container{margin-top:8px;box-shadow:0 0 20px 0 rgba(255,255,255,.25);transition:box-shadow .25s ease-in-out;width:80px;height:80px;overflow:hidden;border-radius:50%;border:2px solid #fff;display:flex;justify-content:center}.dot-tooltip .image_container:hover{box-shadow:0 0 30px 0 rgba(255,255,255,.8)}.dot-tooltip .tooltip-image{width:100%;height:auto;display:block;pointer-events:auto}.dot-tooltip .tooltip-title{font-size:14px;font-weight:400;margin-bottom:2px;text-align:center;text-wrap:balance;-webkit-hyphens:auto;hyphens:auto;line-height:1.1}.dot-tooltip .tooltip-description{font-size:12px;font-weight:300}.dot-tooltip .tooltip-arrow{width:1px;height:30px;background:linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent)}.dot{transition:r .2s ease,fill .2s ease;cursor:pointer}.dot:hover{fill:rgba(255,255,255,.9);filter:drop-shadow(0 0 5px rgba(255, 255, 255, 0.8))}.tooltip-img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:4px}.q-card{box-shadow:none !important}.q-card--bordered{box-shadow:none !important}.q-card--flat{box-shadow:none !important}.bg-white,.q-layout__section--marginal{background:rgba(0,0,0,0) !important}footer .text-primary,footer .text-grey{color:#fff !important} \ No newline at end of file diff --git a/frontend/_src/css/app.scss b/frontend/_src/css/app.scss new file mode 100644 index 0000000..2342f87 --- /dev/null +++ b/frontend/_src/css/app.scss @@ -0,0 +1,54 @@ +// Glass button style +.glass--button { + background: rgba(128, 128, 128, 0.1); + border: 1px solid rgba(128, 128, 128, 0.15); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: background 0.2s ease; + + &:hover { + background: rgba(128, 128, 128, 0.18); + } + + &:active { + transform: scale(0.95); + } +} + +// Glass panel style — strong blur for slide-up panels +.glass--panel { + background: rgba(255, 255, 255, 0.7); + border-top: 1px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + color: #1a1a1a; + + .body--dark & { + background: rgba(30, 30, 30, 0.7); + border-top-color: rgba(255, 255, 255, 0.08); + color: #f5f5f5; + } +} + +// GlowDot animations — soft opacity pulse on the glow aura +@keyframes glowPulse { + 0%, 100% { + opacity: 0.85; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.06); + } +} + +@keyframes ghostPulse { + 0%, 100% { + opacity: 0.5; + transform: scale(1); + } + 50% { + opacity: 0.9; + transform: scale(1.12); + } +} diff --git a/frontend/_src/css/quasar.variables.scss b/frontend/_src/css/quasar.variables.scss new file mode 100644 index 0000000..12caa3d --- /dev/null +++ b/frontend/_src/css/quasar.variables.scss @@ -0,0 +1,25 @@ +// Quasar SCSS (& Sass) Variables +// -------------------------------------------------- +// To customize the look and feel of this app, you can override +// the Sass/SCSS variables found in Quasar's source Sass/SCSS files. + +// Check documentation for full list of Quasar variables + +// Your own variables (that are declared here) and Quasar's own +// ones will be available out of the box in your .vue/.scss/.sass files + +// It's highly recommended to change the default colors +// to match your app's branding. +// Tip: Use the "Theme Builder" on Quasar's documentation website. + +$primary : #d946ef; +$secondary : #a855f7; +$accent : #ec4899; + +$dark : #1D1D1D; +$dark-page : #121212; + +$positive : #21BA45; +$negative : #C10015; +$info : #31CCEC; +$warning : #F2C037; diff --git a/frontend/_src/db/index.js b/frontend/_src/db/index.js new file mode 100644 index 0000000..85e7dbe --- /dev/null +++ b/frontend/_src/db/index.js @@ -0,0 +1,17 @@ +import Dexie from 'dexie' + +export const db = new Dexie('thatsMeDB') + +db.version(1).stores({ + // Events: indexed by id (PK), date for sorted queries, syncStatus for dirty tracking + events: 'id, date, updatedAt, syncStatus', + + // Sync queue: outbound mutations waiting to be pushed to server + syncQueue: '++queueId, eventId, action, createdAt', + + // Image cache: offline blob storage for thumbnails + imageCache: 'url, eventId, type, cachedAt', + + // Metadata: key-value pairs (lastSyncCursor, userId, etc.) + meta: 'key' +}) diff --git a/frontend/_src/layouts/LifeWaveLayout.vue b/frontend/_src/layouts/LifeWaveLayout.vue new file mode 100644 index 0000000..de8fa40 --- /dev/null +++ b/frontend/_src/layouts/LifeWaveLayout.vue @@ -0,0 +1,471 @@ + + + + + diff --git a/frontend/_src/layouts/MainLayout.vue b/frontend/_src/layouts/MainLayout.vue new file mode 100644 index 0000000..529290a --- /dev/null +++ b/frontend/_src/layouts/MainLayout.vue @@ -0,0 +1,741 @@ + + + + + diff --git a/frontend/_src/pages/CategorySelector.vue b/frontend/_src/pages/CategorySelector.vue new file mode 100644 index 0000000..c03e179 --- /dev/null +++ b/frontend/_src/pages/CategorySelector.vue @@ -0,0 +1,369 @@ + + + + + \ No newline at end of file diff --git a/frontend/_src/pages/EditPage.vue b/frontend/_src/pages/EditPage.vue new file mode 100644 index 0000000..03af6ca --- /dev/null +++ b/frontend/_src/pages/EditPage.vue @@ -0,0 +1,2037 @@ + + + + + \ No newline at end of file diff --git a/frontend/_src/pages/EntryDetailPage.vue b/frontend/_src/pages/EntryDetailPage.vue new file mode 100644 index 0000000..db1f822 --- /dev/null +++ b/frontend/_src/pages/EntryDetailPage.vue @@ -0,0 +1,1224 @@ + + + + + \ No newline at end of file diff --git a/frontend/_src/pages/ErrorNotFound.vue b/frontend/_src/pages/ErrorNotFound.vue new file mode 100644 index 0000000..4b53e5a --- /dev/null +++ b/frontend/_src/pages/ErrorNotFound.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/_src/pages/IndexPage.vue b/frontend/_src/pages/IndexPage.vue new file mode 100644 index 0000000..e74e263 --- /dev/null +++ b/frontend/_src/pages/IndexPage.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/_src/pages/LifeWavePage.vue b/frontend/_src/pages/LifeWavePage.vue new file mode 100644 index 0000000..107ce06 --- /dev/null +++ b/frontend/_src/pages/LifeWavePage.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/_src/pages/LoginPage.vue b/frontend/_src/pages/LoginPage.vue new file mode 100644 index 0000000..d9983dc --- /dev/null +++ b/frontend/_src/pages/LoginPage.vue @@ -0,0 +1,68 @@ + + + \ No newline at end of file diff --git a/frontend/_src/pages/PasswordResetPage.vue b/frontend/_src/pages/PasswordResetPage.vue new file mode 100644 index 0000000..316abb7 --- /dev/null +++ b/frontend/_src/pages/PasswordResetPage.vue @@ -0,0 +1,95 @@ + + + \ No newline at end of file diff --git a/frontend/_src/pages/PersonSelector.vue b/frontend/_src/pages/PersonSelector.vue new file mode 100644 index 0000000..2254e7f --- /dev/null +++ b/frontend/_src/pages/PersonSelector.vue @@ -0,0 +1,423 @@ + + + + + \ No newline at end of file diff --git a/frontend/_src/pages/SignUpPage.vue b/frontend/_src/pages/SignUpPage.vue new file mode 100644 index 0000000..9979b90 --- /dev/null +++ b/frontend/_src/pages/SignUpPage.vue @@ -0,0 +1,138 @@ + + \ No newline at end of file diff --git a/frontend/_src/pages/TagSelector.vue b/frontend/_src/pages/TagSelector.vue new file mode 100644 index 0000000..e703d0d --- /dev/null +++ b/frontend/_src/pages/TagSelector.vue @@ -0,0 +1,445 @@ + + + + + \ No newline at end of file diff --git a/frontend/_src/pages/WavePage.vue b/frontend/_src/pages/WavePage.vue new file mode 100644 index 0000000..e125986 --- /dev/null +++ b/frontend/_src/pages/WavePage.vue @@ -0,0 +1,488 @@ + + + + + \ No newline at end of file diff --git a/frontend/_src/router/index.js b/frontend/_src/router/index.js new file mode 100644 index 0000000..226eb50 --- /dev/null +++ b/frontend/_src/router/index.js @@ -0,0 +1,30 @@ +import { defineRouter } from '#q-app/wrappers' +import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router' +import routes from './routes' + +/* + * If not building with SSR mode, you can + * directly export the Router instantiation; + * + * The function below can be async too; either use + * async/await or return a Promise which resolves + * with the Router instance. + */ + +export default defineRouter(function (/* { store, ssrContext } */) { + const createHistory = process.env.SERVER + ? createMemoryHistory + : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory) + + const Router = createRouter({ + scrollBehavior: () => ({ left: 0, top: 0 }), + routes, + + // Leave this as is and make changes in quasar.conf.js instead! + // quasar.conf.js -> build -> vueRouterMode + // quasar.conf.js -> build -> publicPath + history: createHistory(process.env.VUE_ROUTER_BASE) + }) + + return Router +}) diff --git a/frontend/_src/router/routes.js b/frontend/_src/router/routes.js new file mode 100644 index 0000000..ccc6533 --- /dev/null +++ b/frontend/_src/router/routes.js @@ -0,0 +1,17 @@ +const routes = [ + { + path: '/', + component: () => import('layouts/LifeWaveLayout.vue'), + children: [ + { path: '', component: () => import('pages/LifeWavePage.vue') } + ] + }, + + // Always leave this as last one + { + path: '/:catchAll(.*)*', + component: () => import('pages/ErrorNotFound.vue'), + }, +] + +export default routes diff --git a/frontend/_src/services/syncService.js b/frontend/_src/services/syncService.js new file mode 100644 index 0000000..f360f61 --- /dev/null +++ b/frontend/_src/services/syncService.js @@ -0,0 +1,253 @@ +import { ref } from 'vue' +import { db } from 'src/db' + +// API base URL — configured per environment +const API_BASE = import.meta.env.VITE_API_BASE || '/api' + +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 +}) + +/** + * Get the stored OAuth access token. + */ +async function getToken() { + try { + const meta = await db.meta.get('accessToken') + return meta?.value || null + } catch { + return null + } +} + +/** + * Store an OAuth access token. + */ +async function setToken(token) { + await db.meta.put({ key: 'accessToken', value: token }) +} + +/** + * Authenticated fetch wrapper. + */ +async function apiFetch(path, options = {}) { + const token = await getToken() + if (!token) throw new Error('Not authenticated') + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }) + + if (response.status === 401) { + // Token expired — clear it + await db.meta.delete('accessToken') + throw new Error('Unauthorized') + } + + return response +} + +/** + * 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, + emotion: remote.emotion, + customColor: remote.customColor, + gradientPreset: remote.gradientPreset, + 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, + emotion: remote.emotion, + customColor: remote.customColor, + gradientPreset: remote.gradientPreset, + 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, +} diff --git a/frontend/_src/stores/events.js b/frontend/_src/stores/events.js new file mode 100644 index 0000000..ab4eb55 --- /dev/null +++ b/frontend/_src/stores/events.js @@ -0,0 +1,355 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' +import { db } from 'src/db' +import { startAutoSync, getToken } from 'src/services/syncService' + +// Color interpolation +function lerpColor(a, b, t) { + const ar = parseInt(a.slice(1, 3), 16) + const ag = parseInt(a.slice(3, 5), 16) + const ab = parseInt(a.slice(5, 7), 16) + const br = parseInt(b.slice(1, 3), 16) + const bg = parseInt(b.slice(3, 5), 16) + const bb = parseInt(b.slice(5, 7), 16) + const r = Math.round(ar + (br - ar) * t) + const g = Math.round(ag + (bg - ag) * t) + const blue = Math.round(ab + (bb - ab) * t) + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}` +} + +// Gradient presets: [negative, neutral, positive] +const GRADIENT_PRESETS = [ + { name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] }, + { name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] }, + { name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] }, + { name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] }, + { name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] }, + { name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] }, + { name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] }, + { name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] }, + { name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] }, + { name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] } +] + +// Glow color logic: emotion value → color, with optional gradient preset +function emotionToColor(emotion, gradientIdx = null) { + const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null + if (preset) { + const [neg, mid, pos] = preset.colors + if (emotion >= 0) { + return lerpColor(mid, pos, emotion) + } else { + return lerpColor(mid, neg, Math.abs(emotion)) + } + } + if (emotion >= 0) { + if (emotion < 0.5) { + return lerpColor('#FF6B35', '#FFD700', emotion / 0.5) + } + return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5) + } else { + const abs = Math.abs(emotion) + if (abs < 0.5) { + return lerpColor('#2196F3', '#9C27B0', abs / 0.5) + } + return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5) + } +} + +// Demo seed data +const demoEvents = [ + { id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() } +] + +// Generate realistic demo events for testing at scale +function generateManyEvents(count = 500) { + // Realistic life event categories with emotion ranges + const categories = [ + // Positive events + { titles: ['Geburtstag', 'Geburtstagsfeier', 'Überraschungsparty'], emotionRange: [0.3, 0.8], noteChance: 0.4, notes: ['Tolles Fest!', 'Viele Geschenke', 'Schöner Tag mit Freunden', 'Alles Gute!'] }, + { titles: ['Urlaub', 'Strandurlaub', 'Städtereise', 'Roadtrip', 'Backpacking'], emotionRange: [0.4, 0.95], noteChance: 0.6, notes: ['Unvergesslich', 'Wunderschöne Landschaft', 'Endlich Erholung', 'Muss ich wiederholen'] }, + { titles: ['Hochzeit', 'Verlobung', 'Jahrestag'], emotionRange: [0.7, 1.0], noteChance: 0.8, notes: ['Der schönste Tag', 'Für immer', 'Tränen der Freude', 'Unbeschreiblich'] }, + { titles: ['Beförderung', 'Neuer Job', 'Gehaltserhöhung', 'Jobangebot'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Endlich!', 'Harte Arbeit zahlt sich aus', 'Neues Kapitel', 'Verdient'] }, + { titles: ['Konzert', 'Festival', 'Theaterbesuch', 'Oper'], emotionRange: [0.3, 0.85], noteChance: 0.5, notes: ['Gänsehaut', 'Beste Band ever', 'Geniale Atmosphäre', 'Nächstes Jahr wieder'] }, + { titles: ['Geburt', 'Baby da!', 'Nachwuchs'], emotionRange: [0.85, 1.0], noteChance: 0.9, notes: ['Das größte Wunder', 'Willkommen auf der Welt', 'Unbeschreibliches Glück'] }, + { titles: ['Abschluss', 'Prüfung bestanden', 'Diplom', 'Master geschafft'], emotionRange: [0.6, 0.95], noteChance: 0.6, notes: ['Geschafft!', 'Jahre harter Arbeit', 'Stolz', 'Endlich vorbei'] }, + { titles: ['Bergwanderung', 'Gipfel erreicht', 'Marathon geschafft', 'Triathlon'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Was für ein Ausblick!', 'Körperliche Grenzen überwunden', 'Nie aufgeben'] }, + { titles: ['Hauskauf', 'Wohnungseinweihung', 'Renovierung fertig'], emotionRange: [0.4, 0.8], noteChance: 0.5, notes: ['Endlich eigene vier Wände', 'Traum wird wahr', 'Viel Arbeit, aber es lohnt sich'] }, + { titles: ['Erstes Date', 'Zusammengekommen', 'Liebeserklärung'], emotionRange: [0.5, 0.95], noteChance: 0.6, notes: ['Schmetterlinge', 'Liebe auf den ersten Blick', 'Endlich getraut'] }, + // Neutral events + { titles: ['Umzug', 'Neue Stadt', 'Wohnungswechsel'], emotionRange: [-0.2, 0.3], noteChance: 0.4, notes: ['Neuanfang', 'Alles anders', 'Spannend und stressig zugleich'] }, + { titles: ['Arztbesuch', 'Vorsorge', 'Check-up'], emotionRange: [-0.1, 0.1], noteChance: 0.2, notes: ['Alles okay', 'Routine'] }, + { titles: ['Meeting', 'Präsentation', 'Workshop'], emotionRange: [-0.1, 0.4], noteChance: 0.3, notes: ['Gut gelaufen', 'Viel gelernt', 'Anstrengend'] }, + { titles: ['Friseur', 'Shopping', 'Einkauf'], emotionRange: [0.0, 0.3], noteChance: 0.1, notes: ['Neuer Look', 'Guter Fund'] }, + // Negative events + { titles: ['Trennung', 'Beziehungsende', 'Scheidung'], emotionRange: [-1.0, -0.5], noteChance: 0.5, notes: ['Schmerzhaft', 'Warum?', 'Es ist besser so', 'Brauche Zeit'] }, + { titles: ['Jobverlust', 'Kündigung', 'Firma pleite'], emotionRange: [-0.9, -0.4], noteChance: 0.5, notes: ['Schock', 'Wie geht es weiter?', 'Unverdient'] }, + { titles: ['Krankheit', 'OP', 'Krankenhaus'], emotionRange: [-0.8, -0.3], noteChance: 0.6, notes: ['Wird schon', 'Hauptsache gesund werden', 'Lange Genesung'] }, + { titles: ['Abschied', 'Verlust', 'Trauer'], emotionRange: [-1.0, -0.6], noteChance: 0.7, notes: ['Ruhe in Frieden', 'Fehlt mir', 'Unvergessen', 'Schwerer Tag'] }, + { titles: ['Streit', 'Konflikt', 'Auseinandersetzung'], emotionRange: [-0.7, -0.2], noteChance: 0.3, notes: ['Muss nicht sein', 'Hoffe auf Klärung'] }, + { titles: ['Unfall', 'Panne', 'Autopanne'], emotionRange: [-0.6, -0.2], noteChance: 0.4, notes: ['Zum Glück nichts Schlimmes', 'Ärgerlich', 'Hätte schlimmer sein können'] }, + { titles: ['Prüfung nicht bestanden', 'Absage', 'Ablehnung'], emotionRange: [-0.7, -0.3], noteChance: 0.4, notes: ['Nächstes Mal', 'Nicht aufgeben', 'Enttäuschend'] }, + ] + + const demoImages = [ + 'demo/photo-1530103862676-de8c9debad1d.jpeg', + 'demo/photo-1534067783941-51c9c23ecefd.jpeg', + 'demo/photo-1506905925346-21bda4d32df4.jpeg' + ] + + // Seeded random for reproducibility + let seed = 42 + function rand() { + seed = (seed * 16807 + 0) % 2147483647 + return (seed - 1) / 2147483646 + } + + function randInt(min, max) { + return Math.floor(rand() * (max - min + 1)) + min + } + + function pick(arr) { + return arr[Math.floor(rand() * arr.length)] + } + + function randFloat(min, max) { + return Math.round((min + rand() * (max - min)) * 100) / 100 + } + + const evts = [] + const startYear = 1985 + const endYear = 2026 + + // Generate events with realistic distribution (more events in recent years) + for (let i = 0; i < count; i++) { + // Weight towards recent years: cube root distribution + const t = rand() + const yearFloat = startYear + (endYear - startYear) * (t * t * 0.4 + t * 0.6) + const year = Math.floor(yearFloat) + const month = randInt(1, 12) + const day = randInt(1, 28) // safe for all months + const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` + + const cat = pick(categories) + const title = pick(cat.titles) + const emotion = randFloat(cat.emotionRange[0], cat.emotionRange[1]) + const hasNote = rand() < cat.noteChance + const note = hasNote ? pick(cat.notes) : '' + const hasImage = rand() < 0.15 // 15% chance + const image = hasImage ? pick(demoImages) : null + const hasPreset = rand() < 0.25 // 25% chance + const gradientPreset = hasPreset ? randInt(0, 9) : null + + evts.push({ + id: crypto.randomUUID(), + title, + date, + emotion, + customColor: null, + gradientPreset, + image, + note, + syncStatus: 'local', + createdAt: Date.now(), + updatedAt: Date.now() + }) + } + + // Sort by date + evts.sort((a, b) => a.date.localeCompare(b.date)) + return evts +} + +export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents } + +export const useEventsStore = defineStore('events', () => { + const events = ref([]) + const isLoaded = ref(false) + const selectedEventId = ref(null) + const panelOpen = ref(false) + const editingEventId = ref(null) + + // Load events from IndexedDB; seed demo data on first launch + async function init() { + try { + let stored = await db.events.orderBy('date').toArray() + if (stored.length === 0) { + const seed = generateManyEvents(500) + await db.events.bulkPut(seed) + stored = seed + } + events.value = stored + } catch (e) { + console.warn('Dexie load failed, using demo data:', e) + events.value = [...demoEvents] + } + isLoaded.value = true + + // Start auto-sync if authenticated + getToken().then((token) => { + if (token) startAutoSync() + }) + } + + // Fire-and-forget DB write (UI already updated via ref) + function dbPut(event) { + db.events.put(event).catch(e => console.warn('Dexie put failed:', e)) + } + + function dbDelete(id) { + db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e)) + } + + function dbQueueSync(eventId, action, payload) { + db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() }) + .catch(e => console.warn('Dexie sync queue failed:', e)) + } + + // Ghost event for live preview while creating/editing + const ghostEmotion = ref(0) + const ghostCustomColor = ref(null) + const ghostGradientPreset = ref(null) + const ghostTitle = ref('') + const ghostDate = ref(new Date().toISOString().slice(0, 10)) + const ghostNote = ref('') + const ghostImage = ref(null) + + const ghostEvent = computed(() => ({ + id: '__ghost__', + title: ghostTitle.value || 'New Event', + date: ghostDate.value, + emotion: ghostEmotion.value, + customColor: ghostCustomColor.value, + gradientPreset: ghostGradientPreset.value, + image: ghostImage.value, + note: ghostNote.value + })) + + const sortedEvents = computed(() => { + return [...events.value].sort((a, b) => new Date(a.date) - new Date(b.date)) + }) + + function selectEvent(id) { + selectedEventId.value = id + } + + function openPanel(eventId = null) { + if (eventId) { + editingEventId.value = eventId + const event = events.value.find((e) => e.id === eventId) + if (event) { + ghostTitle.value = event.title + ghostDate.value = event.date + ghostEmotion.value = event.emotion + ghostCustomColor.value = event.customColor + ghostGradientPreset.value = event.gradientPreset ?? null + ghostImage.value = event.image || null + ghostNote.value = event.note + } + } else { + editingEventId.value = null + ghostTitle.value = '' + ghostDate.value = new Date().toISOString().slice(0, 10) + ghostEmotion.value = 0 + ghostCustomColor.value = null + ghostGradientPreset.value = null + ghostImage.value = null + ghostNote.value = '' + } + panelOpen.value = true + } + + // Auto-save: persist ghost → event in edit mode + function persistToEvent() { + if (!editingEventId.value) return + const idx = events.value.findIndex((e) => e.id === editingEventId.value) + if (idx === -1) return + const updated = { + ...events.value[idx], + title: ghostTitle.value, + date: ghostDate.value, + emotion: ghostEmotion.value, + customColor: ghostCustomColor.value, + gradientPreset: ghostGradientPreset.value, + image: ghostImage.value, + note: ghostNote.value, + syncStatus: 'modified', + updatedAt: Date.now() + } + events.value[idx] = updated + dbPut(updated) + } + + watch( + [ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote], + () => { persistToEvent() } + ) + + function closePanel() { + if (!editingEventId.value && ghostTitle.value.trim()) { + const newEvent = { + id: crypto.randomUUID(), + title: ghostTitle.value, + date: ghostDate.value, + emotion: ghostEmotion.value, + customColor: ghostCustomColor.value, + gradientPreset: ghostGradientPreset.value, + image: ghostImage.value, + note: ghostNote.value, + syncStatus: 'local', + createdAt: Date.now(), + updatedAt: Date.now() + } + events.value.push(newEvent) + dbPut(newEvent) + dbQueueSync(newEvent.id, 'create', { ...newEvent }) + } + panelOpen.value = false + editingEventId.value = null + selectedEventId.value = null + } + + function deleteEvent(id) { + events.value = events.value.filter((e) => e.id !== id) + dbDelete(id) + dbQueueSync(id, 'delete', null) + closePanel() + } + + function getGlowColor(event) { + if (event.customColor) return event.customColor + return emotionToColor(event.emotion, event.gradientPreset ?? null) + } + + // Auto-init on store creation + init() + + return { + events, + isLoaded, + selectedEventId, + panelOpen, + editingEventId, + ghostEmotion, + ghostCustomColor, + ghostGradientPreset, + ghostTitle, + ghostDate, + ghostNote, + ghostImage, + ghostEvent, + sortedEvents, + selectEvent, + openPanel, + closePanel, + deleteEvent, + getGlowColor + } +}) diff --git a/frontend/_src/stores/example-store.js b/frontend/_src/stores/example-store.js new file mode 100644 index 0000000..041324e --- /dev/null +++ b/frontend/_src/stores/example-store.js @@ -0,0 +1,21 @@ +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)) +} diff --git a/frontend/_src/stores/images/photo-1506905925346-21bda4d32df4.jpeg b/frontend/_src/stores/images/photo-1506905925346-21bda4d32df4.jpeg new file mode 100644 index 0000000..de7378e Binary files /dev/null and b/frontend/_src/stores/images/photo-1506905925346-21bda4d32df4.jpeg differ diff --git a/frontend/_src/stores/images/photo-1530103862676-de8c9debad1d.jpeg b/frontend/_src/stores/images/photo-1530103862676-de8c9debad1d.jpeg new file mode 100644 index 0000000..1d998da Binary files /dev/null and b/frontend/_src/stores/images/photo-1530103862676-de8c9debad1d.jpeg differ diff --git a/frontend/_src/stores/images/photo-1534067783941-51c9c23ecefd.jpeg b/frontend/_src/stores/images/photo-1534067783941-51c9c23ecefd.jpeg new file mode 100644 index 0000000..1ac70a7 Binary files /dev/null and b/frontend/_src/stores/images/photo-1534067783941-51c9c23ecefd.jpeg differ diff --git a/frontend/_src/stores/index.js b/frontend/_src/stores/index.js new file mode 100644 index 0000000..a260a8c --- /dev/null +++ b/frontend/_src/stores/index.js @@ -0,0 +1,20 @@ +import { defineStore } from '#q-app/wrappers' +import { createPinia } from 'pinia' + +/* + * If not building with SSR mode, you can + * directly export the Store instantiation; + * + * The function below can be async too; either use + * async/await or return a Promise which resolves + * with the Store instance. + */ + +export default defineStore((/* { ssrContext } */) => { + const pinia = createPinia() + + // You can add Pinia plugins here + // pinia.use(SomePiniaPlugin) + + return pinia +}) diff --git a/frontend/_src/stores/settings.js b/frontend/_src/stores/settings.js new file mode 100644 index 0000000..bea0137 --- /dev/null +++ b/frontend/_src/stores/settings.js @@ -0,0 +1,107 @@ +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' + +const STORAGE_KEY = 'thatsme-settings' + +export const ACCENT_COLORS = [ + { label: 'Standard', value: 'default', hex: '#9e9e9e' }, + { label: 'Blau', value: 'blue', hex: '#2196F3' }, + { label: 'Grün', value: 'green', hex: '#4CAF50' }, + { label: 'Gelb', value: 'yellow', hex: '#FFC107' }, + { label: 'Rosa', value: 'pink', hex: '#E91E63' }, + { label: 'Orange', value: 'orange', hex: '#FF9800' } +] + +export const LANGUAGES = [ + { label: 'Deutsch', value: 'de' }, + { label: 'English', value: 'en' } +] + +const FLOATING_LINES_DEFAULTS = { + // Linien + speed: 1.0, + lineCount: 10, + spread: 0.05, + fanSpread: 0.05, + lineSharpness: 8.0, + waveFrequency: 7.0, + bezierCurvature: 0.2, + circleRadius: 75, + glowSize: 18, + glowStrength: 1.5, + lineBrightness: 1.0, + // Hintergrund + bgCenter: '#0a0514', + bgEdge: '#000000', + gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', + backgroundImage: '', + // Labels + labelSize: 'small', // 'small' | 'medium' | 'large' + labelColor: '#ffffff' +} + +function loadFromStorage() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? JSON.parse(stored) : null + } catch { + return null + } +} + +export { FLOATING_LINES_DEFAULTS } + +export const useSettingsStore = defineStore('settings', () => { + const stored = loadFromStorage() + + const theme = ref(stored?.theme ?? 'light') + const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS }) + + // App preferences + const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark' + const accentColor = ref(stored?.accentColor ?? 'default') + const language = ref(stored?.language ?? 'de') + + // Developer / debug + const showFps = ref(stored?.showFps ?? false) + + function persist() { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + theme: theme.value, + floatingLines: floatingLines.value, + appearance: appearance.value, + accentColor: accentColor.value, + language: language.value, + showFps: showFps.value + }) + ) + } + + watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true }) + + function toggleTheme() { + theme.value = theme.value === 'light' ? 'dark' : 'light' + } + + function updateFloatingLines(updates) { + floatingLines.value = { ...floatingLines.value, ...updates } + } + + function resetFloatingLines() { + floatingLines.value = { ...FLOATING_LINES_DEFAULTS } + } + + return { + theme, + floatingLines, + appearance, + accentColor, + language, + showFps, + toggleTheme, + updateFloatingLines, + resetFloatingLines + } +}) diff --git a/frontend/_src/utils/ConnectedDotsVisualization.ts b/frontend/_src/utils/ConnectedDotsVisualization.ts new file mode 100644 index 0000000..eba1518 --- /dev/null +++ b/frontend/_src/utils/ConnectedDotsVisualization.ts @@ -0,0 +1,550 @@ +// 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 = 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 + ) { + // 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: + .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): 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(); + } +} diff --git a/frontend/_src/utils/editFormOptions.js b/frontend/_src/utils/editFormOptions.js new file mode 100644 index 0000000..0679b42 --- /dev/null +++ b/frontend/_src/utils/editFormOptions.js @@ -0,0 +1,238 @@ +// 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: [] +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d9eaf63..51ca807 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4336,12 +4336,6 @@ "dev": true, "license": "ISC" }, - "node_modules/gsap": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", - "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5021,21 +5015,6 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",