From f79dcb992839cba04e824b3d9e23231efa7b6bdc Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 7 Jul 2026 15:54:35 +0000 Subject: [PATCH] Optimiere LifeWave-Timeline-Performance und entferne toten Code in _src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimelineView: Shader-Event-Mapping in computed ausgelagert, deep-Watch entfernt, natives Wheel-Scrolling statt @wheel.prevent - Events-Store: shallowRef statt tiefer Reaktivität, String-Sortierung statt Date-Objekten, getGlowColor memoized, DB-Writes debounced - Edits werden jetzt dedupliziert als 'update' in die SyncQueue geschrieben (vorher gingen Änderungen beim Sync verloren) - useImageCache: In-Flight-Deduping, Cache-Limit mit revokeObjectURL - FloatingLines: Antialiasing aus (Fullscreen-Quad), deep-Watches entfernt - Ungenutzte Seiten/Komponenten entfernt (~6.700 Zeilen, nicht geroutet) - implementation-plan.md: Hinweis zu update-Action im Sync ergänzt --- frontend/_src/assets/quasar-logo-vertical.svg | 15 - frontend/_src/components/EssentialLink.vue | 44 - frontend/_src/components/FloatingLines.vue | 14 +- frontend/_src/components/GlowDot.vue | 26 +- frontend/_src/components/TimelineView.vue | 57 +- frontend/_src/components/UserMenuButton.vue | 32 - frontend/_src/composables/useImageCache.js | 93 +- frontend/_src/layouts/MainLayout.vue | 741 ------ frontend/_src/pages/CategorySelector.vue | 369 --- frontend/_src/pages/EditPage.vue | 2037 ----------------- frontend/_src/pages/EntryDetailPage.vue | 1224 ---------- frontend/_src/pages/IndexPage.vue | 71 - frontend/_src/pages/PersonSelector.vue | 423 ---- frontend/_src/pages/TagSelector.vue | 445 ---- frontend/_src/pages/WavePage.vue | 488 ---- frontend/_src/stores/events.js | 73 +- frontend/_src/stores/example-store.js | 21 - .../_src/utils/ConnectedDotsVisualization.ts | 550 ----- frontend/_src/utils/editFormOptions.js | 238 -- .../dev/db-api-connect/implementation-plan.md | 5 + 20 files changed, 185 insertions(+), 6781 deletions(-) delete mode 100644 frontend/_src/assets/quasar-logo-vertical.svg delete mode 100644 frontend/_src/components/EssentialLink.vue delete mode 100644 frontend/_src/components/UserMenuButton.vue delete mode 100644 frontend/_src/layouts/MainLayout.vue delete mode 100644 frontend/_src/pages/CategorySelector.vue delete mode 100644 frontend/_src/pages/EditPage.vue delete mode 100644 frontend/_src/pages/EntryDetailPage.vue delete mode 100644 frontend/_src/pages/IndexPage.vue delete mode 100644 frontend/_src/pages/PersonSelector.vue delete mode 100644 frontend/_src/pages/TagSelector.vue delete mode 100644 frontend/_src/pages/WavePage.vue delete mode 100644 frontend/_src/stores/example-store.js delete mode 100644 frontend/_src/utils/ConnectedDotsVisualization.ts delete mode 100644 frontend/_src/utils/editFormOptions.js diff --git a/frontend/_src/assets/quasar-logo-vertical.svg b/frontend/_src/assets/quasar-logo-vertical.svg deleted file mode 100644 index 8210831..0000000 --- a/frontend/_src/assets/quasar-logo-vertical.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/frontend/_src/components/EssentialLink.vue b/frontend/_src/components/EssentialLink.vue deleted file mode 100644 index 54afb06..0000000 --- a/frontend/_src/components/EssentialLink.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/frontend/_src/components/FloatingLines.vue b/frontend/_src/components/FloatingLines.vue index 054bdd3..e239135 100644 --- a/frontend/_src/components/FloatingLines.vue +++ b/frontend/_src/components/FloatingLines.vue @@ -295,20 +295,22 @@ watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.v watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v }) watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v }) watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.value = v }) +// No deep watches needed: these array props come from computeds that +// produce fresh arrays on every change, so reference equality suffices watch(() => props.pointXValues, (values) => { if (!uniforms) return for (let i = 0; i < 16; i++) { uniforms.pointX.value[i] = values[i] ?? 0 } -}, { deep: true }) +}) watch(() => props.pointYValues, (values) => { if (!uniforms) return for (let i = 0; i < 16; i++) { uniforms.pointY.value[i] = values[i] ?? 0 } -}, { deep: true }) -watch(() => props.pointColors, applyPointColors, { deep: true }) -watch(() => props.linesGradient, applyGradient, { deep: true }) +}) +watch(() => props.pointColors, applyPointColors) +watch(() => props.linesGradient, applyGradient) watch(() => props.bgColorCenter, applyBgColors) watch(() => props.bgColorEdge, applyBgColors) @@ -331,7 +333,9 @@ onMounted(() => { let currentDpr = DPR_IDLE let scrolling = false - renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' }) + // No antialias: we render a single fullscreen quad — MSAA only smooths + // geometry edges (there are none); line edges are smoothed in the shader + renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' }) renderer.setPixelRatio(currentDpr) renderer.domElement.style.width = '100%' renderer.domElement.style.height = '100%' diff --git a/frontend/_src/components/GlowDot.vue b/frontend/_src/components/GlowDot.vue index 627de28..5ded369 100644 --- a/frontend/_src/components/GlowDot.vue +++ b/frontend/_src/components/GlowDot.vue @@ -26,10 +26,10 @@ - - diff --git a/frontend/_src/composables/useImageCache.js b/frontend/_src/composables/useImageCache.js index 8685a28..17148cc 100644 --- a/frontend/_src/composables/useImageCache.js +++ b/frontend/_src/composables/useImageCache.js @@ -4,9 +4,25 @@ 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 +// Shared across all component instances. Bounded: oldest entries are evicted +// and their blob URLs revoked once the cap is reached. +const MEMORY_CACHE_MAX = 300 const memoryCache = new Map() +// In-flight resolutions: parallel requests for the same URL share one promise +// (many dots can mount at once referencing the same image) +const inFlight = new Map() + +function memoryCacheSet(url, blobUrl) { + if (memoryCache.size >= MEMORY_CACHE_MAX) { + // Map iterates in insertion order → first entry is the oldest + const [oldestUrl, oldestBlobUrl] = memoryCache.entries().next().value + URL.revokeObjectURL(oldestBlobUrl) + memoryCache.delete(oldestUrl) + } + memoryCache.set(url, blobUrl) +} + /** * Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob. * Returns a new Blob (JPEG, quality 0.8). @@ -68,7 +84,7 @@ async function fetchAndCache(imageUrl, eventId) { }) const blobUrl = URL.createObjectURL(thumbBlob) - memoryCache.set(imageUrl, blobUrl) + memoryCacheSet(imageUrl, blobUrl) return blobUrl } @@ -84,7 +100,7 @@ async function getCachedImage(imageUrl) { const entry = await db.imageCache.get(imageUrl) if (entry?.blob) { const blobUrl = URL.createObjectURL(entry.blob) - memoryCache.set(imageUrl, blobUrl) + memoryCacheSet(imageUrl, blobUrl) return blobUrl } } catch (e) { @@ -93,6 +109,38 @@ async function getCachedImage(imageUrl) { return null } +/** + * Resolve an image URL to a displayable src (memory → IndexedDB → fetch). + * Concurrent calls for the same URL share a single in-flight promise. + * Falls back to the original URL if caching fails. + */ +export function resolveImage(imageUrl, eventId) { + if (!imageUrl) return Promise.resolve(null) + + if (memoryCache.has(imageUrl)) { + return Promise.resolve(memoryCache.get(imageUrl)) + } + + if (inFlight.has(imageUrl)) { + return inFlight.get(imageUrl) + } + + const promise = (async () => { + const cached = await getCachedImage(imageUrl) + if (cached) return cached + try { + return await fetchAndCache(imageUrl, eventId) + } catch (e) { + // Fallback: use original URL directly (works when online) + console.warn('Image cache failed, using direct URL:', e) + return imageUrl + } + })().finally(() => inFlight.delete(imageUrl)) + + inFlight.set(imageUrl, promise) + return promise +} + /** * Composable: resolves an event's image to a displayable src. * - Checks memory cache → IndexedDB cache → fetches & caches thumbnail. @@ -100,42 +148,11 @@ async function getCachedImage(imageUrl) { */ export function useImageCache(imageUrl, eventId) { const resolvedSrc = ref(null) - const loading = ref(false) + const loading = ref(true) - 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() + resolveImage(imageUrl, eventId) + .then((src) => { resolvedSrc.value = src }) + .finally(() => { loading.value = false }) return { resolvedSrc, loading } } diff --git a/frontend/_src/layouts/MainLayout.vue b/frontend/_src/layouts/MainLayout.vue deleted file mode 100644 index 529290a..0000000 --- a/frontend/_src/layouts/MainLayout.vue +++ /dev/null @@ -1,741 +0,0 @@ - - - - - diff --git a/frontend/_src/pages/CategorySelector.vue b/frontend/_src/pages/CategorySelector.vue deleted file mode 100644 index c03e179..0000000 --- a/frontend/_src/pages/CategorySelector.vue +++ /dev/null @@ -1,369 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/_src/pages/EditPage.vue b/frontend/_src/pages/EditPage.vue deleted file mode 100644 index 03af6ca..0000000 --- a/frontend/_src/pages/EditPage.vue +++ /dev/null @@ -1,2037 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/_src/pages/EntryDetailPage.vue b/frontend/_src/pages/EntryDetailPage.vue deleted file mode 100644 index db1f822..0000000 --- a/frontend/_src/pages/EntryDetailPage.vue +++ /dev/null @@ -1,1224 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/_src/pages/IndexPage.vue b/frontend/_src/pages/IndexPage.vue deleted file mode 100644 index e74e263..0000000 --- a/frontend/_src/pages/IndexPage.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/frontend/_src/pages/PersonSelector.vue b/frontend/_src/pages/PersonSelector.vue deleted file mode 100644 index 2254e7f..0000000 --- a/frontend/_src/pages/PersonSelector.vue +++ /dev/null @@ -1,423 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/_src/pages/TagSelector.vue b/frontend/_src/pages/TagSelector.vue deleted file mode 100644 index e703d0d..0000000 --- a/frontend/_src/pages/TagSelector.vue +++ /dev/null @@ -1,445 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/_src/pages/WavePage.vue b/frontend/_src/pages/WavePage.vue deleted file mode 100644 index e125986..0000000 --- a/frontend/_src/pages/WavePage.vue +++ /dev/null @@ -1,488 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/_src/stores/events.js b/frontend/_src/stores/events.js index ab4eb55..07dfe81 100644 --- a/frontend/_src/stores/events.js +++ b/frontend/_src/stores/events.js @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref, computed, watch } from 'vue' +import { ref, computed, watch, shallowRef, triggerRef } from 'vue' import { db } from 'src/db' import { startAutoSync, getToken } from 'src/services/syncService' @@ -170,7 +170,10 @@ function generateManyEvents(count = 500) { export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents } export const useEventsStore = defineStore('events', () => { - const events = ref([]) + // shallowRef: event objects are never mutated field-by-field, only replaced + // wholesale — deep reactivity on 500+ objects would be pure proxy overhead. + // In-place array mutations must call triggerRef(events) afterwards. + const events = shallowRef([]) const isLoaded = ref(false) const selectedEventId = ref(null) const panelOpen = ref(false) @@ -212,6 +215,22 @@ export const useEventsStore = defineStore('events', () => { .catch(e => console.warn('Dexie sync queue failed:', e)) } + // Queue an 'update' mutation. Any older pending update for the same event + // is replaced, so the queue holds at most one update per event. + function dbQueueUpdate(event) { + db.syncQueue + .where('eventId').equals(event.id) + .and((item) => item.action === 'update') + .delete() + .then(() => db.syncQueue.add({ + eventId: event.id, + action: 'update', + payload: { ...event }, + 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) @@ -233,7 +252,8 @@ export const useEventsStore = defineStore('events', () => { })) const sortedEvents = computed(() => { - return [...events.value].sort((a, b) => new Date(a.date) - new Date(b.date)) + // Dates are ISO strings (YYYY-MM-DD) — lexicographic order is chronological + return [...events.value].sort((a, b) => a.date.localeCompare(b.date)) }) function selectEvent(id) { @@ -266,6 +286,34 @@ export const useEventsStore = defineStore('events', () => { panelOpen.value = true } + // Debounced DB write: the UI updates on every keystroke via the ref, + // but IndexedDB only gets the final state after typing pauses + const PERSIST_DEBOUNCE_MS = 400 + let persistTimer = null + let pendingWrite = null + + function schedulePersist(event) { + pendingWrite = event + clearTimeout(persistTimer) + persistTimer = setTimeout(flushPersist, PERSIST_DEBOUNCE_MS) + } + + function flushPersist() { + clearTimeout(persistTimer) + persistTimer = null + if (pendingWrite) { + dbPut(pendingWrite) + dbQueueUpdate(pendingWrite) + pendingWrite = null + } + } + + function cancelPersist() { + clearTimeout(persistTimer) + persistTimer = null + pendingWrite = null + } + // Auto-save: persist ghost → event in edit mode function persistToEvent() { if (!editingEventId.value) return @@ -284,7 +332,8 @@ export const useEventsStore = defineStore('events', () => { updatedAt: Date.now() } events.value[idx] = updated - dbPut(updated) + triggerRef(events) + schedulePersist(updated) } watch( @@ -293,6 +342,7 @@ export const useEventsStore = defineStore('events', () => { ) function closePanel() { + flushPersist() if (!editingEventId.value && ghostTitle.value.trim()) { const newEvent = { id: crypto.randomUUID(), @@ -308,6 +358,7 @@ export const useEventsStore = defineStore('events', () => { updatedAt: Date.now() } events.value.push(newEvent) + triggerRef(events) dbPut(newEvent) dbQueueSync(newEvent.id, 'create', { ...newEvent }) } @@ -317,15 +368,27 @@ export const useEventsStore = defineStore('events', () => { } function deleteEvent(id) { + if (pendingWrite?.id === id) cancelPersist() events.value = events.value.filter((e) => e.id !== id) dbDelete(id) dbQueueSync(id, 'delete', null) closePanel() } + // Memoized: emotionToColor is pure and its input space is small + // (emotion in 0.05 steps × 10 presets), so the cache stays bounded + const glowColorCache = new Map() + function getGlowColor(event) { if (event.customColor) return event.customColor - return emotionToColor(event.emotion, event.gradientPreset ?? null) + const preset = event.gradientPreset ?? null + const key = `${event.emotion}|${preset}` + let color = glowColorCache.get(key) + if (!color) { + color = emotionToColor(event.emotion, preset) + glowColorCache.set(key, color) + } + return color } // Auto-init on store creation diff --git a/frontend/_src/stores/example-store.js b/frontend/_src/stores/example-store.js deleted file mode 100644 index 041324e..0000000 --- a/frontend/_src/stores/example-store.js +++ /dev/null @@ -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)) -} diff --git a/frontend/_src/utils/ConnectedDotsVisualization.ts b/frontend/_src/utils/ConnectedDotsVisualization.ts deleted file mode 100644 index eba1518..0000000 --- a/frontend/_src/utils/ConnectedDotsVisualization.ts +++ /dev/null @@ -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 = 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 deleted file mode 100644 index 0679b42..0000000 --- a/frontend/_src/utils/editFormOptions.js +++ /dev/null @@ -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: [] -} \ No newline at end of file diff --git a/frontend/dev/db-api-connect/implementation-plan.md b/frontend/dev/db-api-connect/implementation-plan.md index b99a9c2..3d43205 100644 --- a/frontend/dev/db-api-connect/implementation-plan.md +++ b/frontend/dev/db-api-connect/implementation-plan.md @@ -181,6 +181,11 @@ Umgesetzt: - Frontend haelt Remote-Events nur als lokalen Cache in IndexedDB. - Lokaler Demo-User bleibt browserlokal. +Hinweis zur Offline-SyncQueue (Stand 2026-07-07): + +- Der Timeline-Prototyp unter `frontend/_src` schreibt jetzt auch `update`-Mutationen in die lokale SyncQueue: debounced beim Bearbeiten, dedupliziert auf maximal einen offenen Update-Eintrag pro Event. Vorher wurden nur `create` und `delete` in die Queue geschrieben, Edits gingen beim Sync verloren. +- `POST /api/events/sync` validiert und verarbeitet bereits alle drei Actions `create`, `update` und `delete`; backendseitig ist dafuer keine Anpassung noetig. + Aufgaben: - Bestehende `events`-Tabelle mit Frontend-Modell abgleichen.