30-04-2026

This commit is contained in:
Kevin Adametz 2026-04-30 14:54:39 +02:00
parent 761b1156c1
commit d054732bf5
35 changed files with 2796 additions and 505 deletions

View file

@ -0,0 +1,5 @@
Legacy frontend screens archived on 2026-04-30.
These files are not mounted by `frontend/src/router/routes.js`. They are kept here
as reference while the active LifeWave experience is consolidated around events,
the timeline, and Floating Lines.

View 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>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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>

View 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>

View 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>

View 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();
}
}

View 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: []
}