10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
7
frontend/_src/App.vue
Normal file
7
frontend/_src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
15
frontend/_src/assets/quasar-logo-vertical.svg
Normal file
15
frontend/_src/assets/quasar-logo-vertical.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
0
frontend/_src/boot/.gitkeep
Normal file
0
frontend/_src/boot/.gitkeep
Normal file
33
frontend/_src/components/AddEventButton.vue
Normal file
33
frontend/_src/components/AddEventButton.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<button class="add-event-btn glass--button" @click="$emit('click')">
|
||||
<q-icon name="add" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-event-btn {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
322
frontend/_src/components/AppSettingsModal.vue
Normal file
322
frontend/_src/components/AppSettingsModal.vue
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<template>
|
||||
<ModalCard
|
||||
:open="open"
|
||||
title="Allgemein"
|
||||
:tabs="tabs"
|
||||
v-model="activeTab"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<!-- Allgemein -->
|
||||
<div v-if="activeTab === 'general'">
|
||||
<div class="settings-section__title">Allgemein</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Aussehen -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Aussehen</span>
|
||||
<div class="settings-row__control">
|
||||
<select
|
||||
:value="settingsStore.appearance"
|
||||
@change="onAppearanceChange"
|
||||
class="settings-select"
|
||||
:class="{ 'settings-select--dark': isDark }"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Hell</option>
|
||||
<option value="dark">Dunkel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Akzentfarbe -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Akzentfarbe</span>
|
||||
<div class="settings-row__control">
|
||||
<button
|
||||
class="settings-accent-btn"
|
||||
:class="{ 'settings-accent-btn--dark': isDark }"
|
||||
@click="accentDropdownOpen = !accentDropdownOpen"
|
||||
>
|
||||
<span
|
||||
class="settings-accent-dot"
|
||||
:style="{ background: currentAccentHex }"
|
||||
/>
|
||||
<span>{{ currentAccentLabel }}</span>
|
||||
<q-icon name="expand_more" size="16px" />
|
||||
</button>
|
||||
|
||||
<!-- Accent dropdown -->
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="accentDropdownOpen"
|
||||
class="settings-dropdown"
|
||||
:class="{ 'settings-dropdown--dark': isDark }"
|
||||
>
|
||||
<button
|
||||
v-for="color in ACCENT_COLORS"
|
||||
:key="color.value"
|
||||
class="settings-dropdown__item"
|
||||
@click="selectAccent(color.value)"
|
||||
>
|
||||
<span class="settings-accent-dot" :style="{ background: color.hex }" />
|
||||
<span>{{ color.label }}</span>
|
||||
<q-icon
|
||||
v-if="settingsStore.accentColor === color.value"
|
||||
name="check"
|
||||
size="18px"
|
||||
class="settings-dropdown__check"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Sprache -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Sprache</span>
|
||||
<div class="settings-row__control">
|
||||
<select
|
||||
:value="settingsStore.language"
|
||||
@change="e => { settingsStore.language = e.target.value }"
|
||||
class="settings-select"
|
||||
:class="{ 'settings-select--dark': isDark }"
|
||||
>
|
||||
<option v-for="lang in LANGUAGES" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- FPS-Anzeige -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">FPS-Anzeige</span>
|
||||
<div class="settings-row__control">
|
||||
<q-toggle
|
||||
:model-value="settingsStore.showFps"
|
||||
@update:model-value="v => { settingsStore.showFps = v }"
|
||||
dense
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benachrichtigungen (placeholder) -->
|
||||
<div v-else-if="activeTab === 'notifications'">
|
||||
<div class="settings-section__title">Benachrichtigungen</div>
|
||||
<div class="settings-section__divider" />
|
||||
<p class="settings-placeholder">Kommt bald.</p>
|
||||
</div>
|
||||
|
||||
<!-- Personalisierung (placeholder) -->
|
||||
<div v-else-if="activeTab === 'personalize'">
|
||||
<div class="settings-section__title">Personalisierung</div>
|
||||
<div class="settings-section__divider" />
|
||||
<p class="settings-placeholder">Kommt bald.</p>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import ModalCard from 'components/ModalCard.vue'
|
||||
import { useSettingsStore, ACCENT_COLORS, LANGUAGES } from 'stores/settings'
|
||||
|
||||
defineProps({ open: { type: Boolean, default: false } })
|
||||
defineEmits(['close'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
|
||||
const activeTab = ref('general')
|
||||
const accentDropdownOpen = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ value: 'general', label: 'Allgemein', icon: 'settings' },
|
||||
{ value: 'notifications', label: 'Benachrichtigungen', icon: 'notifications' },
|
||||
{ value: 'personalize', label: 'Personalisierung', icon: 'history' }
|
||||
]
|
||||
|
||||
const currentAccentHex = computed(() => {
|
||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||
return found?.hex ?? '#9e9e9e'
|
||||
})
|
||||
|
||||
const currentAccentLabel = computed(() => {
|
||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||
return found?.label ?? 'Standard'
|
||||
})
|
||||
|
||||
function selectAccent(value) {
|
||||
settingsStore.accentColor = value
|
||||
accentDropdownOpen.value = false
|
||||
}
|
||||
|
||||
function onAppearanceChange(e) {
|
||||
const value = e.target.value
|
||||
settingsStore.appearance = value
|
||||
applyAppearance(value)
|
||||
}
|
||||
|
||||
function applyAppearance(mode) {
|
||||
if (mode === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
$q.dark.set(prefersDark)
|
||||
} else {
|
||||
$q.dark.set(mode === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
// Apply appearance on mount and when it changes externally
|
||||
watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-section__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-section__divider {
|
||||
height: 1px;
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.settings-row__label {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.settings-row__control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Native select styled */
|
||||
.settings-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 6px 28px 6px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath fill='%23999' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.settings-select--dark {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Accent button */
|
||||
.settings-accent-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-accent-btn--dark {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-accent-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Accent Dropdown */
|
||||
.settings-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
min-width: 180px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.settings-dropdown--dark {
|
||||
background: rgba(40, 40, 40, 0.92);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-dropdown__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.settings-dropdown__item:hover {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.settings-dropdown__check {
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Placeholder text */
|
||||
.settings-placeholder {
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
44
frontend/_src/components/EssentialLink.vue
Normal file
44
frontend/_src/components/EssentialLink.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<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>
|
||||
546
frontend/_src/components/EventPanel.vue
Normal file
546
frontend/_src/components/EventPanel.vue
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="eventsStore.panelOpen"
|
||||
class="event-panel glass--panel"
|
||||
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
|
||||
:class="{ 'event-panel--dragging': isDragging }"
|
||||
>
|
||||
<!-- Drag Handle — drag to resize, tap to close -->
|
||||
<div
|
||||
class="event-panel__handle"
|
||||
v-on="handleListeners"
|
||||
>
|
||||
<div class="event-panel__handle-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="event-panel__scroll">
|
||||
<!-- Key Image -->
|
||||
<div class="event-panel__image-section">
|
||||
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
|
||||
<img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
|
||||
<span class="event-panel__image-badge">Key Image</span>
|
||||
</div>
|
||||
<div v-else class="event-panel__image-placeholder" @click="onAddImage">
|
||||
<q-icon name="add_photo_alternate" size="32px" color="grey-5" />
|
||||
<span>Key Image hinzufügen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title — large, editable inline -->
|
||||
<q-input
|
||||
v-model="eventsStore.ghostTitle"
|
||||
placeholder="Was ist passiert?"
|
||||
borderless
|
||||
class="event-panel__title"
|
||||
input-class="event-panel__title-input"
|
||||
:dark="isDark"
|
||||
/>
|
||||
|
||||
<!-- Date row — tap to open QDate picker -->
|
||||
<div class="event-panel__date-row">
|
||||
<q-icon name="event" size="18px" class="event-panel__date-icon" />
|
||||
<span class="event-panel__date-label">{{ formattedDate }}</span>
|
||||
<q-popup-proxy transition-show="scale" transition-hide="scale">
|
||||
<q-date
|
||||
v-model="ghostDateSlash"
|
||||
mask="YYYY/MM/DD"
|
||||
:dark="isDark"
|
||||
:locale="dateLocale"
|
||||
minimal
|
||||
/>
|
||||
</q-popup-proxy>
|
||||
</div>
|
||||
|
||||
<!-- Emotional Level — card style with gradient track -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<div class="event-panel__card-header">
|
||||
<span class="event-panel__card-label">Emotional Level</span>
|
||||
<span class="event-panel__emotion-value" :style="{ color: emotionColor }">
|
||||
{{ emotionLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Gradient slider -->
|
||||
<div class="event-panel__slider-wrap">
|
||||
<q-slider
|
||||
v-model="eventsStore.ghostEmotion"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
track-size="8px"
|
||||
thumb-size="22px"
|
||||
class="event-panel__slider"
|
||||
:style="{
|
||||
'--gradient-bg': sliderGradientCSS,
|
||||
'--thumb-color': emotionColor
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="event-panel__slider-hints">
|
||||
<span>Sehr negativ</span>
|
||||
<span>Neutral</span>
|
||||
<span>Sehr positiv</span>
|
||||
</div>
|
||||
|
||||
<!-- Gradient Preset Selector -->
|
||||
<div class="event-panel__presets">
|
||||
<span class="event-panel__presets-label">Farbverlauf</span>
|
||||
<div class="event-panel__presets-grid">
|
||||
<div
|
||||
v-for="(preset, index) in gradientPresets"
|
||||
:key="index"
|
||||
class="event-panel__preset"
|
||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === index }"
|
||||
:style="{ background: presetGradientCSS(preset.colors) }"
|
||||
:title="preset.name"
|
||||
@click="selectPreset(index)"
|
||||
></div>
|
||||
<!-- "None" option to clear preset -->
|
||||
<div
|
||||
class="event-panel__preset event-panel__preset--none"
|
||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === null }"
|
||||
title="Standard"
|
||||
@click="selectPreset(null)"
|
||||
>
|
||||
<q-icon name="auto_awesome" size="12px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung — card style -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<span class="event-panel__card-label">Beschreibung</span>
|
||||
<q-input
|
||||
v-model="eventsStore.ghostNote"
|
||||
type="textarea"
|
||||
placeholder="Ein neutraler, aber wichtiger Tag"
|
||||
borderless
|
||||
autogrow
|
||||
class="event-panel__note"
|
||||
:dark="isDark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Medien -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<span class="event-panel__card-label">Weitere Medien</span>
|
||||
<div class="event-panel__media-grid">
|
||||
<div class="event-panel__media-add" @click="onAddMedia">
|
||||
<q-icon name="add_photo_alternate" size="24px" color="grey-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete (edit mode only) -->
|
||||
<div v-if="eventsStore.editingEventId" class="event-panel__delete">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
label="Event löschen"
|
||||
icon="delete_outline"
|
||||
color="negative"
|
||||
size="sm"
|
||||
@click="eventsStore.deleteEvent(eventsStore.editingEventId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events'
|
||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||
import { resolveFullRes } from 'composables/useImageCache'
|
||||
|
||||
const $q = useQuasar()
|
||||
const eventsStore = useEventsStore()
|
||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
|
||||
|
||||
// Resolve key image: full-res when online, cached thumbnail when offline
|
||||
const keyImageSrc = ref(null)
|
||||
watch(
|
||||
() => eventsStore.ghostImage,
|
||||
async (img) => {
|
||||
keyImageSrc.value = await resolveFullRes(img)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Reset height when panel opens
|
||||
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const gradientPresets = GRADIENT_PRESETS
|
||||
|
||||
// Current glow color based on emotion + gradient
|
||||
const emotionColor = computed(() => {
|
||||
if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor
|
||||
return emotionToColor(eventsStore.ghostEmotion, eventsStore.ghostGradientPreset)
|
||||
})
|
||||
|
||||
// Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD
|
||||
const ghostDateSlash = computed({
|
||||
get: () => eventsStore.ghostDate.replace(/-/g, '/'),
|
||||
set: (val) => { eventsStore.ghostDate = val.replace(/\//g, '-') }
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = new Date(eventsStore.ghostDate)
|
||||
if (isNaN(d.getTime())) return eventsStore.ghostDate
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const dateLocale = {
|
||||
days: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
|
||||
daysShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
|
||||
months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
|
||||
monthsShort: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
}
|
||||
|
||||
const emotionLabel = computed(() => {
|
||||
const e = eventsStore.ghostEmotion
|
||||
if (e > 0.5) return 'Sehr positiv'
|
||||
if (e > 0.15) return 'Positiv'
|
||||
if (e > -0.15) return 'Neutral'
|
||||
if (e > -0.5) return 'Negativ'
|
||||
return 'Sehr negativ'
|
||||
})
|
||||
|
||||
// CSS gradient for the slider track
|
||||
const sliderGradientCSS = computed(() => {
|
||||
const idx = eventsStore.ghostGradientPreset
|
||||
if (idx !== null && GRADIENT_PRESETS[idx]) {
|
||||
const [neg, mid, pos] = GRADIENT_PRESETS[idx].colors
|
||||
return `linear-gradient(90deg, ${neg} 0%, ${mid} 50%, ${pos} 100%)`
|
||||
}
|
||||
// Default gradient matching the default emotionToColor
|
||||
return 'linear-gradient(90deg, #E91E63 0%, #9C27B0 20%, #2196F3 35%, #FFD700 50%, #FF6B35 65%, #FFD700 80%, #4CAF50 100%)'
|
||||
})
|
||||
|
||||
// CSS gradient for a preset swatch
|
||||
function presetGradientCSS(colors) {
|
||||
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`
|
||||
}
|
||||
|
||||
function selectPreset(index) {
|
||||
eventsStore.ghostGradientPreset = index
|
||||
// Clear custom color when selecting a gradient
|
||||
eventsStore.ghostCustomColor = null
|
||||
}
|
||||
|
||||
function onAddImage() {
|
||||
// TODO: File picker for key image
|
||||
}
|
||||
|
||||
function onAddMedia() {
|
||||
// TODO: File picker for additional media
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 75dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.event-panel--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.event-panel__handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.event-panel__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.event-panel__handle-bar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.event-panel__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 32px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Key Image */
|
||||
.event-panel__image-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-panel__image-wrap {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-panel__image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.event-panel__image-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.event-panel__image-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 120px;
|
||||
border-radius: 16px;
|
||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__image-placeholder:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.event-panel__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-panel__title :deep(.event-panel__title-input) {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Date row */
|
||||
.event-panel__date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__date-row:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.event-panel__date-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-panel__date-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Card sections */
|
||||
.event-panel__card {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid rgba(128, 128, 128, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.event-panel__card--dark {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.event-panel__card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.event-panel__card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.event-panel__emotion-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Slider with gradient track via Quasar deep styling */
|
||||
.event-panel__slider-wrap {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* The actual visible track bar — apply gradient here */
|
||||
.event-panel__slider :deep(.q-slider__track) {
|
||||
background: var(--gradient-bg) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Hide the default grey inner bar */
|
||||
.event-panel__slider :deep(.q-slider__inner) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Hide the blue selection bar */
|
||||
.event-panel__slider :deep(.q-slider__selection) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Thumb — SVG-based, use color for fill/stroke */
|
||||
.event-panel__slider :deep(.q-slider__thumb) {
|
||||
color: var(--thumb-color, #fff);
|
||||
}
|
||||
|
||||
.event-panel__slider :deep(.q-slider__thumb-shape) {
|
||||
filter: drop-shadow(0 0 6px var(--thumb-color, #fff));
|
||||
}
|
||||
|
||||
.event-panel__slider :deep(.q-slider__focus-ring) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-panel__slider-hints {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
opacity: 0.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Gradient Preset Selector */
|
||||
.event-panel__presets {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.event-panel__presets-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
opacity: 0.5;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-panel__presets-grid {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-panel__preset {
|
||||
width: 45px;
|
||||
height: 25px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 2px solid #eee;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.event-panel__preset:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.event-panel__preset--active {
|
||||
border-color: currentColor;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.event-panel__preset--none {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Note */
|
||||
.event-panel__note {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Media grid */
|
||||
.event-panel__media-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-panel__media-add {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__media-add:hover {
|
||||
border-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
|
||||
/* Delete */
|
||||
.event-panel__delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
571
frontend/_src/components/FloatingLines.vue
Normal file
571
frontend/_src/components/FloatingLines.vue
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="floating-lines-container"
|
||||
:style="containerStyle"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
WebGLRenderer,
|
||||
PlaneGeometry,
|
||||
Mesh,
|
||||
ShaderMaterial,
|
||||
Vector3,
|
||||
Vector2,
|
||||
Clock
|
||||
} from 'three'
|
||||
|
||||
const props = defineProps({
|
||||
lineCount: { type: [Array, Number], default: () => [10] },
|
||||
numPoints: { type: Number, default: 0 },
|
||||
pointXValues: { type: Array, default: () => [] },
|
||||
pointYValues: { type: Array, default: () => [] },
|
||||
pointColors: { type: Array, default: () => [] },
|
||||
lineSpread: { type: Number, default: 0.05 },
|
||||
fanSpread: { type: Number, default: 0.05 },
|
||||
lineSharpness: { type: Number, default: 8.0 },
|
||||
waveFrequency: { type: Number, default: 7.0 },
|
||||
bezierCurvature: { type: Number, default: 0.2 },
|
||||
circleRadiusPx: { type: Number, default: 75 },
|
||||
circleGlowSize: { type: Number, default: 18 },
|
||||
circleGlowStrength: { type: Number, default: 1.5 },
|
||||
lineBrightness: { type: Number, default: 1.0 },
|
||||
scrollContainer: { type: Object, default: null },
|
||||
scrollUvScale: { type: Number, default: 0 },
|
||||
animationSpeed: { type: Number, default: 1 },
|
||||
linesGradient: { type: Array, default: () => ['#e947f5', '#2f4ba2', '#0a0a12'] },
|
||||
bgColorCenter: { type: String, default: '#0a0514' },
|
||||
bgColorEdge: { type: String, default: '#000000' },
|
||||
backgroundImage: { type: String, default: '' },
|
||||
mixBlendMode: { type: String, default: 'screen' },
|
||||
parallax: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
// FPS display
|
||||
const fpsDisplay = ref(0)
|
||||
const dprDisplay = ref('0.0')
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const style = {}
|
||||
if (props.backgroundImage) {
|
||||
style.backgroundImage = `url('${props.backgroundImage}')`
|
||||
style.backgroundSize = 'cover'
|
||||
style.backgroundPosition = 'center'
|
||||
style.backgroundRepeat = 'no-repeat'
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
// --- Shader Definitions ---
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
precision mediump float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float animationSpeed;
|
||||
|
||||
uniform int middleLineCount;
|
||||
uniform int numPoints;
|
||||
uniform float pointX[16];
|
||||
uniform float pointY[16];
|
||||
uniform float lineSpread;
|
||||
uniform float fanSpread;
|
||||
uniform float lineSharpness;
|
||||
uniform float waveFrequency;
|
||||
uniform float bezierCurvature;
|
||||
uniform float lineBrightness;
|
||||
uniform vec3 pointColor[16];
|
||||
|
||||
uniform bool parallax;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
uniform vec3 bgColorCenter;
|
||||
uniform vec3 bgColorEdge;
|
||||
|
||||
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
||||
float bestT = 0.0;
|
||||
float bestD = 1e9;
|
||||
for (int k = 0; k <= 8; ++k) {
|
||||
float t = float(k) / 8.0;
|
||||
float mt = 1.0 - t;
|
||||
vec2 b = mt*mt*p0 + 2.0*mt*t*pc + t*t*p1;
|
||||
float d = dot(q - b, q - b);
|
||||
if (d < bestD) { bestD = d; bestT = t; }
|
||||
}
|
||||
|
||||
vec2 A = pc - p0;
|
||||
vec2 B = p0 - 2.0*pc + p1;
|
||||
vec2 D = p0 - q;
|
||||
float a = 2.0*dot(B,B);
|
||||
float bco = 6.0*dot(A,B);
|
||||
float c = 4.0*dot(A,A) + 2.0*dot(D,B);
|
||||
float dco = 2.0*dot(D,A);
|
||||
|
||||
float t = clamp(bestT, 0.001, 0.999);
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
float f = a*t*t*t + bco*t*t + c*t + dco;
|
||||
float fp = 3.0*a*t*t + 2.0*bco*t + c;
|
||||
if (abs(fp) > 1e-8) t -= f / fp;
|
||||
t = clamp(t, -0.08, 1.08);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// Accepts precomputed bezier values (t, curvePos, norm) — computed once per segment
|
||||
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
|
||||
float s = dot(uv - curvePos, norm);
|
||||
|
||||
float time = iTime * animationSpeed;
|
||||
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
||||
|
||||
float envelope = sin(t * 3.14159265359);
|
||||
float linePos = (normalizedI - 0.5) * fanSpread * envelope;
|
||||
float amp = lineSpread * 0.3 * envelope;
|
||||
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
|
||||
* sin(fi * 0.9 + time * 0.18);
|
||||
|
||||
float dist = s - linePos - waveDisp;
|
||||
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
|
||||
|
||||
return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003);
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0;
|
||||
|
||||
if (parallax) {
|
||||
baseUv += parallaxOffset;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
const int MAX_PTS = 16;
|
||||
const int MAX_SEGS = 15;
|
||||
|
||||
for (int s = 0; s < MAX_SEGS; ++s) {
|
||||
if (s >= numPoints - 1) break;
|
||||
|
||||
vec2 sp = vec2(pointX[s], pointY[s]);
|
||||
vec2 ep = vec2(pointX[s + 1], pointY[s + 1]);
|
||||
|
||||
vec2 segD = ep - sp;
|
||||
float segL = length(segD);
|
||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||
|
||||
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
|
||||
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
|
||||
|
||||
// bezierClosestT computed ONCE per segment — shared by fog + all lines
|
||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||
float bmt = 1.0 - bt;
|
||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||
vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc));
|
||||
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
||||
|
||||
float bDist = length(baseUv - bPos);
|
||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||
float fogEnv = sin(bt * 3.14159265359);
|
||||
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
||||
col += lineCol * segFog;
|
||||
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||
}
|
||||
}
|
||||
|
||||
col *= lineBrightness;
|
||||
|
||||
float dist = length(baseUv) / 1.8;
|
||||
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
||||
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = vec4(0.0);
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`
|
||||
|
||||
// --- Helpers ---
|
||||
const MAX_GRADIENT_STOPS = 8
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let value = hex.trim()
|
||||
if (value.startsWith('#')) value = value.slice(1)
|
||||
let r = 255, g = 255, b = 255
|
||||
if (value.length === 3) {
|
||||
r = parseInt(value[0] + value[0], 16)
|
||||
g = parseInt(value[1] + value[1], 16)
|
||||
b = parseInt(value[2] + value[2], 16)
|
||||
} else if (value.length === 6) {
|
||||
r = parseInt(value.slice(0, 2), 16)
|
||||
g = parseInt(value.slice(2, 4), 16)
|
||||
b = parseInt(value.slice(4, 6), 16)
|
||||
}
|
||||
return new Vector3(r / 255, g / 255, b / 255)
|
||||
}
|
||||
|
||||
// --- Component Logic ---
|
||||
const containerRef = ref(null)
|
||||
|
||||
let scene = null
|
||||
let camera = null
|
||||
let renderer = null
|
||||
let material = null
|
||||
let geometry = null
|
||||
let mesh = null
|
||||
let clock = null
|
||||
let rafId = null
|
||||
let resizeObserver = null
|
||||
let uniforms = null
|
||||
let scrollHandler = null
|
||||
let scrollIdleTimer = null
|
||||
let visibilityHandler = null
|
||||
|
||||
// Parallax tracking
|
||||
let targetParallax = null
|
||||
let currentParallax = null
|
||||
const parallaxDamping = 0.05
|
||||
|
||||
function getLineCount() {
|
||||
return typeof props.lineCount === 'number' ? props.lineCount : (props.lineCount[0] ?? 6)
|
||||
}
|
||||
|
||||
function applyGradient() {
|
||||
if (!uniforms) return
|
||||
const lines = props.linesGradient.filter(s => s && s.trim().length > 0)
|
||||
const stops = lines.slice(0, MAX_GRADIENT_STOPS)
|
||||
uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const c = hexToVec3(hex)
|
||||
uniforms.lineGradient.value[i].set(c.x, c.y, c.z)
|
||||
})
|
||||
}
|
||||
|
||||
function applyPointColors() {
|
||||
if (!uniforms) return
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const hex = props.pointColors[i]
|
||||
if (hex) {
|
||||
const c = hexToVec3(hex)
|
||||
uniforms.pointColor.value[i].set(c.x, c.y, c.z)
|
||||
} else {
|
||||
uniforms.pointColor.value[i].set(1, 1, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyBgColors() {
|
||||
if (!uniforms) return
|
||||
const center = hexToVec3(props.bgColorCenter)
|
||||
uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
|
||||
const edge = hexToVec3(props.bgColorEdge)
|
||||
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
|
||||
}
|
||||
|
||||
// Watch all props for live updates
|
||||
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
|
||||
watch(() => props.lineCount, () => {
|
||||
if (!uniforms) return
|
||||
uniforms.middleLineCount.value = getLineCount()
|
||||
})
|
||||
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
|
||||
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
|
||||
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
|
||||
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
|
||||
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
|
||||
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
|
||||
watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.value = v })
|
||||
watch(() => props.pointXValues, (values) => {
|
||||
if (!uniforms) return
|
||||
for (let i = 0; i < 16; i++) {
|
||||
uniforms.pointX.value[i] = values[i] ?? 0
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(() => props.pointYValues, (values) => {
|
||||
if (!uniforms) return
|
||||
for (let i = 0; i < 16; i++) {
|
||||
uniforms.pointY.value[i] = values[i] ?? 0
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(() => props.pointColors, applyPointColors, { deep: true })
|
||||
watch(() => props.linesGradient, applyGradient, { deep: true })
|
||||
watch(() => props.bgColorCenter, applyBgColors)
|
||||
watch(() => props.bgColorEdge, applyBgColors)
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
targetParallax = new Vector2(0, 0)
|
||||
currentParallax = new Vector2(0, 0)
|
||||
|
||||
scene = new Scene()
|
||||
camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
camera.position.z = 1
|
||||
|
||||
// Adaptive DPR: full quality when idle, reduced during scroll
|
||||
const nativeDpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
const DPR_IDLE = isMobile ? Math.min(nativeDpr, 1.6) : nativeDpr
|
||||
const DPR_SCROLL = isMobile ? 0.75 : nativeDpr // reduced on mobile during scroll
|
||||
const DPR_RESTORE_DELAY = 100 // ms after scroll stops to restore quality
|
||||
let currentDpr = DPR_IDLE
|
||||
let scrolling = false
|
||||
|
||||
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' })
|
||||
renderer.setPixelRatio(currentDpr)
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.display = 'block'
|
||||
renderer.domElement.style.mixBlendMode = props.mixBlendMode
|
||||
containerRef.value.appendChild(renderer.domElement)
|
||||
|
||||
const middleLineCount = getLineCount()
|
||||
|
||||
// Initial point positions (UV space, no flip)
|
||||
const initX = [...props.pointXValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
|
||||
const initY = [...props.pointYValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
|
||||
|
||||
uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: props.animationSpeed },
|
||||
|
||||
middleLineCount: { value: middleLineCount },
|
||||
numPoints: { value: props.numPoints },
|
||||
pointX: { value: initX },
|
||||
pointY: { value: initY },
|
||||
lineSpread: { value: props.lineSpread },
|
||||
fanSpread: { value: props.fanSpread },
|
||||
lineSharpness: { value: props.lineSharpness },
|
||||
waveFrequency: { value: props.waveFrequency },
|
||||
bezierCurvature: { value: props.bezierCurvature },
|
||||
lineBrightness: { value: props.lineBrightness },
|
||||
pointColor: {
|
||||
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
|
||||
},
|
||||
|
||||
parallax: { value: props.parallax },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
||||
lineGradient: {
|
||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1))
|
||||
},
|
||||
lineGradientCount: { value: 0 },
|
||||
bgColorCenter: { value: new Vector3(0, 0, 0) },
|
||||
bgColorEdge: { value: new Vector3(0, 0, 0) }
|
||||
}
|
||||
|
||||
// Apply initial values
|
||||
applyGradient()
|
||||
applyBgColors()
|
||||
applyPointColors()
|
||||
|
||||
material = new ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader
|
||||
})
|
||||
|
||||
geometry = new PlaneGeometry(2, 2)
|
||||
mesh = new Mesh(geometry, material)
|
||||
scene.add(mesh)
|
||||
|
||||
clock = new Clock()
|
||||
|
||||
// Resize
|
||||
const setSize = () => {
|
||||
if (!containerRef.value || !renderer) return
|
||||
const width = containerRef.value.clientWidth || 1
|
||||
const height = containerRef.value.clientHeight || 1
|
||||
renderer.setSize(width, height, false)
|
||||
const canvasWidth = renderer.domElement.width
|
||||
const canvasHeight = renderer.domElement.height
|
||||
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
||||
}
|
||||
setSize()
|
||||
|
||||
resizeObserver = new ResizeObserver(setSize)
|
||||
resizeObserver.observe(containerRef.value)
|
||||
|
||||
// Pointer events (parallax only)
|
||||
if (props.parallax) {
|
||||
const handlePointerMove = (event) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
targetParallax.set(
|
||||
((x - centerX) / rect.width) * 0.2,
|
||||
(-(y - centerY) / rect.height) * 0.2
|
||||
)
|
||||
}
|
||||
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
||||
}
|
||||
|
||||
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
|
||||
let cachedScrollLeft = 0
|
||||
|
||||
function setDpr(dpr) {
|
||||
if (dpr === currentDpr) return
|
||||
currentDpr = dpr
|
||||
renderer.setPixelRatio(dpr)
|
||||
// Re-apply size so resolution updates
|
||||
if (containerRef.value) {
|
||||
const w = containerRef.value.clientWidth || 1
|
||||
const h = containerRef.value.clientHeight || 1
|
||||
renderer.setSize(w, h, false)
|
||||
uniforms.iResolution.value.set(renderer.domElement.width, renderer.domElement.height, 1)
|
||||
}
|
||||
}
|
||||
|
||||
scrollHandler = () => {
|
||||
if (props.scrollContainer) {
|
||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||
}
|
||||
// Drop DPR while scrolling on mobile
|
||||
if (!scrolling && DPR_SCROLL < DPR_IDLE) {
|
||||
scrolling = true
|
||||
setDpr(DPR_SCROLL)
|
||||
}
|
||||
clearTimeout(scrollIdleTimer)
|
||||
scrollIdleTimer = setTimeout(() => {
|
||||
scrolling = false
|
||||
setDpr(DPR_IDLE)
|
||||
}, DPR_RESTORE_DELAY)
|
||||
}
|
||||
|
||||
if (props.scrollContainer) {
|
||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
|
||||
}
|
||||
|
||||
// Fast inline scroll sync — reads cached scrollLeft instead of DOM during render
|
||||
function syncScrollFromCache() {
|
||||
if (props.scrollUvScale > 0) {
|
||||
const sl = cachedScrollLeft
|
||||
for (let i = 0; i < 16; i++) {
|
||||
uniforms.pointX.value[i] = (props.pointXValues[i] ?? 0) - sl * props.scrollUvScale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FPS tracking + adaptive DPR
|
||||
let frameCount = 0
|
||||
let fpsLastTime = performance.now()
|
||||
|
||||
// Adaptive DPR steps based on measured FPS (mobile only)
|
||||
// Steps are tried from lowest to highest – first step where fps >= threshold wins
|
||||
const DPR_STEPS = isMobile ? [
|
||||
{ threshold: 50, dpr: DPR_IDLE }, // good fps → full quality
|
||||
{ threshold: 35, dpr: Math.max(DPR_IDLE * 0.75, 0.75) }, // mid fps → 75%
|
||||
{ threshold: 0, dpr: Math.max(DPR_IDLE * 0.5, 0.5) }, // low fps → 50%
|
||||
] : null
|
||||
let adaptiveDprTarget = DPR_IDLE
|
||||
|
||||
// Pause rendering when app/tab is hidden (e.g. iPhone home screen)
|
||||
visibilityHandler = () => {
|
||||
if (document.hidden) {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
} else if (!rafId) {
|
||||
fpsLastTime = performance.now()
|
||||
frameCount = 0
|
||||
renderLoop()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
|
||||
// Render loop
|
||||
const renderLoop = () => {
|
||||
// FPS counter + adaptive DPR
|
||||
frameCount++
|
||||
const now = performance.now()
|
||||
if (now - fpsLastTime >= 1000) {
|
||||
const fps = Math.round(frameCount / ((now - fpsLastTime) / 1000))
|
||||
fpsDisplay.value = fps
|
||||
frameCount = 0
|
||||
fpsLastTime = now
|
||||
|
||||
// Adapt DPR based on measured FPS (only when not scroll-throttled)
|
||||
if (DPR_STEPS && !scrolling) {
|
||||
const step = DPR_STEPS.find(s => fps >= s.threshold) ?? DPR_STEPS[DPR_STEPS.length - 1]
|
||||
if (step.dpr !== adaptiveDprTarget) {
|
||||
adaptiveDprTarget = step.dpr
|
||||
setDpr(adaptiveDprTarget)
|
||||
}
|
||||
}
|
||||
|
||||
dprDisplay.value = currentDpr.toFixed(2)
|
||||
}
|
||||
|
||||
// Read latest scrollLeft from DOM in case scroll event was missed
|
||||
if (props.scrollContainer) {
|
||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||
}
|
||||
|
||||
uniforms.iTime.value = clock.getElapsedTime()
|
||||
|
||||
if (props.parallax) {
|
||||
currentParallax.lerp(targetParallax, parallaxDamping)
|
||||
uniforms.parallaxOffset.value.copy(currentParallax)
|
||||
}
|
||||
|
||||
syncScrollFromCache()
|
||||
|
||||
renderer.render(scene, camera)
|
||||
rafId = requestAnimationFrame(renderLoop)
|
||||
}
|
||||
renderLoop()
|
||||
})
|
||||
|
||||
defineExpose({ fpsDisplay, dprDisplay })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
if (resizeObserver) resizeObserver.disconnect()
|
||||
if (props.scrollContainer && scrollHandler) {
|
||||
props.scrollContainer.removeEventListener('scroll', scrollHandler)
|
||||
}
|
||||
clearTimeout(scrollIdleTimer)
|
||||
if (geometry) geometry.dispose()
|
||||
if (material) material.dispose()
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
if (renderer.domElement && renderer.domElement.parentNode) {
|
||||
renderer.domElement.parentNode.removeChild(renderer.domElement)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-lines-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
200
frontend/_src/components/GlowDot.vue
Normal file
200
frontend/_src/components/GlowDot.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<template>
|
||||
<div
|
||||
class="glow-dot"
|
||||
:class="{
|
||||
'glow-dot--ghost': isGhost,
|
||||
'glow-dot--selected': selected,
|
||||
'glow-dot--dimmed': isDimmed,
|
||||
'glow-dot--label-above': labelAbove
|
||||
}"
|
||||
:style="dotStyle"
|
||||
@click.stop="onSelect"
|
||||
>
|
||||
<div class="glow-dot__inner" :style="{ boxShadow: glowShadow }">
|
||||
<img
|
||||
v-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
class="glow-dot__image"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
|
||||
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
|
||||
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { useImageCache } from 'composables/useImageCache'
|
||||
|
||||
const props = defineProps({
|
||||
event: { type: Object, required: true },
|
||||
x: { type: Number, default: 0 },
|
||||
isGhost: { type: Boolean, default: false },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
const eventsStore = useEventsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
|
||||
const { resolvedSrc: imageSrc } = props.event.image
|
||||
? useImageCache(props.event.image, props.event.id)
|
||||
: { resolvedSrc: computed(() => null) }
|
||||
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
||||
|
||||
// CSS diameter derived from settings (circleRadius is half-size in px)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
|
||||
|
||||
// Y position: emotion +1 → top (20%), 0 → middle (47%), -1 → bottom (74%)
|
||||
|
||||
const yPercent = computed(() => 48 - props.event.emotion * 30)
|
||||
|
||||
// Label above dot when dot is in lower half, below when in upper half
|
||||
const labelAbove = computed(() => yPercent.value > 48)
|
||||
|
||||
// Format date as "12. Feb 2024"
|
||||
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
const formattedDate = computed(() => {
|
||||
const d = new Date(props.event.date)
|
||||
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
|
||||
})
|
||||
|
||||
// Label font sizes per setting
|
||||
const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } }
|
||||
const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
|
||||
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
|
||||
|
||||
const labelStyle = computed(() => ({
|
||||
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
|
||||
}))
|
||||
const titleStyle = computed(() => ({
|
||||
fontSize: `${labelFont.value.title}px`,
|
||||
color: labelColor.value,
|
||||
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
|
||||
}))
|
||||
const dateStyle = computed(() => ({
|
||||
fontSize: `${labelFont.value.date}px`,
|
||||
color: labelColor.value
|
||||
}))
|
||||
|
||||
const dotStyle = computed(() => ({
|
||||
left: `${props.x}px`,
|
||||
top: `${yPercent.value}%`,
|
||||
width: `${dotSize.value}px`,
|
||||
height: `${dotSize.value}px`
|
||||
}))
|
||||
|
||||
// Two-layer box-shadow: tight bright core + wide soft halo
|
||||
const glowShadow = computed(() => {
|
||||
const size = fl.value.glowSize
|
||||
const strength = fl.value.glowStrength
|
||||
const color = glowColor.value
|
||||
const core = alphaHex(Math.min(strength / 3, 1))
|
||||
const halo = alphaHex(Math.min(strength / 7, 1))
|
||||
return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
|
||||
})
|
||||
|
||||
function alphaHex(a) {
|
||||
return Math.round(Math.min(1, Math.max(0, a)) * 255).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
const isDimmed = computed(() =>
|
||||
eventsStore.selectedEventId !== null && !props.selected && !props.isGhost
|
||||
)
|
||||
|
||||
function onSelect() {
|
||||
if (!props.isGhost) emit('select')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glow-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: opacity 0.3s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.glow-dot__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.glow-dot__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.glow-dot--ghost {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.glow-dot--selected {
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.glow-dot--dimmed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Event label (title + date) */
|
||||
.glow-dot__label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: calc(100% + 6px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
max-width: 90px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* When dot is in lower half, show label above */
|
||||
.glow-dot--label-above .glow-dot__label {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.glow-dot__title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 90px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.glow-dot__date {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
opacity: 0.4;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
482
frontend/_src/components/LifeWaveSettings.vue
Normal file
482
frontend/_src/components/LifeWaveSettings.vue
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="open"
|
||||
class="lw-settings glass--panel"
|
||||
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
|
||||
:class="{ 'lw-settings--dragging': isDragging }"
|
||||
>
|
||||
<!-- Handle — drag to resize, tap to close -->
|
||||
<div
|
||||
class="lw-settings__handle"
|
||||
v-on="handleListeners"
|
||||
>
|
||||
<div class="lw-settings__handle-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="lw-settings__scroll">
|
||||
<div class="lw-settings__title">Einstellungen</div>
|
||||
|
||||
<!-- Linien -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Linien</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Speed</span>
|
||||
<span class="lw-settings__value">{{ fl.speed.toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.speed"
|
||||
@update:model-value="v => update({ speed: v })"
|
||||
:min="0.1" :max="3" :step="0.05"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Anzahl</span>
|
||||
<span class="lw-settings__value">{{ fl.lineCount }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.lineCount"
|
||||
@update:model-value="v => update({ lineCount: v })"
|
||||
:min="1" :max="40" :step="1"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Wellen-Amp</span>
|
||||
<span class="lw-settings__value">{{ fl.spread.toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.spread"
|
||||
@update:model-value="v => update({ spread: v })"
|
||||
:min="0.01" :max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Fächerbreite</span>
|
||||
<span class="lw-settings__value">{{ fl.fanSpread.toFixed(3) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.fanSpread"
|
||||
@update:model-value="v => update({ fanSpread: v })"
|
||||
:min="0.01" :max="0.5" :step="0.005"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Feinheit</span>
|
||||
<span class="lw-settings__value">{{ fl.lineSharpness.toFixed(1) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.lineSharpness"
|
||||
@update:model-value="v => update({ lineSharpness: v })"
|
||||
:min="0.3" :max="10" :step="0.1"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Welligkeit</span>
|
||||
<span class="lw-settings__value">{{ fl.waveFrequency.toFixed(1) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.waveFrequency"
|
||||
@update:model-value="v => update({ waveFrequency: v })"
|
||||
:min="1" :max="30" :step="0.5"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Kurve</span>
|
||||
<span class="lw-settings__value">{{ fl.bezierCurvature.toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.bezierCurvature"
|
||||
@update:model-value="v => update({ bezierCurvature: v })"
|
||||
:min="-1" :max="1" :step="0.05"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Kreis</span>
|
||||
<span class="lw-settings__value">{{ fl.circleRadius }}px</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.circleRadius"
|
||||
@update:model-value="v => update({ circleRadius: v })"
|
||||
:min="10" :max="200" :step="5"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Glow Größe</span>
|
||||
<span class="lw-settings__value">{{ fl.glowSize }}px</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.glowSize"
|
||||
@update:model-value="v => update({ glowSize: v })"
|
||||
:min="5" :max="100" :step="1"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Glow Stärke</span>
|
||||
<span class="lw-settings__value">{{ fl.glowStrength.toFixed(1) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.glowStrength"
|
||||
@update:model-value="v => update({ glowStrength: v })"
|
||||
:min="0.5" :max="12" :step="0.5"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Helligkeit</span>
|
||||
<span class="lw-settings__value">{{ (fl.lineBrightness ?? 1).toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.lineBrightness ?? 1"
|
||||
@update:model-value="v => update({ lineBrightness: v })"
|
||||
:min="0.05" :max="2" :step="0.05"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Labels</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Schriftgröße</span>
|
||||
<div class="lw-settings__segmented">
|
||||
<button
|
||||
v-for="size in LABEL_SIZES"
|
||||
:key="size.value"
|
||||
class="lw-settings__seg-btn"
|
||||
:class="{ 'lw-settings__seg-btn--active': fl.labelSize === size.value }"
|
||||
@click="update({ labelSize: size.value })"
|
||||
>
|
||||
{{ size.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Schriftfarbe</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="fl.labelColor ?? '#ffffff'"
|
||||
@input="e => update({ labelColor: e.target.value })"
|
||||
class="lw-settings__color-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hintergrundbild -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Hintergrundbild</span>
|
||||
|
||||
<div class="lw-settings__img-grid">
|
||||
<button
|
||||
class="lw-settings__img-btn"
|
||||
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === '' }"
|
||||
@click="update({ backgroundImage: '' })"
|
||||
>
|
||||
Keins
|
||||
</button>
|
||||
<button
|
||||
v-for="n in 10"
|
||||
:key="'bg' + n"
|
||||
class="lw-settings__img-btn"
|
||||
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === `/images/bg-image-${n}.jpg` }"
|
||||
@click="update({ backgroundImage: `/images/bg-image-${n}.jpg` })"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hintergrundfarbe -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Hintergrundfarbe</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>BG Mitte</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="fl.bgCenter"
|
||||
@input="e => update({ bgCenter: e.target.value })"
|
||||
class="lw-settings__color-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>BG Rand</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="fl.bgEdge"
|
||||
@input="e => update({ bgEdge: e.target.value })"
|
||||
class="lw-settings__color-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farbstopps -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Farbverlauf (je Zeile ein Hex)</span>
|
||||
|
||||
<textarea
|
||||
:value="fl.gradientStops"
|
||||
@input="e => update({ gradientStops: e.target.value })"
|
||||
class="lw-settings__gradient-input"
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Extras -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Extras</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}</span>
|
||||
<q-toggle
|
||||
:model-value="isDark"
|
||||
@update:model-value="toggleDark"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset -->
|
||||
<div class="lw-settings__reset">
|
||||
<q-btn
|
||||
flat dense no-caps
|
||||
label="Zurücksetzen"
|
||||
icon="restart_alt"
|
||||
size="sm"
|
||||
@click="settingsStore.resetFloatingLines()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||
|
||||
const props = defineProps({ open: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['close'])
|
||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => emit('close'))
|
||||
|
||||
watch(() => props.open, (open) => { if (open) resetHeight() })
|
||||
|
||||
const $q = useQuasar()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
|
||||
const LABEL_SIZES = [
|
||||
{ label: 'Klein', value: 'small' },
|
||||
{ label: 'Mittel', value: 'medium' },
|
||||
{ label: 'Groß', value: 'large' }
|
||||
]
|
||||
|
||||
function update(changes) {
|
||||
settingsStore.updateFloatingLines(changes)
|
||||
}
|
||||
|
||||
function toggleDark() {
|
||||
$q.dark.toggle()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lw-settings {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 75dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.lw-settings--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.lw-settings__handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.lw-settings__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.lw-settings__handle-bar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.lw-settings__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 32px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.lw-settings__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lw-settings__card {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid rgba(128, 128, 128, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.lw-settings__card--dark {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.lw-settings__card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.lw-settings__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.lw-settings__row:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.lw-settings__value {
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Segmented control */
|
||||
.lw-settings__segmented {
|
||||
display: flex;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.lw-settings__seg-btn {
|
||||
flex: 1;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lw-settings__seg-btn--active {
|
||||
background: rgba(168, 85, 247, 0.25);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lw-settings__img-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lw-settings__img-btn {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.lw-settings__img-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: #a855f7;
|
||||
}
|
||||
|
||||
.lw-settings__img-btn--active {
|
||||
border-color: #a855f7;
|
||||
color: #a855f7;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lw-settings__color-input {
|
||||
width: 36px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.lw-settings__gradient-input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
color: inherit;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
resize: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.body--dark .lw-settings__gradient-input {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.lw-settings__reset {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
171
frontend/_src/components/ModalCard.vue
Normal file
171
frontend/_src/components/ModalCard.vue
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<template>
|
||||
<Transition name="modal-card">
|
||||
<div v-if="open" class="modal-card glass--panel" :class="{ 'modal-card--dark': isDark }">
|
||||
<!-- Header: title + close -->
|
||||
<div class="modal-card__header">
|
||||
<span class="modal-card__title">{{ title }}</span>
|
||||
<button class="modal-card__close" @click="$emit('close')">
|
||||
<q-icon name="close" size="22px" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs (optional) -->
|
||||
<div v-if="tabs.length" class="modal-card__tabs">
|
||||
<div class="modal-card__tabs-scroll">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="modal-card__tab"
|
||||
:class="{ 'modal-card__tab--active': modelValue === tab.value }"
|
||||
@click="$emit('update:modelValue', tab.value)"
|
||||
>
|
||||
<q-icon v-if="tab.icon" :name="tab.icon" size="16px" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
tabs: { type: Array, default: () => [] },
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['close', 'update:modelValue'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-card {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-card__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-card__close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.modal-card__close:hover {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.modal-card__tabs {
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.modal-card__tabs-scroll {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-card__tabs-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-card__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.modal-card__tab:hover {
|
||||
opacity: 0.85;
|
||||
background: rgba(128, 128, 128, 0.14);
|
||||
}
|
||||
|
||||
.modal-card__tab--active {
|
||||
opacity: 1;
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.modal-card--dark .modal-card__tab--active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.modal-card__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.modal-card-enter-active,
|
||||
.modal-card-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-card-enter-from,
|
||||
.modal-card-leave-to {
|
||||
transform: translateY(30px) scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
547
frontend/_src/components/TimelineView.vue
Normal file
547
frontend/_src/components/TimelineView.vue
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
<template>
|
||||
<div class="timeline-container">
|
||||
<div
|
||||
class="timeline"
|
||||
ref="timelineRef"
|
||||
@scroll="onScroll"
|
||||
@wheel.prevent="onWheel"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
@touchend.passive="onTouchEnd"
|
||||
>
|
||||
<div class="timeline__track" :style="{ width: trackWidth + 'px' }">
|
||||
<!-- GlowDots — only visible ones rendered -->
|
||||
<GlowDot
|
||||
v-for="{ event, globalIndex } in visibleEvents"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:x="getEventX(globalIndex)"
|
||||
:is-ghost="event.id === '__ghost__'"
|
||||
:selected="eventsStore.selectedEventId === event.id"
|
||||
@select="$emit('dotSelect', event.id)"
|
||||
/>
|
||||
|
||||
<!-- Month labels — only visible -->
|
||||
<div class="timeline__labels">
|
||||
<div
|
||||
v-for="{ label, globalIndex } in visibleLabels"
|
||||
:key="label.key"
|
||||
class="timeline__month"
|
||||
:style="{ left: getEventX(globalIndex) + 'px' }"
|
||||
:class="{ 'timeline__month--active': label.key === activeLabel }"
|
||||
@click="scrollToIndex(globalIndex)"
|
||||
>
|
||||
{{ label.month }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky year labels with nav arrows (outside scroll container) -->
|
||||
<div class="timeline__sticky-years">
|
||||
<TransitionGroup name="year-fade">
|
||||
<div
|
||||
v-for="label in stickyYearLabels"
|
||||
:key="label.year"
|
||||
class="timeline__sticky-year-group"
|
||||
:style="{ left: label.left + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-if="label.hasPrev"
|
||||
class="timeline__year-arrow timeline__year-arrow--prev"
|
||||
@click="scrollToYearCenter(label.year - 1)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M7 1L3 5l4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<span class="timeline__sticky-year" @click="scrollToYearCenter(label.year)">
|
||||
{{ label.year }}
|
||||
</span>
|
||||
<button
|
||||
v-if="label.hasNext"
|
||||
class="timeline__year-arrow timeline__year-arrow--next"
|
||||
@click="scrollToYearCenter(label.year + 1)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import GlowDot from 'components/GlowDot.vue'
|
||||
|
||||
const emit = defineEmits(['dotSelect', 'viewUpdate'])
|
||||
const eventsStore = useEventsStore()
|
||||
const timelineRef = ref(null)
|
||||
const scrollLeft = ref(0)
|
||||
const viewportWidth = ref(400)
|
||||
const containerHeight = ref(400)
|
||||
|
||||
// Zoom: 1.0 = default, range 0.4–3.0
|
||||
const zoomLevel = ref(1)
|
||||
const MIN_ZOOM = 0.4
|
||||
const MAX_ZOOM = 3.0
|
||||
const ZOOM_STEP = 0.08
|
||||
|
||||
// Spacing: ~4 events visible at a time, scaled by zoom
|
||||
const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
|
||||
const EVENT_SPACING = computed(() => BASE_SPACING.value * zoomLevel.value)
|
||||
const PADDING = computed(() => viewportWidth.value / 2)
|
||||
|
||||
// Display events: sorted events + ghost when creating
|
||||
const showGhost = computed(() => eventsStore.panelOpen && !eventsStore.editingEventId)
|
||||
const displayEvents = computed(() => {
|
||||
const sorted = [...eventsStore.sortedEvents]
|
||||
if (!showGhost.value) return sorted
|
||||
const ghost = eventsStore.ghostEvent
|
||||
const ghostDate = new Date(ghost.date)
|
||||
const insertIdx = sorted.findIndex((e) => new Date(e.date) > ghostDate)
|
||||
if (insertIdx === -1) {
|
||||
sorted.push(ghost)
|
||||
} else {
|
||||
sorted.splice(insertIdx, 0, ghost)
|
||||
}
|
||||
return sorted
|
||||
})
|
||||
|
||||
// Track width
|
||||
const trackWidth = computed(() => {
|
||||
const count = displayEvents.value.length
|
||||
if (count === 0) return viewportWidth.value
|
||||
return PADDING.value * 2 + (count - 1) * EVENT_SPACING.value
|
||||
})
|
||||
|
||||
// X position for event at given index
|
||||
function getEventX(index) {
|
||||
return PADDING.value + index * EVENT_SPACING.value
|
||||
}
|
||||
|
||||
// Month names
|
||||
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
|
||||
// One label per event showing its month
|
||||
const eventLabels = computed(() => {
|
||||
return displayEvents.value.map((event, index) => {
|
||||
const d = new Date(event.date)
|
||||
return {
|
||||
key: `${event.id}-${index}`,
|
||||
month: MONTHS[d.getMonth()],
|
||||
year: d.getFullYear(),
|
||||
fullMonth: `${d.getFullYear()}-${d.getMonth()}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Year ranges — for each year, the world-space X range of its events
|
||||
const yearRanges = computed(() => {
|
||||
const ranges = []
|
||||
const events = displayEvents.value
|
||||
if (events.length === 0) return ranges
|
||||
|
||||
let currentYear = new Date(events[0].date).getFullYear()
|
||||
let startIdx = 0
|
||||
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
const year = new Date(events[i].date).getFullYear()
|
||||
if (year !== currentYear) {
|
||||
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
|
||||
currentYear = year
|
||||
startIdx = i
|
||||
}
|
||||
}
|
||||
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(events.length - 1) })
|
||||
return ranges
|
||||
})
|
||||
|
||||
// All years that exist in the data (for prev/next navigation)
|
||||
const allYears = computed(() => yearRanges.value.map(r => r.year))
|
||||
|
||||
// Sticky year labels — positioned relative to viewport, clamped to edges
|
||||
const YEAR_MARGIN = 24
|
||||
const stickyYearLabels = computed(() => {
|
||||
const sl = scrollLeft.value
|
||||
const vw = viewportWidth.value
|
||||
const viewLeft = sl
|
||||
const viewRight = sl + vw
|
||||
const years = allYears.value
|
||||
|
||||
// Find years whose event range overlaps the viewport
|
||||
const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight)
|
||||
if (visible.length === 0) return []
|
||||
|
||||
function makeLabel(year, left) {
|
||||
const idx = years.indexOf(year)
|
||||
return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 }
|
||||
}
|
||||
|
||||
if (visible.length === 1) {
|
||||
const r = visible[0]
|
||||
const visStart = Math.max(r.startX, viewLeft)
|
||||
const visEnd = Math.min(r.endX, viewRight)
|
||||
const center = (visStart + visEnd) / 2 - sl
|
||||
const clamped = Math.max(YEAR_MARGIN, Math.min(vw - YEAR_MARGIN, center))
|
||||
return [makeLabel(r.year, clamped)]
|
||||
}
|
||||
|
||||
// Multiple years: first pins left, last pins right, middles float naturally
|
||||
const result = []
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const r = visible[i]
|
||||
const visStart = Math.max(r.startX, viewLeft)
|
||||
const visEnd = Math.min(r.endX, viewRight)
|
||||
const center = (visStart + visEnd) / 2 - sl
|
||||
|
||||
let pos
|
||||
if (i === 0) {
|
||||
pos = Math.max(YEAR_MARGIN, Math.min(vw / 2 - 30, center))
|
||||
} else if (i === visible.length - 1) {
|
||||
pos = Math.min(vw - YEAR_MARGIN, Math.max(vw / 2 + 30, center))
|
||||
} else {
|
||||
pos = Math.max(YEAR_MARGIN + 60, Math.min(vw - YEAR_MARGIN - 60, center))
|
||||
}
|
||||
result.push(makeLabel(r.year, pos))
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Virtualization: only render events near the viewport
|
||||
const VIS_BUFFER = 2
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const total = displayEvents.value.length
|
||||
if (total === 0) return { start: 0, end: -1 }
|
||||
const spacing = EVENT_SPACING.value
|
||||
if (spacing <= 0) return { start: 0, end: total - 1 }
|
||||
const start = Math.max(0,
|
||||
Math.floor((scrollLeft.value - PADDING.value) / spacing) - VIS_BUFFER
|
||||
)
|
||||
const end = Math.min(total - 1,
|
||||
Math.ceil((scrollLeft.value + viewportWidth.value - PADDING.value) / spacing) + VIS_BUFFER
|
||||
)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const visibleEvents = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
if (end < start) return []
|
||||
return displayEvents.value.slice(start, end + 1).map((event, i) => ({
|
||||
event,
|
||||
globalIndex: start + i
|
||||
}))
|
||||
})
|
||||
|
||||
const visibleLabels = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
if (end < start) return []
|
||||
return eventLabels.value.slice(start, end + 1).map((label, i) => ({
|
||||
label,
|
||||
globalIndex: start + i
|
||||
}))
|
||||
})
|
||||
|
||||
// Active label — closest event to center of viewport (O(1) via index)
|
||||
const activeLabel = computed(() => {
|
||||
const total = displayEvents.value.length
|
||||
if (total === 0) return null
|
||||
const centerX = scrollLeft.value + viewportWidth.value / 2
|
||||
const spacing = EVENT_SPACING.value
|
||||
if (spacing <= 0) return null
|
||||
const index = Math.round((centerX - PADDING.value) / spacing)
|
||||
const clamped = Math.max(0, Math.min(total - 1, index))
|
||||
return eventLabels.value[clamped]?.key ?? null
|
||||
})
|
||||
|
||||
function onScroll() {
|
||||
if (timelineRef.value) {
|
||||
scrollLeft.value = timelineRef.value.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToIndex(index) {
|
||||
if (!timelineRef.value) return
|
||||
const x = getEventX(index)
|
||||
timelineRef.value.scrollTo({
|
||||
left: x - viewportWidth.value / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToYearCenter(year) {
|
||||
if (!timelineRef.value) return
|
||||
// Find exact year, or nearest in the requested direction
|
||||
let range = yearRanges.value.find(r => r.year === year)
|
||||
if (!range) {
|
||||
// Find closest year
|
||||
const sorted = [...yearRanges.value].sort((a, b) => Math.abs(a.year - year) - Math.abs(b.year - year))
|
||||
range = sorted[0]
|
||||
}
|
||||
if (!range) return
|
||||
const centerX = (range.startX + range.endX) / 2
|
||||
timelineRef.value.scrollTo({
|
||||
left: centerX - viewportWidth.value / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
function updateViewportWidth() {
|
||||
if (timelineRef.value) {
|
||||
viewportWidth.value = timelineRef.value.clientWidth || 400
|
||||
containerHeight.value = timelineRef.value.clientHeight || 400
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom while keeping the viewport center stable
|
||||
function applyZoom(newZoom, centerClientX) {
|
||||
const el = timelineRef.value
|
||||
if (!el) return
|
||||
|
||||
// Default center to viewport middle
|
||||
const rect = el.getBoundingClientRect()
|
||||
const cx = centerClientX !== undefined ? centerClientX - rect.left : viewportWidth.value / 2
|
||||
|
||||
// World-space X under the center point before zoom
|
||||
const worldXBefore = el.scrollLeft + cx
|
||||
|
||||
// Ratio of old spacing to new
|
||||
const oldZoom = zoomLevel.value
|
||||
const ratio = newZoom / oldZoom
|
||||
|
||||
zoomLevel.value = newZoom
|
||||
|
||||
// After Vue updates, restore scroll so the same world point stays under center
|
||||
nextTick(() => {
|
||||
el.scrollLeft = worldXBefore * ratio - cx
|
||||
scrollLeft.value = el.scrollLeft
|
||||
})
|
||||
}
|
||||
|
||||
// Desktop: Ctrl+wheel or pinch on trackpad (both fire wheel with ctrlKey)
|
||||
function onWheel(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Pinch / Ctrl+scroll → zoom
|
||||
const delta = -e.deltaY * ZOOM_STEP * 0.1
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoomLevel.value + delta))
|
||||
if (newZoom !== zoomLevel.value) {
|
||||
applyZoom(newZoom, e.clientX)
|
||||
}
|
||||
} else {
|
||||
// Normal scroll — let the browser handle horizontal scroll
|
||||
const el = timelineRef.value
|
||||
if (el) {
|
||||
el.scrollLeft += e.deltaX || e.deltaY
|
||||
scrollLeft.value = el.scrollLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Touch: pinch-to-zoom
|
||||
let touchStartDist = 0
|
||||
let touchStartZoom = 1
|
||||
|
||||
function getTouchDist(touches) {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (e.touches.length === 2) {
|
||||
touchStartDist = getTouchDist(e.touches)
|
||||
touchStartZoom = zoomLevel.value
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (e.touches.length === 2) {
|
||||
const dist = getTouchDist(e.touches)
|
||||
const scale = dist / touchStartDist
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, touchStartZoom * scale))
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
||||
applyZoom(newZoom, cx)
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
touchStartDist = 0
|
||||
}
|
||||
|
||||
// Scroll to center on the last event on mount
|
||||
let resizeObserver = null
|
||||
// Emit timeline state so the layout can position shader points
|
||||
function emitViewState() {
|
||||
const { start, end } = visibleRange.value
|
||||
emit('viewUpdate', {
|
||||
scrollLeft: scrollLeft.value,
|
||||
viewportWidth: viewportWidth.value,
|
||||
containerHeight: containerHeight.value,
|
||||
visibleStart: start,
|
||||
visibleEnd: end,
|
||||
events: displayEvents.value.map((e, i) => ({
|
||||
emotion: e.emotion,
|
||||
x: getEventX(i),
|
||||
color: eventsStore.getGlowColor(e)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
|
||||
emitViewState,
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (!timelineRef.value) return
|
||||
updateViewportWidth()
|
||||
|
||||
const events = displayEvents.value
|
||||
if (events.length === 0) return
|
||||
const lastX = getEventX(events.length - 1)
|
||||
timelineRef.value.scrollLeft = lastX - viewportWidth.value / 2
|
||||
scrollLeft.value = timelineRef.value.scrollLeft
|
||||
|
||||
// Update viewport width on resize
|
||||
resizeObserver = new ResizeObserver(updateViewportWidth)
|
||||
resizeObserver.observe(timelineRef.value)
|
||||
|
||||
// Emit initial state
|
||||
emitViewState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function zoomIn() {
|
||||
const newZoom = Math.min(MAX_ZOOM, zoomLevel.value + ZOOM_STEP * 2)
|
||||
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
const newZoom = Math.max(MIN_ZOOM, zoomLevel.value - ZOOM_STEP * 2)
|
||||
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
||||
}
|
||||
|
||||
defineExpose({ timelineRef, zoomIn, zoomOut, zoomLevel, MIN_ZOOM, MAX_ZOOM })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-container {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 60px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.timeline::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline__track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Month labels */
|
||||
.timeline__labels {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.timeline__month {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.3s ease, font-size 0.3s ease, font-weight 0.3s ease;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline__month--active {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Sticky year labels — positioned outside the scroll container */
|
||||
.timeline__sticky-years {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timeline__sticky-year-group {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: auto;
|
||||
transition: left 0.15s ease-out;
|
||||
}
|
||||
|
||||
.timeline__sticky-year {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline__year-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: inherit;
|
||||
opacity: 0.45;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.timeline__year-arrow:active {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* TransitionGroup animations */
|
||||
.year-fade-enter-active,
|
||||
.year-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.year-fade-enter-from,
|
||||
.year-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
278
frontend/_src/components/UserMenu.vue
Normal file
278
frontend/_src/components/UserMenu.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<template>
|
||||
<Transition name="slide-right">
|
||||
<div v-if="open" class="user-menu glass--panel">
|
||||
<!-- User header -->
|
||||
<div class="user-menu__header">
|
||||
<div class="user-menu__avatar">
|
||||
<span>K</span>
|
||||
</div>
|
||||
<div class="user-menu__info">
|
||||
<div class="user-menu__name">k-adam</div>
|
||||
<div class="user-menu__handle">@k-adam</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="user-menu__items">
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'upgrade')">
|
||||
<q-icon name="auto_awesome" size="20px" />
|
||||
<span>Plan upgraden</span>
|
||||
</button>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'personalize')">
|
||||
<q-icon name="palette" size="20px" />
|
||||
<span>Personalisierung</span>
|
||||
</button>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'settings')">
|
||||
<q-icon name="settings" size="20px" />
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<!-- Help with submenu -->
|
||||
<div class="user-menu__items">
|
||||
<button
|
||||
class="user-menu__item"
|
||||
:class="{ 'user-menu__item--active': helpOpen }"
|
||||
@click="helpOpen = !helpOpen"
|
||||
>
|
||||
<q-icon name="support_agent" size="20px" />
|
||||
<span>Hilfe</span>
|
||||
<q-icon
|
||||
name="chevron_right"
|
||||
size="18px"
|
||||
class="user-menu__chevron"
|
||||
:class="{ 'user-menu__chevron--open': helpOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Help sub-items -->
|
||||
<Transition name="expand">
|
||||
<div v-if="helpOpen" class="user-menu__sub">
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'help-center')">
|
||||
<q-icon name="help_outline" size="18px" />
|
||||
<span>Hilfecenter</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'release-notes')">
|
||||
<q-icon name="new_releases" size="18px" />
|
||||
<span>Release-Hinweise</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'terms')">
|
||||
<q-icon name="description" size="18px" />
|
||||
<span>AGB und Richtlinien</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'report-bug')">
|
||||
<q-icon name="bug_report" size="18px" />
|
||||
<span>Fehler melden</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'download')">
|
||||
<q-icon name="download" size="18px" />
|
||||
<span>Apps herunterladen</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'shortcuts')">
|
||||
<q-icon name="keyboard" size="18px" />
|
||||
<span>Tastaturkuerzel</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<div class="user-menu__items">
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'logout')">
|
||||
<q-icon name="logout" size="20px" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer: user + plan -->
|
||||
<div class="user-menu__footer">
|
||||
<div class="user-menu__avatar user-menu__avatar--sm">
|
||||
<span>K</span>
|
||||
</div>
|
||||
<div class="user-menu__info">
|
||||
<div class="user-menu__name user-menu__name--sm">Kevin Ada</div>
|
||||
<div class="user-menu__plan">Free</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({ open: { type: Boolean, default: false } })
|
||||
defineEmits(['close', 'navigate'])
|
||||
|
||||
const helpOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 25;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 0 0 20px;
|
||||
border-top: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* User header */
|
||||
.user-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.user-menu__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e05a33;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-menu__avatar--sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu__name--sm {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__handle {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.user-menu__plan {
|
||||
font-size: 11px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.user-menu__divider {
|
||||
height: 1px;
|
||||
margin: 4px 16px;
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
/* Menu items */
|
||||
.user-menu__items {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.user-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-menu__item:hover,
|
||||
.user-menu__item--active {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.user-menu__item:active {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.user-menu__item--sub {
|
||||
padding-left: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Help sub-items */
|
||||
.user-menu__sub {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.user-menu__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 24px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
/* Slide-right transition */
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-right-enter-from,
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Expand transition for help sub-menu */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||
max-height: 300px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
32
frontend/_src/components/UserMenuButton.vue
Normal file
32
frontend/_src/components/UserMenuButton.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<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>
|
||||
134
frontend/_src/components/ZoomControl.vue
Normal file
134
frontend/_src/components/ZoomControl.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<div class="zoom-control glass--button">
|
||||
<button class="zoom-control__btn" @click="$emit('zoomIn')">
|
||||
<q-icon name="add" size="18px" />
|
||||
</button>
|
||||
<div class="zoom-control__track" ref="trackRef" @pointerdown="onTrackDown">
|
||||
<div class="zoom-control__fill" :style="{ height: fillPercent + '%' }" />
|
||||
<div class="zoom-control__thumb" :style="{ bottom: fillPercent + '%' }" />
|
||||
</div>
|
||||
<button class="zoom-control__btn" @click="$emit('zoomOut')">
|
||||
<q-icon name="remove" size="18px" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
zoom: { type: Number, default: 1 },
|
||||
min: { type: Number, default: 0.4 },
|
||||
max: { type: Number, default: 3.0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['zoomIn', 'zoomOut', 'zoomTo'])
|
||||
const trackRef = ref(null)
|
||||
|
||||
const fillPercent = computed(() => {
|
||||
return ((props.zoom - props.min) / (props.max - props.min)) * 100
|
||||
})
|
||||
|
||||
function onTrackDown(e) {
|
||||
if (!trackRef.value) return
|
||||
updateFromPointer(e)
|
||||
|
||||
const onMove = (ev) => updateFromPointer(ev)
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
function updateFromPointer(e) {
|
||||
if (!trackRef.value) return
|
||||
const rect = trackRef.value.getBoundingClientRect()
|
||||
// Bottom = min, top = max → invert Y
|
||||
const ratio = 1 - Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
const newZoom = props.min + ratio * (props.max - props.min)
|
||||
emit('zoomTo', newZoom)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.zoom-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 22px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.zoom-control__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.zoom-control__btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.zoom-control__track {
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.zoom-control__fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zoom-control__thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: translate(-50%, 50%);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
.body--light .zoom-control__track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.body--light .zoom-control__fill {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.body--light .zoom-control__thumb {
|
||||
background: #333;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.body--light .zoom-control__btn:active {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
175
frontend/_src/composables/useImageCache.js
Normal file
175
frontend/_src/composables/useImageCache.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { ref } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
|
||||
const THUMB_SIZE = 200
|
||||
|
||||
// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation
|
||||
// Shared across all component instances
|
||||
const memoryCache = new Map()
|
||||
|
||||
/**
|
||||
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
|
||||
* Returns a new Blob (JPEG, quality 0.8).
|
||||
*/
|
||||
function createThumbnail(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(blob)
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = THUMB_SIZE
|
||||
canvas.height = THUMB_SIZE
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
// Cover crop: center the image
|
||||
const scale = Math.max(THUMB_SIZE / img.width, THUMB_SIZE / img.height)
|
||||
const w = img.width * scale
|
||||
const h = img.height * scale
|
||||
const x = (THUMB_SIZE - w) / 2
|
||||
const y = (THUMB_SIZE - h) / 2
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
canvas.toBlob(
|
||||
(thumbBlob) => {
|
||||
URL.revokeObjectURL(url)
|
||||
if (thumbBlob) resolve(thumbBlob)
|
||||
else reject(new Error('Canvas toBlob failed'))
|
||||
},
|
||||
'image/jpeg',
|
||||
0.8
|
||||
)
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('Image load failed'))
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
|
||||
*/
|
||||
async function fetchAndCache(imageUrl, eventId) {
|
||||
const response = await fetch(imageUrl)
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
|
||||
// Create thumbnail
|
||||
const thumbBlob = await createThumbnail(blob)
|
||||
|
||||
// Store in IndexedDB
|
||||
await db.imageCache.put({
|
||||
url: imageUrl,
|
||||
eventId,
|
||||
type: 'thumbnail',
|
||||
blob: thumbBlob,
|
||||
cachedAt: Date.now()
|
||||
})
|
||||
|
||||
const blobUrl = URL.createObjectURL(thumbBlob)
|
||||
memoryCache.set(imageUrl, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached thumbnail blob URL from IndexedDB.
|
||||
* Returns null if not cached.
|
||||
*/
|
||||
async function getCachedImage(imageUrl) {
|
||||
// Check memory first
|
||||
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl)
|
||||
|
||||
try {
|
||||
const entry = await db.imageCache.get(imageUrl)
|
||||
if (entry?.blob) {
|
||||
const blobUrl = URL.createObjectURL(entry.blob)
|
||||
memoryCache.set(imageUrl, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Image cache read failed:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable: resolves an event's image to a displayable src.
|
||||
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail.
|
||||
* - Returns reactive `resolvedSrc` ref.
|
||||
*/
|
||||
export function useImageCache(imageUrl, eventId) {
|
||||
const resolvedSrc = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function resolve() {
|
||||
if (!imageUrl) {
|
||||
resolvedSrc.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Memory cache (instant)
|
||||
if (memoryCache.has(imageUrl)) {
|
||||
resolvedSrc.value = memoryCache.get(imageUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. IndexedDB cache
|
||||
const cached = await getCachedImage(imageUrl)
|
||||
if (cached) {
|
||||
resolvedSrc.value = cached
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Fetch, create thumbnail, cache
|
||||
loading.value = true
|
||||
try {
|
||||
const blobUrl = await fetchAndCache(imageUrl, eventId)
|
||||
resolvedSrc.value = blobUrl
|
||||
} catch (e) {
|
||||
// Fallback: use original URL directly (works when online)
|
||||
console.warn('Image cache failed, using direct URL:', e)
|
||||
resolvedSrc.value = imageUrl
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
|
||||
return { resolvedSrc, loading }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve full-res image for EventPanel (no thumbnail, just cache check).
|
||||
* Returns the original URL — browser Cache-Control handles caching.
|
||||
* When offline, falls back to cached thumbnail.
|
||||
*/
|
||||
export async function resolveFullRes(imageUrl) {
|
||||
if (!imageUrl) return null
|
||||
|
||||
// If online, return original URL (browser caches via HTTP headers)
|
||||
if (navigator.onLine) return imageUrl
|
||||
|
||||
// Offline: try cached thumbnail as fallback
|
||||
const cached = await getCachedImage(imageUrl)
|
||||
return cached || imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached images for a specific event.
|
||||
*/
|
||||
export async function clearEventImages(eventId) {
|
||||
try {
|
||||
const entries = await db.imageCache.where('eventId').equals(eventId).toArray()
|
||||
for (const entry of entries) {
|
||||
if (memoryCache.has(entry.url)) {
|
||||
URL.revokeObjectURL(memoryCache.get(entry.url))
|
||||
memoryCache.delete(entry.url)
|
||||
}
|
||||
}
|
||||
await db.imageCache.where('eventId').equals(eventId).delete()
|
||||
} catch (e) {
|
||||
console.warn('Clear event images failed:', e)
|
||||
}
|
||||
}
|
||||
137
frontend/_src/composables/usePanelDrag.js
Normal file
137
frontend/_src/composables/usePanelDrag.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for draggable bottom-sheet panels with snap points.
|
||||
*
|
||||
* Snap stops (in dvh): 100, 75, 50
|
||||
* Close threshold: below 25dvh
|
||||
*
|
||||
* @param {Function} onClose - called when panel is dragged below threshold
|
||||
* @returns {{ panelHeight, handleListeners, resetHeight }}
|
||||
*/
|
||||
export function usePanelDrag(onClose) {
|
||||
const SNAP_POINTS = [100, 75, 50, 25] // dvh values
|
||||
const CLOSE_THRESHOLD = 15 // below this → close
|
||||
|
||||
// Current panel height in dvh (null = use CSS default)
|
||||
const panelHeight = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
let dragging = false
|
||||
let startY = 0
|
||||
let startHeight = 0
|
||||
|
||||
function getViewportHeight() {
|
||||
return window.innerHeight
|
||||
}
|
||||
|
||||
function pxToDvh(px) {
|
||||
return (px / getViewportHeight()) * 100
|
||||
}
|
||||
|
||||
function findNearestSnap(dvh) {
|
||||
let nearest = SNAP_POINTS[0]
|
||||
let minDist = Infinity
|
||||
for (const snap of SNAP_POINTS) {
|
||||
const dist = Math.abs(dvh - snap)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
nearest = snap
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
// Only primary button / single touch
|
||||
if (e.button && e.button !== 0) return
|
||||
dragging = true
|
||||
isDragging.value = true
|
||||
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
||||
startY = clientY
|
||||
|
||||
// Current height: if panelHeight is set use it, else measure from CSS
|
||||
const currentDvh = panelHeight.value ?? 75
|
||||
startHeight = currentDvh
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false })
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
|
||||
// Prevent text selection
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragging) return
|
||||
const clientY = e.clientY
|
||||
handleMove(clientY)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!dragging) return
|
||||
if (e.touches.length !== 1) return
|
||||
handleMove(e.touches[0].clientY)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handleMove(clientY) {
|
||||
const deltaY = clientY - startY
|
||||
const deltaDvh = pxToDvh(deltaY)
|
||||
const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh))
|
||||
panelHeight.value = newHeight
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
finishDrag()
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
finishDrag()
|
||||
}
|
||||
|
||||
function finishDrag() {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
isDragging.value = false
|
||||
|
||||
cleanup()
|
||||
|
||||
const currentHeight = panelHeight.value ?? 75
|
||||
if (currentHeight < CLOSE_THRESHOLD) {
|
||||
panelHeight.value = null
|
||||
onClose()
|
||||
} else {
|
||||
// Snap to nearest point
|
||||
panelHeight.value = findNearestSnap(currentHeight)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function resetHeight() {
|
||||
panelHeight.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
// Event listeners to bind on the handle element
|
||||
const handleListeners = {
|
||||
pointerdown: onPointerDown,
|
||||
touchstart: onPointerDown,
|
||||
}
|
||||
|
||||
return {
|
||||
panelHeight,
|
||||
isDragging,
|
||||
handleListeners,
|
||||
resetHeight,
|
||||
}
|
||||
}
|
||||
1
frontend/_src/css/app.css
Normal file
1
frontend/_src/css/app.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
.controls{display:flex;justify-content:space-between;width:100%;max-width:500px;margin-bottom:10px}.button{padding:6px 12px;background-color:#4f46e5;color:#fff;border:none;border-radius:4px;cursor:pointer}.visualization-container{position:relative;width:100%;height:calc(100vh - 86px);overflow:hidden}.gradient-bg{position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;background:linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);background-size:200% 200%;animation:gradientAnimation 20s ease infinite}@keyframes gradientAnimation{0%{background-position:0% 0%}25%{background-position:100% 0%}50%{background-position:100% 100%}75%{background-position:0% 100%}100%{background-position:0% 0%}}.median{position:absolute;top:51.2%;left:0;right:0;height:1px;background-color:rgba(255,255,255,.3);z-index:1}.scroll-container{position:relative;width:100%;height:100%;overflow-x:auto;overflow-y:hidden;min-height:400px;z-index:2;-ms-overflow-style:none;scrollbar-width:none}.scroll-container::-webkit-scrollbar{display:none}.smooth-scroll{scroll-behavior:smooth}.active{cursor:grabbing}.spacer{height:100vh}.dot-tooltip{pointer-events:none;opacity:1}.dot-tooltip .tooltip-background{fill:rgba(0,0,0,0)}.dot-tooltip .tooltip-content{display:flex;justify-content:center;align-items:center;flex-direction:column;width:100%;height:100%;color:#fff}.dot-tooltip .image_container{margin-top:8px;box-shadow:0 0 20px 0 rgba(255,255,255,.25);transition:box-shadow .25s ease-in-out;width:80px;height:80px;overflow:hidden;border-radius:50%;border:2px solid #fff;display:flex;justify-content:center}.dot-tooltip .image_container:hover{box-shadow:0 0 30px 0 rgba(255,255,255,.8)}.dot-tooltip .tooltip-image{width:100%;height:auto;display:block;pointer-events:auto}.dot-tooltip .tooltip-title{font-size:14px;font-weight:400;margin-bottom:2px;text-align:center;text-wrap:balance;-webkit-hyphens:auto;hyphens:auto;line-height:1.1}.dot-tooltip .tooltip-description{font-size:12px;font-weight:300}.dot-tooltip .tooltip-arrow{width:1px;height:30px;background:linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent)}.dot{transition:r .2s ease,fill .2s ease;cursor:pointer}.dot:hover{fill:rgba(255,255,255,.9);filter:drop-shadow(0 0 5px rgba(255, 255, 255, 0.8))}.tooltip-img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:4px}.q-card{box-shadow:none !important}.q-card--bordered{box-shadow:none !important}.q-card--flat{box-shadow:none !important}.bg-white,.q-layout__section--marginal{background:rgba(0,0,0,0) !important}footer .text-primary,footer .text-grey{color:#fff !important}
|
||||
54
frontend/_src/css/app.scss
Normal file
54
frontend/_src/css/app.scss
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Glass button style
|
||||
.glass--button {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Glass panel style — strong blur for slide-up panels
|
||||
.glass--panel {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: #1a1a1a;
|
||||
|
||||
.body--dark & {
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
// GlowDot animations — soft opacity pulse on the glow aura
|
||||
@keyframes glowPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ghostPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
25
frontend/_src/css/quasar.variables.scss
Normal file
25
frontend/_src/css/quasar.variables.scss
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Quasar SCSS (& Sass) Variables
|
||||
// --------------------------------------------------
|
||||
// To customize the look and feel of this app, you can override
|
||||
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
|
||||
|
||||
// Check documentation for full list of Quasar variables
|
||||
|
||||
// Your own variables (that are declared here) and Quasar's own
|
||||
// ones will be available out of the box in your .vue/.scss/.sass files
|
||||
|
||||
// It's highly recommended to change the default colors
|
||||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #d946ef;
|
||||
$secondary : #a855f7;
|
||||
$accent : #ec4899;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #21BA45;
|
||||
$negative : #C10015;
|
||||
$info : #31CCEC;
|
||||
$warning : #F2C037;
|
||||
17
frontend/_src/db/index.js
Normal file
17
frontend/_src/db/index.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Dexie from 'dexie'
|
||||
|
||||
export const db = new Dexie('thatsMeDB')
|
||||
|
||||
db.version(1).stores({
|
||||
// Events: indexed by id (PK), date for sorted queries, syncStatus for dirty tracking
|
||||
events: 'id, date, updatedAt, syncStatus',
|
||||
|
||||
// Sync queue: outbound mutations waiting to be pushed to server
|
||||
syncQueue: '++queueId, eventId, action, createdAt',
|
||||
|
||||
// Image cache: offline blob storage for thumbnails
|
||||
imageCache: 'url, eventId, type, cachedAt',
|
||||
|
||||
// Metadata: key-value pairs (lastSyncCursor, userId, etc.)
|
||||
meta: 'key'
|
||||
})
|
||||
471
frontend/_src/layouts/LifeWaveLayout.vue
Normal file
471
frontend/_src/layouts/LifeWaveLayout.vue
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
<template>
|
||||
<div ref="layoutRef" class="lifewave-layout" :class="{ 'lifewave-layout--dark': isDark }">
|
||||
<!-- FloatingLines Fullscreen Background (always visible) -->
|
||||
<FloatingLines
|
||||
ref="floatingLinesRef"
|
||||
class="lifewave-layout__background"
|
||||
:line-count="[fl.lineCount]"
|
||||
:animation-speed="fl.speed"
|
||||
:num-points="shaderNumPoints"
|
||||
:point-x-values="shaderPointXBase"
|
||||
:scroll-container="scrollContainerEl"
|
||||
:scroll-uv-scale="scrollUvScale"
|
||||
:point-y-values="shaderPointY"
|
||||
:point-colors="shaderPointColors"
|
||||
:line-spread="fl.spread"
|
||||
:fan-spread="fl.fanSpread"
|
||||
:line-sharpness="fl.lineSharpness"
|
||||
:wave-frequency="fl.waveFrequency"
|
||||
:bezier-curvature="fl.bezierCurvature"
|
||||
:circle-radius-px="fl.circleRadius"
|
||||
:circle-glow-size="fl.glowSize"
|
||||
:circle-glow-strength="fl.glowStrength"
|
||||
:line-brightness="fl.lineBrightness ?? 1"
|
||||
:lines-gradient="parsedGradient"
|
||||
:bg-color-center="fl.bgCenter"
|
||||
:bg-color-edge="fl.bgEdge"
|
||||
:background-image="fl.backgroundImage"
|
||||
:mix-blend-mode="'screen'"
|
||||
/>
|
||||
|
||||
<!-- Scrollable Timeline -->
|
||||
<TimelineView
|
||||
ref="timelineViewRef"
|
||||
class="lifewave-layout__timeline"
|
||||
@dot-select="onDotSelect"
|
||||
@view-update="onViewUpdate"
|
||||
/>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="lifewave-header">
|
||||
<span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span>
|
||||
<button class="lifewave-header__user glass--button" @click="userMenuOpen = !userMenuOpen">
|
||||
<q-icon name="person" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- FPS Overlay -->
|
||||
<div v-if="settingsStore.showFps" class="lifewave-fps">
|
||||
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS
|
||||
<span class="lifewave-fps__dpr">{{ floatingLinesRef?.dprDisplay ?? '0' }}x</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="lifewave-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Zoom Control (Left Side) — hidden when panel is open -->
|
||||
<ZoomControl
|
||||
v-if="!eventsStore.panelOpen"
|
||||
class="lifewave-layout__zoom"
|
||||
:zoom="currentZoom"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
@zoom-in="timelineViewRef?.zoomIn()"
|
||||
@zoom-out="timelineViewRef?.zoomOut()"
|
||||
@zoom-to="onZoomTo"
|
||||
/>
|
||||
|
||||
<!-- Settings Button (Bottom-Left, below zoom) — hidden when panel is open -->
|
||||
<button
|
||||
v-if="!eventsStore.panelOpen"
|
||||
class="lifewave-settings-btn glass--button"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
|
||||
<!-- Add Event Button (Bottom-Center) — hidden when panel is open -->
|
||||
<AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" />
|
||||
|
||||
<!-- Backdrop blur when panel is open -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="eventsStore.panelOpen"
|
||||
class="lifewave-backdrop"
|
||||
@click="eventsStore.closePanel()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Event Panel (Slide-Up) -->
|
||||
<EventPanel />
|
||||
|
||||
<!-- Wave Settings Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="settingsOpen && !eventsStore.panelOpen"
|
||||
class="lifewave-backdrop lifewave-backdrop--no-blur"
|
||||
@click="settingsOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Wave Settings Panel (Slide-Up) -->
|
||||
<LifeWaveSettings
|
||||
:open="settingsOpen && !eventsStore.panelOpen"
|
||||
@close="settingsOpen = false"
|
||||
/>
|
||||
|
||||
<!-- User Menu Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="lifewave-backdrop"
|
||||
@click="userMenuOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- User Menu (Slide from right) -->
|
||||
<UserMenu
|
||||
:open="userMenuOpen"
|
||||
@close="userMenuOpen = false"
|
||||
@navigate="onUserMenuNavigate"
|
||||
/>
|
||||
|
||||
<!-- App Settings Modal Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="appSettingsOpen"
|
||||
class="lifewave-backdrop"
|
||||
@click="appSettingsOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- App Settings Modal -->
|
||||
<AppSettingsModal
|
||||
:open="appSettingsOpen"
|
||||
@close="appSettingsOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import AddEventButton from 'components/AddEventButton.vue'
|
||||
import EventPanel from 'components/EventPanel.vue'
|
||||
import FloatingLines from 'components/FloatingLines.vue'
|
||||
import LifeWaveSettings from 'components/LifeWaveSettings.vue'
|
||||
import TimelineView from 'components/TimelineView.vue'
|
||||
import AppSettingsModal from 'components/AppSettingsModal.vue'
|
||||
import UserMenu from 'components/UserMenu.vue'
|
||||
import ZoomControl from 'components/ZoomControl.vue'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
|
||||
const $q = useQuasar()
|
||||
const eventsStore = useEventsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const settingsOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
const appSettingsOpen = ref(false)
|
||||
const floatingLinesRef = ref(null)
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
|
||||
// Timeline view ref (for direct scroll access in render loop)
|
||||
const timelineViewRef = ref(null)
|
||||
const scrollContainerEl = computed(() => timelineViewRef.value?.timelineRef ?? null)
|
||||
|
||||
// Layout dimensions (for screen→UV conversion)
|
||||
const layoutRef = ref(null)
|
||||
const layoutWidth = ref(window.innerWidth)
|
||||
const layoutHeight = ref(window.innerHeight)
|
||||
let layoutResizeObserver = null
|
||||
|
||||
onMounted(() => {
|
||||
if (layoutRef.value) {
|
||||
layoutWidth.value = layoutRef.value.clientWidth
|
||||
layoutHeight.value = layoutRef.value.clientHeight
|
||||
layoutResizeObserver = new ResizeObserver(() => {
|
||||
layoutWidth.value = layoutRef.value.clientWidth
|
||||
layoutHeight.value = layoutRef.value.clientHeight
|
||||
})
|
||||
layoutResizeObserver.observe(layoutRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
layoutResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
// Timeline state (received from TimelineView via emit)
|
||||
const timelineState = ref(null)
|
||||
|
||||
function onViewUpdate(state) {
|
||||
timelineState.value = state
|
||||
}
|
||||
|
||||
// Convert screen pixel coordinates → shader UV space
|
||||
// Shader: baseUv = (2*fragCoord - iResolution) / iResolution.y; baseUv.y *= -1;
|
||||
// For CSS pixel (sx, sy) from top-left:
|
||||
// uvX = (2*sx - cssWidth) / cssHeight
|
||||
// uvY = (2*sy - cssHeight) / cssHeight
|
||||
function screenToUV(sx, sy) {
|
||||
const w = layoutWidth.value
|
||||
const h = layoutHeight.value
|
||||
return {
|
||||
x: (2 * sx - w) / h,
|
||||
y: (2 * sy - h) / h
|
||||
}
|
||||
}
|
||||
|
||||
// Compute shader point positions from event positions
|
||||
const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px }
|
||||
|
||||
// Select up to 8 points from visible window + boundary events for shader lines
|
||||
const shaderSelection = computed(() => {
|
||||
if (!timelineState.value) return []
|
||||
const { events, visibleStart, visibleEnd } = timelineState.value
|
||||
if (events.length === 0) return []
|
||||
|
||||
// Include 3 events before and after visible range for smooth line continuity
|
||||
const rangeStart = Math.max(0, (visibleStart ?? 0) - 3)
|
||||
const rangeEnd = Math.min(events.length - 1, (visibleEnd ?? events.length - 1) + 3)
|
||||
|
||||
let candidates = events.slice(rangeStart, rangeEnd + 1)
|
||||
|
||||
// If more than 16, subsample evenly (keep first + last)
|
||||
if (candidates.length > 16) {
|
||||
const sampled = [candidates[0]]
|
||||
const step = (candidates.length - 1) / 15
|
||||
for (let i = 1; i < 15; i++) {
|
||||
sampled.push(candidates[Math.round(i * step)])
|
||||
}
|
||||
sampled.push(candidates[candidates.length - 1])
|
||||
candidates = sampled
|
||||
}
|
||||
|
||||
return candidates
|
||||
})
|
||||
|
||||
const shaderNumPoints = computed(() => shaderSelection.value.length)
|
||||
|
||||
// Base X positions in UV space WITHOUT scroll offset.
|
||||
// FloatingLines applies the live scrollLeft in its render loop for perfect sync.
|
||||
const shaderPointXBase = computed(() => {
|
||||
const xs = Array(16).fill(0)
|
||||
const sel = shaderSelection.value
|
||||
const w = layoutWidth.value
|
||||
const h = layoutHeight.value
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
xs[i] = (2 * sel[i].x - w) / h
|
||||
}
|
||||
return xs
|
||||
})
|
||||
|
||||
// Scale factor to convert scrollLeft pixels → UV offset
|
||||
const scrollUvScale = computed(() => 2.0 / layoutHeight.value)
|
||||
|
||||
const shaderPointY = computed(() => {
|
||||
const ys = Array(16).fill(0)
|
||||
if (!timelineState.value) return ys
|
||||
const sel = shaderSelection.value
|
||||
const tlHeight = timelineState.value.containerHeight
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
const yPercent = 48 - sel[i].emotion * 30
|
||||
const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight
|
||||
ys[i] = screenToUV(0, screenY).y
|
||||
}
|
||||
return ys
|
||||
})
|
||||
|
||||
const shaderPointColors = computed(() => {
|
||||
return shaderSelection.value.map(e => e.color || '#ffffff')
|
||||
})
|
||||
|
||||
// Parse gradient stops from textarea string
|
||||
const parsedGradient = computed(() => {
|
||||
return fl.value.gradientStops
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && s.startsWith('#'))
|
||||
})
|
||||
|
||||
// Zoom state from TimelineView
|
||||
const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1)
|
||||
const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4)
|
||||
const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0)
|
||||
|
||||
function onZoomTo(value) {
|
||||
if (!timelineViewRef.value) return
|
||||
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
|
||||
// Use applyZoom exposed or set directly — we use the internal method indirectly
|
||||
// by computing step from current to target
|
||||
const tv = timelineViewRef.value
|
||||
const el = tv.timelineRef
|
||||
if (!el) return
|
||||
const cx = el.clientWidth / 2
|
||||
const worldX = el.scrollLeft + cx
|
||||
const ratio = clamped / tv.zoomLevel
|
||||
tv.zoomLevel = clamped
|
||||
// Restore scroll position to keep center stable
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = worldX * ratio - cx
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
$q.dark.toggle()
|
||||
}
|
||||
|
||||
const onAddEvent = () => {
|
||||
eventsStore.openPanel()
|
||||
}
|
||||
|
||||
const onDotSelect = (id) => {
|
||||
eventsStore.selectEvent(id)
|
||||
eventsStore.openPanel(id)
|
||||
}
|
||||
|
||||
const onUserMenuNavigate = (target) => {
|
||||
userMenuOpen.value = false
|
||||
if (target === 'settings') {
|
||||
appSettingsOpen.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lifewave-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #F5F5F5;
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.lifewave-layout--dark {
|
||||
background: #000;
|
||||
color: #F5F5F5;
|
||||
}
|
||||
|
||||
/* FloatingLines Background */
|
||||
.lifewave-layout__background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Timeline — positioning comes from TimelineView's own .timeline class */
|
||||
.lifewave-layout__timeline {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.lifewave-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.lifewave-header__logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lifewave-header__user {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Zoom Control */
|
||||
.lifewave-layout__zoom {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Settings Button — bottom-left, below zoom */
|
||||
.lifewave-settings-btn {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #F5F5F5;
|
||||
}
|
||||
.lifewave-settings-btn--dark {
|
||||
color: #000;
|
||||
}
|
||||
/* Content */
|
||||
.lifewave-content {
|
||||
padding-top: 72px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Backdrop blur overlay */
|
||||
.lifewave-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(1px);
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.lifewave-backdrop--no-blur {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* FPS Overlay */
|
||||
.lifewave-fps {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
right: 16px;
|
||||
z-index: 30;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #0f0;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lifewave-fps__dpr {
|
||||
color: #999;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
741
frontend/_src/layouts/MainLayout.vue
Normal file
741
frontend/_src/layouts/MainLayout.vue
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
<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>
|
||||
369
frontend/_src/pages/CategorySelector.vue
Normal file
369
frontend/_src/pages/CategorySelector.vue
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
<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>
|
||||
2037
frontend/_src/pages/EditPage.vue
Normal file
2037
frontend/_src/pages/EditPage.vue
Normal file
File diff suppressed because it is too large
Load diff
1224
frontend/_src/pages/EntryDetailPage.vue
Normal file
1224
frontend/_src/pages/EntryDetailPage.vue
Normal file
File diff suppressed because it is too large
Load diff
27
frontend/_src/pages/ErrorNotFound.vue
Normal file
27
frontend/_src/pages/ErrorNotFound.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
404
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.4">
|
||||
Oops. Nothing here...
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
label="Go Home"
|
||||
no-caps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
71
frontend/_src/pages/IndexPage.vue
Normal file
71
frontend/_src/pages/IndexPage.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<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>
|
||||
8
frontend/_src/pages/LifeWavePage.vue
Normal file
8
frontend/_src/pages/LifeWavePage.vue
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<!-- LifeWave page is intentionally empty — all visuals are rendered by LifeWaveLayout -->
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
68
frontend/_src/pages/LoginPage.vue
Normal file
68
frontend/_src/pages/LoginPage.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<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>
|
||||
95
frontend/_src/pages/PasswordResetPage.vue
Normal file
95
frontend/_src/pages/PasswordResetPage.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Password Reset</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p class="text-body2 q-mb-md">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
filled
|
||||
:rules="[val => !!val || 'Email is required']"
|
||||
/>
|
||||
|
||||
<div class="q-mt-md">
|
||||
<q-btn
|
||||
label="Send Reset Link"
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="full-width"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/login" class="text-primary">Back to login</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
|
||||
<q-dialog v-model="successDialog">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Email Sent</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p>If an account exists with that email, we've sent instructions to reset your password.</p>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup @click="goToLogin" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'PasswordResetPage',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const successDialog = ref(false)
|
||||
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
|
||||
// Here you would call your API to request password reset
|
||||
console.log('Password reset requested for:', email.value)
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
successDialog.value = true
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
loading,
|
||||
onSubmit,
|
||||
successDialog,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
423
frontend/_src/pages/PersonSelector.vue
Normal file
423
frontend/_src/pages/PersonSelector.vue
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
<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>
|
||||
138
frontend/_src/pages/SignUpPage.vue
Normal file
138
frontend/_src/pages/SignUpPage.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Sign Up</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input v-model="firstName" label="First Name" filled :rules="[val => !!val || 'First name is required']" />
|
||||
<q-input v-model="lastName" label="Last Name" filled :rules="[val => !!val || 'Last name is required']" />
|
||||
<!-- Add gender selection -->
|
||||
<q-select v-model="gender" :options="genderOptions" label="Gender" filled
|
||||
:rules="[val => !!val || 'Please select a gender']" />
|
||||
<q-input v-model="email" type="email" label="Email" filled :rules="[
|
||||
val => !!val || 'Email is required',
|
||||
val => /^[^@]+@[^@]+\.[^@]+$/.test(val) || 'Please enter a valid email'
|
||||
]" />
|
||||
<q-input v-model="password" type="password" label="Password" filled :rules="[
|
||||
val => !!val || 'Password is required',
|
||||
val => val.length >= 8 || 'Password must be at least 8 characters'
|
||||
]" />
|
||||
<q-input v-model="confirmPassword" type="password" label="Confirm Password" filled :rules="[
|
||||
val => !!val || 'Please confirm your password',
|
||||
val => val === password || 'Passwords do not match'
|
||||
]" />
|
||||
<q-checkbox v-model="termsAccepted" label="I agree to the Terms of Service">
|
||||
<template v-slot:default>
|
||||
I agree to the <a href="#" @click.prevent="showTerms = true" class="text-primary">Terms of Service</a>
|
||||
</template>
|
||||
</q-checkbox>
|
||||
<div class="q-mt-md">
|
||||
<q-btn label="Sign Up" type="submit" color="primary" class="full-width" :loading="loading"
|
||||
:disable="!termsAccepted" />
|
||||
</div>
|
||||
<div class="text-center q-mt-sm">
|
||||
Already have an account? <router-link to="/login" class="text-primary">Log in</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-dialog v-model="successDialog">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Registration Successful</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<p>Your account has been created successfully. You can now log in.</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Go to Login" color="primary" v-close-popup @click="goToLogin" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="showTerms">
|
||||
<q-card style="width: 700px; max-width: 80vw">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Terms of Service</div>
|
||||
</q-card-section>
|
||||
<q-card-section style="max-height: 70vh" class="scroll">
|
||||
<p>These Terms of Service ("Terms") govern your use of our website and services.</p>
|
||||
<p>By using our services, you agree to these Terms. Please read them carefully.</p>
|
||||
<h6 class="text-subtitle1 q-mt-md">1. Your Account</h6>
|
||||
<p>You are responsible for safeguarding your account and for any activities or actions under your account.
|
||||
</p>
|
||||
<h6 class="text-subtitle1 q-mt-md">2. Privacy</h6>
|
||||
<p>Our Privacy Policy explains how we treat your personal data and protect your privacy.</p>
|
||||
<!-- Add more terms as needed -->
|
||||
<h6 class="text-subtitle1 q-mt-md">3. Termination</h6>
|
||||
<p>We may terminate or suspend your account at any time for any reason.</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
export default {
|
||||
name: 'SignUpPage',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const termsAccepted = ref(false)
|
||||
const loading = ref(false)
|
||||
const successDialog = ref(false)
|
||||
const showTerms = ref(false)
|
||||
// Add gender field
|
||||
const gender = ref('')
|
||||
const genderOptions = [
|
||||
'Male',
|
||||
'Female',
|
||||
'Non-binary',
|
||||
'Prefer not to say'
|
||||
]
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
// Here you would call your API to register the user
|
||||
console.log('Sign up attempt with:', {
|
||||
firstName: firstName.value,
|
||||
lastName: lastName.value,
|
||||
gender: gender.value,
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
successDialog.value = true
|
||||
}, 1500)
|
||||
}
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
gender,
|
||||
genderOptions,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
termsAccepted,
|
||||
loading,
|
||||
onSubmit,
|
||||
successDialog,
|
||||
showTerms,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
445
frontend/_src/pages/TagSelector.vue
Normal file
445
frontend/_src/pages/TagSelector.vue
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
<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>
|
||||
488
frontend/_src/pages/WavePage.vue
Normal file
488
frontend/_src/pages/WavePage.vue
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
<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>
|
||||
30
frontend/_src/router/index.js
Normal file
30
frontend/_src/router/index.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineRouter } from '#q-app/wrappers'
|
||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import routes from './routes'
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Router instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Router instance.
|
||||
*/
|
||||
|
||||
export default defineRouter(function (/* { store, ssrContext } */) {
|
||||
const createHistory = process.env.SERVER
|
||||
? createMemoryHistory
|
||||
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
|
||||
|
||||
const Router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
routes,
|
||||
|
||||
// Leave this as is and make changes in quasar.conf.js instead!
|
||||
// quasar.conf.js -> build -> vueRouterMode
|
||||
// quasar.conf.js -> build -> publicPath
|
||||
history: createHistory(process.env.VUE_ROUTER_BASE)
|
||||
})
|
||||
|
||||
return Router
|
||||
})
|
||||
17
frontend/_src/router/routes.js
Normal file
17
frontend/_src/router/routes.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('layouts/LifeWaveLayout.vue'),
|
||||
children: [
|
||||
{ path: '', component: () => import('pages/LifeWavePage.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
// Always leave this as last one
|
||||
{
|
||||
path: '/:catchAll(.*)*',
|
||||
component: () => import('pages/ErrorNotFound.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
253
frontend/_src/services/syncService.js
Normal file
253
frontend/_src/services/syncService.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { ref } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
|
||||
// API base URL — configured per environment
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||
|
||||
const isSyncing = ref(false)
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const lastSyncAt = ref(null)
|
||||
|
||||
// Track online status
|
||||
window.addEventListener('online', () => {
|
||||
isOnline.value = true
|
||||
processSyncQueue()
|
||||
})
|
||||
window.addEventListener('offline', () => {
|
||||
isOnline.value = false
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the stored OAuth access token.
|
||||
*/
|
||||
async function getToken() {
|
||||
try {
|
||||
const meta = await db.meta.get('accessToken')
|
||||
return meta?.value || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an OAuth access token.
|
||||
*/
|
||||
async function setToken(token) {
|
||||
await db.meta.put({ key: 'accessToken', value: token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper.
|
||||
*/
|
||||
async function apiFetch(path, options = {}) {
|
||||
const token = await getToken()
|
||||
if (!token) throw new Error('Not authenticated')
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired — clear it
|
||||
await db.meta.delete('accessToken')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the outbound sync queue (FIFO).
|
||||
* Called on app start, every 30s when online, and on reconnect.
|
||||
*/
|
||||
async function processSyncQueue() {
|
||||
if (!isOnline.value || isSyncing.value) return
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
isSyncing.value = true
|
||||
|
||||
try {
|
||||
const queue = await db.syncQueue.orderBy('queueId').toArray()
|
||||
if (queue.length === 0) {
|
||||
isSyncing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Batch sync: send up to 100 mutations at once
|
||||
const batch = queue.slice(0, 100)
|
||||
const mutations = batch.map((item) => ({
|
||||
action: item.action,
|
||||
eventId: item.eventId,
|
||||
payload: item.payload,
|
||||
}))
|
||||
|
||||
const response = await apiFetch('/events/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mutations }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Remove successfully processed items from queue
|
||||
const processedIds = []
|
||||
data.results.forEach((result, i) => {
|
||||
if (result.status === 'ok') {
|
||||
processedIds.push(batch[i].queueId)
|
||||
}
|
||||
})
|
||||
|
||||
if (processedIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(processedIds)
|
||||
}
|
||||
|
||||
// Update syncStatus on local events
|
||||
for (const result of data.results) {
|
||||
if (result.status === 'ok') {
|
||||
const event = await db.events.get(result.eventId)
|
||||
if (event && event.syncStatus !== 'local') {
|
||||
await db.events.update(result.eventId, { syncStatus: 'synced' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSyncAt.value = Date.now()
|
||||
|
||||
// If there are more items, process next batch
|
||||
if (queue.length > 100) {
|
||||
await processSyncQueue()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Sync queue processing failed:', e)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes since last sync cursor.
|
||||
* Merges with local data using "last write wins" on updatedAt.
|
||||
*/
|
||||
async function pullRemoteChanges() {
|
||||
if (!isOnline.value) return
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const lastSync = await db.meta.get('lastSyncCursor')
|
||||
const since = lastSync?.value || null
|
||||
|
||||
let url = '/events?limit=200'
|
||||
if (since) {
|
||||
url += `&since=${since}`
|
||||
}
|
||||
|
||||
const response = await apiFetch(url)
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
const remoteEvents = data.data || []
|
||||
|
||||
for (const remote of remoteEvents) {
|
||||
const local = await db.events.get(remote.id)
|
||||
|
||||
if (!local) {
|
||||
// New event from server
|
||||
await db.events.put({
|
||||
id: remote.id,
|
||||
title: remote.title,
|
||||
date: remote.date,
|
||||
emotion: remote.emotion,
|
||||
customColor: remote.customColor,
|
||||
gradientPreset: remote.gradientPreset,
|
||||
image: remote.image,
|
||||
note: remote.note,
|
||||
syncStatus: 'synced',
|
||||
createdAt: remote.createdAt,
|
||||
updatedAt: remote.updatedAt,
|
||||
})
|
||||
} else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') {
|
||||
// Remote is newer and local hasn't been modified — update
|
||||
await db.events.update(remote.id, {
|
||||
title: remote.title,
|
||||
date: remote.date,
|
||||
emotion: remote.emotion,
|
||||
customColor: remote.customColor,
|
||||
gradientPreset: remote.gradientPreset,
|
||||
image: remote.image,
|
||||
note: remote.note,
|
||||
syncStatus: 'synced',
|
||||
updatedAt: remote.updatedAt,
|
||||
})
|
||||
}
|
||||
// If local is modified, skip — local changes will be pushed via sync queue
|
||||
}
|
||||
|
||||
// Update sync cursor
|
||||
await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() })
|
||||
|
||||
// Handle pagination (cursor-based)
|
||||
if (data.next_cursor) {
|
||||
// There are more pages — but for now we only pull one batch
|
||||
// Future: iterate through pages
|
||||
}
|
||||
|
||||
lastSyncAt.value = Date.now()
|
||||
} catch (e) {
|
||||
console.warn('Pull remote changes failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: push local changes, then pull remote.
|
||||
*/
|
||||
async function fullSync() {
|
||||
await processSyncQueue()
|
||||
await pullRemoteChanges()
|
||||
}
|
||||
|
||||
// Auto-sync interval (30s)
|
||||
let syncInterval = null
|
||||
|
||||
function startAutoSync() {
|
||||
if (syncInterval) return
|
||||
syncInterval = setInterval(() => {
|
||||
if (isOnline.value) {
|
||||
fullSync()
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
// Initial sync
|
||||
fullSync()
|
||||
}
|
||||
|
||||
function stopAutoSync() {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
lastSyncAt,
|
||||
getToken,
|
||||
setToken,
|
||||
apiFetch,
|
||||
processSyncQueue,
|
||||
pullRemoteChanges,
|
||||
fullSync,
|
||||
startAutoSync,
|
||||
stopAutoSync,
|
||||
}
|
||||
355
frontend/_src/stores/events.js
Normal file
355
frontend/_src/stores/events.js
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
import { startAutoSync, getToken } from 'src/services/syncService'
|
||||
|
||||
// Color interpolation
|
||||
function lerpColor(a, b, t) {
|
||||
const ar = parseInt(a.slice(1, 3), 16)
|
||||
const ag = parseInt(a.slice(3, 5), 16)
|
||||
const ab = parseInt(a.slice(5, 7), 16)
|
||||
const br = parseInt(b.slice(1, 3), 16)
|
||||
const bg = parseInt(b.slice(3, 5), 16)
|
||||
const bb = parseInt(b.slice(5, 7), 16)
|
||||
const r = Math.round(ar + (br - ar) * t)
|
||||
const g = Math.round(ag + (bg - ag) * t)
|
||||
const blue = Math.round(ab + (bb - ab) * t)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Gradient presets: [negative, neutral, positive]
|
||||
const GRADIENT_PRESETS = [
|
||||
{ name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] },
|
||||
{ name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] },
|
||||
{ name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] },
|
||||
{ name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] },
|
||||
{ name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] },
|
||||
{ name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] },
|
||||
{ name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] },
|
||||
{ name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] },
|
||||
{ name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] },
|
||||
{ name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] }
|
||||
]
|
||||
|
||||
// Glow color logic: emotion value → color, with optional gradient preset
|
||||
function emotionToColor(emotion, gradientIdx = null) {
|
||||
const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null
|
||||
if (preset) {
|
||||
const [neg, mid, pos] = preset.colors
|
||||
if (emotion >= 0) {
|
||||
return lerpColor(mid, pos, emotion)
|
||||
} else {
|
||||
return lerpColor(mid, neg, Math.abs(emotion))
|
||||
}
|
||||
}
|
||||
if (emotion >= 0) {
|
||||
if (emotion < 0.5) {
|
||||
return lerpColor('#FF6B35', '#FFD700', emotion / 0.5)
|
||||
}
|
||||
return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5)
|
||||
} else {
|
||||
const abs = Math.abs(emotion)
|
||||
if (abs < 0.5) {
|
||||
return lerpColor('#2196F3', '#9C27B0', abs / 0.5)
|
||||
}
|
||||
return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Demo seed data
|
||||
const demoEvents = [
|
||||
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
|
||||
]
|
||||
|
||||
// Generate realistic demo events for testing at scale
|
||||
function generateManyEvents(count = 500) {
|
||||
// Realistic life event categories with emotion ranges
|
||||
const categories = [
|
||||
// Positive events
|
||||
{ titles: ['Geburtstag', 'Geburtstagsfeier', 'Überraschungsparty'], emotionRange: [0.3, 0.8], noteChance: 0.4, notes: ['Tolles Fest!', 'Viele Geschenke', 'Schöner Tag mit Freunden', 'Alles Gute!'] },
|
||||
{ titles: ['Urlaub', 'Strandurlaub', 'Städtereise', 'Roadtrip', 'Backpacking'], emotionRange: [0.4, 0.95], noteChance: 0.6, notes: ['Unvergesslich', 'Wunderschöne Landschaft', 'Endlich Erholung', 'Muss ich wiederholen'] },
|
||||
{ titles: ['Hochzeit', 'Verlobung', 'Jahrestag'], emotionRange: [0.7, 1.0], noteChance: 0.8, notes: ['Der schönste Tag', 'Für immer', 'Tränen der Freude', 'Unbeschreiblich'] },
|
||||
{ titles: ['Beförderung', 'Neuer Job', 'Gehaltserhöhung', 'Jobangebot'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Endlich!', 'Harte Arbeit zahlt sich aus', 'Neues Kapitel', 'Verdient'] },
|
||||
{ titles: ['Konzert', 'Festival', 'Theaterbesuch', 'Oper'], emotionRange: [0.3, 0.85], noteChance: 0.5, notes: ['Gänsehaut', 'Beste Band ever', 'Geniale Atmosphäre', 'Nächstes Jahr wieder'] },
|
||||
{ titles: ['Geburt', 'Baby da!', 'Nachwuchs'], emotionRange: [0.85, 1.0], noteChance: 0.9, notes: ['Das größte Wunder', 'Willkommen auf der Welt', 'Unbeschreibliches Glück'] },
|
||||
{ titles: ['Abschluss', 'Prüfung bestanden', 'Diplom', 'Master geschafft'], emotionRange: [0.6, 0.95], noteChance: 0.6, notes: ['Geschafft!', 'Jahre harter Arbeit', 'Stolz', 'Endlich vorbei'] },
|
||||
{ titles: ['Bergwanderung', 'Gipfel erreicht', 'Marathon geschafft', 'Triathlon'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Was für ein Ausblick!', 'Körperliche Grenzen überwunden', 'Nie aufgeben'] },
|
||||
{ titles: ['Hauskauf', 'Wohnungseinweihung', 'Renovierung fertig'], emotionRange: [0.4, 0.8], noteChance: 0.5, notes: ['Endlich eigene vier Wände', 'Traum wird wahr', 'Viel Arbeit, aber es lohnt sich'] },
|
||||
{ titles: ['Erstes Date', 'Zusammengekommen', 'Liebeserklärung'], emotionRange: [0.5, 0.95], noteChance: 0.6, notes: ['Schmetterlinge', 'Liebe auf den ersten Blick', 'Endlich getraut'] },
|
||||
// Neutral events
|
||||
{ titles: ['Umzug', 'Neue Stadt', 'Wohnungswechsel'], emotionRange: [-0.2, 0.3], noteChance: 0.4, notes: ['Neuanfang', 'Alles anders', 'Spannend und stressig zugleich'] },
|
||||
{ titles: ['Arztbesuch', 'Vorsorge', 'Check-up'], emotionRange: [-0.1, 0.1], noteChance: 0.2, notes: ['Alles okay', 'Routine'] },
|
||||
{ titles: ['Meeting', 'Präsentation', 'Workshop'], emotionRange: [-0.1, 0.4], noteChance: 0.3, notes: ['Gut gelaufen', 'Viel gelernt', 'Anstrengend'] },
|
||||
{ titles: ['Friseur', 'Shopping', 'Einkauf'], emotionRange: [0.0, 0.3], noteChance: 0.1, notes: ['Neuer Look', 'Guter Fund'] },
|
||||
// Negative events
|
||||
{ titles: ['Trennung', 'Beziehungsende', 'Scheidung'], emotionRange: [-1.0, -0.5], noteChance: 0.5, notes: ['Schmerzhaft', 'Warum?', 'Es ist besser so', 'Brauche Zeit'] },
|
||||
{ titles: ['Jobverlust', 'Kündigung', 'Firma pleite'], emotionRange: [-0.9, -0.4], noteChance: 0.5, notes: ['Schock', 'Wie geht es weiter?', 'Unverdient'] },
|
||||
{ titles: ['Krankheit', 'OP', 'Krankenhaus'], emotionRange: [-0.8, -0.3], noteChance: 0.6, notes: ['Wird schon', 'Hauptsache gesund werden', 'Lange Genesung'] },
|
||||
{ titles: ['Abschied', 'Verlust', 'Trauer'], emotionRange: [-1.0, -0.6], noteChance: 0.7, notes: ['Ruhe in Frieden', 'Fehlt mir', 'Unvergessen', 'Schwerer Tag'] },
|
||||
{ titles: ['Streit', 'Konflikt', 'Auseinandersetzung'], emotionRange: [-0.7, -0.2], noteChance: 0.3, notes: ['Muss nicht sein', 'Hoffe auf Klärung'] },
|
||||
{ titles: ['Unfall', 'Panne', 'Autopanne'], emotionRange: [-0.6, -0.2], noteChance: 0.4, notes: ['Zum Glück nichts Schlimmes', 'Ärgerlich', 'Hätte schlimmer sein können'] },
|
||||
{ titles: ['Prüfung nicht bestanden', 'Absage', 'Ablehnung'], emotionRange: [-0.7, -0.3], noteChance: 0.4, notes: ['Nächstes Mal', 'Nicht aufgeben', 'Enttäuschend'] },
|
||||
]
|
||||
|
||||
const demoImages = [
|
||||
'demo/photo-1530103862676-de8c9debad1d.jpeg',
|
||||
'demo/photo-1534067783941-51c9c23ecefd.jpeg',
|
||||
'demo/photo-1506905925346-21bda4d32df4.jpeg'
|
||||
]
|
||||
|
||||
// Seeded random for reproducibility
|
||||
let seed = 42
|
||||
function rand() {
|
||||
seed = (seed * 16807 + 0) % 2147483647
|
||||
return (seed - 1) / 2147483646
|
||||
}
|
||||
|
||||
function randInt(min, max) {
|
||||
return Math.floor(rand() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(rand() * arr.length)]
|
||||
}
|
||||
|
||||
function randFloat(min, max) {
|
||||
return Math.round((min + rand() * (max - min)) * 100) / 100
|
||||
}
|
||||
|
||||
const evts = []
|
||||
const startYear = 1985
|
||||
const endYear = 2026
|
||||
|
||||
// Generate events with realistic distribution (more events in recent years)
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Weight towards recent years: cube root distribution
|
||||
const t = rand()
|
||||
const yearFloat = startYear + (endYear - startYear) * (t * t * 0.4 + t * 0.6)
|
||||
const year = Math.floor(yearFloat)
|
||||
const month = randInt(1, 12)
|
||||
const day = randInt(1, 28) // safe for all months
|
||||
const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
|
||||
const cat = pick(categories)
|
||||
const title = pick(cat.titles)
|
||||
const emotion = randFloat(cat.emotionRange[0], cat.emotionRange[1])
|
||||
const hasNote = rand() < cat.noteChance
|
||||
const note = hasNote ? pick(cat.notes) : ''
|
||||
const hasImage = rand() < 0.15 // 15% chance
|
||||
const image = hasImage ? pick(demoImages) : null
|
||||
const hasPreset = rand() < 0.25 // 25% chance
|
||||
const gradientPreset = hasPreset ? randInt(0, 9) : null
|
||||
|
||||
evts.push({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
date,
|
||||
emotion,
|
||||
customColor: null,
|
||||
gradientPreset,
|
||||
image,
|
||||
note,
|
||||
syncStatus: 'local',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by date
|
||||
evts.sort((a, b) => a.date.localeCompare(b.date))
|
||||
return evts
|
||||
}
|
||||
|
||||
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
|
||||
|
||||
export const useEventsStore = defineStore('events', () => {
|
||||
const events = ref([])
|
||||
const isLoaded = ref(false)
|
||||
const selectedEventId = ref(null)
|
||||
const panelOpen = ref(false)
|
||||
const editingEventId = ref(null)
|
||||
|
||||
// Load events from IndexedDB; seed demo data on first launch
|
||||
async function init() {
|
||||
try {
|
||||
let stored = await db.events.orderBy('date').toArray()
|
||||
if (stored.length === 0) {
|
||||
const seed = generateManyEvents(500)
|
||||
await db.events.bulkPut(seed)
|
||||
stored = seed
|
||||
}
|
||||
events.value = stored
|
||||
} catch (e) {
|
||||
console.warn('Dexie load failed, using demo data:', e)
|
||||
events.value = [...demoEvents]
|
||||
}
|
||||
isLoaded.value = true
|
||||
|
||||
// Start auto-sync if authenticated
|
||||
getToken().then((token) => {
|
||||
if (token) startAutoSync()
|
||||
})
|
||||
}
|
||||
|
||||
// Fire-and-forget DB write (UI already updated via ref)
|
||||
function dbPut(event) {
|
||||
db.events.put(event).catch(e => console.warn('Dexie put failed:', e))
|
||||
}
|
||||
|
||||
function dbDelete(id) {
|
||||
db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e))
|
||||
}
|
||||
|
||||
function dbQueueSync(eventId, action, payload) {
|
||||
db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() })
|
||||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
||||
}
|
||||
|
||||
// Ghost event for live preview while creating/editing
|
||||
const ghostEmotion = ref(0)
|
||||
const ghostCustomColor = ref(null)
|
||||
const ghostGradientPreset = ref(null)
|
||||
const ghostTitle = ref('')
|
||||
const ghostDate = ref(new Date().toISOString().slice(0, 10))
|
||||
const ghostNote = ref('')
|
||||
const ghostImage = ref(null)
|
||||
|
||||
const ghostEvent = computed(() => ({
|
||||
id: '__ghost__',
|
||||
title: ghostTitle.value || 'New Event',
|
||||
date: ghostDate.value,
|
||||
emotion: ghostEmotion.value,
|
||||
customColor: ghostCustomColor.value,
|
||||
gradientPreset: ghostGradientPreset.value,
|
||||
image: ghostImage.value,
|
||||
note: ghostNote.value
|
||||
}))
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
return [...events.value].sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
})
|
||||
|
||||
function selectEvent(id) {
|
||||
selectedEventId.value = id
|
||||
}
|
||||
|
||||
function openPanel(eventId = null) {
|
||||
if (eventId) {
|
||||
editingEventId.value = eventId
|
||||
const event = events.value.find((e) => e.id === eventId)
|
||||
if (event) {
|
||||
ghostTitle.value = event.title
|
||||
ghostDate.value = event.date
|
||||
ghostEmotion.value = event.emotion
|
||||
ghostCustomColor.value = event.customColor
|
||||
ghostGradientPreset.value = event.gradientPreset ?? null
|
||||
ghostImage.value = event.image || null
|
||||
ghostNote.value = event.note
|
||||
}
|
||||
} else {
|
||||
editingEventId.value = null
|
||||
ghostTitle.value = ''
|
||||
ghostDate.value = new Date().toISOString().slice(0, 10)
|
||||
ghostEmotion.value = 0
|
||||
ghostCustomColor.value = null
|
||||
ghostGradientPreset.value = null
|
||||
ghostImage.value = null
|
||||
ghostNote.value = ''
|
||||
}
|
||||
panelOpen.value = true
|
||||
}
|
||||
|
||||
// Auto-save: persist ghost → event in edit mode
|
||||
function persistToEvent() {
|
||||
if (!editingEventId.value) return
|
||||
const idx = events.value.findIndex((e) => e.id === editingEventId.value)
|
||||
if (idx === -1) return
|
||||
const updated = {
|
||||
...events.value[idx],
|
||||
title: ghostTitle.value,
|
||||
date: ghostDate.value,
|
||||
emotion: ghostEmotion.value,
|
||||
customColor: ghostCustomColor.value,
|
||||
gradientPreset: ghostGradientPreset.value,
|
||||
image: ghostImage.value,
|
||||
note: ghostNote.value,
|
||||
syncStatus: 'modified',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
events.value[idx] = updated
|
||||
dbPut(updated)
|
||||
}
|
||||
|
||||
watch(
|
||||
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote],
|
||||
() => { persistToEvent() }
|
||||
)
|
||||
|
||||
function closePanel() {
|
||||
if (!editingEventId.value && ghostTitle.value.trim()) {
|
||||
const newEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
title: ghostTitle.value,
|
||||
date: ghostDate.value,
|
||||
emotion: ghostEmotion.value,
|
||||
customColor: ghostCustomColor.value,
|
||||
gradientPreset: ghostGradientPreset.value,
|
||||
image: ghostImage.value,
|
||||
note: ghostNote.value,
|
||||
syncStatus: 'local',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
events.value.push(newEvent)
|
||||
dbPut(newEvent)
|
||||
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
||||
}
|
||||
panelOpen.value = false
|
||||
editingEventId.value = null
|
||||
selectedEventId.value = null
|
||||
}
|
||||
|
||||
function deleteEvent(id) {
|
||||
events.value = events.value.filter((e) => e.id !== id)
|
||||
dbDelete(id)
|
||||
dbQueueSync(id, 'delete', null)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function getGlowColor(event) {
|
||||
if (event.customColor) return event.customColor
|
||||
return emotionToColor(event.emotion, event.gradientPreset ?? null)
|
||||
}
|
||||
|
||||
// Auto-init on store creation
|
||||
init()
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoaded,
|
||||
selectedEventId,
|
||||
panelOpen,
|
||||
editingEventId,
|
||||
ghostEmotion,
|
||||
ghostCustomColor,
|
||||
ghostGradientPreset,
|
||||
ghostTitle,
|
||||
ghostDate,
|
||||
ghostNote,
|
||||
ghostImage,
|
||||
ghostEvent,
|
||||
sortedEvents,
|
||||
selectEvent,
|
||||
openPanel,
|
||||
closePanel,
|
||||
deleteEvent,
|
||||
getGlowColor
|
||||
}
|
||||
})
|
||||
21
frontend/_src/stores/example-store.js
Normal file
21
frontend/_src/stores/example-store.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', {
|
||||
state: () => ({
|
||||
counter: 0
|
||||
}),
|
||||
|
||||
getters: {
|
||||
doubleCount: (state) => state.counter * 2
|
||||
},
|
||||
|
||||
actions: {
|
||||
increment() {
|
||||
this.counter++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
20
frontend/_src/stores/index.js
Normal file
20
frontend/_src/stores/index.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineStore } from '#q-app/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Store instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Store instance.
|
||||
*/
|
||||
|
||||
export default defineStore((/* { ssrContext } */) => {
|
||||
const pinia = createPinia()
|
||||
|
||||
// You can add Pinia plugins here
|
||||
// pinia.use(SomePiniaPlugin)
|
||||
|
||||
return pinia
|
||||
})
|
||||
107
frontend/_src/stores/settings.js
Normal file
107
frontend/_src/stores/settings.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'thatsme-settings'
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ label: 'Standard', value: 'default', hex: '#9e9e9e' },
|
||||
{ label: 'Blau', value: 'blue', hex: '#2196F3' },
|
||||
{ label: 'Grün', value: 'green', hex: '#4CAF50' },
|
||||
{ label: 'Gelb', value: 'yellow', hex: '#FFC107' },
|
||||
{ label: 'Rosa', value: 'pink', hex: '#E91E63' },
|
||||
{ label: 'Orange', value: 'orange', hex: '#FF9800' }
|
||||
]
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'English', value: 'en' }
|
||||
]
|
||||
|
||||
const FLOATING_LINES_DEFAULTS = {
|
||||
// Linien
|
||||
speed: 1.0,
|
||||
lineCount: 10,
|
||||
spread: 0.05,
|
||||
fanSpread: 0.05,
|
||||
lineSharpness: 8.0,
|
||||
waveFrequency: 7.0,
|
||||
bezierCurvature: 0.2,
|
||||
circleRadius: 75,
|
||||
glowSize: 18,
|
||||
glowStrength: 1.5,
|
||||
lineBrightness: 1.0,
|
||||
// Hintergrund
|
||||
bgCenter: '#0a0514',
|
||||
bgEdge: '#000000',
|
||||
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
|
||||
backgroundImage: '',
|
||||
// Labels
|
||||
labelSize: 'small', // 'small' | 'medium' | 'large'
|
||||
labelColor: '#ffffff'
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { FLOATING_LINES_DEFAULTS }
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const stored = loadFromStorage()
|
||||
|
||||
const theme = ref(stored?.theme ?? 'light')
|
||||
const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS })
|
||||
|
||||
// App preferences
|
||||
const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark'
|
||||
const accentColor = ref(stored?.accentColor ?? 'default')
|
||||
const language = ref(stored?.language ?? 'de')
|
||||
|
||||
// Developer / debug
|
||||
const showFps = ref(stored?.showFps ?? false)
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
theme: theme.value,
|
||||
floatingLines: floatingLines.value,
|
||||
appearance: appearance.value,
|
||||
accentColor: accentColor.value,
|
||||
language: language.value,
|
||||
showFps: showFps.value
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true })
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function updateFloatingLines(updates) {
|
||||
floatingLines.value = { ...floatingLines.value, ...updates }
|
||||
}
|
||||
|
||||
function resetFloatingLines() {
|
||||
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
floatingLines,
|
||||
appearance,
|
||||
accentColor,
|
||||
language,
|
||||
showFps,
|
||||
toggleTheme,
|
||||
updateFloatingLines,
|
||||
resetFloatingLines
|
||||
}
|
||||
})
|
||||
550
frontend/_src/utils/ConnectedDotsVisualization.ts
Normal file
550
frontend/_src/utils/ConnectedDotsVisualization.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
onClick?: () => void; // Function to call when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
private preloadedImages: Map<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();
|
||||
}
|
||||
}
|
||||
238
frontend/_src/utils/editFormOptions.js
Normal file
238
frontend/_src/utils/editFormOptions.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// Form options and data for EditPage component
|
||||
|
||||
// Main categories with their subcategories
|
||||
export const categoryStructure = [
|
||||
{
|
||||
label: 'Career',
|
||||
value: 'career',
|
||||
subcategories: [
|
||||
{ label: 'Promotion', value: 'career-promotion' },
|
||||
{ label: 'Retirement', value: 'career-retirement' },
|
||||
{ label: 'Career Changes', value: 'career-changes' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Education',
|
||||
value: 'education',
|
||||
subcategories: [
|
||||
{ label: 'Graduation', value: 'education-graduation' },
|
||||
{ label: 'Schooling', value: 'education-schooling' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Awards',
|
||||
value: 'awards',
|
||||
subcategories: []
|
||||
},
|
||||
{
|
||||
label: 'Personal Celebrations',
|
||||
value: 'personal-celebrations',
|
||||
subcategories: [
|
||||
{ label: 'Birthday', value: 'birthday' },
|
||||
{ label: 'Anniversary', value: 'anniversary' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Relationships',
|
||||
value: 'relationships',
|
||||
subcategories: [
|
||||
{ label: 'Engagement', value: 'relationships-engagement' },
|
||||
{ label: 'Marriage', value: 'relationships-marriage' },
|
||||
{ label: 'Divorce', value: 'relationships-divorce' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Parenthood',
|
||||
value: 'parenthood',
|
||||
subcategories: [
|
||||
{ label: 'Pregnancy', value: 'parenthood-pregnancy' },
|
||||
{ label: 'Birth', value: 'parenthood-birth' },
|
||||
{ label: 'Adoption', value: 'parenthood-adoption' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Loss & Passing',
|
||||
value: 'passing',
|
||||
subcategories: [
|
||||
{ label: 'Funeral', value: 'passing-funeral' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Festivities',
|
||||
value: 'festivities',
|
||||
subcategories: [
|
||||
{ label: 'Christmas', value: 'festivities-christmas' },
|
||||
{ label: 'Thanksgiving', value: 'festivities-thanksgiving' },
|
||||
{ label: 'New Year', value: 'festivities-new-year' },
|
||||
{ label: 'Easter', value: 'festivities-easter' },
|
||||
{ label: 'Holidays', value: 'festivities-holidays' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Social Events',
|
||||
value: 'social-events',
|
||||
subcategories: [
|
||||
{ label: 'Reunions', value: 'reunions' },
|
||||
{ label: 'Concerts', value: 'concerts' },
|
||||
{ label: 'Sports', value: 'sports' },
|
||||
{ label: 'Festivals', value: 'festivals' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Community',
|
||||
value: 'community',
|
||||
subcategories: [
|
||||
{ label: 'Charity', value: 'charity' },
|
||||
{ label: 'Community Service', value: 'community-service' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Health',
|
||||
value: 'health',
|
||||
subcategories: [
|
||||
{ label: 'Surgery', value: 'health-surgery' },
|
||||
{ label: 'Illness', value: 'health-illness' },
|
||||
{ label: 'Recovery', value: 'health-recovery' },
|
||||
{ label: 'Transplants', value: 'health-transplants' },
|
||||
{ label: 'Mental Health', value: 'health-mental-health' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Religious & Spiritual',
|
||||
value: 'religious',
|
||||
subcategories: [
|
||||
{ label: 'Baptism', value: 'religious-baptism' },
|
||||
{ label: 'Bar/Bat Mitzvah', value: 'religious-bar-bat-mitzvah' },
|
||||
{ label: 'Communion', value: 'religious-communion' },
|
||||
{ label: 'Confirmation', value: 'religious-confirmation' },
|
||||
{ label: 'Pilgrimage', value: 'religious-pilgrimage' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Travel & Adventure',
|
||||
value: 'travel',
|
||||
subcategories: [
|
||||
{ label: 'Travel', value: 'travel-general' },
|
||||
{ label: 'Vacation', value: 'vacation' },
|
||||
{ label: 'Adventure', value: 'adventure' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Life Changes',
|
||||
value: 'life-changes',
|
||||
subcategories: [
|
||||
{ label: 'Moving', value: 'moving' },
|
||||
{ label: 'License', value: 'license' },
|
||||
{ label: 'Voting', value: 'voting' },
|
||||
{ label: 'Citizenship', value: 'citizenship' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Milestones',
|
||||
value: 'milestones',
|
||||
subcategories: []
|
||||
}
|
||||
]
|
||||
|
||||
// Flattened category options for backward compatibility and simple select usage
|
||||
export const categoryOptions = categoryStructure.reduce((acc, category) => {
|
||||
// Add main category if it has no subcategories
|
||||
if (category.subcategories.length === 0) {
|
||||
acc.push({ label: category.label, value: category.value })
|
||||
} else {
|
||||
// Add subcategories with main category prefix
|
||||
category.subcategories.forEach(sub => {
|
||||
acc.push({
|
||||
label: `${category.label} - ${sub.label}`,
|
||||
value: sub.value,
|
||||
mainCategory: category.value,
|
||||
subcategory: sub.value
|
||||
})
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// Helper functions for category management
|
||||
export const getCategoryStructure = () => categoryStructure
|
||||
|
||||
export const getMainCategories = () => {
|
||||
return categoryStructure.map(cat => ({
|
||||
label: cat.label,
|
||||
value: cat.value
|
||||
}))
|
||||
}
|
||||
|
||||
export const getSubcategories = (mainCategoryValue) => {
|
||||
const mainCategory = categoryStructure.find(cat => cat.value === mainCategoryValue)
|
||||
return mainCategory ? mainCategory.subcategories : []
|
||||
}
|
||||
|
||||
export const getCategoryByValue = (value) => {
|
||||
return categoryOptions.find(cat => cat.value === value)
|
||||
}
|
||||
|
||||
export const getMainCategoryFromValue = (value) => {
|
||||
const category = getCategoryByValue(value)
|
||||
return category ? category.mainCategory : null
|
||||
}
|
||||
|
||||
export const tagOptions = [
|
||||
// Emotions
|
||||
'happy', 'sad', 'exciting', 'stressful', 'memorable', 'important',
|
||||
'fun', 'challenging', 'rewarding', 'disappointing', 'surprising',
|
||||
'life-changing', 'routine', 'special', 'difficult', 'joyful',
|
||||
'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful',
|
||||
'emotional', 'touching', 'inspiring', 'motivating', 'healing',
|
||||
|
||||
// Significance
|
||||
'milestone', 'achievement', 'breakthrough', 'turning-point',
|
||||
'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected',
|
||||
'planned', 'spontaneous', 'tradition', 'new-experience',
|
||||
|
||||
// Social
|
||||
'family', 'friends', 'colleagues', 'community', 'solo',
|
||||
'group', 'intimate', 'public', 'private', 'celebration',
|
||||
|
||||
// Intensity
|
||||
'intense', 'mild', 'dramatic', 'subtle', 'overwhelming',
|
||||
'gradual', 'sudden', 'anticipated', 'shocking', 'gentle',
|
||||
|
||||
// Time-related
|
||||
'brief', 'extended', 'momentary', 'lasting', 'temporary',
|
||||
'permanent', 'seasonal', 'annual', 'weekly', 'daily'
|
||||
]
|
||||
|
||||
export const personOptions = [
|
||||
'Anna Mueller',
|
||||
'Max Schmidt',
|
||||
'Sarah Johnson',
|
||||
'Michael Weber',
|
||||
'Lisa Anderson',
|
||||
'Thomas Brown',
|
||||
'Julia Martinez',
|
||||
'David Wilson',
|
||||
'Emma Garcia',
|
||||
'Robert Davis'
|
||||
]
|
||||
|
||||
export const defaultFormData = {
|
||||
keyImage: null,
|
||||
keyImageUrl: '',
|
||||
additionalImages: [],
|
||||
additionalImageUrls: [],
|
||||
level: 0,
|
||||
categories: [],
|
||||
headline: '',
|
||||
subheadline: '',
|
||||
text: '',
|
||||
tags: [],
|
||||
location: '',
|
||||
date: '',
|
||||
time: '',
|
||||
audioFiles: [],
|
||||
audioRecordings: [],
|
||||
videoFiles: [],
|
||||
videoRecordings: [],
|
||||
relatedPersons: []
|
||||
}
|
||||
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
|
|
@ -4336,12 +4336,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
|
||||
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
@ -5021,21 +5015,6 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue