10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue