Add entry edit form and options

This commit is contained in:
Tilman Behrend 2025-09-14 22:59:50 +02:00
parent 7b55e8770c
commit 646355d0f9
11 changed files with 1352 additions and 36 deletions

109
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,109 @@
# Copilot Instructions for "That's Me" Project
## Project Architecture
This is a dual-stack application with separate **Laravel backend** and **Quasar frontend**:
- **Backend** (`/backend/`): Laravel 12 with Livewire/Volt for server-side rendered admin panels
- **Frontend** (`/frontend/`): Quasar/Vue 3 SPA with anime.js for "LifeWave" visualizations
- **Core Feature**: Interactive wave visualization showing life events as animated points
## Key Technology Patterns
### Backend (Laravel/Livewire/Volt)
- **Livewire Volt**: Single-file components in `resources/views/livewire/` with PHP class logic at top
- **Flux UI**: Use `flux:` prefixed components (`flux:input`, `flux:button`, `flux:modal`) instead of raw HTML
- **Layout Pattern**: Nested layouts via `x-layouts.app``x-layouts.app.sidebar` for main app UI
- **Auth**: Uses Laravel Breeze with Volt components (`auth.login`, `auth.register`, etc.)
- **Routing**: Volt routes defined in `routes/web.php` as `Volt::route('path', 'component.name')`
### Frontend (Quasar/Vue)
- **Animation Core**: Uses anime.js for wave animations (see `/test/` prototypes)
- **State Management**: Pinia for Vue state
- **Build Tool**: Vite with Quasar CLI
- **Components**: Lives in `frontend/src/pages/` and `frontend/src/components/`
### Development Setup
- **Backend Dev**: `cd backend && php artisan serve` + `npm run dev` (Vite for assets)
- **Frontend Dev**: `cd frontend && quasar dev`
- **HTTPS Config**: Backend Vite configured for MAMP SSL certificates
- **Testing**: Pest for backend tests (`php artisan test`)
## File Structure Conventions
### Backend Key Files
- `routes/web.php`: Main routing with Volt component mappings
- `resources/views/livewire/`: Volt single-file components
- `resources/views/components/layouts/`: Layout components
- `app/Providers/VoltServiceProvider.php`: Volt mount configuration
- `resources/css/app.css`: Imports Tailwind + Flux UI
### Frontend Key Files
- `src/pages/WavePage.vue`: Main life visualization component
- `src/utils/ConnectedDotsVisualization.js`: Wave animation utilities
- `quasar.config.js`: Build configuration
- `/test/anime-*.html`: Animation prototypes and examples
## Specific Coding Patterns
### Livewire/Volt Components
```php
<?php
use Livewire\Volt\Component;
use Livewire\Attributes\Layout;
new #[Layout('components.layouts.auth')] class extends Component {
public string $email = '';
public function someMethod(): void {
// Component logic
}
}; ?>
<div>
<!-- Blade template with Flux UI -->
<flux:input wire:model="email" :label="__('Email')" />
</div>
```
### Wave Animation Pattern
The project centers around animated life event visualizations:
- Life events have `value` (emotional weight), `x` (timeline position), `imageUrl`, `title`
- Uses anime.js for smooth wave animations with multiple sine wave layers
- See `test/anime-points-animation.html` for complete implementation patterns
### Flux UI Usage
- Always use `flux:` components: `flux:button`, `flux:input`, `flux:modal`, `flux:navlist`
- Layout components: `flux:header`, `flux:sidebar`, `flux:main`
- Include `@fluxScripts` in layout files
- Wire navigation: `wire:navigate` for SPA-like navigation
## Development Workflows
- **Backend Changes**: Run `npm run dev` in `/backend/` for asset hot reload
- **Component Testing**: Use Pest with Volt test helpers: `Volt::test('component.name')`
- **Styling**: Tailwind + Flux UI (no custom CSS files needed)
- **Animation Prototyping**: Create in `/test/` directory first, then integrate
## Project-Specific Guidelines
1. **Animation First**: Wave visualizations are core - always consider animation performance
2. **Offline Support**: Frontend designed for PWA with offline capabilities
3. **Dual Development**: Backend and frontend are separate apps - coordinate API contracts
4. **Component Isolation**: Livewire components should be self-contained with embedded logic
5. **German Language**: UI text often in German (`__('German text')` for translations)
## Common Commands
```bash
# Backend
cd backend && composer install && php artisan serve
cd backend && npm run dev # Asset compilation
# Frontend
cd frontend && npm install && quasar dev
cd frontend && quasar build # Production build
# Testing
cd backend && php artisan test
```

View file

@ -14,7 +14,8 @@
"pinia": "^3.0.1", "pinia": "^3.0.1",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"vue": "^3.4.18", "vue": "^3.4.18",
"vue-router": "^4.0.0" "vue-router": "^4.0.0",
"vue-select": "^4.0.0-beta.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
@ -7148,6 +7149,14 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue-select": {
"version": "4.0.0-beta.6",
"resolved": "https://registry.npmjs.org/vue-select/-/vue-select-4.0.0-beta.6.tgz",
"integrity": "sha512-K+zrNBSpwMPhAxYLTCl56gaMrWZGgayoWCLqe5rWwkB8aUbAUh7u6sXjIR7v4ckp2WKC7zEEUY27g6h1MRsIHw==",
"peerDependencies": {
"vue": "3.x"
}
},
"node_modules/wcwidth": { "node_modules/wcwidth": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",

View file

@ -20,7 +20,8 @@
"pinia": "^3.0.1", "pinia": "^3.0.1",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"vue": "^3.4.18", "vue": "^3.4.18",
"vue-router": "^4.0.0" "vue-router": "^4.0.0",
"vue-select": "^4.0.0-beta.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",

1
frontend/src/css/app.css Normal file
View file

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

View file

@ -62,7 +62,7 @@ $image-size: 80px;
.median { .median {
position: absolute; position: absolute;
top: 54.75%; top: 51%;
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 2px;
@ -76,6 +76,7 @@ $image-size: 80px;
height: 100%; height: 100%;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
min-height:400px;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }

View file

@ -51,7 +51,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn"> <q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn" to="/edit">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="add_circle" /> <q-icon name="add_circle" />
</q-item-section> </q-item-section>

View file

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

View file

@ -112,7 +112,7 @@ export default defineComponent({
imageUrl: "/images/0_3.png", imageUrl: "/images/0_3.png",
title: "Beginn des neuen Abenteuers", title: "Beginn des neuen Abenteuers",
description: "01.10.2024", description: "01.10.2024",
link: "/page1", link: "/edit/1",
}, },
{ {
id: 2, id: 2,
@ -121,7 +121,7 @@ export default defineComponent({
imageUrl: "/images/0_2.png", imageUrl: "/images/0_2.png",
title: "Omas Annis Geburtstag", title: "Omas Annis Geburtstag",
description: "02.10.2024", description: "02.10.2024",
link: "/page2", link: "/edit/2",
}, },
{ {
id: 3, id: 3,
@ -130,7 +130,7 @@ export default defineComponent({
imageUrl: "/images/disco.png", imageUrl: "/images/disco.png",
title: "Konzertbesuch mit Freunden", title: "Konzertbesuch mit Freunden",
description: "03.10.2024", description: "03.10.2024",
link: "/page3", link: "/edit/3",
}, },
{ {
id: 4, id: 4,
@ -139,7 +139,7 @@ export default defineComponent({
imageUrl: "/images/pferd.png", imageUrl: "/images/pferd.png",
title: "Wanderreiten in den Bergen", title: "Wanderreiten in den Bergen",
description: "04.10.2024", description: "04.10.2024",
link: "/page4", link: "/edit/4",
}, },
{ {
id: 5, id: 5,
@ -148,7 +148,7 @@ export default defineComponent({
imageUrl: "/images/gpt.png", imageUrl: "/images/gpt.png",
title: "Ruhiger Tag zu Hause", title: "Ruhiger Tag zu Hause",
description: "05.10.2024", description: "05.10.2024",
link: "/page5", link: "/edit/5",
}, },
{ {
id: 6, id: 6,
@ -157,7 +157,7 @@ export default defineComponent({
imageUrl: "/images/oma.png", imageUrl: "/images/oma.png",
title: "Oma Erna verstorben", title: "Oma Erna verstorben",
description: "06.10.2024", description: "06.10.2024",
link: "/page6", link: "/edit/6",
}, },
{ {
id: 7, id: 7,
@ -166,7 +166,7 @@ export default defineComponent({
imageUrl: "/images/see.png", imageUrl: "/images/see.png",
title: "Erholungsausflug zum See", title: "Erholungsausflug zum See",
description: "07.10.2024", description: "07.10.2024",
link: "/page7", link: "/edit/7",
}, },
{ {
id: 8, id: 8,
@ -175,7 +175,7 @@ export default defineComponent({
imageUrl: "/images/feier.png", imageUrl: "/images/feier.png",
title: "Kleine Wochenendsfeier", title: "Kleine Wochenendsfeier",
description: "08.10.2024", description: "08.10.2024",
link: "/page8", link: "/edit/8",
}, },
{ {
id: 9, id: 9,
@ -184,7 +184,7 @@ export default defineComponent({
imageUrl: "/images/hochzeit.png", imageUrl: "/images/hochzeit.png",
title: "Hochzeit von Cousine Tatjana", title: "Hochzeit von Cousine Tatjana",
description: "09.10.2024", description: "09.10.2024",
link: "/page9", link: "/edit/9",
}, },
{ {
id: 10, id: 10,
@ -193,7 +193,7 @@ export default defineComponent({
imageUrl: "/images/work.png", imageUrl: "/images/work.png",
title: "Erster Tag im neuen Job", title: "Erster Tag im neuen Job",
description: "10.10.2024", description: "10.10.2024",
link: "/page10", link: "/edit/10",
}, },
{ {
id: 11, id: 11,
@ -202,7 +202,7 @@ export default defineComponent({
imageUrl: "/images/klasse.png", imageUrl: "/images/klasse.png",
title: "Klassentreffen nach vielen Jahren", title: "Klassentreffen nach vielen Jahren",
description: "11.10.2024", description: "11.10.2024",
link: "/page11", link: "/edit/11",
}, },
{ {
id: 12, id: 12,
@ -211,7 +211,7 @@ export default defineComponent({
imageUrl: "/images/familie.png", imageUrl: "/images/familie.png",
title: "Familienabendessen", title: "Familienabendessen",
description: "12.10.2024", description: "12.10.2024",
link: "/page12", link: "/edit/12",
}, },
{ {
id: 13, id: 13,
@ -221,7 +221,7 @@ export default defineComponent({
"/images/kinobesuch.png", "/images/kinobesuch.png",
title: "Kinobesuch mit der ganzen Familie", title: "Kinobesuch mit der ganzen Familie",
description: "13.10.2024", description: "13.10.2024",
link: "/page13", link: "/edit/13",
}, },
{ {
id: 14, id: 14,
@ -231,7 +231,7 @@ export default defineComponent({
"/images/entspannung.png", "/images/entspannung.png",
title: "Entspannung", title: "Entspannung",
description: "14.10.2024", description: "14.10.2024",
link: "/page14", link: "/edit/14",
}, },
{ {
id: 15, id: 15,
@ -240,7 +240,7 @@ export default defineComponent({
imageUrl: "/images/sonntag.png", imageUrl: "/images/sonntag.png",
title: "Geruhsamer Sonntag", title: "Geruhsamer Sonntag",
description: "15.10.2024", description: "15.10.2024",
link: "/page15", link: "/edit/15",
}, },
{ {
id: 16, id: 16,
@ -250,7 +250,7 @@ export default defineComponent({
"/images/kindergeburtstag.png", "/images/kindergeburtstag.png",
title: "Kindergeburtstag", title: "Kindergeburtstag",
description: "16.10.2024", description: "16.10.2024",
link: "/page16", link: "/edit/16",
}, },
{ {
id: 17, id: 17,
@ -260,7 +260,7 @@ export default defineComponent({
"/images/familie2.png", "/images/familie2.png",
title: "Spaziergang mit der Familie", title: "Spaziergang mit der Familie",
description: "17.10.2024", description: "17.10.2024",
link: "/page17", link: "/edit/17",
}, },
{ {
id: 18, id: 18,
@ -270,7 +270,7 @@ export default defineComponent({
"/images/grosseltern.png", "/images/grosseltern.png",
title: "Familienfeier bei den Großeltern", title: "Familienfeier bei den Großeltern",
description: "18.10.2024", description: "18.10.2024",
link: "/page18", link: "/edit/18",
}, },
]; ];

View file

@ -24,6 +24,11 @@ const routes = [
name: 'wave', name: 'wave',
component: () => import('pages/WavePage.vue') component: () => import('pages/WavePage.vue')
}, },
{
path: 'edit/:id?',
name: 'edit',
component: () => import('pages/EditPage.vue')
},
], ],
}, },

View file

@ -75,6 +75,26 @@ export class ConnectedDotsVisualization {
}; };
// Initialize DOM elements // Initialize DOM elements
this.scrollContainer = document.getElementById(containerId) as HTMLElement; this.scrollContainer = document.getElementById(containerId) as HTMLElement;
// Calculate the container height dynamically
const containerHeight =
this.scrollContainer.clientHeight ||
this.scrollContainer.offsetHeight ||
window.innerHeight;
// Default configuration
this.config = {
totalWidth: calculatedWidth,
height: containerHeight, // Use the calculated container height
dotRadius: 6,
xUnitSize: xUnitSize,
tension: 0.5,
showGrid: false,
tooltipWidth: 128,
tooltipHeight: 128,
...config,
};
// Create SVG elements // Create SVG elements
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this.gridGroup = document.createElementNS( this.gridGroup = document.createElementNS(
@ -494,25 +514,26 @@ export class ConnectedDotsVisualization {
} }
// Public API methods for external use // Public API methods for external use
public updateDots(newDots: DotConfig[]): void { public updateDots(newDots: DotConfig[]): void {
this.dots = newDots; this.dots = newDots;
// Initial width calculation based on dot positions (for grid) // Initial width calculation based on dot positions (for grid)
if (this.dots.length > 0) { if (this.dots.length > 0) {
// Find the minimum and maximum x values // Find the minimum and maximum x values
const minX = Math.min(...this.dots.map((dot) => dot.x)); const minX = Math.min(...this.dots.map((dot) => dot.x));
const maxX = Math.max(...this.dots.map((dot) => dot.x)); const maxX = Math.max(...this.dots.map((dot) => dot.x));
// Calculate width based on the range of x values // Calculate width based on the range of x values
// Add padding on both sides (3 units on each side) // Add padding on both sides (3 units on each side)
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize; this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
} }
// Render will calculate the tooltip edges and update the SVG width // Render will calculate the tooltip edges and update the SVG width
this.render(); this.render();
} }
public updateConfig(newConfig: Partial<Config>): void { public updateConfig(newConfig: Partial<Config>): void {
this.config = { ...this.config, ...newConfig }; this.config = { ...this.config, ...newConfig };
this.render(); this.render();
} }
public resize(): void { public resize(): void {
this.config.height = window.innerHeight; const containerHeight = this.scrollContainer.clientHeight || this.scrollContainer.offsetHeight || window.innerHeight;
this.config.height = containerHeight;
this.svg.setAttribute("height", `${this.config.height}`); this.svg.setAttribute("height", `${this.config.height}`);
this.render(); this.render();
} }

View file

@ -0,0 +1,238 @@
// Form options and data for EditPage component
// Main categories with their subcategories
export const categoryStructure = [
{
label: 'Career',
value: 'career',
subcategories: [
{ label: 'Promotion', value: 'career-promotion' },
{ label: 'Retirement', value: 'career-retirement' },
{ label: 'Career Changes', value: 'career-changes' }
]
},
{
label: 'Education',
value: 'education',
subcategories: [
{ label: 'Graduation', value: 'education-graduation' },
{ label: 'Schooling', value: 'education-schooling' }
]
},
{
label: 'Awards',
value: 'awards',
subcategories: []
},
{
label: 'Personal Celebrations',
value: 'personal-celebrations',
subcategories: [
{ label: 'Birthday', value: 'birthday' },
{ label: 'Anniversary', value: 'anniversary' }
]
},
{
label: 'Relationships',
value: 'relationships',
subcategories: [
{ label: 'Engagement', value: 'relationships-engagement' },
{ label: 'Marriage', value: 'relationships-marriage' },
{ label: 'Divorce', value: 'relationships-divorce' }
]
},
{
label: 'Parenthood',
value: 'parenthood',
subcategories: [
{ label: 'Pregnancy', value: 'parenthood-pregnancy' },
{ label: 'Birth', value: 'parenthood-birth' },
{ label: 'Adoption', value: 'parenthood-adoption' }
]
},
{
label: 'Loss & Passing',
value: 'passing',
subcategories: [
{ label: 'Funeral', value: 'passing-funeral' }
]
},
{
label: 'Festivities',
value: 'festivities',
subcategories: [
{ label: 'Christmas', value: 'festivities-christmas' },
{ label: 'Thanksgiving', value: 'festivities-thanksgiving' },
{ label: 'New Year', value: 'festivities-new-year' },
{ label: 'Easter', value: 'festivities-easter' },
{ label: 'Holidays', value: 'festivities-holidays' }
]
},
{
label: 'Social Events',
value: 'social-events',
subcategories: [
{ label: 'Reunions', value: 'reunions' },
{ label: 'Concerts', value: 'concerts' },
{ label: 'Sports', value: 'sports' },
{ label: 'Festivals', value: 'festivals' }
]
},
{
label: 'Community',
value: 'community',
subcategories: [
{ label: 'Charity', value: 'charity' },
{ label: 'Community Service', value: 'community-service' }
]
},
{
label: 'Health',
value: 'health',
subcategories: [
{ label: 'Surgery', value: 'health-surgery' },
{ label: 'Illness', value: 'health-illness' },
{ label: 'Recovery', value: 'health-recovery' },
{ label: 'Transplants', value: 'health-transplants' },
{ label: 'Mental Health', value: 'health-mental-health' }
]
},
{
label: 'Religious & Spiritual',
value: 'religious',
subcategories: [
{ label: 'Baptism', value: 'religious-baptism' },
{ label: 'Bar/Bat Mitzvah', value: 'religious-bar-bat-mitzvah' },
{ label: 'Communion', value: 'religious-communion' },
{ label: 'Confirmation', value: 'religious-confirmation' },
{ label: 'Pilgrimage', value: 'religious-pilgrimage' }
]
},
{
label: 'Travel & Adventure',
value: 'travel',
subcategories: [
{ label: 'Travel', value: 'travel-general' },
{ label: 'Vacation', value: 'vacation' },
{ label: 'Adventure', value: 'adventure' }
]
},
{
label: 'Life Changes',
value: 'life-changes',
subcategories: [
{ label: 'Moving', value: 'moving' },
{ label: 'License', value: 'license' },
{ label: 'Voting', value: 'voting' },
{ label: 'Citizenship', value: 'citizenship' }
]
},
{
label: 'Milestones',
value: 'milestones',
subcategories: []
}
]
// Flattened category options for backward compatibility and simple select usage
export const categoryOptions = categoryStructure.reduce((acc, category) => {
// Add main category if it has no subcategories
if (category.subcategories.length === 0) {
acc.push({ label: category.label, value: category.value })
} else {
// Add subcategories with main category prefix
category.subcategories.forEach(sub => {
acc.push({
label: `${category.label} - ${sub.label}`,
value: sub.value,
mainCategory: category.value,
subcategory: sub.value
})
})
}
return acc
}, [])
// Helper functions for category management
export const getCategoryStructure = () => categoryStructure
export const getMainCategories = () => {
return categoryStructure.map(cat => ({
label: cat.label,
value: cat.value
}))
}
export const getSubcategories = (mainCategoryValue) => {
const mainCategory = categoryStructure.find(cat => cat.value === mainCategoryValue)
return mainCategory ? mainCategory.subcategories : []
}
export const getCategoryByValue = (value) => {
return categoryOptions.find(cat => cat.value === value)
}
export const getMainCategoryFromValue = (value) => {
const category = getCategoryByValue(value)
return category ? category.mainCategory : null
}
export const tagOptions = [
// Emotions
'happy', 'sad', 'exciting', 'stressful', 'memorable', 'important',
'fun', 'challenging', 'rewarding', 'disappointing', 'surprising',
'life-changing', 'routine', 'special', 'difficult', 'joyful',
'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful',
'emotional', 'touching', 'inspiring', 'motivating', 'healing',
// Significance
'milestone', 'achievement', 'breakthrough', 'turning-point',
'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected',
'planned', 'spontaneous', 'tradition', 'new-experience',
// Social
'family', 'friends', 'colleagues', 'community', 'solo',
'group', 'intimate', 'public', 'private', 'celebration',
// Intensity
'intense', 'mild', 'dramatic', 'subtle', 'overwhelming',
'gradual', 'sudden', 'anticipated', 'shocking', 'gentle',
// Time-related
'brief', 'extended', 'momentary', 'lasting', 'temporary',
'permanent', 'seasonal', 'annual', 'weekly', 'daily'
]
export const personOptions = [
'Anna Mueller',
'Max Schmidt',
'Sarah Johnson',
'Michael Weber',
'Lisa Anderson',
'Thomas Brown',
'Julia Martinez',
'David Wilson',
'Emma Garcia',
'Robert Davis'
]
export const defaultFormData = {
keyImage: null,
keyImageUrl: '',
additionalImages: [],
additionalImageUrls: [],
level: 0,
category: '',
headline: '',
subheadline: '',
text: '',
tags: [],
location: '',
date: '',
time: '',
audioFiles: [],
audioRecordings: [],
videoFiles: [],
videoRecordings: [],
relatedPersons: []
}