Optimiere LifeWave-Timeline-Performance und entferne toten Code in _src
- 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
This commit is contained in:
parent
c1514999be
commit
f79dcb9928
20 changed files with 185 additions and 6781 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
|
|
||||||
<path
|
|
||||||
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
|
|
||||||
<path fill="#050A14"
|
|
||||||
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
|
|
||||||
<path fill="#00B4FF"
|
|
||||||
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
|
|
||||||
<path fill="#00B4FF"
|
|
||||||
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
|
|
||||||
<path fill="#050A14"
|
|
||||||
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
|
|
||||||
<path fill="#00B4FF"
|
|
||||||
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
|
|
||||||
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
|
@ -1,44 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-item
|
|
||||||
clickable
|
|
||||||
tag="a"
|
|
||||||
target="_blank"
|
|
||||||
:href="props.link"
|
|
||||||
>
|
|
||||||
<q-item-section
|
|
||||||
v-if="props.icon"
|
|
||||||
avatar
|
|
||||||
>
|
|
||||||
<q-icon :name="props.icon" />
|
|
||||||
</q-item-section>
|
|
||||||
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ props.title }}</q-item-label>
|
|
||||||
<q-item-label caption>{{ props.caption }}</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
|
|
||||||
caption: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
|
|
||||||
link: {
|
|
||||||
type: String,
|
|
||||||
default: '#'
|
|
||||||
},
|
|
||||||
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
@ -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.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
|
||||||
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
|
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
|
||||||
watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.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) => {
|
watch(() => props.pointXValues, (values) => {
|
||||||
if (!uniforms) return
|
if (!uniforms) return
|
||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < 16; i++) {
|
||||||
uniforms.pointX.value[i] = values[i] ?? 0
|
uniforms.pointX.value[i] = values[i] ?? 0
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
})
|
||||||
watch(() => props.pointYValues, (values) => {
|
watch(() => props.pointYValues, (values) => {
|
||||||
if (!uniforms) return
|
if (!uniforms) return
|
||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < 16; i++) {
|
||||||
uniforms.pointY.value[i] = values[i] ?? 0
|
uniforms.pointY.value[i] = values[i] ?? 0
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
})
|
||||||
watch(() => props.pointColors, applyPointColors, { deep: true })
|
watch(() => props.pointColors, applyPointColors)
|
||||||
watch(() => props.linesGradient, applyGradient, { deep: true })
|
watch(() => props.linesGradient, applyGradient)
|
||||||
watch(() => props.bgColorCenter, applyBgColors)
|
watch(() => props.bgColorCenter, applyBgColors)
|
||||||
watch(() => props.bgColorEdge, applyBgColors)
|
watch(() => props.bgColorEdge, applyBgColors)
|
||||||
|
|
||||||
|
|
@ -331,7 +333,9 @@ onMounted(() => {
|
||||||
let currentDpr = DPR_IDLE
|
let currentDpr = DPR_IDLE
|
||||||
let scrolling = false
|
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.setPixelRatio(currentDpr)
|
||||||
renderer.domElement.style.width = '100%'
|
renderer.domElement.style.width = '100%'
|
||||||
renderer.domElement.style.height = '100%'
|
renderer.domElement.style.height = '100%'
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useEventsStore } from 'stores/events'
|
import { useEventsStore } from 'stores/events'
|
||||||
import { useSettingsStore } from 'stores/settings'
|
import { useSettingsStore } from 'stores/settings'
|
||||||
import { useImageCache } from 'composables/useImageCache'
|
import { resolveImage } from 'composables/useImageCache'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
event: { type: Object, required: true },
|
event: { type: Object, required: true },
|
||||||
|
|
@ -42,10 +42,18 @@ const emit = defineEmits(['select'])
|
||||||
const eventsStore = useEventsStore()
|
const eventsStore = useEventsStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
|
// Resolve image: cached thumbnail from IndexedDB or fetch & cache.
|
||||||
const { resolvedSrc: imageSrc } = props.event.image
|
// Watched so the dot updates when the event's image changes while mounted.
|
||||||
? useImageCache(props.event.image, props.event.id)
|
const imageSrc = ref(null)
|
||||||
: { resolvedSrc: computed(() => null) }
|
watch(
|
||||||
|
() => props.event.image,
|
||||||
|
async (img) => {
|
||||||
|
const src = await resolveImage(img, props.event.id)
|
||||||
|
// Guard against stale async result after the image changed again
|
||||||
|
if (props.event.image === img) imageSrc.value = src
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const fl = computed(() => settingsStore.floatingLines)
|
const fl = computed(() => settingsStore.floatingLines)
|
||||||
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
||||||
|
|
@ -61,11 +69,11 @@ const yPercent = computed(() => 48 - props.event.emotion * 30)
|
||||||
// Label above dot when dot is in lower half, below when in upper half
|
// Label above dot when dot is in lower half, below when in upper half
|
||||||
const labelAbove = computed(() => yPercent.value > 48)
|
const labelAbove = computed(() => yPercent.value > 48)
|
||||||
|
|
||||||
// Format date as "12. Feb 2024"
|
// Format date as "12. Feb 2024" (parsed from ISO string, no Date allocation)
|
||||||
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||||
const formattedDate = computed(() => {
|
const formattedDate = computed(() => {
|
||||||
const d = new Date(props.event.date)
|
const [year, month, day] = props.event.date.split('-')
|
||||||
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
|
return `${Number(day)}. ${MONTH_SHORT[Number(month) - 1]} ${year}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Label font sizes per setting
|
// Label font sizes per setting
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
class="timeline"
|
class="timeline"
|
||||||
ref="timelineRef"
|
ref="timelineRef"
|
||||||
@scroll="onScroll"
|
@scroll="onScroll"
|
||||||
@wheel.prevent="onWheel"
|
@wheel="onWheel"
|
||||||
@touchstart.passive="onTouchStart"
|
@touchstart.passive="onTouchStart"
|
||||||
@touchmove.passive="onTouchMove"
|
@touchmove.passive="onTouchMove"
|
||||||
@touchend.passive="onTouchEnd"
|
@touchend.passive="onTouchEnd"
|
||||||
|
|
@ -98,8 +98,8 @@ const displayEvents = computed(() => {
|
||||||
const sorted = [...eventsStore.sortedEvents]
|
const sorted = [...eventsStore.sortedEvents]
|
||||||
if (!showGhost.value) return sorted
|
if (!showGhost.value) return sorted
|
||||||
const ghost = eventsStore.ghostEvent
|
const ghost = eventsStore.ghostEvent
|
||||||
const ghostDate = new Date(ghost.date)
|
// Dates are ISO strings (YYYY-MM-DD) — string comparison is chronological
|
||||||
const insertIdx = sorted.findIndex((e) => new Date(e.date) > ghostDate)
|
const insertIdx = sorted.findIndex((e) => e.date > ghost.date)
|
||||||
if (insertIdx === -1) {
|
if (insertIdx === -1) {
|
||||||
sorted.push(ghost)
|
sorted.push(ghost)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -123,15 +123,16 @@ function getEventX(index) {
|
||||||
// Month names
|
// Month names
|
||||||
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||||
|
|
||||||
// One label per event showing its month
|
// One label per event showing its month (parsed from ISO string, no Date allocation)
|
||||||
const eventLabels = computed(() => {
|
const eventLabels = computed(() => {
|
||||||
return displayEvents.value.map((event, index) => {
|
return displayEvents.value.map((event, index) => {
|
||||||
const d = new Date(event.date)
|
const year = event.date.slice(0, 4)
|
||||||
|
const monthIdx = Number(event.date.slice(5, 7)) - 1
|
||||||
return {
|
return {
|
||||||
key: `${event.id}-${index}`,
|
key: `${event.id}-${index}`,
|
||||||
month: MONTHS[d.getMonth()],
|
month: MONTHS[monthIdx],
|
||||||
year: d.getFullYear(),
|
year: Number(year),
|
||||||
fullMonth: `${d.getFullYear()}-${d.getMonth()}`
|
fullMonth: `${year}-${monthIdx}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -142,11 +143,11 @@ const yearRanges = computed(() => {
|
||||||
const events = displayEvents.value
|
const events = displayEvents.value
|
||||||
if (events.length === 0) return ranges
|
if (events.length === 0) return ranges
|
||||||
|
|
||||||
let currentYear = new Date(events[0].date).getFullYear()
|
let currentYear = Number(events[0].date.slice(0, 4))
|
||||||
let startIdx = 0
|
let startIdx = 0
|
||||||
|
|
||||||
for (let i = 1; i < events.length; i++) {
|
for (let i = 1; i < events.length; i++) {
|
||||||
const year = new Date(events[i].date).getFullYear()
|
const year = Number(events[i].date.slice(0, 4))
|
||||||
if (year !== currentYear) {
|
if (year !== currentYear) {
|
||||||
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
|
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
|
||||||
currentYear = year
|
currentYear = year
|
||||||
|
|
@ -322,19 +323,17 @@ function applyZoom(newZoom, centerClientX) {
|
||||||
// Desktop: Ctrl+wheel or pinch on trackpad (both fire wheel with ctrlKey)
|
// Desktop: Ctrl+wheel or pinch on trackpad (both fire wheel with ctrlKey)
|
||||||
function onWheel(e) {
|
function onWheel(e) {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
// Pinch / Ctrl+scroll → zoom
|
// Pinch / Ctrl+scroll → zoom (prevent browser page zoom)
|
||||||
|
e.preventDefault()
|
||||||
const delta = -e.deltaY * ZOOM_STEP * 0.1
|
const delta = -e.deltaY * ZOOM_STEP * 0.1
|
||||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoomLevel.value + delta))
|
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoomLevel.value + delta))
|
||||||
if (newZoom !== zoomLevel.value) {
|
if (newZoom !== zoomLevel.value) {
|
||||||
applyZoom(newZoom, e.clientX)
|
applyZoom(newZoom, e.clientX)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||||
// Normal scroll — let the browser handle horizontal scroll
|
// Redirect vertical wheel to horizontal scroll; horizontal deltas
|
||||||
const el = timelineRef.value
|
// (trackpads) scroll natively with momentum — onScroll syncs the ref
|
||||||
if (el) {
|
timelineRef.value?.scrollBy({ left: e.deltaY })
|
||||||
el.scrollLeft += e.deltaX || e.deltaY
|
|
||||||
scrollLeft.value = el.scrollLeft
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,6 +370,17 @@ function onTouchEnd() {
|
||||||
|
|
||||||
// Scroll to center on the last event on mount
|
// Scroll to center on the last event on mount
|
||||||
let resizeObserver = null
|
let resizeObserver = null
|
||||||
|
|
||||||
|
// Shader point data — only recomputed when events or spacing change,
|
||||||
|
// NOT on every scroll tick (scroll only changes which slice is visible)
|
||||||
|
const shaderEvents = computed(() => {
|
||||||
|
return displayEvents.value.map((e, i) => ({
|
||||||
|
emotion: e.emotion,
|
||||||
|
x: getEventX(i),
|
||||||
|
color: eventsStore.getGlowColor(e)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
// Emit timeline state so the layout can position shader points
|
// Emit timeline state so the layout can position shader points
|
||||||
function emitViewState() {
|
function emitViewState() {
|
||||||
const { start, end } = visibleRange.value
|
const { start, end } = visibleRange.value
|
||||||
|
|
@ -380,18 +390,13 @@ function emitViewState() {
|
||||||
containerHeight: containerHeight.value,
|
containerHeight: containerHeight.value,
|
||||||
visibleStart: start,
|
visibleStart: start,
|
||||||
visibleEnd: end,
|
visibleEnd: end,
|
||||||
events: displayEvents.value.map((e, i) => ({
|
events: shaderEvents.value
|
||||||
emotion: e.emotion,
|
|
||||||
x: getEventX(i),
|
|
||||||
color: eventsStore.getGlowColor(e)
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
|
[scrollLeft, viewportWidth, containerHeight, shaderEvents],
|
||||||
emitViewState,
|
emitViewState
|
||||||
{ deep: true }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<template>
|
|
||||||
<button class="user-menu-btn glass--button" @click="$emit('openMenu')">
|
|
||||||
<q-icon name="person_outline" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useQuasar } from 'quasar'
|
|
||||||
|
|
||||||
defineEmits(['openMenu'])
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
|
||||||
const isDark = computed(() => $q.dark.isActive)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.user-menu-btn {
|
|
||||||
position: fixed;
|
|
||||||
top: 16px;
|
|
||||||
left: 16px;
|
|
||||||
z-index: 30;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -4,9 +4,25 @@ import { db } from 'src/db'
|
||||||
const THUMB_SIZE = 200
|
const THUMB_SIZE = 200
|
||||||
|
|
||||||
// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation
|
// 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()
|
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.
|
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
|
||||||
* Returns a new Blob (JPEG, quality 0.8).
|
* Returns a new Blob (JPEG, quality 0.8).
|
||||||
|
|
@ -68,7 +84,7 @@ async function fetchAndCache(imageUrl, eventId) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const blobUrl = URL.createObjectURL(thumbBlob)
|
const blobUrl = URL.createObjectURL(thumbBlob)
|
||||||
memoryCache.set(imageUrl, blobUrl)
|
memoryCacheSet(imageUrl, blobUrl)
|
||||||
return blobUrl
|
return blobUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +100,7 @@ async function getCachedImage(imageUrl) {
|
||||||
const entry = await db.imageCache.get(imageUrl)
|
const entry = await db.imageCache.get(imageUrl)
|
||||||
if (entry?.blob) {
|
if (entry?.blob) {
|
||||||
const blobUrl = URL.createObjectURL(entry.blob)
|
const blobUrl = URL.createObjectURL(entry.blob)
|
||||||
memoryCache.set(imageUrl, blobUrl)
|
memoryCacheSet(imageUrl, blobUrl)
|
||||||
return blobUrl
|
return blobUrl
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -93,6 +109,38 @@ async function getCachedImage(imageUrl) {
|
||||||
return null
|
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.
|
* Composable: resolves an event's image to a displayable src.
|
||||||
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail.
|
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail.
|
||||||
|
|
@ -100,42 +148,11 @@ async function getCachedImage(imageUrl) {
|
||||||
*/
|
*/
|
||||||
export function useImageCache(imageUrl, eventId) {
|
export function useImageCache(imageUrl, eventId) {
|
||||||
const resolvedSrc = ref(null)
|
const resolvedSrc = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
|
|
||||||
async function resolve() {
|
resolveImage(imageUrl, eventId)
|
||||||
if (!imageUrl) {
|
.then((src) => { resolvedSrc.value = src })
|
||||||
resolvedSrc.value = null
|
.finally(() => { loading.value = false })
|
||||||
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 }
|
return { resolvedSrc, loading }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,741 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-layout view="lHh Lpr lFf" class="dynamic-gradient-bg">
|
|
||||||
<q-header elevated>
|
|
||||||
<q-toolbar>
|
|
||||||
<q-toolbar-title>
|
|
||||||
THATS ME
|
|
||||||
</q-toolbar-title>
|
|
||||||
|
|
||||||
<!-- Add login status indicator -->
|
|
||||||
<q-chip v-if="isLoggedIn" color="green" text-color="white" icon="check_circle">
|
|
||||||
Logged In
|
|
||||||
</q-chip>
|
|
||||||
<q-chip v-else color="grey" text-color="white" icon="person_off">
|
|
||||||
Not Logged In
|
|
||||||
</q-chip>
|
|
||||||
|
|
||||||
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleDrawer" />
|
|
||||||
|
|
||||||
</q-toolbar>
|
|
||||||
</q-header>
|
|
||||||
|
|
||||||
<!-- Off-Canvas Drawer -->
|
|
||||||
<q-drawer v-model="drawer" show-if-above side="right" :width="280" :breakpoint="768" class="bg-grey-1"
|
|
||||||
style="overflow: hidden;">
|
|
||||||
<!-- Sliding Menu Container -->
|
|
||||||
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
|
|
||||||
|
|
||||||
<!-- Main Menu Panel -->
|
|
||||||
<div class="menu-panel">
|
|
||||||
|
|
||||||
<!-- Main Menu List -->
|
|
||||||
<q-list>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('login')" v-if="!isLoggedIn" to="/login">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="login" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Login</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<!-- Move password reset to Account submenu and only show when logged in -->
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('wave')" v-if="isLoggedIn" to="/wave">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="line_axis" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Wave</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn" to="/edit">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="add_circle" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>New entry</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')" v-if="isLoggedIn">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="people" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>My People</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')" v-if="isLoggedIn">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="mail" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Messages</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-separator />
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')" v-if="isLoggedIn">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="person" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Profile</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')" v-if="isLoggedIn">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="account_circle" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Account</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('sign-up')" to="/sign-up" v-if="!isLoggedIn">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="person_add" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Sign Up</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-separator />
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="help" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Need Help?</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('legal', 'Legal Stuff')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="gavel" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Legal Stuff</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Second Level Menu Panel -->
|
|
||||||
<div class="menu-panel">
|
|
||||||
<div class="q-pa-md bg-secondary text-white">
|
|
||||||
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
|
||||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- My People Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'my-people'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('relatives')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="family_restroom" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Relatives</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('friends')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="group" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Friends</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Messages Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'messages'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('notifications')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="notifications" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Notifications</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('message-center', 'Message Center')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="mail" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Messages</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('groups')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="group_work" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Groups</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Profile Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'profile'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('personal-data')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="badge" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Personal Data</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('portrait', 'Portrait')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="portrait" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Portrait</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('voice', 'Voice')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="record_voice_over" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Voice</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Account Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'account'">
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('plan', 'Plan')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="subscriptions" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Plan</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('billing', 'Billing')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="payment" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Billing</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<!-- Add a logout button that only appears when logged in -->
|
|
||||||
<q-item clickable v-ripple @click="logout" v-if="isLoggedIn">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="logout" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Logout</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Login Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'login'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('password-reset')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="lock_reset" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Password reset</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="logout">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="logout" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Logout</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Support Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'support'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('video-tutorials')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="play_circle" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Video tutorials</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('knowledge-base')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="library_books" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Knowledge base</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('faq')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="quiz" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>FAQ</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('chatbot')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="smart_toy" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Chatbot</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Legal Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'legal'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('legal-terms')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="article" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Legal Terms</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('privacy-policy')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="privacy_tip" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Privacy Policy</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Third Level Menu Panel -->
|
|
||||||
<div class="menu-panel">
|
|
||||||
<div class="q-pa-md bg-accent text-white">
|
|
||||||
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
|
||||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Message Center Submenu -->
|
|
||||||
|
|
||||||
<q-list v-if="currentSubmenu === 'message-center'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('new-message')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="add" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Chat</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('direct-messages')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="message" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Direct Messages</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('group-messages')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="group" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Group Messages</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('message-requests')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="request_page" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Message Requests</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Portrait Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'portrait'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('animated-avatar')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="face" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Animated Avatar</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Voice Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'voice'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('voice-cloning')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="content_copy" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Voice cloning</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Plan Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'plan'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('upgrade')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="upgrade" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Upgrade</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<!-- Billing Submenu -->
|
|
||||||
<q-list v-if="currentSubmenu === 'billing'">
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('billing-information')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="credit_card" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Billing Information</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('invoices')">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="receipt" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Invoices</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</q-drawer>
|
|
||||||
|
|
||||||
<q-page-container>
|
|
||||||
<router-view />
|
|
||||||
</q-page-container>
|
|
||||||
|
|
||||||
<q-footer
|
|
||||||
v-if="!$route.meta.hideFooter"
|
|
||||||
elevated
|
|
||||||
class="bg-white text-black font"
|
|
||||||
>
|
|
||||||
<q-tabs align="justify" dense>
|
|
||||||
<q-route-tab to="/" icon="home" />
|
|
||||||
<q-route-tab to="/people" icon="people" />
|
|
||||||
<q-route-tab to="/edit" icon="add_circle_outline" />
|
|
||||||
<q-route-tab to="/messages" icon="mail" />
|
|
||||||
<q-route-tab to="/profile" icon="person" />
|
|
||||||
</q-tabs>
|
|
||||||
</q-footer>
|
|
||||||
</q-layout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'MultiLevelSlidingMenu',
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const drawer = ref(false)
|
|
||||||
const currentRoute = ref('home')
|
|
||||||
const menuLevel = ref(0)
|
|
||||||
const currentSubmenu = ref('')
|
|
||||||
const currentSubmenuTitle = ref('')
|
|
||||||
const menuHistory = ref([])
|
|
||||||
|
|
||||||
// Make isLoggedIn reactive to localStorage changes
|
|
||||||
const isLoggedIn = ref(localStorage.getItem('isLoggedIn') === 'true')
|
|
||||||
|
|
||||||
// Listen for storage changes (when logging in from another tab/component)
|
|
||||||
const updateLoginStatus = () => {
|
|
||||||
isLoggedIn.value = localStorage.getItem('isLoggedIn') === 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('storage', updateLoginStatus)
|
|
||||||
// Also check on route changes
|
|
||||||
router.afterEach(() => {
|
|
||||||
updateLoginStatus()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const slideOffset = computed(() => {
|
|
||||||
return menuLevel.value * -280 // Each level slides 280px (drawer width) to the left
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleDrawer = () => {
|
|
||||||
drawer.value = !drawer.value
|
|
||||||
// Reset menu to main level when drawer is opened
|
|
||||||
if (drawer.value) {
|
|
||||||
resetMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetMenu = () => {
|
|
||||||
menuLevel.value = 0
|
|
||||||
currentSubmenu.value = ''
|
|
||||||
currentSubmenuTitle.value = ''
|
|
||||||
menuHistory.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSubmenu = (submenuKey, title) => {
|
|
||||||
console.log(`Opening submenu: ${submenuKey} - ${title}`)
|
|
||||||
|
|
||||||
// Save current state to history
|
|
||||||
menuHistory.value.push({
|
|
||||||
level: menuLevel.value,
|
|
||||||
submenu: currentSubmenu.value,
|
|
||||||
title: currentSubmenuTitle.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Navigate to new submenu
|
|
||||||
menuLevel.value += 1
|
|
||||||
currentSubmenu.value = submenuKey
|
|
||||||
currentSubmenuTitle.value = title
|
|
||||||
|
|
||||||
console.log(`New menu level: ${menuLevel.value}, submenu: ${currentSubmenu.value}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
console.log('Going back, history length:', menuHistory.value.length)
|
|
||||||
|
|
||||||
if (menuHistory.value.length > 0) {
|
|
||||||
const previousState = menuHistory.value.pop()
|
|
||||||
menuLevel.value = previousState.level
|
|
||||||
currentSubmenu.value = previousState.submenu
|
|
||||||
currentSubmenuTitle.value = previousState.title
|
|
||||||
|
|
||||||
console.log(`Back to level: ${menuLevel.value}, submenu: ${currentSubmenu.value}`)
|
|
||||||
} else {
|
|
||||||
resetMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateTo = (route) => {
|
|
||||||
console.log(`Navigating to: ${route}`)
|
|
||||||
currentRoute.value = route
|
|
||||||
// In a real app, you would use Vue Router here
|
|
||||||
// this.$router.push('/' + route)
|
|
||||||
|
|
||||||
// Close drawer and reset menu on mobile after navigation
|
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
drawer.value = false
|
|
||||||
resetMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the login function
|
|
||||||
const login = () => {
|
|
||||||
isLoggedIn.value = true
|
|
||||||
localStorage.setItem('isLoggedIn', 'true')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the logout function
|
|
||||||
const logout = () => {
|
|
||||||
console.log('Logging out...')
|
|
||||||
isLoggedIn.value = false
|
|
||||||
localStorage.setItem('isLoggedIn', 'false')
|
|
||||||
currentRoute.value = 'login'
|
|
||||||
drawer.value = false
|
|
||||||
resetMenu()
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
drawer,
|
|
||||||
currentRoute,
|
|
||||||
menuLevel,
|
|
||||||
currentSubmenu,
|
|
||||||
currentSubmenuTitle,
|
|
||||||
menuHistory,
|
|
||||||
slideOffset,
|
|
||||||
toggleDrawer,
|
|
||||||
openSubmenu,
|
|
||||||
goBack,
|
|
||||||
navigateTo,
|
|
||||||
logout,
|
|
||||||
isLoggedIn,
|
|
||||||
login
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dynamic-gradient-bg {
|
|
||||||
/* background: linear-gradient(45deg, #541ba2, #d4890b, #fc1c98);
|
|
||||||
background: linear-gradient(45deg, #7317c3, #d2348b, #31e6cb); */
|
|
||||||
/*background: linear-gradient(-45deg, #a0197c, #fe4374, #541ba2, #a0197c);*/
|
|
||||||
background: linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);
|
|
||||||
background-size: 300% 300%;
|
|
||||||
animation: gradientAnimation 30s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gradientAnimation {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
/* Start Links Mitte */
|
|
||||||
}
|
|
||||||
|
|
||||||
25% {
|
|
||||||
background-position: 100% 0%;
|
|
||||||
/* Oben Rechts */
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
/* Unten Rechts */
|
|
||||||
}
|
|
||||||
|
|
||||||
75% {
|
|
||||||
background-position: 0% 100%;
|
|
||||||
/* Unten Links */
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
/* Zurück zum Start Links Mitte */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.q-footer .q-tab__label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
/* Passe diesen Wert nach Bedarf an */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Custom styles for the drawer */
|
|
||||||
/* .q-drawer {
|
|
||||||
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* Menu container for sliding panels */
|
|
||||||
.menu-container {
|
|
||||||
display: flex;
|
|
||||||
width: calc(100% * 3);
|
|
||||||
/* Width for 3 levels */
|
|
||||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual menu panel */
|
|
||||||
.menu-panel {
|
|
||||||
width: 280px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover effects for menu items */
|
|
||||||
.q-item:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chevron icon styling */
|
|
||||||
.q-item-section--side .q-icon {
|
|
||||||
color: rgba(0, 0, 0, 0.54);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back button styling */
|
|
||||||
.q-btn[round] {
|
|
||||||
min-height: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header gradient for different levels */
|
|
||||||
.bg-secondary {
|
|
||||||
background: linear-gradient(135deg, #9c27b0, #673ab7) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-accent {
|
|
||||||
background: linear-gradient(135deg, #ff5722, #f44336) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth scrolling for menu panels */
|
|
||||||
.menu-panel {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile optimizations */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.menu-container {
|
|
||||||
width: calc(100vw * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-panel {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,369 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page padding class="category-selector-page">
|
|
||||||
<div class="row justify-center">
|
|
||||||
<div class="col-12 col-md-8 col-lg-6">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h5">Select Categories</div>
|
|
||||||
<div class="text-subtitle2 text-grey">Choose categories that best describe this life event</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-section>
|
|
||||||
<!-- Search bar -->
|
|
||||||
<q-input
|
|
||||||
v-model="searchQuery"
|
|
||||||
label="Search categories..."
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
class="q-mb-md"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="search" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<!-- Category list -->
|
|
||||||
<div class="category-list q-gutter-md">
|
|
||||||
<div
|
|
||||||
v-for="category in filteredCategories"
|
|
||||||
:key="category.id"
|
|
||||||
class="category-item"
|
|
||||||
:class="{ 'selected': isCategorySelected(category.id) }"
|
|
||||||
@click="toggleCategorySelection(category.id)"
|
|
||||||
>
|
|
||||||
<div class="category-icon">
|
|
||||||
<q-icon :name="category.icon" size="32px" color="primary" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="category-info">
|
|
||||||
<div class="category-name">{{ category.label }}</div>
|
|
||||||
<div v-if="category.description" class="category-description text-grey">{{ category.description }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selection indicator -->
|
|
||||||
<q-icon
|
|
||||||
v-if="isCategorySelected(category.id)"
|
|
||||||
name="check_circle"
|
|
||||||
color="positive"
|
|
||||||
size="24px"
|
|
||||||
class="selection-indicator"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Selected categories section -->
|
|
||||||
<q-card-section v-if="selectedCategories.length > 0" class="selected-section">
|
|
||||||
<q-separator class="q-mb-md" />
|
|
||||||
|
|
||||||
<div class="text-subtitle2 q-mb-md">Selected Categories ({{ selectedCategories.length }})</div>
|
|
||||||
|
|
||||||
<div class="selected-categories-container">
|
|
||||||
<div class="selected-categories-list">
|
|
||||||
<div
|
|
||||||
v-for="category in selectedCategoriesData"
|
|
||||||
:key="category.id"
|
|
||||||
class="selected-category"
|
|
||||||
@mouseenter="hoveredCategoryId = category.id"
|
|
||||||
@mouseleave="hoveredCategoryId = null"
|
|
||||||
>
|
|
||||||
<q-chip
|
|
||||||
:removable="hoveredCategoryId === category.id"
|
|
||||||
@remove="removeCategorySelection(category.id)"
|
|
||||||
color="primary"
|
|
||||||
text-color="white"
|
|
||||||
:icon="category.icon"
|
|
||||||
class="selected-category-chip"
|
|
||||||
>
|
|
||||||
{{ category.label }}
|
|
||||||
</q-chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed Footer with Action Buttons -->
|
|
||||||
<q-footer elevated class="bg-white text-dark selector-footer">
|
|
||||||
<q-toolbar class="justify-between">
|
|
||||||
<q-btn
|
|
||||||
icon="arrow_back"
|
|
||||||
color="grey"
|
|
||||||
flat
|
|
||||||
round
|
|
||||||
@click="onCancel"
|
|
||||||
class="q-ml-sm"
|
|
||||||
>
|
|
||||||
<q-tooltip class="bg-grey">Cancel</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
|
|
||||||
<q-btn
|
|
||||||
label="Add Categories"
|
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
:disable="selectedCategories.length === 0"
|
|
||||||
@click="onAddCategories"
|
|
||||||
class="q-mr-sm"
|
|
||||||
/>
|
|
||||||
</q-toolbar>
|
|
||||||
</q-footer>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { categoryStructure } from '../utils/editFormOptions.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CategorySelector',
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedCategories = ref([])
|
|
||||||
const hoveredCategoryId = ref(null)
|
|
||||||
|
|
||||||
// Category icons mapping
|
|
||||||
const categoryIcons = {
|
|
||||||
'career': 'work',
|
|
||||||
'education': 'school',
|
|
||||||
'awards': 'emoji_events',
|
|
||||||
'personal-celebrations': 'celebration',
|
|
||||||
'relationships': 'favorite',
|
|
||||||
'parenthood': 'child_care',
|
|
||||||
'passing': 'sentiment_very_dissatisfied',
|
|
||||||
'festivities': 'party_mode',
|
|
||||||
'social-events': 'groups',
|
|
||||||
'community': 'volunteer_activism',
|
|
||||||
'health': 'health_and_safety',
|
|
||||||
'travel': 'flight',
|
|
||||||
'hobbies': 'palette',
|
|
||||||
'sports': 'sports_soccer',
|
|
||||||
'technology': 'computer',
|
|
||||||
'financial': 'attach_money',
|
|
||||||
'legal': 'gavel',
|
|
||||||
'spiritual': 'spa',
|
|
||||||
'creative': 'brush',
|
|
||||||
'learning': 'menu_book'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert category structure to flat list without subcategories
|
|
||||||
const categories = ref(
|
|
||||||
categoryStructure.map((cat, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
label: cat.label,
|
|
||||||
value: cat.value,
|
|
||||||
icon: categoryIcons[cat.value] || 'category',
|
|
||||||
description: null // Could be added later
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const filteredCategories = computed(() => {
|
|
||||||
if (!searchQuery.value) {
|
|
||||||
return categories.value
|
|
||||||
}
|
|
||||||
return categories.value.filter(category =>
|
|
||||||
category.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedCategoriesData = computed(() => {
|
|
||||||
return categories.value.filter(category => selectedCategories.value.includes(category.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const isCategorySelected = (categoryId) => {
|
|
||||||
return selectedCategories.value.includes(categoryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleCategorySelection = (categoryId) => {
|
|
||||||
const index = selectedCategories.value.indexOf(categoryId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedCategories.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
selectedCategories.value.push(categoryId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeCategorySelection = (categoryId) => {
|
|
||||||
const index = selectedCategories.value.indexOf(categoryId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedCategories.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
router.go(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAddCategories = () => {
|
|
||||||
// Pass selected categories back to the edit page
|
|
||||||
const selectedCategoriesLabels = selectedCategoriesData.value.map(category => category.label)
|
|
||||||
|
|
||||||
// Navigate back with the selected categories data
|
|
||||||
router.push({
|
|
||||||
name: 'edit',
|
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
selectedCategories: JSON.stringify(selectedCategoriesLabels)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load previously selected categories from route query
|
|
||||||
onMounted(() => {
|
|
||||||
if (route.query.currentSelection) {
|
|
||||||
try {
|
|
||||||
const currentSelection = JSON.parse(route.query.currentSelection)
|
|
||||||
// Convert labels back to IDs
|
|
||||||
selectedCategories.value = categories.value
|
|
||||||
.filter(category => currentSelection.includes(category.label))
|
|
||||||
.map(category => category.id)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing current selection:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
searchQuery,
|
|
||||||
selectedCategories,
|
|
||||||
hoveredCategoryId,
|
|
||||||
categories,
|
|
||||||
filteredCategories,
|
|
||||||
selectedCategoriesData,
|
|
||||||
isCategorySelected,
|
|
||||||
toggleCategorySelection,
|
|
||||||
removeCategorySelection,
|
|
||||||
onCancel,
|
|
||||||
onAddCategories
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.category-selector-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding-bottom: 80px; /* Space for footer */
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-list {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-item:hover {
|
|
||||||
background-color: rgba(25, 118, 210, 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-item.selected {
|
|
||||||
background-color: rgba(25, 118, 210, 0.15);
|
|
||||||
border-color: var(--q-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-icon {
|
|
||||||
margin-right: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(25, 118, 210, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-name {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-description {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-indicator {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-section {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-categories-container {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-categories-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-category {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-category-chip {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.category-item {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-icon {
|
|
||||||
margin-right: 12px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-categories-list {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer styling */
|
|
||||||
.selector-footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-footer .q-toolbar {
|
|
||||||
min-height: 60px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +0,0 @@
|
||||||
<template>
|
|
||||||
|
|
||||||
<q-page padding class="flex flex-center">
|
|
||||||
<q-card style="width: 350px">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">Login</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-section>
|
|
||||||
<q-form @submit.prevent="onSubmit" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
v-model="email"
|
|
||||||
type="email"
|
|
||||||
label="Email"
|
|
||||||
filled
|
|
||||||
:rules="[val => !!val || 'Email is required']"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<q-input
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
label="Password"
|
|
||||||
filled
|
|
||||||
:rules="[val => !!val || 'Password is required']"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="q-mt-md">
|
|
||||||
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center q-mt-sm">
|
|
||||||
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-page>
|
|
||||||
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'LoginPage',
|
|
||||||
setup() {
|
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
|
||||||
console.log('Login attempt with:', email.value, password.value)
|
|
||||||
|
|
||||||
// Save login status
|
|
||||||
localStorage.setItem('isLoggedIn', 'true')
|
|
||||||
window.dispatchEvent(new Event('storage'))
|
|
||||||
|
|
||||||
console.log('Redirecting to wave page...')
|
|
||||||
|
|
||||||
// Direct navigation
|
|
||||||
window.location.href = '/#/wave'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
onSubmit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,423 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page padding class="person-selector-page">
|
|
||||||
<div class="row justify-center">
|
|
||||||
<div class="col-12 col-md-8 col-lg-6">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h5">Select Related Persons</div>
|
|
||||||
<div class="text-subtitle2 text-grey">Choose people related to this life event</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-section>
|
|
||||||
<!-- Search bar -->
|
|
||||||
<q-input
|
|
||||||
v-model="searchQuery"
|
|
||||||
label="Search persons..."
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
class="q-mb-md"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="search" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<!-- Person list -->
|
|
||||||
<div class="person-list q-gutter-md">
|
|
||||||
<div
|
|
||||||
v-for="person in filteredPersons"
|
|
||||||
:key="person.id"
|
|
||||||
class="person-item"
|
|
||||||
:class="{ 'selected': isPersonSelected(person.id) }"
|
|
||||||
@click="togglePersonSelection(person.id)"
|
|
||||||
>
|
|
||||||
<div class="person-avatar">
|
|
||||||
<q-avatar
|
|
||||||
v-if="person.avatar"
|
|
||||||
size="60px"
|
|
||||||
class="person-avatar-img"
|
|
||||||
>
|
|
||||||
<img :src="person.avatar" :alt="person.name" />
|
|
||||||
</q-avatar>
|
|
||||||
<q-avatar
|
|
||||||
v-else
|
|
||||||
size="60px"
|
|
||||||
color="primary"
|
|
||||||
text-color="white"
|
|
||||||
class="person-avatar-initials"
|
|
||||||
>
|
|
||||||
{{ getInitials(person.name) }}
|
|
||||||
</q-avatar>
|
|
||||||
|
|
||||||
<!-- Selection indicator -->
|
|
||||||
<q-icon
|
|
||||||
v-if="isPersonSelected(person.id)"
|
|
||||||
name="check_circle"
|
|
||||||
color="positive"
|
|
||||||
size="24px"
|
|
||||||
class="selection-indicator"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="person-info">
|
|
||||||
<div class="person-name">{{ person.name }}</div>
|
|
||||||
<div v-if="person.role" class="person-role text-grey">{{ person.role }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Selected persons section -->
|
|
||||||
<q-card-section v-if="selectedPersons.length > 0" class="selected-section">
|
|
||||||
<q-separator class="q-mb-md" />
|
|
||||||
|
|
||||||
<div class="text-subtitle2 q-mb-md">Selected Persons ({{ selectedPersons.length }})</div>
|
|
||||||
|
|
||||||
<div class="selected-persons-container">
|
|
||||||
<div class="selected-persons-list">
|
|
||||||
<div
|
|
||||||
v-for="person in selectedPersonsData"
|
|
||||||
:key="person.id"
|
|
||||||
class="selected-person"
|
|
||||||
@mouseenter="hoveredPersonId = person.id"
|
|
||||||
@mouseleave="hoveredPersonId = null"
|
|
||||||
>
|
|
||||||
<q-avatar
|
|
||||||
v-if="person.avatar"
|
|
||||||
size="50px"
|
|
||||||
class="selected-avatar"
|
|
||||||
>
|
|
||||||
<img :src="person.avatar" :alt="person.name" />
|
|
||||||
</q-avatar>
|
|
||||||
<q-avatar
|
|
||||||
v-else
|
|
||||||
size="50px"
|
|
||||||
color="primary"
|
|
||||||
text-color="white"
|
|
||||||
class="selected-avatar"
|
|
||||||
>
|
|
||||||
{{ getInitials(person.name) }}
|
|
||||||
</q-avatar>
|
|
||||||
|
|
||||||
<!-- Remove button on hover -->
|
|
||||||
<div
|
|
||||||
v-if="hoveredPersonId === person.id"
|
|
||||||
class="remove-person"
|
|
||||||
@click.stop="removePersonSelection(person.id)"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="selected-person-name">{{ person.name }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed Footer with Action Buttons -->
|
|
||||||
<q-footer elevated class="bg-white text-dark selector-footer">
|
|
||||||
<q-toolbar class="justify-between">
|
|
||||||
<q-btn
|
|
||||||
icon="arrow_back"
|
|
||||||
color="grey"
|
|
||||||
flat
|
|
||||||
round
|
|
||||||
@click="onCancel"
|
|
||||||
class="q-ml-sm"
|
|
||||||
>
|
|
||||||
<q-tooltip class="bg-grey">Cancel</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
|
|
||||||
<q-btn
|
|
||||||
label="Add Persons"
|
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
:disable="selectedPersons.length === 0"
|
|
||||||
@click="onAddPersons"
|
|
||||||
class="q-mr-sm"
|
|
||||||
/>
|
|
||||||
</q-toolbar>
|
|
||||||
</q-footer>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { personOptions } from '../utils/editFormOptions.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'PersonSelector',
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedPersons = ref([])
|
|
||||||
const hoveredPersonId = ref(null)
|
|
||||||
|
|
||||||
// Convert simple person names to objects with IDs
|
|
||||||
const persons = ref(
|
|
||||||
personOptions.map((name, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
name: name,
|
|
||||||
avatar: null, // Could be loaded from API later
|
|
||||||
role: null // Could be added later
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const filteredPersons = computed(() => {
|
|
||||||
if (!searchQuery.value) {
|
|
||||||
return persons.value
|
|
||||||
}
|
|
||||||
return persons.value.filter(person =>
|
|
||||||
person.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedPersonsData = computed(() => {
|
|
||||||
return persons.value.filter(person => selectedPersons.value.includes(person.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const getInitials = (name) => {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map(word => word.charAt(0).toUpperCase())
|
|
||||||
.slice(0, 2)
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPersonSelected = (personId) => {
|
|
||||||
return selectedPersons.value.includes(personId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePersonSelection = (personId) => {
|
|
||||||
const index = selectedPersons.value.indexOf(personId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedPersons.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
selectedPersons.value.push(personId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removePersonSelection = (personId) => {
|
|
||||||
const index = selectedPersons.value.indexOf(personId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedPersons.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
router.go(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAddPersons = () => {
|
|
||||||
// Pass selected persons back to the edit page
|
|
||||||
const selectedPersonsNames = selectedPersonsData.value.map(person => person.name)
|
|
||||||
|
|
||||||
// Navigate back with the selected persons data
|
|
||||||
// We'll use query parameters to pass the data
|
|
||||||
router.push({
|
|
||||||
name: 'edit',
|
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
selectedPersons: JSON.stringify(selectedPersonsNames)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load previously selected persons from route query
|
|
||||||
onMounted(() => {
|
|
||||||
if (route.query.currentSelection) {
|
|
||||||
try {
|
|
||||||
const currentSelection = JSON.parse(route.query.currentSelection)
|
|
||||||
// Convert names back to IDs
|
|
||||||
selectedPersons.value = persons.value
|
|
||||||
.filter(person => currentSelection.includes(person.name))
|
|
||||||
.map(person => person.id)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing current selection:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
searchQuery,
|
|
||||||
selectedPersons,
|
|
||||||
hoveredPersonId,
|
|
||||||
persons,
|
|
||||||
filteredPersons,
|
|
||||||
selectedPersonsData,
|
|
||||||
getInitials,
|
|
||||||
isPersonSelected,
|
|
||||||
togglePersonSelection,
|
|
||||||
removePersonSelection,
|
|
||||||
onCancel,
|
|
||||||
onAddPersons
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.person-selector-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding-bottom: 80px; /* Space for footer */
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-list {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-item:hover {
|
|
||||||
background-color: rgba(25, 118, 210, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-item.selected {
|
|
||||||
background-color: rgba(25, 118, 210, 0.15);
|
|
||||||
border-color: var(--q-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-avatar {
|
|
||||||
position: relative;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-name {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-role {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-section {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-persons-container {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-persons-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-person {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-person:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-avatar {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-person-name {
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.2;
|
|
||||||
max-width: 80px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-person {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: rgba(244, 67, 54, 0.9);
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-person:hover {
|
|
||||||
background: #f44336;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.selected-persons-list {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-item {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.person-avatar {
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer styling */
|
|
||||||
.selector-footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-footer .q-toolbar {
|
|
||||||
min-height: 60px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,445 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page padding class="tag-selector-page">
|
|
||||||
<div class="row justify-center">
|
|
||||||
<div class="col-12 col-md-8 col-lg-6">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h5">Select Tags</div>
|
|
||||||
<div class="text-subtitle2 text-grey">Add tags to describe the mood and nature of this event</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-section>
|
|
||||||
<!-- Search bar -->
|
|
||||||
<q-input
|
|
||||||
v-model="searchQuery"
|
|
||||||
label="Search tags..."
|
|
||||||
filled
|
|
||||||
clearable
|
|
||||||
class="q-mb-md"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="search" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<!-- Tag categories -->
|
|
||||||
<div class="tag-categories q-mb-md">
|
|
||||||
<q-btn-toggle
|
|
||||||
v-model="selectedTagCategory"
|
|
||||||
:options="tagCategoryOptions"
|
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
class="full-width"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tag grid -->
|
|
||||||
<div class="tag-grid">
|
|
||||||
<div
|
|
||||||
v-for="tag in filteredTags"
|
|
||||||
:key="tag.id"
|
|
||||||
class="tag-item"
|
|
||||||
:class="{ 'selected': isTagSelected(tag.id) }"
|
|
||||||
@click="toggleTagSelection(tag.id)"
|
|
||||||
>
|
|
||||||
<div class="tag-content">
|
|
||||||
<q-icon :name="tag.icon" size="20px" class="tag-icon" />
|
|
||||||
<span class="tag-label">{{ tag.label }}</span>
|
|
||||||
|
|
||||||
<!-- Selection indicator -->
|
|
||||||
<q-icon
|
|
||||||
v-if="isTagSelected(tag.id)"
|
|
||||||
name="check_circle"
|
|
||||||
color="positive"
|
|
||||||
size="18px"
|
|
||||||
class="selection-indicator"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Selected tags section -->
|
|
||||||
<q-card-section v-if="selectedTags.length > 0" class="selected-section">
|
|
||||||
<q-separator class="q-mb-md" />
|
|
||||||
|
|
||||||
<div class="text-subtitle2 q-mb-md">Selected Tags ({{ selectedTags.length }})</div>
|
|
||||||
|
|
||||||
<div class="selected-tags-container">
|
|
||||||
<div class="selected-tags-list">
|
|
||||||
<q-chip
|
|
||||||
v-for="tag in selectedTagsData"
|
|
||||||
:key="tag.id"
|
|
||||||
removable
|
|
||||||
@remove="removeTagSelection(tag.id)"
|
|
||||||
color="secondary"
|
|
||||||
text-color="white"
|
|
||||||
:icon="tag.icon"
|
|
||||||
class="selected-tag-chip"
|
|
||||||
>
|
|
||||||
{{ tag.label }}
|
|
||||||
</q-chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed Footer with Action Buttons -->
|
|
||||||
<q-footer elevated class="bg-white text-dark selector-footer">
|
|
||||||
<q-toolbar class="justify-between">
|
|
||||||
<q-btn
|
|
||||||
icon="arrow_back"
|
|
||||||
color="grey"
|
|
||||||
flat
|
|
||||||
round
|
|
||||||
@click="onCancel"
|
|
||||||
class="q-ml-sm"
|
|
||||||
>
|
|
||||||
<q-tooltip class="bg-grey">Cancel</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
|
|
||||||
<q-btn
|
|
||||||
label="Add Tags"
|
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
:disable="selectedTags.length === 0"
|
|
||||||
@click="onAddTags"
|
|
||||||
class="q-mr-sm"
|
|
||||||
/>
|
|
||||||
</q-toolbar>
|
|
||||||
</q-footer>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { tagOptions } from '../utils/editFormOptions.js'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'TagSelector',
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedTags = ref([])
|
|
||||||
const selectedTagCategory = ref('all')
|
|
||||||
|
|
||||||
// Tag category options
|
|
||||||
const tagCategoryOptions = [
|
|
||||||
{ label: 'All', value: 'all' },
|
|
||||||
{ label: 'Emotions', value: 'emotions' },
|
|
||||||
{ label: 'Significance', value: 'significance' },
|
|
||||||
{ label: 'Social', value: 'social' },
|
|
||||||
{ label: 'Intensity', value: 'intensity' },
|
|
||||||
{ label: 'Time', value: 'time' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Tag categorization and icons
|
|
||||||
const tagCategories = {
|
|
||||||
'emotions': ['happy', 'sad', 'exciting', 'stressful', 'memorable', 'important', 'fun', 'challenging', 'rewarding', 'disappointing', 'surprising', 'life-changing', 'routine', 'special', 'difficult', 'joyful', 'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful', 'emotional', 'touching', 'inspiring', 'motivating', 'healing'],
|
|
||||||
'significance': ['milestone', 'achievement', 'breakthrough', 'turning-point', 'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected', 'planned', 'spontaneous', 'tradition', 'new-experience'],
|
|
||||||
'social': ['family', 'friends', 'colleagues', 'community', 'solo', 'group', 'intimate', 'public', 'private', 'celebration'],
|
|
||||||
'intensity': ['intense', 'mild', 'dramatic', 'subtle', 'overwhelming', 'gradual', 'sudden', 'anticipated', 'shocking', 'gentle'],
|
|
||||||
'time': ['brief', 'extended', 'momentary', 'lasting', 'temporary', 'permanent', 'seasonal', 'annual', 'weekly', 'daily']
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagIcons = {
|
|
||||||
// Emotions
|
|
||||||
'happy': 'sentiment_very_satisfied',
|
|
||||||
'sad': 'sentiment_very_dissatisfied',
|
|
||||||
'exciting': 'bolt',
|
|
||||||
'stressful': 'stress_management',
|
|
||||||
'memorable': 'star',
|
|
||||||
'important': 'priority_high',
|
|
||||||
'fun': 'sports_esports',
|
|
||||||
'challenging': 'fitness_center',
|
|
||||||
'rewarding': 'emoji_events',
|
|
||||||
'disappointing': 'thumb_down',
|
|
||||||
'surprising': 'surprise',
|
|
||||||
'life-changing': 'transform',
|
|
||||||
'routine': 'repeat',
|
|
||||||
'special': 'auto_awesome',
|
|
||||||
'difficult': 'warning',
|
|
||||||
'joyful': 'celebration',
|
|
||||||
'overwhelming': 'waves',
|
|
||||||
'peaceful': 'spa',
|
|
||||||
'anxious': 'psychology',
|
|
||||||
'proud': 'military_tech',
|
|
||||||
'grateful': 'volunteer_activism',
|
|
||||||
'emotional': 'favorite',
|
|
||||||
'touching': 'touch_app',
|
|
||||||
'inspiring': 'lightbulb',
|
|
||||||
'motivating': 'trending_up',
|
|
||||||
'healing': 'healing',
|
|
||||||
|
|
||||||
// Significance
|
|
||||||
'milestone': 'flag',
|
|
||||||
'achievement': 'workspace_premium',
|
|
||||||
'breakthrough': 'psychology_alt',
|
|
||||||
'turning-point': 'turn_right',
|
|
||||||
'first-time': 'new_releases',
|
|
||||||
'last-time': 'last_page',
|
|
||||||
'once-in-a-lifetime': 'diamond',
|
|
||||||
'unexpected': 'help_outline',
|
|
||||||
'planned': 'event_note',
|
|
||||||
'spontaneous': 'flash_on',
|
|
||||||
'tradition': 'history',
|
|
||||||
'new-experience': 'explore',
|
|
||||||
|
|
||||||
// Social
|
|
||||||
'family': 'family_restroom',
|
|
||||||
'friends': 'group',
|
|
||||||
'colleagues': 'business',
|
|
||||||
'community': 'diversity_3',
|
|
||||||
'solo': 'person',
|
|
||||||
'group': 'groups',
|
|
||||||
'intimate': 'favorite_border',
|
|
||||||
'public': 'public',
|
|
||||||
'private': 'lock',
|
|
||||||
'celebration': 'cake',
|
|
||||||
|
|
||||||
// Default for others
|
|
||||||
'default': 'tag'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert tag options to objects with categories and icons
|
|
||||||
const tags = ref(
|
|
||||||
tagOptions.map((tag, index) => {
|
|
||||||
// Find which category this tag belongs to
|
|
||||||
let category = 'other'
|
|
||||||
for (const [cat, tagList] of Object.entries(tagCategories)) {
|
|
||||||
if (tagList.includes(tag)) {
|
|
||||||
category = cat
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: index + 1,
|
|
||||||
label: tag,
|
|
||||||
value: tag,
|
|
||||||
category: category,
|
|
||||||
icon: tagIcons[tag] || tagIcons['default']
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const filteredTags = computed(() => {
|
|
||||||
let filtered = tags.value
|
|
||||||
|
|
||||||
// Filter by category
|
|
||||||
if (selectedTagCategory.value !== 'all') {
|
|
||||||
filtered = filtered.filter(tag => tag.category === selectedTagCategory.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search query
|
|
||||||
if (searchQuery.value) {
|
|
||||||
filtered = filtered.filter(tag =>
|
|
||||||
tag.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedTagsData = computed(() => {
|
|
||||||
return tags.value.filter(tag => selectedTags.value.includes(tag.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const isTagSelected = (tagId) => {
|
|
||||||
return selectedTags.value.includes(tagId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleTagSelection = (tagId) => {
|
|
||||||
const index = selectedTags.value.indexOf(tagId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedTags.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
selectedTags.value.push(tagId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeTagSelection = (tagId) => {
|
|
||||||
const index = selectedTags.value.indexOf(tagId)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedTags.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
router.go(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAddTags = () => {
|
|
||||||
// Pass selected tags back to the edit page
|
|
||||||
const selectedTagsLabels = selectedTagsData.value.map(tag => tag.label)
|
|
||||||
|
|
||||||
// Navigate back with the selected tags data
|
|
||||||
router.push({
|
|
||||||
name: 'edit',
|
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
selectedTags: JSON.stringify(selectedTagsLabels)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load previously selected tags from route query
|
|
||||||
onMounted(() => {
|
|
||||||
if (route.query.currentSelection) {
|
|
||||||
try {
|
|
||||||
const currentSelection = JSON.parse(route.query.currentSelection)
|
|
||||||
// Convert labels back to IDs
|
|
||||||
selectedTags.value = tags.value
|
|
||||||
.filter(tag => currentSelection.includes(tag.label))
|
|
||||||
.map(tag => tag.id)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing current selection:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
searchQuery,
|
|
||||||
selectedTags,
|
|
||||||
selectedTagCategory,
|
|
||||||
tagCategoryOptions,
|
|
||||||
tags,
|
|
||||||
filteredTags,
|
|
||||||
selectedTagsData,
|
|
||||||
isTagSelected,
|
|
||||||
toggleTagSelection,
|
|
||||||
removeTagSelection,
|
|
||||||
onCancel,
|
|
||||||
onAddTags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tag-selector-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding-bottom: 80px; /* Space for footer */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-categories {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-item {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-item:hover {
|
|
||||||
background-color: rgba(25, 118, 210, 0.1);
|
|
||||||
border-color: rgba(25, 118, 210, 0.3);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-item.selected {
|
|
||||||
background-color: rgba(25, 118, 210, 0.15);
|
|
||||||
border-color: var(--q-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--q-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-label {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-indicator {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-section {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-tags-container {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-tags-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-tag-chip {
|
|
||||||
font-size: 13px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.tag-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-content {
|
|
||||||
padding: 6px 10px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-tags-list {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer styling */
|
|
||||||
.selector-footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-footer .q-toolbar {
|
|
||||||
min-height: 60px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,488 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page>
|
|
||||||
<div class="visualization-container">
|
|
||||||
<div class="gradient-bg"></div>
|
|
||||||
<div class="scroll-container" id="scroll-container"></div>
|
|
||||||
<div class="median"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Entry Detail Lightbox -->
|
|
||||||
<q-dialog v-model="showEntryDetail" maximized transition-show="slide-up" transition-hide="slide-down">
|
|
||||||
<q-card class="entry-lightbox">
|
|
||||||
<q-card-section class="lightbox-header">
|
|
||||||
<q-btn icon="close" flat round dense v-close-popup color="white" />
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-section class="lightbox-content">
|
|
||||||
<EntryDetailPage :entry-data="selectedEntryData" />
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { onMounted, onBeforeUnmount, defineComponent, ref } from 'vue'
|
|
||||||
import { ConnectedDotsVisualization } from "../utils/ConnectedDotsVisualization"
|
|
||||||
import EntryDetailPage from './EntryDetailPage.vue'
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'WavePage',
|
|
||||||
components: {
|
|
||||||
EntryDetailPage
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
let visualization = null;
|
|
||||||
let isDown = false;
|
|
||||||
let startX;
|
|
||||||
let scrollLeft;
|
|
||||||
|
|
||||||
// Lightbox state
|
|
||||||
const showEntryDetail = ref(false)
|
|
||||||
const selectedEntryData = ref(null)
|
|
||||||
|
|
||||||
// Sample detailed entry data
|
|
||||||
const sampleEntryData = {
|
|
||||||
// 1: {
|
|
||||||
// id: 1,
|
|
||||||
// title: "Beginn des neuen Abenteuers",
|
|
||||||
// subtitle: "Ein wichtiger Meilenstein in meinem Leben",
|
|
||||||
// date: "2024-10-01",
|
|
||||||
// time: "14:30",
|
|
||||||
// location: "München, Deutschland",
|
|
||||||
// level: 2,
|
|
||||||
// keyImage: "/images/familie2.png",
|
|
||||||
// description: "Dies war ein wirklich wichtiger Tag für mich. Nach langer Planung und Vorbereitung konnte ich endlich mein neues Abenteuer beginnen. Es war aufregend und gleichzeitig etwas beängstigend, aber ich wusste, dass es der richtige Schritt war. Die Erfahrungen, die ich an diesem Tag gemacht habe, werden mich noch lange begleiten.",
|
|
||||||
// additionalImages: [
|
|
||||||
// { url: "/images/feier.png", caption: "Familie beim Start" },
|
|
||||||
// { url: "/images/see.png", caption: "Der schöne Ort" }
|
|
||||||
// ],
|
|
||||||
// audioRecordings: [
|
|
||||||
// { name: "Gedanken zum Tag", url: "/audio/ferien_erlebnisbericht.mp3" }
|
|
||||||
// ],
|
|
||||||
// videoRecordings: [
|
|
||||||
// { name: "UHD Nature Video", url: "/videos/3191901-uhd_3840_2160_25fps.mp4" },
|
|
||||||
// { name: "HD Landscape Video", url: "/videos/3326928-hd_1920_1080_24fps.mp4" }
|
|
||||||
// ],
|
|
||||||
// relatedPersons: [
|
|
||||||
// { id: 1, name: "Maria Schmidt", relation: "Freundin", avatar: null },
|
|
||||||
// { id: 2, name: "Thomas Müller", relation: "Bruder", avatar: null }
|
|
||||||
// ],
|
|
||||||
// categories: [
|
|
||||||
// { id: 1, name: "Familie", icon: "family_restroom" },
|
|
||||||
// { id: 2, name: "Abenteuer", icon: "explore" }
|
|
||||||
// ],
|
|
||||||
// tags: [
|
|
||||||
// { id: 1, name: "Aufregend", icon: "emoji_emotions" },
|
|
||||||
// { id: 2, name: "Neuanfang", icon: "new_releases" }
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
// Add more entries as needed
|
|
||||||
1:{
|
|
||||||
id: 1,
|
|
||||||
title: "Beginn des neuen Abenteuers",
|
|
||||||
subtitle: "Ein wichtiger Meilenstein in meinem Leben",
|
|
||||||
date: "2024-10-01",
|
|
||||||
time: "14:30",
|
|
||||||
location: "München, Deutschland",
|
|
||||||
level: 2,
|
|
||||||
keyImage: "/images/familie2.png",
|
|
||||||
description: "Dies war ein wirklich wichtiger Tag für mich. Nach langer Planung und Vorbereitung konnte ich endlich mein neues Abenteuer beginnen. Es war aufregend und gleichzeitig etwas beängstigenden, aber ich wusste, dass es der richtige Schritt war. Die Erfahrungen, die ich an diesem Tag gemacht habe, werden mich noch lange begleiten.",
|
|
||||||
additionalImages: [
|
|
||||||
{ url: "/images/see.png", caption: "Der schöne Ort" },
|
|
||||||
{ url: "/images/feier.png", caption: "Kleiner Umtrunk danach" }
|
|
||||||
],
|
|
||||||
audioRecordings: [
|
|
||||||
{ name: "Gedanken zum Tag", url: "/audio/ferien_erlebnisbericht.mp3" },
|
|
||||||
],
|
|
||||||
videoRecordings: [
|
|
||||||
{
|
|
||||||
name: "UHD Nature Video",
|
|
||||||
url: "/videos/3191901-uhd_3840_2160_25fps.mp4",
|
|
||||||
poster: "/videos/thumbs/3191901-uhd_3840_2160_25fps_thumb.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HD Landscape Video",
|
|
||||||
url: "/videos/3326928-hd_1920_1080_24fps.mp4",
|
|
||||||
poster: "/videos/thumbs/3326928-hd_1920_1080_24fps_thumb.jpg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
relatedPersons: [
|
|
||||||
{ id: 1, name: "Maria Schmidt", relation: "Freundin", avatar: null },
|
|
||||||
{ id: 2, name: "Thomas Müller", relation: "Bruder", avatar: null }
|
|
||||||
],
|
|
||||||
categories: [
|
|
||||||
{ id: 1, name: "Familie", icon: "family_restroom" },
|
|
||||||
{ id: 2, name: "Abenteuer", icon: "explore" }
|
|
||||||
],
|
|
||||||
tags: [
|
|
||||||
{ id: 1, name: "Aufregend", icon: "emoji_emotions" },
|
|
||||||
{ id: 2, name: "Neuanfang", icon: "new_releases" },
|
|
||||||
{ id: 3, name: "Wichtig", icon: "star" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to open entry detail lightbox
|
|
||||||
const openEntryDetail = (entryId) => {
|
|
||||||
selectedEntryData.value = sampleEntryData[entryId] || sampleEntryData[1] // Fallback to first entry
|
|
||||||
showEntryDetail.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to handle cleanup of event listeners
|
|
||||||
const cleanupEventListeners = () => {
|
|
||||||
const scrollContainer = document.querySelector('.scroll-container');
|
|
||||||
if (scrollContainer) {
|
|
||||||
scrollContainer.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
|
|
||||||
scrollContainer.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
scrollContainer.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
scrollContainer.removeEventListener('touchstart', handleTouchStart);
|
|
||||||
scrollContainer.removeEventListener('touchend', handleTouchEnd);
|
|
||||||
scrollContainer.removeEventListener('touchmove', handleTouchMove);
|
|
||||||
}
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleResize = () => {
|
|
||||||
if (visualization) {
|
|
||||||
visualization.resize();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseDown = (e) => {
|
|
||||||
const scrollContainer = e.currentTarget;
|
|
||||||
isDown = true;
|
|
||||||
scrollContainer.classList.add('active');
|
|
||||||
startX = e.pageX - scrollContainer.offsetLeft;
|
|
||||||
scrollLeft = scrollContainer.scrollLeft;
|
|
||||||
scrollContainer.classList.remove('smooth-scroll');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = (e) => {
|
|
||||||
if (!isDown) return;
|
|
||||||
isDown = false;
|
|
||||||
e.currentTarget.classList.remove('active');
|
|
||||||
e.currentTarget.classList.add('smooth-scroll');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = (e) => {
|
|
||||||
if (!isDown) return;
|
|
||||||
isDown = false;
|
|
||||||
e.currentTarget.classList.remove('active');
|
|
||||||
e.currentTarget.classList.add('smooth-scroll');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e) => {
|
|
||||||
if (!isDown) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const scrollContainer = e.currentTarget;
|
|
||||||
const x = e.pageX - scrollContainer.offsetLeft;
|
|
||||||
const walk = (x - startX) * 3;
|
|
||||||
scrollContainer.scrollLeft = scrollLeft - walk;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchStart = (e) => {
|
|
||||||
const scrollContainer = e.currentTarget;
|
|
||||||
isDown = true;
|
|
||||||
scrollContainer.classList.add('active');
|
|
||||||
startX = e.touches[0].pageX - scrollContainer.offsetLeft;
|
|
||||||
scrollLeft = scrollContainer.scrollLeft;
|
|
||||||
scrollContainer.classList.remove('smooth-scroll');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = (e) => {
|
|
||||||
if (!isDown) return;
|
|
||||||
isDown = false;
|
|
||||||
e.currentTarget.classList.remove('active');
|
|
||||||
e.currentTarget.classList.add('smooth-scroll');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e) => {
|
|
||||||
if (!isDown) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const scrollContainer = e.currentTarget;
|
|
||||||
const x = e.touches[0].pageX - scrollContainer.offsetLeft;
|
|
||||||
const walk = (x - startX) * 3;
|
|
||||||
scrollContainer.scrollLeft = scrollLeft - walk;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
try {
|
|
||||||
console.log("Initializing Wave visualization...");
|
|
||||||
|
|
||||||
// Example sample dots data
|
|
||||||
const sampleDots = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
value: -1.8,
|
|
||||||
x: -2,
|
|
||||||
imageUrl: "/images/0_3.png",
|
|
||||||
title: "Beginn des neuen Abenteuers",
|
|
||||||
description: "01.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
value: 1.2,
|
|
||||||
x: 0,
|
|
||||||
imageUrl: "/images/0_2.png",
|
|
||||||
title: "Omas Annis Geburtstag",
|
|
||||||
description: "02.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
value: -0.6,
|
|
||||||
x: 2,
|
|
||||||
imageUrl: "/images/disco.png",
|
|
||||||
title: "Konzertbesuch mit Freunden",
|
|
||||||
description: "03.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
value: 3,
|
|
||||||
x: 4,
|
|
||||||
imageUrl: "/images/pferd.png",
|
|
||||||
title: "Wanderreiten in den Bergen",
|
|
||||||
description: "04.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
value: 1,
|
|
||||||
x: 6,
|
|
||||||
imageUrl: "/images/gpt.png",
|
|
||||||
title: "Ruhiger Tag zu Hause",
|
|
||||||
description: "05.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
value: -3,
|
|
||||||
x: 8,
|
|
||||||
imageUrl: "/images/oma.png",
|
|
||||||
title: "Oma Erna verstorben",
|
|
||||||
description: "06.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
value: 1.5,
|
|
||||||
x: 10,
|
|
||||||
imageUrl: "/images/see.png",
|
|
||||||
title: "Erholungsausflug zum See",
|
|
||||||
description: "07.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
value: 0,
|
|
||||||
x: 12,
|
|
||||||
imageUrl: "/images/feier.png",
|
|
||||||
title: "Kleine Wochenendsfeier",
|
|
||||||
description: "08.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
value: 3,
|
|
||||||
x: 14,
|
|
||||||
imageUrl: "/images/hochzeit.png",
|
|
||||||
title: "Hochzeit von Cousine Tatjana",
|
|
||||||
description: "09.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
value: 1,
|
|
||||||
x: 16,
|
|
||||||
imageUrl: "/images/work.png",
|
|
||||||
title: "Erster Tag im neuen Job",
|
|
||||||
description: "10.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
value: -1.2,
|
|
||||||
x: 18,
|
|
||||||
imageUrl: "/images/klasse.png",
|
|
||||||
title: "Klassentreffen nach vielen Jahren",
|
|
||||||
description: "11.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 12,
|
|
||||||
value: -0.6,
|
|
||||||
x: 20,
|
|
||||||
imageUrl: "/images/familie.png",
|
|
||||||
title: "Familienabendessen",
|
|
||||||
description: "12.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 13,
|
|
||||||
value: 2.7,
|
|
||||||
x: 22,
|
|
||||||
imageUrl:
|
|
||||||
"/images/kinobesuch.png",
|
|
||||||
title: "Kinobesuch mit der ganzen Familie",
|
|
||||||
description: "13.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 14,
|
|
||||||
value: 0,
|
|
||||||
x: 24,
|
|
||||||
imageUrl:
|
|
||||||
"/images/entspannung.png",
|
|
||||||
title: "Entspannung",
|
|
||||||
description: "14.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 15,
|
|
||||||
value: -2.9,
|
|
||||||
x: 26,
|
|
||||||
imageUrl: "/images/sonntag.png",
|
|
||||||
title: "Geruhsamer Sonntag",
|
|
||||||
description: "15.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 16,
|
|
||||||
value: 1.5,
|
|
||||||
x: 28,
|
|
||||||
imageUrl:
|
|
||||||
"/images/kindergeburtstag.png",
|
|
||||||
title: "Kindergeburtstag",
|
|
||||||
description: "16.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 17,
|
|
||||||
value: 0,
|
|
||||||
x: 30,
|
|
||||||
imageUrl:
|
|
||||||
"/images/familie2.png",
|
|
||||||
title: "Spaziergang mit der Familie",
|
|
||||||
description: "17.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 18,
|
|
||||||
value: 2.1,
|
|
||||||
x: 32,
|
|
||||||
imageUrl:
|
|
||||||
"/images/grosseltern.png",
|
|
||||||
title: "Familienfeier bei den Großeltern",
|
|
||||||
description: "18.10.2024",
|
|
||||||
onClick: () => openEntryDetail(1), // Using same entry for now
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Initialize the visualization with the sample dots
|
|
||||||
visualization = new ConnectedDotsVisualization('scroll-container', sampleDots, {
|
|
||||||
// Optional custom configuration
|
|
||||||
dotRadius: 6,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle window resize
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
// Set up scroll interactions
|
|
||||||
const scrollContainer = document.querySelector('.scroll-container');
|
|
||||||
if (scrollContainer) {
|
|
||||||
// Mouse events
|
|
||||||
scrollContainer.addEventListener('mousedown', handleMouseDown);
|
|
||||||
scrollContainer.addEventListener('mouseleave', handleMouseLeave);
|
|
||||||
scrollContainer.addEventListener('mouseup', handleMouseUp);
|
|
||||||
scrollContainer.addEventListener('mousemove', handleMouseMove);
|
|
||||||
|
|
||||||
// Touch events
|
|
||||||
scrollContainer.addEventListener('touchstart', handleTouchStart);
|
|
||||||
scrollContainer.addEventListener('touchend', handleTouchEnd);
|
|
||||||
scrollContainer.addEventListener('touchmove', handleTouchMove);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error initializing visualization:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up event listeners when component is unmounted
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
cleanupEventListeners();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
showEntryDetail,
|
|
||||||
selectedEntryData,
|
|
||||||
openEntryDetail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.visualization-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-bg {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
z-index: 2;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-container.active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-container.smooth-scroll {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lightbox Styles */
|
|
||||||
.entry-lightbox {
|
|
||||||
background: rgba(0, 0, 0, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox-header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 24px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox-content {
|
|
||||||
padding: 0;
|
|
||||||
max-height: calc(100vh - 80px);
|
|
||||||
overflow-y: auto;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, shallowRef, triggerRef } from 'vue'
|
||||||
import { db } from 'src/db'
|
import { db } from 'src/db'
|
||||||
import { startAutoSync, getToken } from 'src/services/syncService'
|
import { startAutoSync, getToken } from 'src/services/syncService'
|
||||||
|
|
||||||
|
|
@ -170,7 +170,10 @@ function generateManyEvents(count = 500) {
|
||||||
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
|
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
|
||||||
|
|
||||||
export const useEventsStore = defineStore('events', () => {
|
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 isLoaded = ref(false)
|
||||||
const selectedEventId = ref(null)
|
const selectedEventId = ref(null)
|
||||||
const panelOpen = ref(false)
|
const panelOpen = ref(false)
|
||||||
|
|
@ -212,6 +215,22 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
.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
|
// Ghost event for live preview while creating/editing
|
||||||
const ghostEmotion = ref(0)
|
const ghostEmotion = ref(0)
|
||||||
const ghostCustomColor = ref(null)
|
const ghostCustomColor = ref(null)
|
||||||
|
|
@ -233,7 +252,8 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sortedEvents = computed(() => {
|
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) {
|
function selectEvent(id) {
|
||||||
|
|
@ -266,6 +286,34 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
panelOpen.value = true
|
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
|
// Auto-save: persist ghost → event in edit mode
|
||||||
function persistToEvent() {
|
function persistToEvent() {
|
||||||
if (!editingEventId.value) return
|
if (!editingEventId.value) return
|
||||||
|
|
@ -284,7 +332,8 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
events.value[idx] = updated
|
events.value[idx] = updated
|
||||||
dbPut(updated)
|
triggerRef(events)
|
||||||
|
schedulePersist(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -293,6 +342,7 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
|
flushPersist()
|
||||||
if (!editingEventId.value && ghostTitle.value.trim()) {
|
if (!editingEventId.value && ghostTitle.value.trim()) {
|
||||||
const newEvent = {
|
const newEvent = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|
@ -308,6 +358,7 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
events.value.push(newEvent)
|
events.value.push(newEvent)
|
||||||
|
triggerRef(events)
|
||||||
dbPut(newEvent)
|
dbPut(newEvent)
|
||||||
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
||||||
}
|
}
|
||||||
|
|
@ -317,15 +368,27 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent(id) {
|
function deleteEvent(id) {
|
||||||
|
if (pendingWrite?.id === id) cancelPersist()
|
||||||
events.value = events.value.filter((e) => e.id !== id)
|
events.value = events.value.filter((e) => e.id !== id)
|
||||||
dbDelete(id)
|
dbDelete(id)
|
||||||
dbQueueSync(id, 'delete', null)
|
dbQueueSync(id, 'delete', null)
|
||||||
closePanel()
|
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) {
|
function getGlowColor(event) {
|
||||||
if (event.customColor) return event.customColor
|
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
|
// Auto-init on store creation
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,550 +0,0 @@
|
||||||
// Define interfaces
|
|
||||||
export interface DotConfig {
|
|
||||||
id: number;
|
|
||||||
value: number;
|
|
||||||
x: number;
|
|
||||||
link?: string; // URL to navigate to when dot is clicked
|
|
||||||
onClick?: () => void; // Function to call when dot is clicked
|
|
||||||
imageUrl?: string; // Image to display in tooltip
|
|
||||||
title?: string; // Optional title for the tooltip
|
|
||||||
description?: string; // Optional description for the tooltip
|
|
||||||
}
|
|
||||||
export interface Config {
|
|
||||||
totalWidth: number;
|
|
||||||
height: number;
|
|
||||||
dotRadius: number;
|
|
||||||
xUnitSize: number;
|
|
||||||
tension: number;
|
|
||||||
showGrid: boolean;
|
|
||||||
tooltipWidth: number;
|
|
||||||
tooltipHeight: number;
|
|
||||||
}
|
|
||||||
interface ControlPoints {
|
|
||||||
x1: number;
|
|
||||||
y1: number;
|
|
||||||
x2: number;
|
|
||||||
y2: number;
|
|
||||||
}
|
|
||||||
interface TooltipEdges {
|
|
||||||
leftmost: number;
|
|
||||||
rightmost: number;
|
|
||||||
}
|
|
||||||
export class ConnectedDotsVisualization {
|
|
||||||
private config: Config;
|
|
||||||
private dots: DotConfig[];
|
|
||||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
|
||||||
// DOM Elements
|
|
||||||
private scrollContainer: HTMLElement;
|
|
||||||
private svg: SVGElement;
|
|
||||||
private gridGroup: SVGGElement;
|
|
||||||
private curvePath: SVGPathElement;
|
|
||||||
private dotsGroup: SVGGElement;
|
|
||||||
private tooltipGroup: SVGGElement;
|
|
||||||
// Active tooltip
|
|
||||||
private activeTooltip: SVGElement | null = null;
|
|
||||||
constructor(
|
|
||||||
containerId: string,
|
|
||||||
dots: DotConfig[],
|
|
||||||
config?: Partial<Config>
|
|
||||||
) {
|
|
||||||
// Use the provided dots or empty array
|
|
||||||
this.dots = dots || [];
|
|
||||||
// Calculate the total width based on dots data
|
|
||||||
const xUnitSize = config?.xUnitSize || 100;
|
|
||||||
let calculatedWidth = 0;
|
|
||||||
if (this.dots.length > 0) {
|
|
||||||
// Find the minimum and maximum x values
|
|
||||||
const minX = Math.min(...this.dots.map((dot) => dot.x));
|
|
||||||
const maxX = Math.max(...this.dots.map((dot) => dot.x));
|
|
||||||
// Calculate width based on the range of x values
|
|
||||||
// Add padding on both sides (3 units on each side)
|
|
||||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
|
||||||
} else {
|
|
||||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
|
||||||
}
|
|
||||||
// Default configuration
|
|
||||||
this.config = {
|
|
||||||
totalWidth: calculatedWidth,
|
|
||||||
height: window.innerHeight,
|
|
||||||
dotRadius: 6,
|
|
||||||
xUnitSize: xUnitSize,
|
|
||||||
tension: 0.5,
|
|
||||||
showGrid: false,
|
|
||||||
tooltipWidth: 128,
|
|
||||||
tooltipHeight: 128,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
// Initialize DOM elements
|
|
||||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
|
||||||
|
|
||||||
// Calculate the container height dynamically
|
|
||||||
const containerHeight =
|
|
||||||
this.scrollContainer.clientHeight ||
|
|
||||||
this.scrollContainer.offsetHeight ||
|
|
||||||
window.innerHeight;
|
|
||||||
|
|
||||||
// Default configuration
|
|
||||||
this.config = {
|
|
||||||
totalWidth: calculatedWidth,
|
|
||||||
height: containerHeight, // Use the calculated container height
|
|
||||||
dotRadius: 6,
|
|
||||||
xUnitSize: xUnitSize,
|
|
||||||
tension: 0.5,
|
|
||||||
showGrid: false,
|
|
||||||
tooltipWidth: 128,
|
|
||||||
tooltipHeight: 128,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SVG elements
|
|
||||||
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
||||||
this.gridGroup = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"g"
|
|
||||||
);
|
|
||||||
this.curvePath = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"path"
|
|
||||||
);
|
|
||||||
this.dotsGroup = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"g"
|
|
||||||
);
|
|
||||||
this.tooltipGroup = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"g"
|
|
||||||
);
|
|
||||||
// Initialize the visualization
|
|
||||||
this.addStyles();
|
|
||||||
this.initializeSVG();
|
|
||||||
this.setupEventListeners();
|
|
||||||
this.preloadImages();
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
private preloadImages(): void {
|
|
||||||
// Extract all unique image URLs from dots
|
|
||||||
const imageUrls: string[] = this.dots
|
|
||||||
.filter((dot) => dot.imageUrl)
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
|
||||||
.map((dot) => dot.imageUrl!)
|
|
||||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
|
||||||
// Create a loading indicator (optional)
|
|
||||||
const loadingCount = { current: 0, total: imageUrls.length };
|
|
||||||
if (imageUrls.length > 0) {
|
|
||||||
console.log(`Preloading ${imageUrls.length} images...`);
|
|
||||||
}
|
|
||||||
// Preload each image
|
|
||||||
for (const url of imageUrls) {
|
|
||||||
const img = new Image();
|
|
||||||
// Optional loading events
|
|
||||||
img.onload = () => {
|
|
||||||
loadingCount.current++;
|
|
||||||
if (loadingCount.current === loadingCount.total) {
|
|
||||||
console.log("All images preloaded successfully");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
img.onerror = () => {
|
|
||||||
loadingCount.current++;
|
|
||||||
console.error(`Failed to preload image: ${url}`);
|
|
||||||
};
|
|
||||||
// Set src to start loading
|
|
||||||
img.src = url;
|
|
||||||
// Store in map for potential later use
|
|
||||||
this.preloadedImages.set(url, img);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private addStyles(): void {
|
|
||||||
// Add necessary styles for tooltips and interactions
|
|
||||||
const styleId = "connected-dots-styles";
|
|
||||||
if (!document.getElementById(styleId)) {
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = styleId;
|
|
||||||
// style.textContent = `
|
|
||||||
|
|
||||||
// `;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private initializeSVG(): void {
|
|
||||||
// Configure SVG
|
|
||||||
this.svg.setAttribute("width", `${this.config.totalWidth}`);
|
|
||||||
this.svg.setAttribute("height", `${this.config.height}`);
|
|
||||||
this.svg.style.overflow = "visible";
|
|
||||||
this.scrollContainer.appendChild(this.svg);
|
|
||||||
// Configure grid group
|
|
||||||
this.gridGroup.classList.add("grid");
|
|
||||||
this.svg.appendChild(this.gridGroup);
|
|
||||||
// Configure curve path
|
|
||||||
this.curvePath.setAttribute("fill", "none");
|
|
||||||
this.curvePath.setAttribute("stroke", "white");
|
|
||||||
this.curvePath.setAttribute("stroke-width", "2");
|
|
||||||
this.curvePath.setAttribute("stroke-linecap", "round");
|
|
||||||
this.curvePath.classList.add("curve-path");
|
|
||||||
this.svg.appendChild(this.curvePath);
|
|
||||||
// Configure dots group
|
|
||||||
this.svg.appendChild(this.dotsGroup);
|
|
||||||
// Configure tooltip group (always on top)
|
|
||||||
this.tooltipGroup.classList.add("tooltips");
|
|
||||||
this.svg.appendChild(this.tooltipGroup);
|
|
||||||
}
|
|
||||||
private setupEventListeners(): void {
|
|
||||||
// Event listeners removed as the controls were removed
|
|
||||||
}
|
|
||||||
private getDotX(x: number): number {
|
|
||||||
return (x + 3) * this.config.xUnitSize;
|
|
||||||
}
|
|
||||||
private getDotY(value: number): number {
|
|
||||||
const centerY = this.config.height / 1.95;
|
|
||||||
// Calculate raw Y position
|
|
||||||
// height of the amplitude
|
|
||||||
const rawY = centerY - (value / 3) * ((this.config.height / 2) * 0.6);
|
|
||||||
// Calculate minimum Y position to ensure tooltip fits
|
|
||||||
const minY = this.config.tooltipHeight + 40; // tooltip height + some padding
|
|
||||||
// Ensure Y is never less than minimum (never too high on screen)
|
|
||||||
return Math.max(rawY, minY);
|
|
||||||
}
|
|
||||||
private calculateBezierControlPoints(
|
|
||||||
dots: DotConfig[],
|
|
||||||
index: number
|
|
||||||
): ControlPoints {
|
|
||||||
const tension = this.config.tension * 500; // Scale tension for Bezier curve Rundung Kurve
|
|
||||||
// Get current point and its neighbors
|
|
||||||
const curr = dots[index];
|
|
||||||
const next = dots[index + 1];
|
|
||||||
// Calculate control points for a smooth bezier curve
|
|
||||||
const x1 = this.getDotX(curr.x) + tension;
|
|
||||||
const y1 = this.getDotY(curr.value);
|
|
||||||
const x2 = this.getDotX(next.x) - tension;
|
|
||||||
const y2 = this.getDotY(next.value);
|
|
||||||
return { x1, y1, x2, y2 };
|
|
||||||
}
|
|
||||||
private generateBezierPath(): string {
|
|
||||||
if (this.dots.length < 2) return "";
|
|
||||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(
|
|
||||||
this.dots[0].value
|
|
||||||
)}`;
|
|
||||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
|
||||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(
|
|
||||||
this.dots,
|
|
||||||
i
|
|
||||||
);
|
|
||||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
|
||||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
|
||||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
private drawGrid(): void {
|
|
||||||
// Clear previous grid
|
|
||||||
while (this.gridGroup.firstChild) {
|
|
||||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
|
||||||
}
|
|
||||||
if (!this.config.showGrid) return;
|
|
||||||
// Horizontal grid lines
|
|
||||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
|
||||||
const line = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"line"
|
|
||||||
);
|
|
||||||
line.setAttribute("x1", "0");
|
|
||||||
line.setAttribute("y1", this.getDotY(value).toString());
|
|
||||||
line.setAttribute("x2", this.config.totalWidth.toString());
|
|
||||||
line.setAttribute("y2", this.getDotY(value).toString());
|
|
||||||
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
|
|
||||||
line.setAttribute("stroke-width", "1");
|
|
||||||
this.gridGroup.appendChild(line);
|
|
||||||
const text = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"text"
|
|
||||||
);
|
|
||||||
text.setAttribute("x", "10");
|
|
||||||
text.setAttribute("y", (this.getDotY(value) + 4).toString());
|
|
||||||
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
|
|
||||||
text.setAttribute("font-size", "12");
|
|
||||||
text.textContent = value.toString();
|
|
||||||
this.gridGroup.appendChild(text);
|
|
||||||
}
|
|
||||||
// Vertical grid lines
|
|
||||||
const numVertLines = Math.ceil(
|
|
||||||
this.config.totalWidth / this.config.xUnitSize
|
|
||||||
);
|
|
||||||
for (let i = 0; i < numVertLines; i++) {
|
|
||||||
const x = i * this.config.xUnitSize;
|
|
||||||
const xValue = i - 3; // Starting from -3
|
|
||||||
const line = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"line"
|
|
||||||
);
|
|
||||||
line.setAttribute("x1", x.toString());
|
|
||||||
line.setAttribute("y1", "0");
|
|
||||||
line.setAttribute("x2", x.toString());
|
|
||||||
line.setAttribute("y2", this.config.height.toString());
|
|
||||||
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
|
|
||||||
line.setAttribute("stroke-width", "1");
|
|
||||||
this.gridGroup.appendChild(line);
|
|
||||||
if (xValue !== 0) {
|
|
||||||
const text = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"text"
|
|
||||||
);
|
|
||||||
text.setAttribute("x", x.toString());
|
|
||||||
text.setAttribute("y", (this.config.height / 2 + 20).toString());
|
|
||||||
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
|
|
||||||
text.setAttribute("font-size", "12");
|
|
||||||
text.setAttribute("text-anchor", "middle");
|
|
||||||
text.textContent = xValue.toString();
|
|
||||||
this.gridGroup.appendChild(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
|
||||||
const tooltip = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
||||||
tooltip.classList.add("dot-tooltip");
|
|
||||||
tooltip.setAttribute("data-dot-id", dot.id.toString());
|
|
||||||
|
|
||||||
// Calculate tooltip dimensions and position
|
|
||||||
const tooltipWidth = 128; // Base width for your tooltip
|
|
||||||
const tooltipHeight = (4 / 3) * tooltipWidth;
|
|
||||||
const tooltipX = x - tooltipWidth / 2;
|
|
||||||
let tooltipY = y - tooltipHeight - 10; // Positioned above the dot
|
|
||||||
tooltipY = Math.max(tooltipY, 10); // Ensure it doesn't go above the view
|
|
||||||
|
|
||||||
// Create background rectangle
|
|
||||||
const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
||||||
bg.setAttribute("x", tooltipX.toString());
|
|
||||||
bg.setAttribute("y", tooltipY.toString());
|
|
||||||
bg.setAttribute("width", tooltipWidth.toString());
|
|
||||||
bg.setAttribute("height", tooltipHeight.toString());
|
|
||||||
bg.setAttribute("rx", "0"); // Rounded corners
|
|
||||||
bg.classList.add("tooltip-background");
|
|
||||||
tooltip.appendChild(bg);
|
|
||||||
|
|
||||||
// Create foreignObject for the content
|
|
||||||
const contentContainer = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"foreignObject"
|
|
||||||
);
|
|
||||||
contentContainer.setAttribute("x", tooltipX.toString());
|
|
||||||
contentContainer.setAttribute("y", tooltipY.toString());
|
|
||||||
contentContainer.setAttribute("width", tooltipWidth.toString());
|
|
||||||
contentContainer.setAttribute("height", tooltipHeight.toString());
|
|
||||||
|
|
||||||
// Create a div to contain the content
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.classList.add("tooltip-content");
|
|
||||||
|
|
||||||
// Add title if available
|
|
||||||
if (dot.title) {
|
|
||||||
const title = document.createElement("div");
|
|
||||||
title.textContent = dot.title;
|
|
||||||
title.classList.add("tooltip-title");
|
|
||||||
div.appendChild(title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add description if available
|
|
||||||
if (dot.description) {
|
|
||||||
const desc = document.createElement("div");
|
|
||||||
desc.textContent = dot.description;
|
|
||||||
desc.classList.add("tooltip-description");
|
|
||||||
div.appendChild(desc);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add image if available
|
|
||||||
// Create a container div
|
|
||||||
const imageContainer = document.createElement("div");
|
|
||||||
imageContainer.classList.add("image_container"); // Add image_container class
|
|
||||||
|
|
||||||
// Define a variable for handling case with or without link
|
|
||||||
let imgWrapper: HTMLElement;
|
|
||||||
|
|
||||||
// if (dot.imageUrl) {
|
|
||||||
if (dot.link || dot.onClick) {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
if (dot.link) {
|
|
||||||
link.href = dot.link;
|
|
||||||
} else {
|
|
||||||
link.href = "#"; // Prevent default href for onClick
|
|
||||||
}
|
|
||||||
link.target = "_self"; // Opens in the same window
|
|
||||||
|
|
||||||
const imgElement = document.createElement("img");
|
|
||||||
imgElement.src = dot.imageUrl;
|
|
||||||
imgElement.classList.add("tooltip-image");
|
|
||||||
|
|
||||||
// Append the image element to the link
|
|
||||||
link.appendChild(imgElement);
|
|
||||||
imgWrapper = link; // Use the link as the wrapper
|
|
||||||
|
|
||||||
// Add the event listener to the link
|
|
||||||
link.addEventListener("click", (e) => {
|
|
||||||
if (dot.onClick) {
|
|
||||||
e.preventDefault(); // Prevent default navigation
|
|
||||||
dot.onClick();
|
|
||||||
} else if (dot.link) {
|
|
||||||
window.location.href = dot.link;
|
|
||||||
} else {
|
|
||||||
console.error("Dot has no link or onClick handler");
|
|
||||||
throw new Error("Dot has no link or onClick handler");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = dot.imageUrl;
|
|
||||||
img.classList.add("tooltip-image");
|
|
||||||
imgWrapper = img; // Use the image directly as the wrapper
|
|
||||||
}
|
|
||||||
// } else {
|
|
||||||
// console.error("Dot has no image URL");
|
|
||||||
// throw new Error("Dot has no image URL");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Append imageWrapper to the container
|
|
||||||
imageContainer.appendChild(imgWrapper);
|
|
||||||
|
|
||||||
|
|
||||||
// Append the image container to the main div
|
|
||||||
div.appendChild(imageContainer);
|
|
||||||
|
|
||||||
const arrow = document.createElement("div");
|
|
||||||
|
|
||||||
arrow.classList.add("tooltip-arrow");
|
|
||||||
|
|
||||||
div.appendChild(arrow); // Append the arrow to the tooltip-content div
|
|
||||||
|
|
||||||
contentContainer.appendChild(div);
|
|
||||||
tooltip.appendChild(contentContainer);
|
|
||||||
|
|
||||||
return tooltip;
|
|
||||||
}
|
|
||||||
|
|
||||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
|
||||||
// Create tooltip
|
|
||||||
const tooltip = this.createTooltip(dot, x, y);
|
|
||||||
this.tooltipGroup.appendChild(tooltip);
|
|
||||||
this.activeTooltip = tooltip;
|
|
||||||
}
|
|
||||||
private hideTooltip(): void {
|
|
||||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
|
||||||
}
|
|
||||||
private drawCurve(): void {
|
|
||||||
const pathData = this.generateBezierPath();
|
|
||||||
this.curvePath.setAttribute("d", pathData);
|
|
||||||
}
|
|
||||||
private calculateTooltipEdges(): TooltipEdges {
|
|
||||||
let leftmost = 0;
|
|
||||||
let rightmost = 0;
|
|
||||||
let firstTooltipFound = false;
|
|
||||||
// If no dots with tooltips, return default values
|
|
||||||
if (this.dots.length === 0) {
|
|
||||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
|
||||||
}
|
|
||||||
// Calculate the leftmost and rightmost edges of all tooltips
|
|
||||||
for (const dot of this.dots) {
|
|
||||||
// Skip dots without tooltip content
|
|
||||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const x = this.getDotX(dot.x);
|
|
||||||
const tooltipWidth = this.config.tooltipWidth;
|
|
||||||
const tooltipX = x - tooltipWidth / 2;
|
|
||||||
if (!firstTooltipFound) {
|
|
||||||
leftmost = tooltipX;
|
|
||||||
rightmost = tooltipX + tooltipWidth;
|
|
||||||
firstTooltipFound = true;
|
|
||||||
} else {
|
|
||||||
// Update leftmost and rightmost values
|
|
||||||
leftmost = Math.min(leftmost, tooltipX);
|
|
||||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no dots with tooltips were found, use default values
|
|
||||||
if (!firstTooltipFound) {
|
|
||||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
|
||||||
}
|
|
||||||
return { leftmost, rightmost };
|
|
||||||
}
|
|
||||||
private drawDots(): void {
|
|
||||||
// Clear previous dots
|
|
||||||
while (this.dotsGroup.firstChild) {
|
|
||||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
|
||||||
}
|
|
||||||
// Clear previous tooltips
|
|
||||||
while (this.tooltipGroup.firstChild) {
|
|
||||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
|
||||||
}
|
|
||||||
for (const dot of this.dots) {
|
|
||||||
const x = this.getDotX(dot.x);
|
|
||||||
const y = this.getDotY(dot.value);
|
|
||||||
const circle = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"circle"
|
|
||||||
);
|
|
||||||
circle.setAttribute("cx", x.toString());
|
|
||||||
circle.setAttribute("cy", y.toString());
|
|
||||||
circle.setAttribute("r", this.config.dotRadius.toString());
|
|
||||||
circle.setAttribute("fill", "white");
|
|
||||||
circle.setAttribute("data-dot-id", dot.id.toString());
|
|
||||||
circle.classList.add("dot");
|
|
||||||
// Always show tooltip if it has content
|
|
||||||
if (dot.imageUrl || dot.title || dot.description) {
|
|
||||||
this.showTooltip(dot, x, y);
|
|
||||||
}
|
|
||||||
// Click event for navigation or custom function
|
|
||||||
if (dot.link || dot.onClick) {
|
|
||||||
circle.addEventListener("click", () => {
|
|
||||||
if (dot.onClick) {
|
|
||||||
dot.onClick();
|
|
||||||
} else if (dot.link) {
|
|
||||||
window.location.href = dot.link;
|
|
||||||
} else {
|
|
||||||
console.error("Dot has no link or onClick handler");
|
|
||||||
throw new Error("Dot has no link or onClick handler");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.dotsGroup.appendChild(circle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public render(): void {
|
|
||||||
this.drawGrid();
|
|
||||||
this.drawCurve();
|
|
||||||
this.drawDots();
|
|
||||||
// Calculate tooltip edges and set SVG width
|
|
||||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
|
||||||
// Set the SVG width based on the rightmost tooltip edge
|
|
||||||
if (rightmost > 0) {
|
|
||||||
// Add some padding
|
|
||||||
const padding = 40;
|
|
||||||
this.config.totalWidth = rightmost + padding;
|
|
||||||
this.svg.setAttribute("width", `${this.config.totalWidth}`);
|
|
||||||
// Update grid width
|
|
||||||
this.drawGrid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Public API methods for external use
|
|
||||||
public updateDots(newDots: DotConfig[]): void {
|
|
||||||
this.dots = newDots;
|
|
||||||
// Initial width calculation based on dot positions (for grid)
|
|
||||||
if (this.dots.length > 0) {
|
|
||||||
// Find the minimum and maximum x values
|
|
||||||
const minX = Math.min(...this.dots.map((dot) => dot.x));
|
|
||||||
const maxX = Math.max(...this.dots.map((dot) => dot.x));
|
|
||||||
// Calculate width based on the range of x values
|
|
||||||
// Add padding on both sides (3 units on each side)
|
|
||||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
|
||||||
}
|
|
||||||
// Render will calculate the tooltip edges and update the SVG width
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
public updateConfig(newConfig: Partial<Config>): void {
|
|
||||||
this.config = { ...this.config, ...newConfig };
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
public resize(): void {
|
|
||||||
const containerHeight = this.scrollContainer.clientHeight || this.scrollContainer.offsetHeight || window.innerHeight;
|
|
||||||
this.config.height = containerHeight;
|
|
||||||
this.svg.setAttribute("height", `${this.config.height}`);
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
// Form options and data for EditPage component
|
|
||||||
|
|
||||||
// Main categories with their subcategories
|
|
||||||
export const categoryStructure = [
|
|
||||||
{
|
|
||||||
label: 'Career',
|
|
||||||
value: 'career',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Promotion', value: 'career-promotion' },
|
|
||||||
{ label: 'Retirement', value: 'career-retirement' },
|
|
||||||
{ label: 'Career Changes', value: 'career-changes' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Education',
|
|
||||||
value: 'education',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Graduation', value: 'education-graduation' },
|
|
||||||
{ label: 'Schooling', value: 'education-schooling' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Awards',
|
|
||||||
value: 'awards',
|
|
||||||
subcategories: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Personal Celebrations',
|
|
||||||
value: 'personal-celebrations',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Birthday', value: 'birthday' },
|
|
||||||
{ label: 'Anniversary', value: 'anniversary' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Relationships',
|
|
||||||
value: 'relationships',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Engagement', value: 'relationships-engagement' },
|
|
||||||
{ label: 'Marriage', value: 'relationships-marriage' },
|
|
||||||
{ label: 'Divorce', value: 'relationships-divorce' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Parenthood',
|
|
||||||
value: 'parenthood',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Pregnancy', value: 'parenthood-pregnancy' },
|
|
||||||
{ label: 'Birth', value: 'parenthood-birth' },
|
|
||||||
{ label: 'Adoption', value: 'parenthood-adoption' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Loss & Passing',
|
|
||||||
value: 'passing',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Funeral', value: 'passing-funeral' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Festivities',
|
|
||||||
value: 'festivities',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Christmas', value: 'festivities-christmas' },
|
|
||||||
{ label: 'Thanksgiving', value: 'festivities-thanksgiving' },
|
|
||||||
{ label: 'New Year', value: 'festivities-new-year' },
|
|
||||||
{ label: 'Easter', value: 'festivities-easter' },
|
|
||||||
{ label: 'Holidays', value: 'festivities-holidays' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Social Events',
|
|
||||||
value: 'social-events',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Reunions', value: 'reunions' },
|
|
||||||
{ label: 'Concerts', value: 'concerts' },
|
|
||||||
{ label: 'Sports', value: 'sports' },
|
|
||||||
{ label: 'Festivals', value: 'festivals' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Community',
|
|
||||||
value: 'community',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Charity', value: 'charity' },
|
|
||||||
{ label: 'Community Service', value: 'community-service' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Health',
|
|
||||||
value: 'health',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Surgery', value: 'health-surgery' },
|
|
||||||
{ label: 'Illness', value: 'health-illness' },
|
|
||||||
{ label: 'Recovery', value: 'health-recovery' },
|
|
||||||
{ label: 'Transplants', value: 'health-transplants' },
|
|
||||||
{ label: 'Mental Health', value: 'health-mental-health' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Religious & Spiritual',
|
|
||||||
value: 'religious',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Baptism', value: 'religious-baptism' },
|
|
||||||
{ label: 'Bar/Bat Mitzvah', value: 'religious-bar-bat-mitzvah' },
|
|
||||||
{ label: 'Communion', value: 'religious-communion' },
|
|
||||||
{ label: 'Confirmation', value: 'religious-confirmation' },
|
|
||||||
{ label: 'Pilgrimage', value: 'religious-pilgrimage' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Travel & Adventure',
|
|
||||||
value: 'travel',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Travel', value: 'travel-general' },
|
|
||||||
{ label: 'Vacation', value: 'vacation' },
|
|
||||||
{ label: 'Adventure', value: 'adventure' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Life Changes',
|
|
||||||
value: 'life-changes',
|
|
||||||
subcategories: [
|
|
||||||
{ label: 'Moving', value: 'moving' },
|
|
||||||
{ label: 'License', value: 'license' },
|
|
||||||
{ label: 'Voting', value: 'voting' },
|
|
||||||
{ label: 'Citizenship', value: 'citizenship' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Milestones',
|
|
||||||
value: 'milestones',
|
|
||||||
subcategories: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// Flattened category options for backward compatibility and simple select usage
|
|
||||||
export const categoryOptions = categoryStructure.reduce((acc, category) => {
|
|
||||||
// Add main category if it has no subcategories
|
|
||||||
if (category.subcategories.length === 0) {
|
|
||||||
acc.push({ label: category.label, value: category.value })
|
|
||||||
} else {
|
|
||||||
// Add subcategories with main category prefix
|
|
||||||
category.subcategories.forEach(sub => {
|
|
||||||
acc.push({
|
|
||||||
label: `${category.label} - ${sub.label}`,
|
|
||||||
value: sub.value,
|
|
||||||
mainCategory: category.value,
|
|
||||||
subcategory: sub.value
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Helper functions for category management
|
|
||||||
export const getCategoryStructure = () => categoryStructure
|
|
||||||
|
|
||||||
export const getMainCategories = () => {
|
|
||||||
return categoryStructure.map(cat => ({
|
|
||||||
label: cat.label,
|
|
||||||
value: cat.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSubcategories = (mainCategoryValue) => {
|
|
||||||
const mainCategory = categoryStructure.find(cat => cat.value === mainCategoryValue)
|
|
||||||
return mainCategory ? mainCategory.subcategories : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCategoryByValue = (value) => {
|
|
||||||
return categoryOptions.find(cat => cat.value === value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getMainCategoryFromValue = (value) => {
|
|
||||||
const category = getCategoryByValue(value)
|
|
||||||
return category ? category.mainCategory : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tagOptions = [
|
|
||||||
// Emotions
|
|
||||||
'happy', 'sad', 'exciting', 'stressful', 'memorable', 'important',
|
|
||||||
'fun', 'challenging', 'rewarding', 'disappointing', 'surprising',
|
|
||||||
'life-changing', 'routine', 'special', 'difficult', 'joyful',
|
|
||||||
'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful',
|
|
||||||
'emotional', 'touching', 'inspiring', 'motivating', 'healing',
|
|
||||||
|
|
||||||
// Significance
|
|
||||||
'milestone', 'achievement', 'breakthrough', 'turning-point',
|
|
||||||
'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected',
|
|
||||||
'planned', 'spontaneous', 'tradition', 'new-experience',
|
|
||||||
|
|
||||||
// Social
|
|
||||||
'family', 'friends', 'colleagues', 'community', 'solo',
|
|
||||||
'group', 'intimate', 'public', 'private', 'celebration',
|
|
||||||
|
|
||||||
// Intensity
|
|
||||||
'intense', 'mild', 'dramatic', 'subtle', 'overwhelming',
|
|
||||||
'gradual', 'sudden', 'anticipated', 'shocking', 'gentle',
|
|
||||||
|
|
||||||
// Time-related
|
|
||||||
'brief', 'extended', 'momentary', 'lasting', 'temporary',
|
|
||||||
'permanent', 'seasonal', 'annual', 'weekly', 'daily'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const personOptions = [
|
|
||||||
'Anna Mueller',
|
|
||||||
'Max Schmidt',
|
|
||||||
'Sarah Johnson',
|
|
||||||
'Michael Weber',
|
|
||||||
'Lisa Anderson',
|
|
||||||
'Thomas Brown',
|
|
||||||
'Julia Martinez',
|
|
||||||
'David Wilson',
|
|
||||||
'Emma Garcia',
|
|
||||||
'Robert Davis'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const defaultFormData = {
|
|
||||||
keyImage: null,
|
|
||||||
keyImageUrl: '',
|
|
||||||
additionalImages: [],
|
|
||||||
additionalImageUrls: [],
|
|
||||||
level: 0,
|
|
||||||
categories: [],
|
|
||||||
headline: '',
|
|
||||||
subheadline: '',
|
|
||||||
text: '',
|
|
||||||
tags: [],
|
|
||||||
location: '',
|
|
||||||
date: '',
|
|
||||||
time: '',
|
|
||||||
audioFiles: [],
|
|
||||||
audioRecordings: [],
|
|
||||||
videoFiles: [],
|
|
||||||
videoRecordings: [],
|
|
||||||
relatedPersons: []
|
|
||||||
}
|
|
||||||
|
|
@ -181,6 +181,11 @@ Umgesetzt:
|
||||||
- Frontend haelt Remote-Events nur als lokalen Cache in IndexedDB.
|
- Frontend haelt Remote-Events nur als lokalen Cache in IndexedDB.
|
||||||
- Lokaler Demo-User bleibt browserlokal.
|
- 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:
|
Aufgaben:
|
||||||
|
|
||||||
- Bestehende `events`-Tabelle mit Frontend-Modell abgleichen.
|
- Bestehende `events`-Tabelle mit Frontend-Modell abgleichen.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue