f564ecfbc7
Shell: - One shared NexusLayout for ALL routes (dashboard + pages): compact 68px icon rail with hover-expand overlay, replaces both old sidebars + topbars - Single flat nav source (railNav) — same menu everywhere incl. Settings - Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup, NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView Live updates: - Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and ChannelReader.ReadAsync were re-invoked while pending — every published update threw InvalidOperationException and killed ALL live streams - liveSync store: treat graceful stream close as disconnect (was stuck connected=true with polling stopped -> page frozen until manual reload), fast first retry, heartbeat watchdog (65s), reconnect on online/visibility - One app-wide SSE connection owned by the layout instead of per-view connect/disconnect churn; removed duplicate live-sync.ts store Performance: - Board endpoint: drop nested childTasks duplication (counts stay) — payload 104KB -> 65KB; SSE snapshots shrink equally - nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire); text/event-stream excluded to keep SSE unbuffered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
4.2 KiB
Vue
181 lines
4.2 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* NexusLayout — gemeinsame Shell für ALLE Seiten
|
|
*
|
|
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
|
|
* Content bekommt die volle restliche Fläche:
|
|
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
|
|
* - alle anderen: scrollbarer Container mit Seiten-Padding
|
|
*
|
|
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
|
|
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
|
|
*/
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { RouterView, useRoute } from 'vue-router'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { useLiveSyncStore } from '../stores/liveSync'
|
|
import { useNotificationStore } from '../stores/notifications'
|
|
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
|
import Sidebar from '../components/layout/Sidebar.vue'
|
|
import { svg } from '../composables/icons'
|
|
|
|
const route = useRoute()
|
|
const auth = useAuthStore()
|
|
const liveSync = useLiveSyncStore()
|
|
const notificationStore = useNotificationStore()
|
|
|
|
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
|
|
|
|
/* ── Mobile Drawer ─────────────────────────────── */
|
|
const mobileMenuOpen = ref(false)
|
|
|
|
function closeMobileMenu() {
|
|
mobileMenuOpen.value = false
|
|
}
|
|
|
|
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
|
|
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
|
|
|
|
function onVisibilityChange() {
|
|
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
|
|
liveSync.connect(liveUser.value)
|
|
}
|
|
}
|
|
|
|
function onOnline() {
|
|
liveSync.reconnectNow()
|
|
}
|
|
|
|
onMounted(() => {
|
|
liveSync.connect(liveUser.value)
|
|
notificationStore.startPolling()
|
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
window.addEventListener('online', onOnline)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
liveSync.disconnect()
|
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
window.removeEventListener('online', onOnline)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="nexus-layout">
|
|
<GalaxyBackground />
|
|
|
|
<div class="rail-slot">
|
|
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
|
|
</div>
|
|
|
|
<!-- Mobile: Hamburger + Backdrop -->
|
|
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
|
|
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
|
|
|
|
<main class="nexus-main">
|
|
<div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
|
|
<RouterView />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.nexus-layout {
|
|
display: flex;
|
|
flex-direction: row;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
|
|
und kann expandieren, ohne den Content zu verschieben. */
|
|
.rail-slot {
|
|
width: 68px;
|
|
flex: 0 0 68px;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.nexus-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.nexus-content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.nexus-content.full-bleed {
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.nexus-content.full-bleed > :deep(*) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.nexus-content.page-scroll {
|
|
overflow-y: auto;
|
|
padding: 24px 28px 64px;
|
|
}
|
|
|
|
.mobile-toggle,
|
|
.mobile-backdrop {
|
|
display: none;
|
|
}
|
|
|
|
@media (max-width: 767px) {
|
|
.rail-slot {
|
|
width: 0;
|
|
flex: 0 0 0;
|
|
}
|
|
|
|
.nexus-main {
|
|
width: 100%;
|
|
}
|
|
|
|
.mobile-toggle {
|
|
display: grid;
|
|
place-items: center;
|
|
position: fixed;
|
|
top: 12px;
|
|
left: 12px;
|
|
z-index: 90;
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 1px solid var(--line-2);
|
|
border-radius: 12px;
|
|
background: var(--glass);
|
|
backdrop-filter: blur(12px);
|
|
color: var(--tx-2);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mobile-toggle :deep(svg) {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
|
|
.mobile-backdrop {
|
|
display: block;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 99;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.nexus-content.page-scroll {
|
|
padding: 60px 16px 48px;
|
|
}
|
|
}
|
|
</style>
|