10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
471
frontend/_src/layouts/LifeWaveLayout.vue
Normal file
471
frontend/_src/layouts/LifeWaveLayout.vue
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
<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>
|
||||
741
frontend/_src/layouts/MainLayout.vue
Normal file
741
frontend/_src/layouts/MainLayout.vue
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
<template>
|
||||
<q-layout view="lHh Lpr lFf" class="dynamic-gradient-bg">
|
||||
<q-header elevated>
|
||||
<q-toolbar>
|
||||
<q-toolbar-title>
|
||||
THATS ME
|
||||
</q-toolbar-title>
|
||||
|
||||
<!-- Add login status indicator -->
|
||||
<q-chip v-if="isLoggedIn" color="green" text-color="white" icon="check_circle">
|
||||
Logged In
|
||||
</q-chip>
|
||||
<q-chip v-else color="grey" text-color="white" icon="person_off">
|
||||
Not Logged In
|
||||
</q-chip>
|
||||
|
||||
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleDrawer" />
|
||||
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<!-- Off-Canvas Drawer -->
|
||||
<q-drawer v-model="drawer" show-if-above side="right" :width="280" :breakpoint="768" class="bg-grey-1"
|
||||
style="overflow: hidden;">
|
||||
<!-- Sliding Menu Container -->
|
||||
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
|
||||
|
||||
<!-- Main Menu Panel -->
|
||||
<div class="menu-panel">
|
||||
|
||||
<!-- Main Menu List -->
|
||||
<q-list>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('login')" v-if="!isLoggedIn" to="/login">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="login" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Login</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Move password reset to Account submenu and only show when logged in -->
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('wave')" v-if="isLoggedIn" to="/wave">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="line_axis" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Wave</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn" to="/edit">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="add_circle" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>New entry</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="people" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>My People</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="mail" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Messages</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="person" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Profile</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="account_circle" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Account</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('sign-up')" to="/sign-up" v-if="!isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="person_add" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Sign Up</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="help" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Need Help?</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('legal', 'Legal Stuff')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="gavel" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Legal Stuff</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
<!-- Second Level Menu Panel -->
|
||||
<div class="menu-panel">
|
||||
<div class="q-pa-md bg-secondary text-white">
|
||||
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
||||
</div>
|
||||
|
||||
<!-- My People Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'my-people'">
|
||||
<q-item clickable v-ripple @click="navigateTo('relatives')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="family_restroom" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Relatives</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('friends')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="group" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Friends</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Messages Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'messages'">
|
||||
<q-item clickable v-ripple @click="navigateTo('notifications')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="notifications" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Notifications</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('message-center', 'Message Center')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="mail" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Messages</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('groups')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="group_work" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Groups</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Profile Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'profile'">
|
||||
<q-item clickable v-ripple @click="navigateTo('personal-data')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="badge" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Personal Data</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('portrait', 'Portrait')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="portrait" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Portrait</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('voice', 'Voice')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="record_voice_over" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Voice</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Account Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'account'">
|
||||
<q-item clickable v-ripple @click="openSubmenu('plan', 'Plan')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="subscriptions" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Plan</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('billing', 'Billing')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="payment" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Billing</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Add a logout button that only appears when logged in -->
|
||||
<q-item clickable v-ripple @click="logout" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
|
||||
<!-- Login Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'login'">
|
||||
<q-item clickable v-ripple @click="navigateTo('password-reset')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="lock_reset" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Password reset</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="logout">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Support Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'support'">
|
||||
<q-item clickable v-ripple @click="navigateTo('video-tutorials')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="play_circle" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Video tutorials</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('knowledge-base')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="library_books" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Knowledge base</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('faq')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="quiz" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>FAQ</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('chatbot')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="smart_toy" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Chatbot</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Legal Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'legal'">
|
||||
<q-item clickable v-ripple @click="navigateTo('legal-terms')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="article" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Legal Terms</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('privacy-policy')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="privacy_tip" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Privacy Policy</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
<!-- Third Level Menu Panel -->
|
||||
<div class="menu-panel">
|
||||
<div class="q-pa-md bg-accent text-white">
|
||||
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Message Center Submenu -->
|
||||
|
||||
<q-list v-if="currentSubmenu === 'message-center'">
|
||||
<q-item clickable v-ripple @click="navigateTo('new-message')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="add" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Chat</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('direct-messages')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="message" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Direct Messages</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('group-messages')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="group" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Group Messages</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('message-requests')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="request_page" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Message Requests</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
|
||||
|
||||
<!-- Portrait Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'portrait'">
|
||||
<q-item clickable v-ripple @click="navigateTo('animated-avatar')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="face" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Animated Avatar</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Voice Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'voice'">
|
||||
<q-item clickable v-ripple @click="navigateTo('voice-cloning')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="content_copy" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Voice cloning</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Plan Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'plan'">
|
||||
<q-item clickable v-ripple @click="navigateTo('upgrade')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="upgrade" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Upgrade</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Billing Submenu -->
|
||||
<q-list v-if="currentSubmenu === 'billing'">
|
||||
<q-item clickable v-ripple @click="navigateTo('billing-information')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="credit_card" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Billing Information</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('invoices')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="receipt" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Invoices</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<q-footer
|
||||
v-if="!$route.meta.hideFooter"
|
||||
elevated
|
||||
class="bg-white text-black font"
|
||||
>
|
||||
<q-tabs align="justify" dense>
|
||||
<q-route-tab to="/" icon="home" />
|
||||
<q-route-tab to="/people" icon="people" />
|
||||
<q-route-tab to="/edit" icon="add_circle_outline" />
|
||||
<q-route-tab to="/messages" icon="mail" />
|
||||
<q-route-tab to="/profile" icon="person" />
|
||||
</q-tabs>
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'MultiLevelSlidingMenu',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const drawer = ref(false)
|
||||
const currentRoute = ref('home')
|
||||
const menuLevel = ref(0)
|
||||
const currentSubmenu = ref('')
|
||||
const currentSubmenuTitle = ref('')
|
||||
const menuHistory = ref([])
|
||||
|
||||
// Make isLoggedIn reactive to localStorage changes
|
||||
const isLoggedIn = ref(localStorage.getItem('isLoggedIn') === 'true')
|
||||
|
||||
// Listen for storage changes (when logging in from another tab/component)
|
||||
const updateLoginStatus = () => {
|
||||
isLoggedIn.value = localStorage.getItem('isLoggedIn') === 'true'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('storage', updateLoginStatus)
|
||||
// Also check on route changes
|
||||
router.afterEach(() => {
|
||||
updateLoginStatus()
|
||||
})
|
||||
})
|
||||
|
||||
const slideOffset = computed(() => {
|
||||
return menuLevel.value * -280 // Each level slides 280px (drawer width) to the left
|
||||
})
|
||||
|
||||
const toggleDrawer = () => {
|
||||
drawer.value = !drawer.value
|
||||
// Reset menu to main level when drawer is opened
|
||||
if (drawer.value) {
|
||||
resetMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const resetMenu = () => {
|
||||
menuLevel.value = 0
|
||||
currentSubmenu.value = ''
|
||||
currentSubmenuTitle.value = ''
|
||||
menuHistory.value = []
|
||||
}
|
||||
|
||||
const openSubmenu = (submenuKey, title) => {
|
||||
console.log(`Opening submenu: ${submenuKey} - ${title}`)
|
||||
|
||||
// Save current state to history
|
||||
menuHistory.value.push({
|
||||
level: menuLevel.value,
|
||||
submenu: currentSubmenu.value,
|
||||
title: currentSubmenuTitle.value
|
||||
})
|
||||
|
||||
// Navigate to new submenu
|
||||
menuLevel.value += 1
|
||||
currentSubmenu.value = submenuKey
|
||||
currentSubmenuTitle.value = title
|
||||
|
||||
console.log(`New menu level: ${menuLevel.value}, submenu: ${currentSubmenu.value}`)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
console.log('Going back, history length:', menuHistory.value.length)
|
||||
|
||||
if (menuHistory.value.length > 0) {
|
||||
const previousState = menuHistory.value.pop()
|
||||
menuLevel.value = previousState.level
|
||||
currentSubmenu.value = previousState.submenu
|
||||
currentSubmenuTitle.value = previousState.title
|
||||
|
||||
console.log(`Back to level: ${menuLevel.value}, submenu: ${currentSubmenu.value}`)
|
||||
} else {
|
||||
resetMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const navigateTo = (route) => {
|
||||
console.log(`Navigating to: ${route}`)
|
||||
currentRoute.value = route
|
||||
// In a real app, you would use Vue Router here
|
||||
// this.$router.push('/' + route)
|
||||
|
||||
// Close drawer and reset menu on mobile after navigation
|
||||
if (window.innerWidth < 768) {
|
||||
drawer.value = false
|
||||
resetMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Update the login function
|
||||
const login = () => {
|
||||
isLoggedIn.value = true
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
}
|
||||
|
||||
// Update the logout function
|
||||
const logout = () => {
|
||||
console.log('Logging out...')
|
||||
isLoggedIn.value = false
|
||||
localStorage.setItem('isLoggedIn', 'false')
|
||||
currentRoute.value = 'login'
|
||||
drawer.value = false
|
||||
resetMenu()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
drawer,
|
||||
currentRoute,
|
||||
menuLevel,
|
||||
currentSubmenu,
|
||||
currentSubmenuTitle,
|
||||
menuHistory,
|
||||
slideOffset,
|
||||
toggleDrawer,
|
||||
openSubmenu,
|
||||
goBack,
|
||||
navigateTo,
|
||||
logout,
|
||||
isLoggedIn,
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-gradient-bg {
|
||||
/* background: linear-gradient(45deg, #541ba2, #d4890b, #fc1c98);
|
||||
background: linear-gradient(45deg, #7317c3, #d2348b, #31e6cb); */
|
||||
/*background: linear-gradient(-45deg, #a0197c, #fe4374, #541ba2, #a0197c);*/
|
||||
background: linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);
|
||||
background-size: 300% 300%;
|
||||
animation: gradientAnimation 30s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientAnimation {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
/* Start Links Mitte */
|
||||
}
|
||||
|
||||
25% {
|
||||
background-position: 100% 0%;
|
||||
/* Oben Rechts */
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
/* Unten Rechts */
|
||||
}
|
||||
|
||||
75% {
|
||||
background-position: 0% 100%;
|
||||
/* Unten Links */
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
/* Zurück zum Start Links Mitte */
|
||||
}
|
||||
}
|
||||
|
||||
.q-footer .q-tab__label {
|
||||
font-size: 0.7rem;
|
||||
/* Passe diesen Wert nach Bedarf an */
|
||||
}
|
||||
|
||||
|
||||
/* Custom styles for the drawer */
|
||||
/* .q-drawer {
|
||||
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
|
||||
} */
|
||||
|
||||
/* Menu container for sliding panels */
|
||||
.menu-container {
|
||||
display: flex;
|
||||
width: calc(100% * 3);
|
||||
/* Width for 3 levels */
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
/* Individual menu panel */
|
||||
.menu-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Hover effects for menu items */
|
||||
.q-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Chevron icon styling */
|
||||
.q-item-section--side .q-icon {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
/* Back button styling */
|
||||
.q-btn[round] {
|
||||
min-height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
/* Header gradient for different levels */
|
||||
.bg-secondary {
|
||||
background: linear-gradient(135deg, #9c27b0, #673ab7) !important;
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background: linear-gradient(135deg, #ff5722, #f44336) !important;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for menu panels */
|
||||
.menu-panel {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.menu-container {
|
||||
width: calc(100vw * 3);
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue