thats-me/frontend/src/layouts/LifeWaveLayout.vue
2026-03-06 13:56:20 +01:00

293 lines
7.4 KiB
Vue

<template>
<div ref="layoutRef" class="lifewave-layout" :class="{ 'lifewave-layout--dark': isDark }">
<!-- FloatingLines Fullscreen Background (always visible) -->
<FloatingLines
class="lifewave-layout__background"
:enabled-waves="['middle']"
:line-count="[fl.lineCount]"
:animation-speed="fl.speed"
:num-points="shaderNumPoints"
:point-x-values="shaderPointX"
:point-y-values="shaderPointY"
:point-colors="shaderPointColors"
:line-spread="fl.spread"
:fan-spread="fl.fanSpread"
:line-sharpness="fl.lineSharpness"
:wave-frequency="fl.waveFrequency"
:bezier-curvature="fl.bezierCurvature"
:circle-radius-px="fl.circleRadius"
:circle-glow-size="fl.glowSize"
:circle-glow-strength="fl.glowStrength"
:lines-gradient="parsedGradient"
:bg-color-center="fl.bgCenter"
:bg-color-edge="fl.bgEdge"
:background-image="fl.backgroundImage"
:mix-blend-mode="'screen'"
/>
<!-- Scrollable Timeline -->
<TimelineView
class="lifewave-layout__timeline"
@dot-select="onDotSelect"
@view-update="onViewUpdate"
/>
<!-- Header -->
<header class="lifewave-header">
<span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span>
<button class="lifewave-header__user glass--button" @click="settingsOpen = !settingsOpen">
<q-icon name="tune" size="22px" />
</button>
</header>
<!-- Content -->
<main class="lifewave-content">
<router-view />
</main>
<!-- Add Event Button (Bottom-Center) hidden when panel is open -->
<AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" />
<!-- Backdrop blur when panel is open -->
<Transition name="fade">
<div
v-if="eventsStore.panelOpen"
class="lifewave-backdrop"
@click="eventsStore.closePanel()"
/>
</Transition>
<!-- Event Panel (Slide-Up) -->
<EventPanel />
<!-- Wave Settings Backdrop -->
<Transition name="fade">
<div
v-if="settingsOpen && !eventsStore.panelOpen"
class="lifewave-backdrop"
@click="settingsOpen = false"
/>
</Transition>
<!-- Wave Settings Panel (Slide-Up) -->
<LifeWaveSettings
:open="settingsOpen && !eventsStore.panelOpen"
@close="settingsOpen = false"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import AddEventButton from 'components/AddEventButton.vue'
import EventPanel from 'components/EventPanel.vue'
import FloatingLines from 'components/FloatingLines.vue'
import LifeWaveSettings from 'components/LifeWaveSettings.vue'
import TimelineView from 'components/TimelineView.vue'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
const $q = useQuasar()
const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive)
const settingsOpen = ref(false)
const fl = computed(() => settingsStore.floatingLines)
// Layout dimensions (for screen→UV conversion)
const layoutRef = ref(null)
const layoutWidth = ref(window.innerWidth)
const layoutHeight = ref(window.innerHeight)
let layoutResizeObserver = null
onMounted(() => {
if (layoutRef.value) {
layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight
layoutResizeObserver = new ResizeObserver(() => {
layoutWidth.value = layoutRef.value.clientWidth
layoutHeight.value = layoutRef.value.clientHeight
})
layoutResizeObserver.observe(layoutRef.value)
}
})
onBeforeUnmount(() => {
layoutResizeObserver?.disconnect()
})
// Timeline state (received from TimelineView via emit)
const timelineState = ref(null)
function onViewUpdate(state) {
timelineState.value = state
}
// Convert screen pixel coordinates → shader UV space
// Shader: baseUv = (2*fragCoord - iResolution) / iResolution.y; baseUv.y *= -1;
// For CSS pixel (sx, sy) from top-left:
// uvX = (2*sx - cssWidth) / cssHeight
// uvY = (2*sy - cssHeight) / cssHeight
function screenToUV(sx, sy) {
const w = layoutWidth.value
const h = layoutHeight.value
return {
x: (2 * sx - w) / h,
y: (2 * sy - h) / h
}
}
// Compute shader point positions from event positions
const TIMELINE_TOP = 60 // CSS: .timeline { top: 60px }
const shaderNumPoints = computed(() => {
if (!timelineState.value) return 0
return Math.min(timelineState.value.events.length, 8)
})
const shaderPointX = computed(() => {
const xs = Array(8).fill(0)
if (!timelineState.value) return xs
const { scrollLeft, events } = timelineState.value
const count = Math.min(events.length, 8)
for (let i = 0; i < count; i++) {
const screenX = events[i].x - scrollLeft
xs[i] = screenToUV(screenX, 0).x
}
return xs
})
const shaderPointY = computed(() => {
const ys = Array(8).fill(0)
if (!timelineState.value) return ys
const { containerHeight: tlHeight, events } = timelineState.value
const count = Math.min(events.length, 8)
for (let i = 0; i < count; i++) {
// GlowDot: top = (50 - emotion*35)% of timeline container
const yPercent = 50 - events[i].emotion * 35
const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight
ys[i] = screenToUV(0, screenY).y
}
return ys
})
const shaderPointColors = computed(() => {
if (!timelineState.value) return []
const { events } = timelineState.value
const count = Math.min(events.length, 8)
const colors = []
for (let i = 0; i < count; i++) {
colors.push(events[i].color || '#ffffff')
}
return colors
})
// Parse gradient stops from textarea string
const parsedGradient = computed(() => {
return fl.value.gradientStops
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0 && s.startsWith('#'))
})
const toggleDarkMode = () => {
$q.dark.toggle()
}
const onAddEvent = () => {
eventsStore.openPanel()
}
const onDotSelect = (id) => {
eventsStore.selectEvent(id)
eventsStore.openPanel(id)
}
</script>
<style scoped>
.lifewave-layout {
position: relative;
width: 100%;
height: 100dvh;
overflow: hidden;
background: #000;
color: #F5F5F5;
transition: background 0.3s ease, color 0.3s ease;
}
.lifewave-layout--dark {
background: #000;
color: #F5F5F5;
}
/* FloatingLines Background */
.lifewave-layout__background {
position: absolute;
inset: 0;
z-index: 0;
}
/* Timeline — positioning comes from TimelineView's own .timeline class */
.lifewave-layout__timeline {
z-index: 5;
}
/* Header */
.lifewave-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
}
.lifewave-header__logo {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
cursor: pointer;
user-select: none;
}
.lifewave-header__user {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
/* Content */
.lifewave-content {
padding-top: 72px;
height: 100%;
}
/* Backdrop blur overlay */
.lifewave-backdrop {
position: fixed;
inset: 0;
z-index: 15;
background: rgba(0, 0, 0, 0.15);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>