10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
546
frontend/_src/components/EventPanel.vue
Normal file
546
frontend/_src/components/EventPanel.vue
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="eventsStore.panelOpen"
|
||||
class="event-panel glass--panel"
|
||||
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
|
||||
:class="{ 'event-panel--dragging': isDragging }"
|
||||
>
|
||||
<!-- Drag Handle — drag to resize, tap to close -->
|
||||
<div
|
||||
class="event-panel__handle"
|
||||
v-on="handleListeners"
|
||||
>
|
||||
<div class="event-panel__handle-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="event-panel__scroll">
|
||||
<!-- Key Image -->
|
||||
<div class="event-panel__image-section">
|
||||
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
|
||||
<img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
|
||||
<span class="event-panel__image-badge">Key Image</span>
|
||||
</div>
|
||||
<div v-else class="event-panel__image-placeholder" @click="onAddImage">
|
||||
<q-icon name="add_photo_alternate" size="32px" color="grey-5" />
|
||||
<span>Key Image hinzufügen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title — large, editable inline -->
|
||||
<q-input
|
||||
v-model="eventsStore.ghostTitle"
|
||||
placeholder="Was ist passiert?"
|
||||
borderless
|
||||
class="event-panel__title"
|
||||
input-class="event-panel__title-input"
|
||||
:dark="isDark"
|
||||
/>
|
||||
|
||||
<!-- Date row — tap to open QDate picker -->
|
||||
<div class="event-panel__date-row">
|
||||
<q-icon name="event" size="18px" class="event-panel__date-icon" />
|
||||
<span class="event-panel__date-label">{{ formattedDate }}</span>
|
||||
<q-popup-proxy transition-show="scale" transition-hide="scale">
|
||||
<q-date
|
||||
v-model="ghostDateSlash"
|
||||
mask="YYYY/MM/DD"
|
||||
:dark="isDark"
|
||||
:locale="dateLocale"
|
||||
minimal
|
||||
/>
|
||||
</q-popup-proxy>
|
||||
</div>
|
||||
|
||||
<!-- Emotional Level — card style with gradient track -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<div class="event-panel__card-header">
|
||||
<span class="event-panel__card-label">Emotional Level</span>
|
||||
<span class="event-panel__emotion-value" :style="{ color: emotionColor }">
|
||||
{{ emotionLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Gradient slider -->
|
||||
<div class="event-panel__slider-wrap">
|
||||
<q-slider
|
||||
v-model="eventsStore.ghostEmotion"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
track-size="8px"
|
||||
thumb-size="22px"
|
||||
class="event-panel__slider"
|
||||
:style="{
|
||||
'--gradient-bg': sliderGradientCSS,
|
||||
'--thumb-color': emotionColor
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="event-panel__slider-hints">
|
||||
<span>Sehr negativ</span>
|
||||
<span>Neutral</span>
|
||||
<span>Sehr positiv</span>
|
||||
</div>
|
||||
|
||||
<!-- Gradient Preset Selector -->
|
||||
<div class="event-panel__presets">
|
||||
<span class="event-panel__presets-label">Farbverlauf</span>
|
||||
<div class="event-panel__presets-grid">
|
||||
<div
|
||||
v-for="(preset, index) in gradientPresets"
|
||||
:key="index"
|
||||
class="event-panel__preset"
|
||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === index }"
|
||||
:style="{ background: presetGradientCSS(preset.colors) }"
|
||||
:title="preset.name"
|
||||
@click="selectPreset(index)"
|
||||
></div>
|
||||
<!-- "None" option to clear preset -->
|
||||
<div
|
||||
class="event-panel__preset event-panel__preset--none"
|
||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === null }"
|
||||
title="Standard"
|
||||
@click="selectPreset(null)"
|
||||
>
|
||||
<q-icon name="auto_awesome" size="12px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung — card style -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<span class="event-panel__card-label">Beschreibung</span>
|
||||
<q-input
|
||||
v-model="eventsStore.ghostNote"
|
||||
type="textarea"
|
||||
placeholder="Ein neutraler, aber wichtiger Tag"
|
||||
borderless
|
||||
autogrow
|
||||
class="event-panel__note"
|
||||
:dark="isDark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Medien -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<span class="event-panel__card-label">Weitere Medien</span>
|
||||
<div class="event-panel__media-grid">
|
||||
<div class="event-panel__media-add" @click="onAddMedia">
|
||||
<q-icon name="add_photo_alternate" size="24px" color="grey-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete (edit mode only) -->
|
||||
<div v-if="eventsStore.editingEventId" class="event-panel__delete">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
label="Event löschen"
|
||||
icon="delete_outline"
|
||||
color="negative"
|
||||
size="sm"
|
||||
@click="eventsStore.deleteEvent(eventsStore.editingEventId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events'
|
||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||
import { resolveFullRes } from 'composables/useImageCache'
|
||||
|
||||
const $q = useQuasar()
|
||||
const eventsStore = useEventsStore()
|
||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
|
||||
|
||||
// Resolve key image: full-res when online, cached thumbnail when offline
|
||||
const keyImageSrc = ref(null)
|
||||
watch(
|
||||
() => eventsStore.ghostImage,
|
||||
async (img) => {
|
||||
keyImageSrc.value = await resolveFullRes(img)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Reset height when panel opens
|
||||
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const gradientPresets = GRADIENT_PRESETS
|
||||
|
||||
// Current glow color based on emotion + gradient
|
||||
const emotionColor = computed(() => {
|
||||
if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor
|
||||
return emotionToColor(eventsStore.ghostEmotion, eventsStore.ghostGradientPreset)
|
||||
})
|
||||
|
||||
// Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD
|
||||
const ghostDateSlash = computed({
|
||||
get: () => eventsStore.ghostDate.replace(/-/g, '/'),
|
||||
set: (val) => { eventsStore.ghostDate = val.replace(/\//g, '-') }
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = new Date(eventsStore.ghostDate)
|
||||
if (isNaN(d.getTime())) return eventsStore.ghostDate
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const dateLocale = {
|
||||
days: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
|
||||
daysShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
|
||||
months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
|
||||
monthsShort: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
}
|
||||
|
||||
const emotionLabel = computed(() => {
|
||||
const e = eventsStore.ghostEmotion
|
||||
if (e > 0.5) return 'Sehr positiv'
|
||||
if (e > 0.15) return 'Positiv'
|
||||
if (e > -0.15) return 'Neutral'
|
||||
if (e > -0.5) return 'Negativ'
|
||||
return 'Sehr negativ'
|
||||
})
|
||||
|
||||
// CSS gradient for the slider track
|
||||
const sliderGradientCSS = computed(() => {
|
||||
const idx = eventsStore.ghostGradientPreset
|
||||
if (idx !== null && GRADIENT_PRESETS[idx]) {
|
||||
const [neg, mid, pos] = GRADIENT_PRESETS[idx].colors
|
||||
return `linear-gradient(90deg, ${neg} 0%, ${mid} 50%, ${pos} 100%)`
|
||||
}
|
||||
// Default gradient matching the default emotionToColor
|
||||
return 'linear-gradient(90deg, #E91E63 0%, #9C27B0 20%, #2196F3 35%, #FFD700 50%, #FF6B35 65%, #FFD700 80%, #4CAF50 100%)'
|
||||
})
|
||||
|
||||
// CSS gradient for a preset swatch
|
||||
function presetGradientCSS(colors) {
|
||||
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`
|
||||
}
|
||||
|
||||
function selectPreset(index) {
|
||||
eventsStore.ghostGradientPreset = index
|
||||
// Clear custom color when selecting a gradient
|
||||
eventsStore.ghostCustomColor = null
|
||||
}
|
||||
|
||||
function onAddImage() {
|
||||
// TODO: File picker for key image
|
||||
}
|
||||
|
||||
function onAddMedia() {
|
||||
// TODO: File picker for additional media
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 75dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.event-panel--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.event-panel__handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.event-panel__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.event-panel__handle-bar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.event-panel__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 32px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Key Image */
|
||||
.event-panel__image-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-panel__image-wrap {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-panel__image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.event-panel__image-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.event-panel__image-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 120px;
|
||||
border-radius: 16px;
|
||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__image-placeholder:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.event-panel__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-panel__title :deep(.event-panel__title-input) {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Date row */
|
||||
.event-panel__date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__date-row:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.event-panel__date-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-panel__date-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Card sections */
|
||||
.event-panel__card {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid rgba(128, 128, 128, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.event-panel__card--dark {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.event-panel__card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.event-panel__card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.event-panel__emotion-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Slider with gradient track via Quasar deep styling */
|
||||
.event-panel__slider-wrap {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* The actual visible track bar — apply gradient here */
|
||||
.event-panel__slider :deep(.q-slider__track) {
|
||||
background: var(--gradient-bg) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Hide the default grey inner bar */
|
||||
.event-panel__slider :deep(.q-slider__inner) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Hide the blue selection bar */
|
||||
.event-panel__slider :deep(.q-slider__selection) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Thumb — SVG-based, use color for fill/stroke */
|
||||
.event-panel__slider :deep(.q-slider__thumb) {
|
||||
color: var(--thumb-color, #fff);
|
||||
}
|
||||
|
||||
.event-panel__slider :deep(.q-slider__thumb-shape) {
|
||||
filter: drop-shadow(0 0 6px var(--thumb-color, #fff));
|
||||
}
|
||||
|
||||
.event-panel__slider :deep(.q-slider__focus-ring) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-panel__slider-hints {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
opacity: 0.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Gradient Preset Selector */
|
||||
.event-panel__presets {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.event-panel__presets-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
opacity: 0.5;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-panel__presets-grid {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-panel__preset {
|
||||
width: 45px;
|
||||
height: 25px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 2px solid #eee;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.event-panel__preset:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.event-panel__preset--active {
|
||||
border-color: currentColor;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.event-panel__preset--none {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Note */
|
||||
.event-panel__note {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Media grid */
|
||||
.event-panel__media-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-panel__media-add {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__media-add:hover {
|
||||
border-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
|
||||
/* Delete */
|
||||
.event-panel__delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue