471 lines
12 KiB
Vue
471 lines
12 KiB
Vue
<template>
|
|
<div ref="layoutRef" class="lifewave-layout" :class="{ 'lifewave-layout--dark': isDark }">
|
|
<!-- FloatingLines Fullscreen Background (always visible) -->
|
|
<FloatingLines
|
|
ref="floatingLinesRef"
|
|
class="lifewave-layout__background"
|
|
:line-count="[fl.lineCount]"
|
|
:animation-speed="fl.speed"
|
|
:num-points="shaderNumPoints"
|
|
:point-x-values="shaderPointXBase"
|
|
:scroll-container="scrollContainerEl"
|
|
:scroll-uv-scale="scrollUvScale"
|
|
: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"
|
|
:line-brightness="fl.lineBrightness ?? 1"
|
|
:lines-gradient="parsedGradient"
|
|
:bg-color-center="fl.bgCenter"
|
|
:bg-color-edge="fl.bgEdge"
|
|
:background-image="fl.backgroundImage"
|
|
:mix-blend-mode="'screen'"
|
|
/>
|
|
|
|
<!-- Scrollable Timeline -->
|
|
<TimelineView
|
|
ref="timelineViewRef"
|
|
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="userMenuOpen = !userMenuOpen">
|
|
<q-icon name="person" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
|
</button>
|
|
</header>
|
|
|
|
<!-- FPS Overlay -->
|
|
<div v-if="settingsStore.showFps" class="lifewave-fps">
|
|
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS
|
|
<span class="lifewave-fps__dpr">{{ floatingLinesRef?.dprDisplay ?? '0' }}x</span>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<main class="lifewave-content">
|
|
<router-view />
|
|
</main>
|
|
|
|
<!-- Zoom Control (Left Side) — hidden when panel is open -->
|
|
<ZoomControl
|
|
v-if="!eventsStore.panelOpen"
|
|
class="lifewave-layout__zoom"
|
|
:zoom="currentZoom"
|
|
:min="zoomMin"
|
|
:max="zoomMax"
|
|
@zoom-in="timelineViewRef?.zoomIn()"
|
|
@zoom-out="timelineViewRef?.zoomOut()"
|
|
@zoom-to="onZoomTo"
|
|
/>
|
|
|
|
<!-- Settings Button (Bottom-Left, below zoom) — hidden when panel is open -->
|
|
<button
|
|
v-if="!eventsStore.panelOpen"
|
|
class="lifewave-settings-btn glass--button"
|
|
@click="settingsOpen = !settingsOpen"
|
|
>
|
|
<q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
|
</button>
|
|
|
|
<!-- 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 lifewave-backdrop--no-blur"
|
|
@click="settingsOpen = false"
|
|
/>
|
|
</Transition>
|
|
|
|
<!-- Wave Settings Panel (Slide-Up) -->
|
|
<LifeWaveSettings
|
|
:open="settingsOpen && !eventsStore.panelOpen"
|
|
@close="settingsOpen = false"
|
|
/>
|
|
|
|
<!-- User Menu Backdrop -->
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="userMenuOpen"
|
|
class="lifewave-backdrop"
|
|
@click="userMenuOpen = false"
|
|
/>
|
|
</Transition>
|
|
|
|
<!-- User Menu (Slide from right) -->
|
|
<UserMenu
|
|
:open="userMenuOpen"
|
|
@close="userMenuOpen = false"
|
|
@navigate="onUserMenuNavigate"
|
|
/>
|
|
|
|
<!-- App Settings Modal Backdrop -->
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="appSettingsOpen"
|
|
class="lifewave-backdrop"
|
|
@click="appSettingsOpen = false"
|
|
/>
|
|
</Transition>
|
|
|
|
<!-- App Settings Modal -->
|
|
<AppSettingsModal
|
|
:open="appSettingsOpen"
|
|
@close="appSettingsOpen = 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 AppSettingsModal from 'components/AppSettingsModal.vue'
|
|
import UserMenu from 'components/UserMenu.vue'
|
|
import ZoomControl from 'components/ZoomControl.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 userMenuOpen = ref(false)
|
|
const appSettingsOpen = ref(false)
|
|
const floatingLinesRef = ref(null)
|
|
const fl = computed(() => settingsStore.floatingLines)
|
|
|
|
// Timeline view ref (for direct scroll access in render loop)
|
|
const timelineViewRef = ref(null)
|
|
const scrollContainerEl = computed(() => timelineViewRef.value?.timelineRef ?? null)
|
|
|
|
// 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 = 40 // CSS: .timeline-container { top: 40px }
|
|
|
|
// Select up to 8 points from visible window + boundary events for shader lines
|
|
const shaderSelection = computed(() => {
|
|
if (!timelineState.value) return []
|
|
const { events, visibleStart, visibleEnd } = timelineState.value
|
|
if (events.length === 0) return []
|
|
|
|
// Include 3 events before and after visible range for smooth line continuity
|
|
const rangeStart = Math.max(0, (visibleStart ?? 0) - 3)
|
|
const rangeEnd = Math.min(events.length - 1, (visibleEnd ?? events.length - 1) + 3)
|
|
|
|
let candidates = events.slice(rangeStart, rangeEnd + 1)
|
|
|
|
// If more than 16, subsample evenly (keep first + last)
|
|
if (candidates.length > 16) {
|
|
const sampled = [candidates[0]]
|
|
const step = (candidates.length - 1) / 15
|
|
for (let i = 1; i < 15; i++) {
|
|
sampled.push(candidates[Math.round(i * step)])
|
|
}
|
|
sampled.push(candidates[candidates.length - 1])
|
|
candidates = sampled
|
|
}
|
|
|
|
return candidates
|
|
})
|
|
|
|
const shaderNumPoints = computed(() => shaderSelection.value.length)
|
|
|
|
// Base X positions in UV space WITHOUT scroll offset.
|
|
// FloatingLines applies the live scrollLeft in its render loop for perfect sync.
|
|
const shaderPointXBase = computed(() => {
|
|
const xs = Array(16).fill(0)
|
|
const sel = shaderSelection.value
|
|
const w = layoutWidth.value
|
|
const h = layoutHeight.value
|
|
for (let i = 0; i < sel.length; i++) {
|
|
xs[i] = (2 * sel[i].x - w) / h
|
|
}
|
|
return xs
|
|
})
|
|
|
|
// Scale factor to convert scrollLeft pixels → UV offset
|
|
const scrollUvScale = computed(() => 2.0 / layoutHeight.value)
|
|
|
|
const shaderPointY = computed(() => {
|
|
const ys = Array(16).fill(0)
|
|
if (!timelineState.value) return ys
|
|
const sel = shaderSelection.value
|
|
const tlHeight = timelineState.value.containerHeight
|
|
for (let i = 0; i < sel.length; i++) {
|
|
const yPercent = 48 - sel[i].emotion * 30
|
|
const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight
|
|
ys[i] = screenToUV(0, screenY).y
|
|
}
|
|
return ys
|
|
})
|
|
|
|
const shaderPointColors = computed(() => {
|
|
return shaderSelection.value.map(e => e.color || '#ffffff')
|
|
})
|
|
|
|
// 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('#'))
|
|
})
|
|
|
|
// Zoom state from TimelineView
|
|
const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1)
|
|
const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4)
|
|
const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0)
|
|
|
|
function onZoomTo(value) {
|
|
if (!timelineViewRef.value) return
|
|
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
|
|
// Use applyZoom exposed or set directly — we use the internal method indirectly
|
|
// by computing step from current to target
|
|
const tv = timelineViewRef.value
|
|
const el = tv.timelineRef
|
|
if (!el) return
|
|
const cx = el.clientWidth / 2
|
|
const worldX = el.scrollLeft + cx
|
|
const ratio = clamped / tv.zoomLevel
|
|
tv.zoomLevel = clamped
|
|
// Restore scroll position to keep center stable
|
|
requestAnimationFrame(() => {
|
|
el.scrollLeft = worldX * ratio - cx
|
|
})
|
|
}
|
|
|
|
const toggleDarkMode = () => {
|
|
$q.dark.toggle()
|
|
}
|
|
|
|
const onAddEvent = () => {
|
|
eventsStore.openPanel()
|
|
}
|
|
|
|
const onDotSelect = (id) => {
|
|
eventsStore.selectEvent(id)
|
|
eventsStore.openPanel(id)
|
|
}
|
|
|
|
const onUserMenuNavigate = (target) => {
|
|
userMenuOpen.value = false
|
|
if (target === 'settings') {
|
|
appSettingsOpen.value = true
|
|
}
|
|
}
|
|
</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;
|
|
touch-action: pan-x pan-y;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Zoom Control */
|
|
.lifewave-layout__zoom {
|
|
position: fixed;
|
|
left: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
z-index: 10;
|
|
}
|
|
|
|
/* Settings Button — bottom-left, below zoom */
|
|
.lifewave-settings-btn {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
left: 12px;
|
|
z-index: 10;
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
color: #F5F5F5;
|
|
}
|
|
.lifewave-settings-btn--dark {
|
|
color: #000;
|
|
}
|
|
/* 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(1px);
|
|
-webkit-backdrop-filter: blur(1px);
|
|
}
|
|
|
|
.lifewave-backdrop--no-blur {
|
|
backdrop-filter: none;
|
|
-webkit-backdrop-filter: none;
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* FPS Overlay */
|
|
.lifewave-fps {
|
|
position: fixed;
|
|
top: 52px;
|
|
right: 16px;
|
|
z-index: 30;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
color: #0f0;
|
|
font-family: 'SF Mono', 'Menlo', monospace;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.lifewave-fps__dpr {
|
|
color: #999;
|
|
margin-left: 6px;
|
|
}
|
|
</style>
|