add entry detail page

This commit is contained in:
Tilman Behrend 2025-09-21 17:47:28 +02:00
parent 4ab0ca00ad
commit 5402f98d05
10 changed files with 1116 additions and 72 deletions

View file

@ -18,5 +18,10 @@
"titleBar.inactiveBackground": "#ff2fa299",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.color": "#ff2fa2"
"peacock.color": "#ff2fa2",
"cSpell.words": [
"audiotrack",
"Nominatim",
"videocam"
]
}

Binary file not shown.

Binary file not shown.

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.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}
.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:1}.scroll-container{position:relative;width:100%;height:100%;overflow-x:auto;overflow-y:hidden;min-height:400px;z-index:2;-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}.q-card{box-shadow:none !important}.q-card--bordered{box-shadow:none !important}.q-card--flat{box-shadow:none !important}.bg-white,.q-layout__section--marginal{background:rgba(0,0,0,0) !important}footer .text-primary,footer .text-grey{color:#fff !important}

View file

@ -67,7 +67,7 @@ $image-size: 80px;
right: 0;
height: 1px;
background-color: rgba(255,255,255,0.3);
z-index: 0;
z-index: 1;
}
.scroll-container {
@ -77,7 +77,7 @@ $image-size: 80px;
overflow-x: auto;
overflow-y: hidden;
min-height:400px;
z-index: 1;
z-index: 2;
&::-webkit-scrollbar {
display: none;
}

View file

@ -173,15 +173,15 @@
</div>
</div>
<!-- Title (formerly Subheadline) - Now mandatory -->
<!-- Title - Now mandatory -->
<q-input
v-model="form.subheadline"
v-model="form.title"
label="Short title for the wave*"
filled
:rules="[val => !!val || 'Title is required']"
/>
<!-- Headline - Now optional -->
<!-- Headline - Now optionaquasal -->
<q-input
v-model="form.headline"
label="Headline"
@ -747,21 +747,6 @@
</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>
<!-- Fixed Footer with Tab Navigation - Updated order -->
<q-footer elevated class="bg-white text-dark edit-footer">
<q-tabs
@ -794,7 +779,6 @@ export default {
const $q = useQuasar()
const router = useRouter()
const route = useRoute()
const showDeleteDialog = ref(false)
// Tab navigation state
const currentTab = ref('basics')
@ -929,20 +913,6 @@ export default {
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')
}
// Mobile image selection methods
const openCamera = () => {
const cameraInput = document.getElementById('cameraInput')
@ -1487,7 +1457,6 @@ export default {
return {
form,
showDeleteDialog,
currentTab,
selectedMainCategory,
availableSubcategories,
@ -1519,8 +1488,6 @@ export default {
goToTagSelector,
onSave,
onCancel,
confirmDelete,
onDelete,
// Location methods
getCurrentLocation,
// Key image methods

View file

@ -0,0 +1,924 @@
<template>
<q-page class="entry-detail-page">
<div class="entry-container">
<div class="entry-header">
<!-- Image Slider -->
<div v-if="allImages.length > 0" class="image-slider-container">
<q-carousel
v-model="currentSlide"
transition-prev="slide-right"
transition-next="slide-left"
swipeable
animated
control-color="white"
navigation
arrows
class="image-carousel"
>
<q-carousel-slide
v-for="(image, index) in allImages"
:key="index"
:name="index"
class="carousel-slide"
>
<div class="image-container">
<q-img
:src="image.url"
class="slide-image"
fit="cover"
/>
<div v-if="image.caption" class="image-caption">
{{ image.caption }}
</div>
<div v-if="index === 0" class="key-image-badge">
<q-icon name="star" size="sm" />
</div>
</div>
</q-carousel-slide>
</q-carousel>
</div>
<!-- Entry Title and Meta -->
<div class="entry-meta">
<h1 class="entry-title">{{ entry.title }}</h1>
<div class="entry-subtitle" v-if="entry.subtitle">{{ entry.subtitle }}</div>
<div class="entry-date-location">
<div class="entry-date">
<q-icon name="event" size="sm" class="q-mr-sm" />
{{ formatDate(entry.date) }}
<span v-if="entry.time" class="entry-time">{{ entry.time }}</span>
</div>
<div v-if="entry.location" class="entry-location">
<q-icon name="place" size="sm" class="q-mr-sm" />
{{ entry.location }}
</div>
</div>
<!-- Emotional Level Indicator -->
<div class="emotional-level">
<div class="level-label">Emotional Level:</div>
<div class="level-indicator" :class="getEmotionalLevelClass(entry.level)">
{{ getEmotionalLevelText(entry.level) }}
</div>
</div>
</div>
</div>
<!-- Entry Description -->
<div v-if="entry.description" class="entry-description">
<h3>Description</h3>
<p class="description-text">{{ entry.description }}</p>
</div>
<!-- Video Recordings -->
<div v-if="entry.videoRecordings && entry.videoRecordings.length > 0" class="video-section">
<div class="video-slider-container">
<q-carousel
v-model="currentVideoSlide"
transition-prev="slide-right"
transition-next="slide-left"
swipeable
animated
control-color="grey-8"
navigation
arrows
class="video-carousel"
>
<q-carousel-slide
v-for="(video, index) in entry.videoRecordings"
:key="index"
:name="index"
class="video-carousel-slide"
>
<div class="video-container">
<div class="video-thumbnail" @click="openVideoLightbox(video, index)">
<video
:src="video.url"
class="video-preview"
muted
preload="metadata"
/>
<div class="video-play-overlay">
<q-icon name="play_circle_filled" size="4rem" color="white" />
</div>
</div>
</div>
</q-carousel-slide>
</q-carousel>
</div>
</div>
<!-- Audio Recordings -->
<div v-if="entry.audioRecordings && entry.audioRecordings.length > 0" class="audio-section">
<h3>Audio Recordings</h3>
<div class="audio-list">
<div
v-for="(audio, index) in entry.audioRecordings"
:key="index"
class="audio-item"
>
<q-icon name="audiotrack" size="md" color="primary" class="q-mr-md" />
<div class="audio-info">
<div class="audio-name">{{ audio.name || `Recording ${index + 1}` }}</div>
<audio :src="audio.url" controls class="audio-player">
Your browser does not support the audio element.
</audio>
</div>
</div>
</div>
</div>
<!-- Related Persons -->
<div v-if="entry.relatedPersons && entry.relatedPersons.length > 0" class="persons-section">
<h3>Related Persons</h3>
<div class="persons-list">
<div
v-for="person in entry.relatedPersons"
:key="person.id"
class="person-item"
>
<div class="person-avatar">
<q-img
v-if="person.avatar"
:src="person.avatar"
class="avatar-image"
/>
<div v-else class="avatar-initials">
{{ getInitials(person.name) }}
</div>
</div>
<div class="person-info">
<div class="person-name">{{ person.name }}</div>
<div v-if="person.relation" class="person-relation">{{ person.relation }}</div>
</div>
</div>
</div>
</div>
<!-- Categories -->
<div v-if="entry.categories && entry.categories.length > 0" class="categories-section">
<h3>Categories</h3>
<div class="categories-list">
<q-chip
v-for="category in entry.categories"
:key="category.id"
:icon="category.icon"
color="primary"
text-color="white"
class="category-chip"
>
{{ category.name }}
</q-chip>
</div>
</div>
<!-- Tags -->
<div v-if="entry.tags && entry.tags.length > 0" class="tags-section">
<h3>Tags</h3>
<div class="tags-list">
<q-chip
v-for="tag in entry.tags"
:key="tag.id"
:icon="tag.icon"
color="secondary"
text-color="white"
class="tag-chip"
>
{{ tag.name }}
</q-chip>
</div>
</div>
<!-- Edit Entry Button -->
<div class="edit-entry-section">
<q-btn
color="primary"
icon="edit"
label="Edit Entry"
size="lg"
class="edit-entry-btn"
@click="editEntry"
/>
</div>
</div>
<!-- Video Lightbox Dialog -->
<q-dialog v-model="showVideoLightbox" maximized>
<q-card class="video-lightbox-card">
<q-card-section class="video-lightbox-header">
<q-btn icon="close" flat round dense v-close-popup @click="pauseCurrentVideo" class="close-btn" />
</q-card-section>
<q-card-section class="video-lightbox-content">
<video
v-if="currentVideo"
ref="lightboxVideo"
:src="currentVideo.url"
controls
class="lightbox-video"
@loadedmetadata="onVideoLoaded"
>
Your browser does not support the video element.
</video>
</q-card-section>
<q-card-actions v-if="entry.videoRecordings && entry.videoRecordings.length > 1" align="center">
<q-btn
icon="chevron_left"
@click="previousVideo"
:disable="currentVideoIndex === 0"
flat
round
/>
<span class="video-counter">
{{ currentVideoIndex + 1 }} / {{ entry.videoRecordings.length }}
</span>
<q-btn
icon="chevron_right"
@click="nextVideo"
:disable="currentVideoIndex === entry.videoRecordings.length - 1"
flat
round
/>
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
export default {
name: 'EntryDetailPage',
props: {
entryData: {
type: Object,
default: null
}
},
setup(props) {
const route = useRoute()
// Carousel state
const currentSlide = ref(0)
const currentVideoSlide = ref(0)
// Video lightbox state
const showVideoLightbox = ref(false)
const currentVideoIndex = ref(0)
const lightboxVideo = ref(null)
// Sample entry data (for testing)
const sampleEntry = {
id: 1,
title: "Beginn des neuen Abenteuers",
subtitle: "Ein wichtiger Meilenstein in meinem Leben",
date: "2024-10-01",
time: "14:30",
location: "München, Deutschland",
level: 2,
keyImage: "/images/familie2.png",
description: "Dies war ein wirklich wichtiger Tag für mich. Nach langer Planung und Vorbereitung konnte ich endlich mein neues Abenteuer beginnen. Es war aufregend und gleichzeitig etwas beängstigenden, aber ich wusste, dass es der richtige Schritt war. Die Erfahrungen, die ich an diesem Tag gemacht habe, werden mich noch lange begleiten.",
additionalImages: [
{ url: "/images/familie2.png", caption: "Familie beim Start" },
{ url: "/images/see.png", caption: "Der schöne Ort" },
{ url: "/images/feier.png", caption: "Kleiner Umtrunk danach" }
],
audioRecordings: [
{ name: "Gedanken zum Tag", url: "/audio/sample.mp3" },
{ name: "Gespräch mit Familie", url: "/audio/sample2.mp3" }
],
videoRecordings: [
{ name: "UHD Nature Video", url: "/videos/3191901-uhd_3840_2160_25fps.mp4" },
{ name: "HD Landscape Video", url: "/videos/3326928-hd_1920_1080_24fps.mp4" }
],
relatedPersons: [
{ id: 1, name: "Maria Schmidt", relation: "Freundin", avatar: null },
{ id: 2, name: "Thomas Müller", relation: "Bruder", avatar: null }
],
categories: [
{ id: 1, name: "Familie", icon: "family_restroom" },
{ id: 2, name: "Abenteuer", icon: "explore" }
],
tags: [
{ id: 1, name: "Aufregend", icon: "emoji_emotions" },
{ id: 2, name: "Neuanfang", icon: "new_releases" },
{ id: 3, name: "Wichtig", icon: "star" }
]
}
// Use prop data or sample data
const entry = computed(() => props.entryData || sampleEntry)
// Combine key image and additional images for the slider
const allImages = computed(() => {
const images = []
// Add key image first if it exists
if (entry.value.keyImage) {
images.push({
url: entry.value.keyImage,
caption: 'Key Image'
})
}
// Add additional images
if (entry.value.additionalImages) {
images.push(...entry.value.additionalImages)
}
return images
})
// Current video for lightbox
const currentVideo = computed(() =>
entry.value.videoRecordings?.[currentVideoIndex.value]
)
// Methods
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const getEmotionalLevelClass = (level) => {
if (level >= 2) return 'level-very-positive'
if (level >= 1) return 'level-positive'
if (level >= -1) return 'level-neutral'
if (level >= -2) return 'level-negative'
return 'level-very-negative'
}
const getEmotionalLevelText = (level) => {
if (level >= 2) return 'Very Positive'
if (level >= 1) return 'Positive'
if (level >= -1) return 'Neutral'
if (level >= -2) return 'Negative'
return 'Very Negative'
}
const getInitials = (name) => {
return name
.split(' ')
.map(word => word.charAt(0).toUpperCase())
.join('')
.substring(0, 2)
}
// Video lightbox methods
const openVideoLightbox = (video, index) => {
currentVideoIndex.value = index
showVideoLightbox.value = true
}
const pauseCurrentVideo = () => {
if (lightboxVideo.value) {
lightboxVideo.value.pause()
}
}
const previousVideo = () => {
if (currentVideoIndex.value > 0) {
pauseCurrentVideo()
currentVideoIndex.value--
}
}
const nextVideo = () => {
if (currentVideoIndex.value < entry.value.videoRecordings.length - 1) {
pauseCurrentVideo()
currentVideoIndex.value++
}
}
const onVideoLoaded = () => {
// Video metadata loaded, can add additional logic here if needed
}
const editEntry = () => {
// TODO: Navigate to edit page or open edit modal
console.log('Edit entry:', entry.value.id)
// Example: router.push(`/edit/${entry.value.id}`)
}
onMounted(() => {
// If accessing via route, you might want to load data based on route params
const entryId = route.params.id
if (entryId && !props.entryData) {
// TODO: Load entry data from API/store based on entryId
console.log('Loading entry with ID:', entryId)
}
})
return {
entry,
allImages,
currentSlide,
currentVideoSlide,
showVideoLightbox,
currentVideoIndex,
currentVideo,
lightboxVideo,
formatDate,
getEmotionalLevelClass,
getEmotionalLevelText,
getInitials,
openVideoLightbox,
pauseCurrentVideo,
previousVideo,
nextVideo,
onVideoLoaded,
editEntry
}
}
}
</script>
<style scoped>
.entry-detail-page {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.entry-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.entry-header {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 24px;
}
.image-slider-container {
width: 100%;
aspect-ratio: 3/2;
overflow: hidden;
}
.image-carousel {
height: 100%;
width: 100%;
}
.image-carousel .q-carousel__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 40px;
height: 40px;
}
.image-carousel .q-carousel__prev-arrow {
left: 16px;
}
.image-carousel .q-carousel__next-arrow {
right: 16px;
}
.carousel-slide {
padding: 0;
}
.image-container {
position: relative;
width: 100%;
height: 100%;
}
.slide-image {
width: 100%;
height: 100%;
}
.image-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 20px 16px 16px;
font-size: 0.9rem;
font-weight: 500;
}
.key-image-badge {
position: absolute;
top: 12px;
left: 12px;
background: rgba(255, 215, 0, 0.9);
color: #2c3e50;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.entry-meta {
padding: 24px;
}
.entry-title {
font-size: 2rem;
font-weight: 700;
color: #2c3e50;
margin: 0 0 8px 0;
line-height: 1.2;
}
.entry-subtitle {
font-size: 1.2rem;
color: #7f8c8d;
margin-bottom: 16px;
font-style: italic;
}
.entry-date-location {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.entry-date, .entry-location {
display: flex;
align-items: center;
color: #5a6c7d;
font-size: 1rem;
}
.entry-time {
margin-left: 8px;
font-weight: 500;
}
.emotional-level {
display: flex;
align-items: center;
gap: 12px;
}
.level-label {
font-weight: 500;
color: #2c3e50;
}
.level-indicator {
padding: 6px 12px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.level-very-positive {
background: #e8f5e8;
color: #27ae60;
}
.level-positive {
background: #f0f8e8;
color: #7cb342;
}
.level-neutral {
background: #f5f5f5;
color: #5a6c7d;
}
.level-negative {
background: #fff3e0;
color: #ef6c00;
}
.level-very-negative {
background: #ffebee;
color: #e53935;
}
.entry-description,
.audio-section,
.persons-section,
.categories-section,
.tags-section {
background: white;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 24px;
margin-bottom: 24px;
}
.entry-description h3,
.audio-section h3,
.video-section h3,
.persons-section h3,
.categories-section h3,
.tags-section h3 {
color: #2c3e50;
font-size: 1.4rem;
font-weight: 600;
margin: 0 0 16px 0;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
}
.description-text {
font-size: 1.1rem;
line-height: 1.6;
color: #34495e;
margin: 0;
}
.audio-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.audio-item {
display: flex;
align-items: center;
padding: 16px;
background: #f8f9fa;
border-radius: 12px;
}
.audio-info {
flex: 1;
}
.audio-name {
font-weight: 500;
color: #2c3e50;
margin-bottom: 8px;
}
.audio-player {
width: 100%;
height: 40px;
}
/* Video Slider Styles */
.video-section {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 24px;
}
.video-slider-container {
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
}
.video-carousel {
height: 100%;
width: 100%;
}
.video-carousel .q-carousel__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 40px;
height: 40px;
color: white !important;
}
.video-carousel .q-carousel__prev-arrow {
left: 16px;
}
.video-carousel .q-carousel__next-arrow {
right: 16px;
}
.video-carousel .q-carousel__navigation {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.video-carousel .q-carousel__navigation .q-btn {
background: rgba(255, 255, 255, 0.8);
color: #424242 !important;
margin: 0 4px;
min-width: 12px;
min-height: 12px;
border-radius: 50%;
}
.video-carousel-slide {
padding: 0;
}
.video-container {
position: relative;
width: 100%;
height: 100%;
}
.video-thumbnail {
position: relative;
width: 100%;
height: 100%;
cursor: pointer;
overflow: hidden;
}
.video-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
padding: 16px;
transition: all 0.3s ease;
}
.video-play-overlay:hover {
background: rgba(0, 0, 0, 0.8);
transform: translate(-50%, -50%) scale(1.1);
}
/* Video Lightbox Styles */
.video-lightbox-card {
background: #000;
}
.video-lightbox-header {
background: transparent;
color: white;
display: flex;
justify-content: flex-end;
align-items: flex-start;
padding: 16px 24px;
position: absolute;
top: 0;
right: 0;
z-index: 1001;
}
.close-btn {
background: rgba(0, 0, 0, 0.5);
color: white;
}
.video-lightbox-content {
background: #000;
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 20px;
}
.lightbox-video {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
}
.video-counter {
color: white;
font-weight: 500;
margin: 0 16px;
}
.persons-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.person-item {
display: flex;
align-items: center;
background: #f8f9fa;
padding: 12px;
border-radius: 12px;
min-width: 200px;
}
.person-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
margin-right: 12px;
overflow: hidden;
background: linear-gradient(135deg, #3498db, #2980b9);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-image {
width: 100%;
height: 100%;
}
.avatar-initials {
color: white;
font-weight: 600;
font-size: 16px;
}
.person-name {
font-weight: 500;
color: #2c3e50;
}
.person-relation {
font-size: 0.9rem;
color: #7f8c8d;
}
.categories-list, .tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.category-chip, .tag-chip {
font-size: 0.9rem;
}
.edit-entry-section {
display: flex;
justify-content: center;
margin-top: 32px;
margin-bottom: 16px;
}
.edit-entry-btn {
min-width: 200px;
padding: 12px 24px;
font-weight: 600;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
transition: all 0.3s ease;
}
.edit-entry-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4);
}
/* Responsive */
@media (max-width: 600px) {
.entry-container {
padding: 12px;
}
.entry-meta {
padding: 16px;
}
.entry-title {
font-size: 1.6rem;
}
.entry-date-location {
flex-direction: column;
}
.persons-list {
flex-direction: column;
}
.person-item {
min-width: auto;
}
}
</style>

View file

@ -5,21 +5,87 @@
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- Entry Detail Lightbox -->
<q-dialog v-model="showEntryDetail" maximized transition-show="slide-up" transition-hide="slide-down">
<q-card class="entry-lightbox">
<q-card-section class="lightbox-header">
<q-btn icon="close" flat round dense v-close-popup color="white" />
</q-card-section>
<q-card-section class="lightbox-content">
<EntryDetailPage :entry-data="selectedEntryData" />
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script>
import { onMounted, onBeforeUnmount, defineComponent } from 'vue'
import { onMounted, onBeforeUnmount, defineComponent, ref } from 'vue'
import { ConnectedDotsVisualization } from "../utils/ConnectedDotsVisualization"
import EntryDetailPage from './EntryDetailPage.vue'
export default defineComponent({
name: 'WavePage',
components: {
EntryDetailPage
},
setup() {
let visualization = null;
let isDown = false;
let startX;
let scrollLeft;
// Lightbox state
const showEntryDetail = ref(false)
const selectedEntryData = ref(null)
// Sample detailed entry data
const sampleEntryData = {
1: {
id: 1,
title: "Beginn des neuen Abenteuers",
subtitle: "Ein wichtiger Meilenstein in meinem Leben",
date: "2024-10-01",
time: "14:30",
location: "München, Deutschland",
level: 2,
keyImage: "/images/familie2.png",
description: "Dies war ein wirklich wichtiger Tag für mich. Nach langer Planung und Vorbereitung konnte ich endlich mein neues Abenteuer beginnen. Es war aufregend und gleichzeitig etwas beängstigend, aber ich wusste, dass es der richtige Schritt war. Die Erfahrungen, die ich an diesem Tag gemacht habe, werden mich noch lange begleiten.",
additionalImages: [
{ url: "/images/feier.png", caption: "Familie beim Start" },
{ url: "/images/see.png", caption: "Der schöne Ort" }
],
audioRecordings: [
{ name: "Gedanken zum Tag", url: "/audio/sample.mp3" }
],
videoRecordings: [
{ name: "UHD Nature Video", url: "/videos/3191901-uhd_3840_2160_25fps.mp4" },
{ name: "HD Landscape Video", url: "/videos/3326928-hd_1920_1080_24fps.mp4" }
],
relatedPersons: [
{ id: 1, name: "Maria Schmidt", relation: "Freundin", avatar: null },
{ id: 2, name: "Thomas Müller", relation: "Bruder", avatar: null }
],
categories: [
{ id: 1, name: "Familie", icon: "family_restroom" },
{ id: 2, name: "Abenteuer", icon: "explore" }
],
tags: [
{ id: 1, name: "Aufregend", icon: "emoji_emotions" },
{ id: 2, name: "Neuanfang", icon: "new_releases" }
]
}
// Add more entries as needed
}
// Method to open entry detail lightbox
const openEntryDetail = (entryId) => {
selectedEntryData.value = sampleEntryData[entryId] || sampleEntryData[1] // Fallback to first entry
showEntryDetail.value = true
}
// Function to handle cleanup of event listeners
const cleanupEventListeners = () => {
const scrollContainer = document.querySelector('.scroll-container');
@ -112,7 +178,7 @@ export default defineComponent({
imageUrl: "/images/0_3.png",
title: "Beginn des neuen Abenteuers",
description: "01.10.2024",
link: "/edit/1",
onClick: () => openEntryDetail(1),
},
{
id: 2,
@ -121,7 +187,7 @@ export default defineComponent({
imageUrl: "/images/0_2.png",
title: "Omas Annis Geburtstag",
description: "02.10.2024",
link: "/edit/2",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 3,
@ -130,7 +196,7 @@ export default defineComponent({
imageUrl: "/images/disco.png",
title: "Konzertbesuch mit Freunden",
description: "03.10.2024",
link: "/edit/3",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 4,
@ -139,7 +205,7 @@ export default defineComponent({
imageUrl: "/images/pferd.png",
title: "Wanderreiten in den Bergen",
description: "04.10.2024",
link: "/edit/4",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 5,
@ -148,7 +214,7 @@ export default defineComponent({
imageUrl: "/images/gpt.png",
title: "Ruhiger Tag zu Hause",
description: "05.10.2024",
link: "/edit/5",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 6,
@ -157,7 +223,7 @@ export default defineComponent({
imageUrl: "/images/oma.png",
title: "Oma Erna verstorben",
description: "06.10.2024",
link: "/edit/6",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 7,
@ -166,7 +232,7 @@ export default defineComponent({
imageUrl: "/images/see.png",
title: "Erholungsausflug zum See",
description: "07.10.2024",
link: "/edit/7",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 8,
@ -175,7 +241,7 @@ export default defineComponent({
imageUrl: "/images/feier.png",
title: "Kleine Wochenendsfeier",
description: "08.10.2024",
link: "/edit/8",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 9,
@ -184,7 +250,7 @@ export default defineComponent({
imageUrl: "/images/hochzeit.png",
title: "Hochzeit von Cousine Tatjana",
description: "09.10.2024",
link: "/edit/9",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 10,
@ -193,7 +259,7 @@ export default defineComponent({
imageUrl: "/images/work.png",
title: "Erster Tag im neuen Job",
description: "10.10.2024",
link: "/edit/10",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 11,
@ -202,7 +268,7 @@ export default defineComponent({
imageUrl: "/images/klasse.png",
title: "Klassentreffen nach vielen Jahren",
description: "11.10.2024",
link: "/edit/11",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 12,
@ -211,7 +277,7 @@ export default defineComponent({
imageUrl: "/images/familie.png",
title: "Familienabendessen",
description: "12.10.2024",
link: "/edit/12",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 13,
@ -221,7 +287,7 @@ export default defineComponent({
"/images/kinobesuch.png",
title: "Kinobesuch mit der ganzen Familie",
description: "13.10.2024",
link: "/edit/13",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 14,
@ -231,7 +297,7 @@ export default defineComponent({
"/images/entspannung.png",
title: "Entspannung",
description: "14.10.2024",
link: "/edit/14",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 15,
@ -240,7 +306,7 @@ export default defineComponent({
imageUrl: "/images/sonntag.png",
title: "Geruhsamer Sonntag",
description: "15.10.2024",
link: "/edit/15",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 16,
@ -250,7 +316,7 @@ export default defineComponent({
"/images/kindergeburtstag.png",
title: "Kindergeburtstag",
description: "16.10.2024",
link: "/edit/16",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 17,
@ -260,7 +326,7 @@ export default defineComponent({
"/images/familie2.png",
title: "Spaziergang mit der Familie",
description: "17.10.2024",
link: "/edit/17",
onClick: () => openEntryDetail(1), // Using same entry for now
},
{
id: 18,
@ -270,7 +336,7 @@ export default defineComponent({
"/images/grosseltern.png",
title: "Familienfeier bei den Großeltern",
description: "18.10.2024",
link: "/edit/18",
onClick: () => openEntryDetail(1), // Using same entry for now
},
];
@ -307,7 +373,73 @@ export default defineComponent({
cleanupEventListeners();
});
return {}
return {
showEntryDetail,
selectedEntryData,
openEntryDetail
}
}
})
</script>
</script>
<style scoped>
.visualization-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
.scroll-container {
position: relative;
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
z-index: 2;
cursor: grab;
}
.scroll-container.active {
cursor: grabbing;
}
.scroll-container.smooth-scroll {
scroll-behavior: smooth;
}
/* Lightbox Styles */
.entry-lightbox {
background: rgba(0, 0, 0, 0.95);
backdrop-filter: blur(10px);
}
.lightbox-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 1000;
}
.lightbox-content {
padding: 0;
max-height: calc(100vh - 80px);
overflow-y: auto;
background: transparent;
}
</style>

View file

@ -48,6 +48,12 @@ const routes = [
component: () => import('pages/TagSelector.vue'),
meta: { hideFooter: true }
},
{
path: 'entry/:id',
name: 'entry-detail',
component: () => import('pages/EntryDetailPage.vue'),
meta: { hideFooter: true }
},
],
},

View file

@ -4,6 +4,7 @@ export interface DotConfig {
value: number;
x: number;
link?: string; // URL to navigate to when dot is clicked
onClick?: () => void; // Function to call when dot is clicked
imageUrl?: string; // Image to display in tooltip
title?: string; // Optional title for the tooltip
description?: string; // Optional description for the tooltip
@ -358,9 +359,13 @@ export class ConnectedDotsVisualization {
let imgWrapper: HTMLElement;
// if (dot.imageUrl) {
if (dot.link) {
if (dot.link || dot.onClick) {
const link = document.createElement("a");
link.href = dot.link;
if (dot.link) {
link.href = dot.link;
} else {
link.href = "#"; // Prevent default href for onClick
}
link.target = "_self"; // Opens in the same window
const imgElement = document.createElement("img");
@ -372,12 +377,15 @@ export class ConnectedDotsVisualization {
imgWrapper = link; // Use the link as the wrapper
// Add the event listener to the link
link.addEventListener("click", () => {
if (dot.link) {
link.addEventListener("click", (e) => {
if (dot.onClick) {
e.preventDefault(); // Prevent default navigation
dot.onClick();
} else if (dot.link) {
window.location.href = dot.link;
} else {
console.error("Dot has no link");
throw new Error("Dot has no link");
console.error("Dot has no link or onClick handler");
throw new Error("Dot has no link or onClick handler");
}
});
} else {
@ -482,14 +490,16 @@ export class ConnectedDotsVisualization {
if (dot.imageUrl || dot.title || dot.description) {
this.showTooltip(dot, x, y);
}
// Click event for navigation
if (dot.link) {
// Click event for navigation or custom function
if (dot.link || dot.onClick) {
circle.addEventListener("click", () => {
if (dot.link) {
if (dot.onClick) {
dot.onClick();
} else if (dot.link) {
window.location.href = dot.link;
} else {
console.error("Dot has no link");
throw new Error("Dot has no link");
console.error("Dot has no link or onClick handler");
throw new Error("Dot has no link or onClick handler");
}
});
}