-
{{ getInitials(person) }}
+
+
+
- ×
+
{{ getInitials(person) }}
+
{{ person }}
+
+ ×
+
@@ -389,71 +649,72 @@
-
Category
+
Categories
-
-
-
-
-
-
-
-
+
-
- This category has no subcategories
+
+
Tags
-
-
-
-
-
-
-
Danger Zone
-
- Once you delete this entry, it cannot be recovered. Please be certain.
-
+
+
+
+
+
@@ -512,10 +773,10 @@
align="justify"
narrow-indicator
>
-
-
-
-
+
+
+
+
@@ -525,15 +786,10 @@
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()
@@ -570,9 +826,29 @@ export default {
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])
@@ -594,6 +870,39 @@ export default {
}
}
+ 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
@@ -634,6 +943,320 @@ export default {
router.push('/wave')
}
+ // 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',
@@ -659,6 +1282,24 @@ export default {
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) {
@@ -737,6 +1378,18 @@ export default {
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
@@ -763,6 +1416,72 @@ export default {
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()
})
@@ -774,21 +1493,63 @@ export default {
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,
confirmDelete,
onDelete,
+ // 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,
@@ -800,7 +1561,9 @@ export default {
onMainCategoryChange,
getInitials,
addPerson,
- removePerson
+ removePerson,
+ removeCategory,
+ removeTag
}
}
}
@@ -819,7 +1582,64 @@ export default {
/* 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 */
@@ -1018,6 +1838,61 @@ export default {
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);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
/* Media thumbnails styling */
.media-thumbnails {
display: flex;
@@ -1098,4 +1973,108 @@ export default {
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);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.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);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.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);
+}
\ No newline at end of file
diff --git a/frontend/src/pages/PersonSelector.vue b/frontend/src/pages/PersonSelector.vue
new file mode 100644
index 0000000..2254e7f
--- /dev/null
+++ b/frontend/src/pages/PersonSelector.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+ Select Related Persons
+ Choose people related to this life event
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getInitials(person.name) }}
+
+
+
+
+
+
+
+
{{ person.name }}
+
{{ person.role }}
+
+
+
+
+
+
+
+
+
+ Selected Persons ({{ selectedPersons.length }})
+
+
+
+
+
+
+
+
+ {{ getInitials(person.name) }}
+
+
+
+
+ ×
+
+
+
{{ person.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/TagSelector.vue b/frontend/src/pages/TagSelector.vue
new file mode 100644
index 0000000..e703d0d
--- /dev/null
+++ b/frontend/src/pages/TagSelector.vue
@@ -0,0 +1,445 @@
+
+
+
+
+
+
+ Select Tags
+ Add tags to describe the mood and nature of this event
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tag.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Selected Tags ({{ selectedTags.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js
index 5633fbf..4e06d4a 100644
--- a/frontend/src/router/routes.js
+++ b/frontend/src/router/routes.js
@@ -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 }
+ },
],
},
diff --git a/frontend/src/utils/editFormOptions.js b/frontend/src/utils/editFormOptions.js
index 825850f..0679b42 100644
--- a/frontend/src/utils/editFormOptions.js
+++ b/frontend/src/utils/editFormOptions.js
@@ -222,7 +222,7 @@ export const defaultFormData = {
additionalImages: [],
additionalImageUrls: [],
level: 0,
- category: '',
+ categories: [],
headline: '',
subheadline: '',
text: '',