refactor. edit page with new selection pages

This commit is contained in:
Tilman Behrend 2025-09-18 23:04:10 +02:00
parent d95f5fcb45
commit 6c909ea6e7
8 changed files with 2397 additions and 162 deletions

View file

@ -1 +1 @@
.controls{display:flex;justify-content:space-between;width:100%;max-width:500px;margin-bottom:10px}.button{padding:6px 12px;background-color:#4f46e5;color:#fff;border:none;border-radius:4px;cursor:pointer}.visualization-container{position:relative;width:100%;height:calc(100vh - 86px);overflow:hidden}.gradient-bg{position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;background:linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);background-size:200% 200%;animation:gradientAnimation 20s ease infinite}@keyframes gradientAnimation{0%{background-position:0% 0%}25%{background-position:100% 0%}50%{background-position:100% 100%}75%{background-position:0% 100%}100%{background-position:0% 0%}}.median{position:absolute;top:51%;left:0;right:0;height:2px;background-color:rgba(255,255,255,.3);z-index:10}.scroll-container{position:relative;width:100%;height:100%;overflow-x:auto;overflow-y:hidden;min-height:400px;-ms-overflow-style:none;scrollbar-width:none}.scroll-container::-webkit-scrollbar{display:none}.smooth-scroll{scroll-behavior:smooth}.active{cursor:grabbing}.spacer{height:100vh}.dot-tooltip{pointer-events:none;opacity:1}.dot-tooltip .tooltip-background{fill:rgba(0,0,0,0)}.dot-tooltip .tooltip-content{display:flex;justify-content:center;align-items:center;flex-direction:column;width:100%;height:100%;color:#fff}.dot-tooltip .image_container{margin-top:8px;box-shadow:0 0 20px 0 rgba(255,255,255,.25);transition:box-shadow .25s ease-in-out;width:80px;height:80px;overflow:hidden;border-radius:50%;border:2px solid #fff;display:flex;justify-content:center}.dot-tooltip .image_container:hover{box-shadow:0 0 30px 0 rgba(255,255,255,.8)}.dot-tooltip .tooltip-image{width:100%;height:auto;display:block;pointer-events:auto}.dot-tooltip .tooltip-title{font-size:14px;font-weight:400;margin-bottom:2px;text-align:center;text-wrap:balance;-webkit-hyphens:auto;hyphens:auto;line-height:1.1}.dot-tooltip .tooltip-description{font-size:12px;font-weight:300}.dot-tooltip .tooltip-arrow{width:1px;height:30px;background:linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent)}.dot{transition:r .2s ease,fill .2s ease;cursor:pointer}.dot:hover{fill:rgba(255,255,255,.9);filter:drop-shadow(0 0 5px rgba(255, 255, 255, 0.8))}.tooltip-img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:4px}
.controls{display:flex;justify-content:space-between;width:100%;max-width:500px;margin-bottom:10px}.button{padding:6px 12px;background-color:#4f46e5;color:#fff;border:none;border-radius:4px;cursor:pointer}.visualization-container{position:relative;width:100%;height:calc(100vh - 86px);overflow:hidden}.gradient-bg{position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;background:linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);background-size:200% 200%;animation:gradientAnimation 20s ease infinite}@keyframes gradientAnimation{0%{background-position:0% 0%}25%{background-position:100% 0%}50%{background-position:100% 100%}75%{background-position:0% 100%}100%{background-position:0% 0%}}.median{position:absolute;top:51.2%;left:0;right:0;height:1px;background-color:rgba(255,255,255,.3);z-index:0}.scroll-container{position:relative;width:100%;height:100%;overflow-x:auto;overflow-y:hidden;min-height:400px;z-index:1;-ms-overflow-style:none;scrollbar-width:none}.scroll-container::-webkit-scrollbar{display:none}.smooth-scroll{scroll-behavior:smooth}.active{cursor:grabbing}.spacer{height:100vh}.dot-tooltip{pointer-events:none;opacity:1}.dot-tooltip .tooltip-background{fill:rgba(0,0,0,0)}.dot-tooltip .tooltip-content{display:flex;justify-content:center;align-items:center;flex-direction:column;width:100%;height:100%;color:#fff}.dot-tooltip .image_container{margin-top:8px;box-shadow:0 0 20px 0 rgba(255,255,255,.25);transition:box-shadow .25s ease-in-out;width:80px;height:80px;overflow:hidden;border-radius:50%;border:2px solid #fff;display:flex;justify-content:center}.dot-tooltip .image_container:hover{box-shadow:0 0 30px 0 rgba(255,255,255,.8)}.dot-tooltip .tooltip-image{width:100%;height:auto;display:block;pointer-events:auto}.dot-tooltip .tooltip-title{font-size:14px;font-weight:400;margin-bottom:2px;text-align:center;text-wrap:balance;-webkit-hyphens:auto;hyphens:auto;line-height:1.1}.dot-tooltip .tooltip-description{font-size:12px;font-weight:300}.dot-tooltip .tooltip-arrow{width:1px;height:30px;background:linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent)}.dot{transition:r .2s ease,fill .2s ease;cursor:pointer}.dot:hover{fill:rgba(255,255,255,.9);filter:drop-shadow(0 0 5px rgba(255, 255, 255, 0.8))}.tooltip-img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:4px}

View file

@ -62,12 +62,12 @@ $image-size: 80px;
.median {
position: absolute;
top: 51%;
top: 51.2%;
left: 0;
right: 0;
height: 2px;
height: 1px;
background-color: rgba(255,255,255,0.3);
z-index: 10;
z-index: 0;
}
.scroll-container {
@ -77,6 +77,7 @@ $image-size: 80px;
overflow-x: auto;
overflow-y: hidden;
min-height:400px;
z-index: 1;
&::-webkit-scrollbar {
display: none;
}

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

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

@ -30,6 +30,24 @@ const routes = [
component: () => import('pages/EditPage.vue'),
meta: { hideFooter: true }
},
{
path: 'person-selector',
name: 'person-selector',
component: () => import('pages/PersonSelector.vue'),
meta: { hideFooter: true }
},
{
path: 'category-selector',
name: 'category-selector',
component: () => import('pages/CategorySelector.vue'),
meta: { hideFooter: true }
},
{
path: 'tag-selector',
name: 'tag-selector',
component: () => import('pages/TagSelector.vue'),
meta: { hideFooter: true }
},
],
},

View file

@ -222,7 +222,7 @@ export const defaultFormData = {
additionalImages: [],
additionalImageUrls: [],
level: 0,
category: '',
categories: [],
headline: '',
subheadline: '',
text: '',