thats-me/frontend/_src/pages/EditPage.vue
2026-04-22 12:57:10 +02:00

2037 lines
No EOL
63 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 class="edit-page-container full-height">
<div class="row justify-center full-height">
<div class="col-12 col-md-8 col-lg-4 full-height">
<q-card class="full-height-card">
<q-card-section class="flex-1 scroll">
<q-form @submit.prevent="onSave" class="q-gutter-md full-height-form">
<!-- Tab Panels (without tab navigation here) -->
<q-tab-panels v-model="currentTab" animated>
<!-- Tab 1: Basics - Only Image and Emotional Level -->
<q-tab-panel name="basics" class="q-pa-none">
<div class="q-gutter-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">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>
<!-- Mobile-friendly image selection -->
<div class="q-mb-sm">
<q-btn-group spread class="full-width">
<q-btn
icon="photo_camera"
label="Camera"
color="primary"
outline
@click="openCamera"
class="col"
/>
<q-btn
icon="photo_library"
label="Gallery"
color="primary"
outline
@click="openGallery"
class="col"
/>
</q-btn-group>
</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']"
ref="keyImageInput"
style="display: none;"
>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>
</q-file>
<!-- Hidden file inputs for camera and gallery -->
<input
type="file"
ref="cameraInput"
accept="image/*"
capture="environment"
style="display: none;"
@change="handleCameraInput"
/>
<input
type="file"
ref="galleryInput"
accept="image/*"
style="display: none;"
@change="handleGalleryInput"
/>
</div>
</div>
<!-- Tab Navigation for Basics -->
<div class="row q-gutter-md q-mt-lg justify-between">
<q-btn
label="Cancel"
color="grey"
flat
@click="onCancel"
class="col-auto"
/>
<q-btn
label="Continue"
icon-right="arrow_forward"
color="primary"
@click="goToNextTab"
class="col-auto"
/>
</div>
</q-tab-panel>
<!-- Tab 2: Content - Title, Headline, Date/Time, Description -->
<q-tab-panel name="content" class="q-pa-none">
<div class="q-gutter-md ">
<!-- 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>
<!-- Title - Now mandatory -->
<q-input
v-model="form.title"
label="Short title for the wave*"
filled
:rules="[val => !!val || 'Title is required']"
/>
<!-- Headline - Now optionaquasal -->
<q-input
v-model="form.headline"
label="Headline"
filled
/>
<!-- Description (formerly Text) -->
<q-input
v-model="form.text"
label="Description"
filled
type="textarea"
rows="4"
/>
</div>
<!-- Tab Navigation for Content -->
<div class="row q-gutter-md q-mt-lg justify-between">
<q-btn
label="Back"
icon="arrow_back"
color="grey"
flat
@click="goToPreviousTab"
class="col-auto"
/>
<q-btn
label="Continue"
icon-right="arrow_forward"
color="primary"
@click="goToNextTab"
class="col-auto"
/>
</div>
</q-tab-panel>
<!-- Tab 3: 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>
<!-- Mobile-friendly buttons for additional images -->
<q-btn-group unelevated class="full-width q-mb-md">
<q-btn
unelevated
color="primary"
icon="photo_camera"
label="Camera"
class="col"
@click="openAdditionalCamera"
/>
<q-btn
unelevated
color="primary"
icon="photo_library"
label="Gallery"
class="col"
@click="openAdditionalGallery"
/>
</q-btn-group>
<!-- Hidden file inputs for additional images -->
<input
type="file"
id="additionalCameraInput"
accept="image/*"
capture="environment"
multiple
style="display: none;"
@change="handleAdditionalCameraInput"
/>
<input
type="file"
id="additionalGalleryInput"
accept="image/*"
multiple
style="display: none;"
@change="handleAdditionalGalleryInput"
/>
<!-- Preview additional images -->
<div v-if="form.additionalImages && form.additionalImages.length > 0" class="q-mt-md">
<div class="text-caption text-grey q-mb-sm">Selected Images ({{ form.additionalImages.length }})</div>
<div class="additional-images-grid">
<div
v-for="(image, index) in form.additionalImages"
:key="index"
class="additional-image-item"
@mouseenter="hoveredAdditionalImageIndex = index"
@mouseleave="hoveredAdditionalImageIndex = null"
>
<q-img
:src="URL.createObjectURL(image)"
class="additional-image-thumbnail"
fit="cover"
/>
<div
v-show="hoveredAdditionalImageIndex === index"
class="additional-image-overlay"
@click="removeAdditionalImageFile(index)"
>
<q-icon name="close" size="20px" color="white" />
</div>
<div class="additional-image-name">{{ image.name }}</div>
</div>
</div>
</div>
<!-- Preview additional image URLs (for existing entries) -->
<div v-if="form.additionalImageUrls.length > 0" class="q-mt-md">
<div class="text-caption text-grey q-mb-sm">Existing Images ({{ form.additionalImageUrls.length }})</div>
<div class="additional-images-grid">
<div
v-for="(url, index) in form.additionalImageUrls"
:key="'url-' + index"
class="additional-image-item"
@mouseenter="hoveredAdditionalUrlIndex = index"
@mouseleave="hoveredAdditionalUrlIndex = null"
>
<q-img
:src="url"
class="additional-image-thumbnail"
fit="cover"
/>
<div
v-show="hoveredAdditionalUrlIndex === index"
class="additional-image-overlay"
@click="removeAdditionalImage(index)"
>
<q-icon name="close" size="20px" color="white" />
</div>
</div>
</div>
</div>
</div>
<!-- Audio Recordings -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Audio Recordings</q-label>
<!-- Mobile-friendly buttons for audio -->
<q-btn-group unelevated class="full-width q-mb-md">
<q-btn
unelevated
color="accent"
icon="mic"
label="Record"
class="col"
@click="toggleAudioRecording"
:loading="isRecording"
/>
<q-btn
unelevated
color="accent"
icon="audiotrack"
label="Files"
class="col"
@click="openAudioFiles"
/>
</q-btn-group>
<!-- Recording status -->
<div v-if="isRecording" class="q-mb-md text-center">
<q-circular-progress
show-value
:value="recordingProgress"
size="60px"
color="accent"
class="q-mb-sm"
>
{{ recordingTime }}s
</q-circular-progress>
<div class="text-caption text-accent">Recording...</div>
<q-btn
icon="stop"
label="Stop"
color="negative"
@click="stopAudioRecording"
class="q-mt-sm"
/>
</div>
<!-- Hidden file input for audio -->
<input
type="file"
id="audioFilesInput"
accept="audio/*"
multiple
style="display: none;"
@change="handleAudioFilesInput"
/>
<!-- List selected audio files -->
<div v-if="form.audioFiles && form.audioFiles.length > 0" class="q-mt-md">
<div class="text-caption text-grey q-mb-sm">Selected Audio Files ({{ form.audioFiles.length }})</div>
<div class="audio-files-list">
<div
v-for="(file, index) in form.audioFiles"
:key="index"
class="audio-file-item"
@mouseenter="hoveredAudioFileIndex = index"
@mouseleave="hoveredAudioFileIndex = null"
>
<q-icon name="audiotrack" size="24px" color="accent" class="q-mr-sm" />
<div class="audio-file-info">
<div class="audio-file-name">{{ file.name }}</div>
<div class="audio-file-size">{{ formatFileSize(file.size) }}</div>
</div>
<q-btn
v-show="hoveredAudioFileIndex === index"
icon="close"
size="sm"
round
color="negative"
flat
@click="removeAudioFile(index)"
/>
</div>
</div>
</div>
<!-- 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>
<!-- Mobile-friendly buttons for videos -->
<q-btn-group unelevated class="full-width q-mb-md">
<q-btn
unelevated
color="secondary"
icon="videocam"
label="Record"
class="col"
@click="openVideoCamera"
/>
<q-btn
unelevated
color="secondary"
icon="video_library"
label="Gallery"
class="col"
@click="openVideoGallery"
/>
</q-btn-group>
<!-- Hidden file inputs for videos -->
<input
type="file"
id="videoCameraInput"
accept="video/*"
capture="environment"
multiple
style="display: none;"
@change="handleVideoCameraInput"
/>
<input
type="file"
id="videoGalleryInput"
accept="video/*"
multiple
style="display: none;"
@change="handleVideoGalleryInput"
/>
<!-- List selected video files -->
<div v-if="form.videoFiles && form.videoFiles.length > 0" class="q-mt-md">
<div class="text-caption text-grey q-mb-sm">Selected Video Files ({{ form.videoFiles.length }})</div>
<div class="video-files-list">
<div
v-for="(file, index) in form.videoFiles"
:key="index"
class="video-file-item"
@mouseenter="hoveredVideoFileIndex = index"
@mouseleave="hoveredVideoFileIndex = null"
>
<q-icon name="videocam" size="24px" color="secondary" class="q-mr-sm" />
<div class="video-file-info">
<div class="video-file-name">{{ file.name }}</div>
<div class="video-file-size">{{ formatFileSize(file.size) }}</div>
</div>
<q-btn
v-show="hoveredVideoFileIndex === index"
icon="close"
size="sm"
round
color="negative"
flat
@click="removeVideoFile(index)"
/>
</div>
</div>
</div>
<!-- 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>
<!-- Tab Navigation for Media -->
<div class="row q-gutter-md q-mt-lg justify-between">
<q-btn
label="Back"
icon="arrow_back"
color="grey"
flat
@click="goToPreviousTab"
class="col-auto"
/>
<q-btn
label="Continue"
icon-right="arrow_forward"
color="primary"
@click="goToNextTab"
class="col-auto"
/>
</div>
</q-tab-panel>
<!-- Tab 4: Details - Location, Persons, Category, Tags, Delete -->
<q-tab-panel name="details" class="q-pa-none">
<div class="q-gutter-md q-mt-md">
<!-- Location -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Location</q-label>
<!-- Location input with current location button -->
<div class="row q-gutter-sm">
<q-input
v-model="form.location"
label="Enter location or use current"
filled
class="col"
>
<template v-slot:prepend>
<q-icon name="place" />
</template>
</q-input>
<q-btn
icon="my_location"
color="primary"
@click="getCurrentLocation"
:loading="loadingLocation"
:disable="loadingLocation"
class="col-auto"
>
<q-tooltip class="bg-primary">Get current location</q-tooltip>
</q-btn>
</div>
<!-- Location status -->
<div v-if="locationStatus" class="q-mt-xs">
<div class="text-caption" :class="locationStatus.type === 'success' ? 'text-positive' : 'text-negative'">
{{ locationStatus.message }}
</div>
</div>
<!-- Coordinates display (optional) -->
<div v-if="currentCoordinates" class="q-mt-xs">
<div class="text-caption text-grey">
Coordinates: {{ currentCoordinates.lat.toFixed(6) }}, {{ currentCoordinates.lng.toFixed(6) }}
</div>
</div>
</div>
<!-- Related Persons -->
<div class="q-mb-md">
<q-separator class="q-mt-lg q-mb-lg" />
<q-label class="text-subtitle2 q-mb-sm">Related Persons</q-label>
<!-- Person selector button -->
<q-btn
unelevated
color="primary"
icon="person_add"
:label="form.relatedPersons.length > 0 ? `Edit Persons (${form.relatedPersons.length})` : 'Select Persons'"
class="full-width q-mb-md"
@click="goToPersonSelector"
/>
<!-- Selected persons display -->
<div v-if="form.relatedPersons.length > 0" class="selected-persons-display">
<div class="person-circles">
<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 class="person-name-tooltip">{{ person }}</div>
<div
v-if="hoveredPersonIndex === index"
class="person-delete"
@click="removePerson(index)"
>
×
</div>
</div>
</div>
</div>
</div>
<!-- Category Selection -->
<div class="q-mb-md">
<q-separator class="q-mb-md" />
<q-label class="text-subtitle2 q-mb-sm">Categories</q-label>
<!-- Category selector button -->
<q-btn
unelevated
color="primary"
icon="category"
:label="form.categories && form.categories.length > 0 ? `Edit Categories (${form.categories.length})` : 'Select Categories'"
class="full-width q-mb-md"
@click="goToCategorySelector"
/>
<!-- Selected categories display -->
<div v-if="form.categories && form.categories.length > 0" class="selected-categories-display">
<div class="category-chips">
<q-chip
v-for="(category, index) in form.categories"
:key="index"
:removable="hoveredCategoryIndex === index"
@remove="removeCategory(index)"
@mouseenter="hoveredCategoryIndex = index"
@mouseleave="hoveredCategoryIndex = null"
color="primary"
text-color="white"
icon="category"
class="category-chip"
>
{{ category }}
</q-chip>
</div>
</div>
</div>
<!-- Tags -->
<div class="q-mb-md">
<q-label class="text-subtitle2 q-mb-sm">Tags</q-label>
<!-- Tag selector button -->
<q-btn
unelevated
color="secondary"
icon="tag"
:label="form.tags && form.tags.length > 0 ? `Edit Tags (${form.tags.length})` : 'Select Tags'"
class="full-width q-mb-md"
@click="goToTagSelector"
/>
<!-- Selected tags display -->
<div v-if="form.tags && form.tags.length > 0" class="selected-tags-display">
<div class="tag-chips">
<q-chip
v-for="(tag, index) in form.tags"
:key="index"
:removable="hoveredTagIndex === index"
@remove="removeTag(index)"
@mouseenter="hoveredTagIndex = index"
@mouseleave="hoveredTagIndex = null"
color="secondary"
text-color="white"
icon="tag"
class="tag-chip"
>
{{ tag }}
</q-chip>
</div>
</div>
</div>
</div>
<!-- Tab Navigation for Details - Back and Publish -->
<div class="row q-gutter-md q-mt-lg justify-between">
<q-btn
label="Back"
icon="arrow_back"
color="grey"
flat
@click="goToPreviousTab"
class="col-auto"
/>
<q-btn
label="Publish"
icon="publish"
color="primary"
type="submit"
class="col-auto"
/>
</div>
</q-tab-panel>
</q-tab-panels>
</q-form>
</q-card-section>
</q-card>
</div>
</div>
<!-- Fixed Footer with Tab Navigation - Updated order -->
<q-footer elevated class="bg-white text-dark edit-footer">
<q-tabs
v-model="currentTab"
dense
class="text-grey full-width"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab name="basics" icon="event" />
<q-tab name="content" icon="article" />
<q-tab name="media" icon="perm_media" />
<q-tab name="details" icon="info" />
</q-tabs>
</q-footer>
</q-page>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useQuasar } from 'quasar'
import { tagOptions, personOptions, defaultFormData, getMainCategories, getSubcategories } from '../utils/editFormOptions.js'
export default {
name: 'EditPage',
setup() {
const $q = useQuasar()
const router = useRouter()
const route = useRoute()
// Tab navigation state
const currentTab = ref('basics')
const tabOrder = ['basics', 'content', 'media', 'details']
// Get current date and time
const getCurrentDate = () => {
const now = new Date()
return now.toISOString().split('T')[0] // Format: YYYY-MM-DD
}
const getCurrentTime = () => {
const now = new Date()
return now.toTimeString().slice(0, 5) // Format: HH:MM
}
// Form data with current date/time defaults
const form = reactive({
...defaultFormData,
date: getCurrentDate(),
time: getCurrentTime()
})
// Category selection state
const selectedMainCategory = ref('')
const availableSubcategories = ref([])
// Person selection state
const selectedPerson = ref(null)
const hoveredPersonIndex = ref(null)
// Category and tag hover states
const hoveredCategoryIndex = ref(null)
const hoveredTagIndex = ref(null)
// Media file hover states
const hoveredAudioIndex = ref(null)
const hoveredVideoIndex = ref(null)
const hoveredAdditionalImageIndex = ref(null)
const hoveredAdditionalUrlIndex = ref(null)
const hoveredAudioFileIndex = ref(null)
const hoveredVideoFileIndex = ref(null)
// Audio recording state
const isRecording = ref(false)
const recordingTime = ref(0)
const recordingProgress = ref(0)
const mediaRecorder = ref(null)
const recordingInterval = ref(null)
// Location state
const loadingLocation = ref(false)
const locationStatus = ref(null)
const currentCoordinates = ref(null)
// Options for selects - imported from external file
const tagOptionsRef = ref([...tagOptions])
const personOptionsRef = ref([...personOptions])
const mainCategoryOptions = ref(getMainCategories())
// Tab navigation methods
const goToNextTab = () => {
const currentIndex = tabOrder.indexOf(currentTab.value)
if (currentIndex < tabOrder.length - 1) {
currentTab.value = tabOrder[currentIndex + 1]
}
}
const goToPreviousTab = () => {
const currentIndex = tabOrder.indexOf(currentTab.value)
if (currentIndex > 0) {
currentTab.value = tabOrder[currentIndex - 1]
}
}
const goToPersonSelector = () => {
// Navigate to person selector with current selection
router.push({
name: 'person-selector',
query: {
currentSelection: JSON.stringify(form.relatedPersons),
returnTo: 'edit'
}
})
}
const goToCategorySelector = () => {
// Navigate to category selector with current selection
router.push({
name: 'category-selector',
query: {
currentSelection: JSON.stringify(form.categories || []),
returnTo: 'edit'
}
})
}
const goToTagSelector = () => {
// Navigate to tag selector with current selection
router.push({
name: 'tag-selector',
query: {
currentSelection: JSON.stringify(form.tags || []),
returnTo: 'edit'
}
})
}
// 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)
// Note: When loading existing entry, you might want to override the default date/time
}
}
const onSave = () => {
// TODO: Implement publish functionality
$q.notify({
type: 'positive',
message: 'Entry published successfully!',
position: 'top'
})
router.push('/wave')
}
const onCancel = () => {
router.go(-1)
}
// Mobile image selection methods
const openCamera = () => {
const cameraInput = document.getElementById('cameraInput')
if (cameraInput) {
cameraInput.click()
}
}
const openGallery = () => {
const galleryInput = document.getElementById('galleryInput')
if (galleryInput) {
galleryInput.click()
}
}
const handleCameraInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
form.keyImage = files[0]
}
}
const handleGalleryInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
form.keyImage = files[0]
}
}
// Additional Images Methods
const openAdditionalCamera = () => {
const input = document.getElementById('additionalCameraInput')
if (input) input.click()
}
const openAdditionalGallery = () => {
const input = document.getElementById('additionalGalleryInput')
if (input) input.click()
}
const handleAdditionalCameraInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
const newImages = Array.from(files)
if (form.additionalImages) {
form.additionalImages = [...form.additionalImages, ...newImages]
} else {
form.additionalImages = newImages
}
}
}
const handleAdditionalGalleryInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
const newImages = Array.from(files)
if (form.additionalImages) {
form.additionalImages = [...form.additionalImages, ...newImages]
} else {
form.additionalImages = newImages
}
}
}
// Video Methods
const openVideoCamera = () => {
const input = document.getElementById('videoCameraInput')
if (input) input.click()
}
const openVideoGallery = () => {
const input = document.getElementById('videoGalleryInput')
if (input) input.click()
}
const handleVideoCameraInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
const newVideos = Array.from(files)
if (form.videoFiles) {
form.videoFiles = [...form.videoFiles, ...newVideos]
} else {
form.videoFiles = newVideos
}
}
}
const handleVideoGalleryInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
const newVideos = Array.from(files)
if (form.videoFiles) {
form.videoFiles = [...form.videoFiles, ...newVideos]
} else {
form.videoFiles = newVideos
}
}
}
// Audio Methods
const openAudioFiles = () => {
const input = document.getElementById('audioFilesInput')
if (input) input.click()
}
const handleAudioFilesInput = (event) => {
const files = event.target.files
if (files && files.length > 0) {
const newAudio = Array.from(files)
if (form.audioFiles) {
form.audioFiles = [...form.audioFiles, ...newAudio]
} else {
form.audioFiles = newAudio
}
}
}
const toggleAudioRecording = async () => {
if (isRecording.value) {
stopAudioRecording()
} else {
startAudioRecording()
}
}
const startAudioRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder.value = new MediaRecorder(stream)
const chunks = []
mediaRecorder.value.ondataavailable = (event) => {
chunks.push(event.data)
}
mediaRecorder.value.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/wav' })
const file = new File([blob], `recording-${Date.now()}.wav`, { type: 'audio/wav' })
if (form.audioFiles) {
form.audioFiles = [...form.audioFiles, file]
} else {
form.audioFiles = [file]
}
// Cleanup
stream.getTracks().forEach(track => track.stop())
}
isRecording.value = true
recordingTime.value = 0
recordingProgress.value = 0
mediaRecorder.value.start()
// Update recording time
recordingInterval.value = setInterval(() => {
recordingTime.value += 1
recordingProgress.value = (recordingTime.value / 60) * 100 // Max 60 seconds
if (recordingTime.value >= 60) {
stopAudioRecording()
}
}, 1000)
} catch (error) {
console.error('Error accessing microphone:', error)
$q.notify({
type: 'negative',
message: 'Could not access microphone. Please check permissions.'
})
}
}
const stopAudioRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
isRecording.value = false
if (recordingInterval.value) {
clearInterval(recordingInterval.value)
recordingInterval.value = null
}
}
}
// Location Methods
const getCurrentLocation = () => {
if (!navigator.geolocation) {
locationStatus.value = {
type: 'error',
message: 'Geolocation is not supported by this browser.'
}
return
}
loadingLocation.value = true
locationStatus.value = null
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords
currentCoordinates.value = { lat: latitude, lng: longitude }
try {
// Try to get human-readable address using reverse geocoding
const locationName = await reverseGeocode(latitude, longitude)
form.location = locationName
locationStatus.value = {
type: 'success',
message: 'Location updated successfully!'
}
} catch {
// Fallback to coordinates if reverse geocoding fails
form.location = `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`
locationStatus.value = {
type: 'success',
message: 'Coordinates added (address lookup failed)'
}
}
loadingLocation.value = false
// Clear status after 3 seconds
setTimeout(() => {
locationStatus.value = null
}, 3000)
},
(error) => {
loadingLocation.value = false
let message = 'Failed to get location: '
switch (error.code) {
case error.PERMISSION_DENIED:
message += 'Location access denied by user.'
break
case error.POSITION_UNAVAILABLE:
message += 'Location information unavailable.'
break
case error.TIMEOUT:
message += 'Location request timed out.'
break
default:
message += 'Unknown error occurred.'
break
}
locationStatus.value = {
type: 'error',
message: message
}
// Clear status after 5 seconds
setTimeout(() => {
locationStatus.value = null
}, 5000)
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000
}
)
}
// Simple reverse geocoding using a free service
const reverseGeocode = async (lat, lng) => {
try {
// Using OpenStreetMap Nominatim service (free, no API key required)
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=16&addressdetails=1`,
{
headers: {
'User-Agent': 'ThatsMe-App/1.0'
}
}
)
if (!response.ok) {
throw new Error('Geocoding service unavailable')
}
const data = await response.json()
if (data.display_name) {
// Extract useful parts of the address
const address = data.address || {}
const parts = []
if (address.house_number && address.road) {
parts.push(`${address.house_number} ${address.road}`)
} else if (address.road) {
parts.push(address.road)
}
if (address.city || address.town || address.village) {
parts.push(address.city || address.town || address.village)
}
if (address.country) {
parts.push(address.country)
}
return parts.length > 0 ? parts.join(', ') : data.display_name
}
throw new Error('No address found')
} catch (error) {
console.error('Reverse geocoding failed:', error)
throw error
}
}
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)
}
const removeAdditionalImageFile = (index) => {
if (form.additionalImages) {
form.additionalImages.splice(index, 1)
}
}
const removeAudioFile = (index) => {
if (form.audioFiles) {
form.audioFiles.splice(index, 1)
}
}
const removeVideoFile = (index) => {
if (form.videoFiles) {
form.videoFiles.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 removeCategory = (index) => {
if (form.categories) {
form.categories.splice(index, 1)
}
}
const removeTag = (index) => {
if (form.tags) {
form.tags.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 = []
}
}
// Close navigation drawer when page loads
onMounted(() => {
// Close the drawer by setting it to false
const drawerElement = document.querySelector('.q-drawer')
if (drawerElement) {
// Trigger close event
const closeEvent = new CustomEvent('drawer-close')
window.dispatchEvent(closeEvent)
}
// Handle selected persons from PersonSelector
if (route.query.selectedPersons) {
try {
const selectedPersons = JSON.parse(route.query.selectedPersons)
form.relatedPersons = selectedPersons
// Navigate to details tab
currentTab.value = 'details'
// Clear the query parameter
router.replace({
name: route.name,
query: {
...route.query,
selectedPersons: undefined
}
})
} catch (error) {
console.error('Error parsing selected persons:', error)
}
}
// Handle selected categories from CategorySelector
if (route.query.selectedCategories) {
try {
const selectedCategories = JSON.parse(route.query.selectedCategories)
form.categories = selectedCategories
// Navigate to details tab
currentTab.value = 'details'
// Clear the query parameter
router.replace({
name: route.name,
query: {
...route.query,
selectedCategories: undefined
}
})
} catch (error) {
console.error('Error parsing selected categories:', error)
}
}
// Handle selected tags from TagSelector
if (route.query.selectedTags) {
try {
const selectedTags = JSON.parse(route.query.selectedTags)
form.tags = selectedTags
// Navigate to details tab
currentTab.value = 'details'
// Clear the query parameter
router.replace({
name: route.name,
query: {
...route.query,
selectedTags: undefined
}
})
} catch (error) {
console.error('Error parsing selected tags:', error)
}
}
loadEntry()
})
return {
form,
currentTab,
selectedMainCategory,
availableSubcategories,
selectedPerson,
hoveredPersonIndex,
hoveredCategoryIndex,
hoveredTagIndex,
hoveredAudioIndex,
hoveredVideoIndex,
hoveredAdditionalImageIndex,
hoveredAdditionalUrlIndex,
hoveredAudioFileIndex,
hoveredVideoFileIndex,
// Audio recording state
isRecording,
recordingTime,
recordingProgress,
// Location state
loadingLocation,
locationStatus,
currentCoordinates,
tagOptions: tagOptionsRef,
personOptions: personOptionsRef,
mainCategoryOptions,
goToNextTab,
goToPreviousTab,
goToPersonSelector,
goToCategorySelector,
goToTagSelector,
onSave,
onCancel,
// Location methods
getCurrentLocation,
// Key image methods
openCamera,
openGallery,
handleCameraInput,
handleGalleryInput,
// Additional images methods
openAdditionalCamera,
openAdditionalGallery,
handleAdditionalCameraInput,
handleAdditionalGalleryInput,
// Video methods
openVideoCamera,
openVideoGallery,
handleVideoCameraInput,
handleVideoGalleryInput,
// Audio methods
openAudioFiles,
handleAudioFilesInput,
toggleAudioRecording,
// File handling methods
onImageRejected,
onAudioRejected,
onVideoRejected,
removeAdditionalImage,
removeAdditionalImageFile,
removeAudioFile,
removeVideoFile,
getKeyImagePreview,
onKeyImageSelected,
removeKeyImage,
removeAudioRecording,
removeVideoRecording,
formatFileSize,
filterTags,
filterPersons,
onMainCategoryChange,
getInitials,
addPerson,
removePerson,
removeCategory,
removeTag
}
}
}
</script>
<style scoped>
/* Fix for first item in row with q-gutter having unwanted left margin */
.row.q-gutter-md > .col:first-child {
margin-left: 0 !important;
}
/* Add this more specific rule if the above doesn't work */
.row.q-gutter-md.q-mb-md > .col:first-child {
margin-left: 0 !important;
}
/* Add padding bottom to prevent content from being hidden behind footer */
.edit-page-container {
padding: 16px;
padding-bottom: 80px; /* Adjust based on footer height */
min-height: 100vh;
}
/* Full height layout */
.edit-page-container.full-height {
height: 100vh;
display: flex;
flex-direction: column;
}
.edit-page-container .row.full-height {
flex: 1;
margin: 0;
}
.edit-page-container .col-12.full-height {
display: flex;
flex-direction: column;
}
.full-height-card {
display: flex;
flex-direction: column;
flex: 1;
max-height: calc(100vh - 112px); /* Account for page padding and footer */
background-color: rgba(255, 255, 255, 0.8);
}
.full-height-card .q-card-section.flex-1 {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.full-height-form {
flex: 1;
display: flex;
flex-direction: column;
}
.full-height-form .q-tab-panels {
flex: 1;
display: flex;
flex-direction: column;
background: none;
}
.full-height-form .q-tab-panel {
flex: 1;
display: flex;
flex-direction: column;
}
.scroll {
overflow-y: auto;
}
/* Style the edit footer */
.edit-footer {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
/* Ensure tabs are properly sized in footer */
.edit-footer .q-tabs {
min-height: 60px;
}
.edit-footer .q-tab {
padding: 8px 16px;
}
/* Custom tab icon styling */
.tab-icon {
filter: opacity(0.6);
transition: filter 0.2s ease;
}
.q-tab--active .tab-icon {
filter: opacity(1);
}
/* Ensure proper spacing for custom tab content */
.q-tab .column {
gap: 2px;
}
/* Responsive adjustments for mobile */
@media (max-width: 600px) {
.tab-icon {
width: 20px !important;
height: 20px !important;
margin-bottom: 2px !important;
}
.q-tab .column span {
font-size: 11px;
}
}
.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;
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;
}
.person-circle:hover {
transform: scale(1.05);
}
.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;
}
.person-delete:hover {
background: #d32f2f;
transform: scale(1.1);
}
.person-name-tooltip {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 10;
}
.person-circle:hover .person-name-tooltip {
opacity: 1;
}
.selected-persons-display {
margin-top: 8px;
padding: 12px;
background: rgba(25, 118, 210, 0.05);
border-radius: 8px;
border: 1px solid rgba(25, 118, 210, 0.2);
}
/* Category and Tag chips styling */
.selected-categories-display,
.selected-tags-display {
margin-top: 8px;
}
.category-chips,
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.category-chip,
.tag-chip {
font-size: 14px;
min-height: 32px;
transition: all 0.2s ease;
}
.category-chip:hover,
.tag-chip:hover {
transform: translateY(-1px);
}
/* 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);
}
.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;
}
.media-delete:hover {
background: #d32f2f;
transform: scale(1.1);
}
/* Additional Images Grid */
.additional-images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.additional-image-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #f5f5f5;
transition: all 0.2s ease;
}
.additional-image-item:hover {
transform: translateY(-2px);
}
.additional-image-thumbnail {
width: 100%;
height: 120px;
border-radius: 8px 8px 0 0;
}
.additional-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 20px;
background: rgba(244, 67, 54, 0.8);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.additional-image-overlay:hover {
background: rgba(244, 67, 54, 0.9);
}
.additional-image-name {
padding: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
/* Audio and Video File Lists */
.audio-files-list,
.video-files-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.audio-file-item,
.video-file-item {
display: flex;
align-items: center;
background: #f5f5f5;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
padding: 12px;
transition: all 0.2s ease;
}
.audio-file-item:hover,
.video-file-item:hover {
transform: translateY(-1px);
}
.audio-file-info,
.video-file-info {
flex: 1;
min-width: 0;
margin-left: 8px;
}
.audio-file-name,
.video-file-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;
}
.audio-file-size,
.video-file-size {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
}
</style>