thats-me/frontend/src/pages/EditPage.vue
2025-09-14 22:59:50 +02:00

931 lines
No EOL
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-page padding>
<div class="row justify-center">
<div class="col-12 col-md-8 col-lg-6">
<q-card>
<q-card-section>
<div class="text-h5">Edit Entry</div>
<div class="text-subtitle2 text-grey">Modify your life event details</div>
</q-card-section>
<q-card-section>
<q-form @submit.prevent="onSave" class="q-gutter-md">
<!-- Tab Navigation -->
<q-tabs
v-model="currentTab"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab name="basics" icon="mood" label="Basics" />
<q-tab name="media" icon="perm_media" label="Media" />
<q-tab name="content" icon="article" label="Content" />
<q-tab name="details" icon="info" label="Details" />
</q-tabs>
<q-separator />
<!-- Tab Panels -->
<q-tab-panels v-model="currentTab" animated>
<!-- Tab 1: Basics -->
<q-tab-panel name="basics" class="q-pa-none">
<div class="q-gutter-md q-mt-md">
<!-- Emotional Level -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Emotional Level</q-label>
<q-slider
v-model="form.level"
:min="-3"
:max="3"
:step="1"
snap
markers
label
:label-value="`Level: ${form.level}`"
color="primary"
class="q-mt-md"
/>
<div class="row justify-between text-caption text-grey q-mt-xs">
<span>Very Negative (-3)</span>
<span>Neutral (0)</span>
<span>Very Positive (+3)</span>
</div>
</div>
<!-- Key Image -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Key Image *</q-label>
<!-- Preview selected key image -->
<div v-if="form.keyImage || form.keyImageUrl" class="q-mb-sm">
<div class="relative-position" style="display: inline-block;">
<q-img
:src="getKeyImagePreview()"
style="height: 120px; max-width: 180px"
class="rounded-borders"
/>
<q-btn
icon="close"
size="sm"
round
color="negative"
class="absolute-top-right"
style="margin: 4px"
@click="removeKeyImage"
/>
</div>
</div>
<q-file
v-model="form.keyImage"
label="Select key image *"
filled
accept="image/*"
max-file-size="5242880"
@rejected="onImageRejected"
@update:model-value="onKeyImageSelected"
:rules="[val => !!val || !!form.keyImageUrl || 'Key image is required']"
>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>
</q-file>
</div>
<!-- Headline -->
<q-input
v-model="form.headline"
label="Headline *"
filled
:rules="[val => !!val || 'Headline is required']"
/>
<!-- Date and Time -->
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-input
v-model="form.date"
label="Date *"
filled
type="date"
:rules="[val => !!val || 'Date is required']"
>
<template v-slot:prepend>
<q-icon name="event" />
</template>
</q-input>
</div>
<div class="col">
<q-input
v-model="form.time"
label="Time"
filled
type="time"
>
<template v-slot:prepend>
<q-icon name="access_time" />
</template>
</q-input>
</div>
</div>
</div>
</q-tab-panel>
<!-- Tab 2: Media -->
<q-tab-panel name="media" class="q-pa-none">
<div class="q-gutter-md q-mt-md">
<!-- Additional Images -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Additional Images</q-label>
<q-file
v-model="form.additionalImages"
label="Select additional images"
filled
multiple
accept="image/*"
max-file-size="5242880"
@rejected="onImageRejected"
>
<template v-slot:prepend>
<q-icon name="photo_library" />
</template>
</q-file>
<!-- Preview additional images -->
<div v-if="form.additionalImageUrls.length > 0" class="row q-gutter-sm q-mt-sm">
<div v-for="(url, index) in form.additionalImageUrls" :key="index" class="relative-position">
<q-img
:src="url"
style="height: 80px; width: 80px"
class="rounded-borders"
/>
<q-btn
icon="close"
size="xs"
round
color="negative"
class="absolute-top-right"
style="margin: 4px"
@click="removeAdditionalImage(index)"
/>
</div>
</div>
</div>
<!-- Audio Recordings -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Audio Recordings</q-label>
<q-file
v-model="form.audioFiles"
label="Select audio files"
filled
multiple
accept="audio/*"
max-file-size="10485760"
@rejected="onAudioRejected"
>
<template v-slot:prepend>
<q-icon name="audiotrack" />
</template>
</q-file>
<!-- List existing audio recordings -->
<div v-if="form.audioRecordings.length > 0" class="q-mt-sm">
<div class="media-thumbnails">
<div
v-for="(audio, index) in form.audioRecordings"
:key="index"
class="media-thumbnail audio-thumbnail"
@mouseenter="hoveredAudioIndex = index"
@mouseleave="hoveredAudioIndex = null"
>
<div class="media-icon">
<q-icon name="audiotrack" size="24px" />
</div>
<div class="media-info">
<div class="media-name">{{ audio.name }}</div>
<div class="media-size">{{ formatFileSize(audio.size) }}</div>
</div>
<div
v-if="hoveredAudioIndex === index"
class="media-delete"
@click="removeAudioRecording(index)"
>
×
</div>
</div>
</div>
</div>
</div>
<!-- Video Recordings -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Video Recordings</q-label>
<q-file
v-model="form.videoFiles"
label="Select video files"
filled
multiple
accept="video/*"
max-file-size="52428800"
@rejected="onVideoRejected"
>
<template v-slot:prepend>
<q-icon name="videocam" />
</template>
</q-file>
<!-- List existing video recordings -->
<div v-if="form.videoRecordings.length > 0" class="q-mt-sm">
<div class="media-thumbnails">
<div
v-for="(video, index) in form.videoRecordings"
:key="index"
class="media-thumbnail video-thumbnail"
@mouseenter="hoveredVideoIndex = index"
@mouseleave="hoveredVideoIndex = null"
>
<div class="media-icon">
<q-icon name="videocam" size="24px" />
</div>
<div class="media-info">
<div class="media-name">{{ video.name }}</div>
<div class="media-size">{{ formatFileSize(video.size) }}</div>
</div>
<div
v-if="hoveredVideoIndex === index"
class="media-delete"
@click="removeVideoRecording(index)"
>
×
</div>
</div>
</div>
</div>
</div>
</div>
</q-tab-panel>
<!-- Tab 3: Content -->
<q-tab-panel name="content" class="q-pa-none">
<div class="q-gutter-md q-mt-md">
<!-- Subheadline -->
<q-input
v-model="form.subheadline"
label="Subheadline"
filled
/>
<!-- Text -->
<q-input
v-model="form.text"
label="Description"
filled
type="textarea"
rows="4"
/>
<!-- Category Selection -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Category</q-label>
<!-- Main Category Selection with vue-select -->
<div class="q-mb-md">
<label class="text-caption text-grey q-mb-xs block">Main category</label>
<v-select
v-model="selectedMainCategory"
:options="mainCategoryOptions"
label="label"
:reduce="option => option.value"
placeholder="Select main category"
:clearable="true"
@update:modelValue="onMainCategoryChange"
class="vue-select-custom"
/>
</div>
<!-- Subcategory Selection -->
<q-select
v-model="form.category"
label="Subcategory"
filled
:options="availableSubcategories"
emit-value
map-options
clearable
:disable="!selectedMainCategory || availableSubcategories.length === 0"
/>
<div v-if="selectedMainCategory && availableSubcategories.length === 0" class="text-caption text-grey q-mt-xs">
This category has no subcategories
</div>
</div>
<!-- Tags -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Tags</q-label>
<q-select
v-model="form.tags"
label="Add tags"
filled
multiple
use-input
use-chips
new-value-mode="add-unique"
:options="tagOptions"
@filter="filterTags"
/>
</div>
</div>
</q-tab-panel>
<!-- Tab 4: Details -->
<q-tab-panel name="details" class="q-pa-none">
<div class="q-gutter-md q-mt-md">
<!-- Location -->
<q-input
v-model="form.location"
label="Location"
filled
>
<template v-slot:prepend>
<q-icon name="place" />
</template>
</q-input>
<!-- Related Persons -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Related Persons</q-label>
<v-select
v-model="selectedPerson"
:options="personOptions"
placeholder="Add related persons"
class="vue-select-custom"
@option:selected="addPerson"
clearable
/>
<!-- Selected persons circles -->
<div v-if="form.relatedPersons.length > 0" class="person-circles q-mt-md">
<div
v-for="(person, index) in form.relatedPersons"
:key="index"
class="person-circle"
@mouseenter="hoveredPersonIndex = index"
@mouseleave="hoveredPersonIndex = null"
>
<div class="person-initials">{{ getInitials(person) }}</div>
<div
v-if="hoveredPersonIndex === index"
class="person-delete"
@click="removePerson(index)"
>
×
</div>
</div>
</div>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
<!-- Action Buttons -->
<div class="row q-gutter-md q-mt-lg">
<q-btn
label="Delete Entry"
icon="delete"
color="negative"
outline
@click="confirmDelete"
class="col-auto"
/>
<q-space />
<q-btn
label="Cancel"
color="grey"
flat
@click="onCancel"
class="col-auto"
/>
<q-btn
label="Save"
icon="save"
color="primary"
type="submit"
class="col-auto"
/>
</div>
</q-form>
</q-card-section>
</q-card>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<q-dialog v-model="showDeleteDialog" persistent>
<q-card>
<q-card-section class="row items-center">
<q-avatar icon="warning" color="negative" text-color="white" />
<span class="q-ml-sm">Are you sure you want to delete this entry? This action cannot be undone.</span>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" color="primary" v-close-popup />
<q-btn flat label="Delete" color="negative" @click="onDelete" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import vSelect from 'vue-select'
import 'vue-select/dist/vue-select.css'
import { tagOptions, personOptions, defaultFormData, getMainCategories, getSubcategories } from '../utils/editFormOptions.js'
export default {
name: 'EditPage',
components: {
'v-select': vSelect
},
setup() {
const $q = useQuasar()
const router = useRouter()
const route = useRoute()
const showDeleteDialog = ref(false)
// Tab navigation state
const currentTab = ref('basics')
// Form data - using default form data structure
const form = reactive({ ...defaultFormData })
// Category selection state
const selectedMainCategory = ref('')
const availableSubcategories = ref([])
// Person selection state
const selectedPerson = ref(null)
const hoveredPersonIndex = ref(null)
// Media file hover states
const hoveredAudioIndex = ref(null)
const hoveredVideoIndex = ref(null)
// Options for selects - imported from external file
const tagOptionsRef = ref([...tagOptions])
const personOptionsRef = ref([...personOptions])
const mainCategoryOptions = ref(getMainCategories())
// Methods
const loadEntry = () => {
// TODO: Load entry data based on route params
const entryId = route.params.id
if (entryId) {
// Load existing entry data
// This would typically come from an API call
console.log('Loading entry:', entryId)
}
}
const onSave = () => {
// TODO: Implement save functionality
$q.notify({
type: 'positive',
message: 'Entry saved successfully!',
position: 'top'
})
router.push('/wave')
}
const onCancel = () => {
router.go(-1)
}
const confirmDelete = () => {
showDeleteDialog.value = true
}
const onDelete = () => {
// TODO: Implement delete functionality
$q.notify({
type: 'negative',
message: 'Entry deleted successfully!',
position: 'top'
})
router.push('/wave')
}
const onImageRejected = (rejectedEntries) => {
$q.notify({
type: 'negative',
message: `${rejectedEntries.length} file(s) rejected. Max file size is 5MB.`
})
}
const onAudioRejected = (rejectedEntries) => {
$q.notify({
type: 'negative',
message: `${rejectedEntries.length} audio file(s) rejected. Max file size is 10MB.`
})
}
const onVideoRejected = (rejectedEntries) => {
$q.notify({
type: 'negative',
message: `${rejectedEntries.length} video file(s) rejected. Max file size is 50MB.`
})
}
const removeAdditionalImage = (index) => {
form.additionalImageUrls.splice(index, 1)
}
// Key image handling functions
const getKeyImagePreview = () => {
if (form.keyImage) {
return URL.createObjectURL(form.keyImage)
}
return form.keyImageUrl
}
const onKeyImageSelected = (file) => {
// Clear any existing URL when a new file is selected
if (file) {
form.keyImageUrl = ''
}
}
const removeKeyImage = () => {
form.keyImage = null
form.keyImageUrl = ''
}
const removeAudioRecording = (index) => {
form.audioRecordings.splice(index, 1)
}
const removeVideoRecording = (index) => {
form.videoRecordings.splice(index, 1)
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const filterTags = (val, update) => {
update(() => {
if (val === '') {
tagOptionsRef.value = [...tagOptions]
} else {
const needle = val.toLowerCase()
tagOptionsRef.value = tagOptions.filter(v => v.toLowerCase().indexOf(needle) > -1)
}
})
}
const filterPersons = (val, update) => {
update(() => {
if (val === '') {
personOptionsRef.value = [...personOptions]
} else {
const needle = val.toLowerCase()
personOptionsRef.value = personOptions.filter(v => v.toLowerCase().indexOf(needle) > -1)
}
})
}
// Person management functions
const getInitials = (name) => {
return name
.split(' ')
.map(word => word.charAt(0).toUpperCase())
.join('')
.substring(0, 2)
}
const addPerson = (person) => {
if (person && !form.relatedPersons.includes(person)) {
form.relatedPersons.push(person)
}
selectedPerson.value = null // Clear selection after adding
}
const removePerson = (index) => {
form.relatedPersons.splice(index, 1)
}
const onMainCategoryChange = (newMainCategory) => {
selectedMainCategory.value = newMainCategory
form.category = '' // Reset subcategory when main category changes
if (newMainCategory) {
availableSubcategories.value = getSubcategories(newMainCategory)
// If main category has no subcategories, use the main category value
if (availableSubcategories.value.length === 0) {
form.category = newMainCategory
}
} else {
availableSubcategories.value = []
}
}
onMounted(() => {
loadEntry()
})
return {
form,
showDeleteDialog,
currentTab,
selectedMainCategory,
availableSubcategories,
selectedPerson,
hoveredPersonIndex,
hoveredAudioIndex,
hoveredVideoIndex,
tagOptions: tagOptionsRef,
personOptions: personOptionsRef,
mainCategoryOptions,
onSave,
onCancel,
confirmDelete,
onDelete,
onImageRejected,
onAudioRejected,
onVideoRejected,
removeAdditionalImage,
getKeyImagePreview,
onKeyImageSelected,
removeKeyImage,
removeAudioRecording,
removeVideoRecording,
formatFileSize,
filterTags,
filterPersons,
onMainCategoryChange,
getInitials,
addPerson,
removePerson
}
}
}
</script>
<style scoped>
.q-card {
max-width: 100%;
}
.q-slider {
margin: 16px 0;
}
/* Vue-select custom styling to match Quasar design */
:deep(.vue-select-custom) {
font-family: inherit;
}
:deep(.vue-select-custom .vs__dropdown-toggle) {
border: 1px solid rgba(0, 0, 0, 0.24);
border-radius: 4px;
background: #f5f5f5;
padding: 8px 12px;
min-height: 56px;
display: flex;
align-items: center;
}
:deep(.vue-select-custom .vs__selected-options) {
flex-wrap: nowrap;
padding: 0;
}
:deep(.vue-select-custom .vs__selected) {
margin: 0;
padding: 0;
border: none;
background: transparent;
color: rgba(0, 0, 0, 0.87);
font-size: 16px;
}
:deep(.vue-select-custom .vs__search) {
margin: 0;
padding: 0;
font-size: 16px;
line-height: 1.5;
color: rgba(0, 0, 0, 0.87);
border: none;
background: transparent;
}
:deep(.vue-select-custom .vs__search::placeholder) {
color: rgba(0, 0, 0, 0.6);
}
:deep(.vue-select-custom .vs__actions) {
padding: 0 8px 0 0;
}
:deep(.vue-select-custom .vs__clear) {
margin-right: 8px;
}
:deep(.vue-select-custom .vs__open-indicator) {
fill: rgba(0, 0, 0, 0.54);
}
:deep(.vue-select-custom .vs__dropdown-menu) {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
background: white;
}
:deep(.vue-select-custom .vs__dropdown-option) {
padding: 12px 16px;
color: rgba(0, 0, 0, 0.87);
font-size: 16px;
}
:deep(.vue-select-custom .vs__dropdown-option--highlight) {
background: #1976d2;
color: white;
}
:deep(.vue-select-custom.vs--open .vs__dropdown-toggle) {
border-color: #1976d2;
border-bottom-color: transparent;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
:deep(.vue-select-custom .vs__dropdown-menu) {
border-top-color: transparent;
border-top-left-radius: 0;
border-top-right-radius: 0;
margin-top: -1px;
}
/* Person circles styling */
.person-circles {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.person-circle {
position: relative;
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #1976d2, #42a5f5);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.person-circle:hover {
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.person-initials {
color: white;
font-weight: 600;
font-size: 16px;
text-transform: uppercase;
user-select: none;
}
.person-delete {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f44336;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.person-delete:hover {
background: #d32f2f;
transform: scale(1.1);
}
/* Media thumbnails styling */
.media-thumbnails {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.media-thumbnail {
position: relative;
display: flex;
align-items: center;
background: #f5f5f5;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
padding: 12px;
min-width: 200px;
max-width: 300px;
cursor: default;
transition: all 0.2s ease;
}
.media-thumbnail:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.audio-thumbnail .media-icon {
color: #1976d2;
margin-right: 12px;
}
.video-thumbnail .media-icon {
color: #7b1fa2;
margin-right: 12px;
}
.media-info {
flex: 1;
min-width: 0;
}
.media-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.media-size {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
}
.media-delete {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
background: #f44336;
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;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.media-delete:hover {
background: #d32f2f;
transform: scale(1.1);
}
</style>