2c882fce4a
Layout.vue trug die Sidebar-Filterlogik (Visibility/Rolle/Pfadabgleich) inline. Sie liegt jetzt in composables/useSidebarRoutes.ts; Layout.vue verliert ~85 Zeilen Skript ohne Verhaltensänderung. Der ungenutzte stores/counter.ts (Vite-Boilerplate) wird entfernt.
1028 lines
24 KiB
Vue
1028 lines
24 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useDisplay, useTheme } from 'vuetify'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import iconImage from '@/assets/images/icon.svg'
|
|
import { useSidebarRoutes } from '@/composables/useSidebarRoutes'
|
|
import { routes } from '@/plugins/routesLayout'
|
|
import {
|
|
AuthRequestError,
|
|
fetchCurrentUser,
|
|
logout,
|
|
type CurrentUser,
|
|
} from '@/services/authSession'
|
|
import { useAppBannersStore } from '@/stores/appBanners'
|
|
|
|
const display = useDisplay()
|
|
const theme = useTheme()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const showDrawer = ref(true)
|
|
const currentYear = new Date().getFullYear()
|
|
const themeStorageKey = 'theme'
|
|
const currentUser = ref<CurrentUser | null>(null)
|
|
const isLoggingOut = ref(false)
|
|
const appBannersStore = useAppBannersStore()
|
|
const { banners } = storeToRefs(appBannersStore)
|
|
|
|
const { adminSidebarRoutes, primarySidebarRoutes, footerRoutes } = useSidebarRoutes(currentUser)
|
|
|
|
const activeRoute = computed(() => {
|
|
const byName = routes.find((x) => x.meta?.name === route.name)
|
|
if (byName) {
|
|
return byName
|
|
}
|
|
|
|
return routes.find((x) => x.path === route.path)
|
|
})
|
|
|
|
const pageName = computed(() => activeRoute.value?.name ?? 'Hoard')
|
|
const pageDescription = computed(
|
|
() => activeRoute.value?.description ?? 'Self-hosted Datei-Workspace im Browser',
|
|
)
|
|
const shouldShowFooter = computed(() => activeRoute.value?.disableFooter !== true)
|
|
const isDarkTheme = computed(() => theme.global.name.value === 'dark')
|
|
const themeIcon = computed(() => (isDarkTheme.value ? 'mdi-white-balance-sunny' : 'mdi-weather-night'))
|
|
const themeLabel = computed(() =>
|
|
isDarkTheme.value ? 'Hellen Modus aktivieren' : 'Dunklen Modus aktivieren',
|
|
)
|
|
const isAuthenticated = computed(() => currentUser.value !== null)
|
|
const userLabel = computed(() => currentUser.value?.userName ?? 'Konto')
|
|
const userInitials = computed(() => {
|
|
const name = currentUser.value?.userName?.trim() ?? ''
|
|
if (name.length === 0) {
|
|
return 'H'
|
|
}
|
|
|
|
const parts = name.split(/[\s._-]+/).filter((part) => part.length > 0)
|
|
if (parts.length === 0) {
|
|
return name.slice(0, 2).toUpperCase()
|
|
}
|
|
|
|
if (parts.length === 1) {
|
|
const first = parts[0] as string
|
|
return first.slice(0, 2).toUpperCase()
|
|
}
|
|
|
|
const first = parts[0] as string
|
|
const second = parts[1] as string
|
|
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase()
|
|
})
|
|
|
|
function applyTheme(nextTheme: 'light' | 'dark', persist = true) {
|
|
theme.global.name.value = nextTheme
|
|
document.documentElement.setAttribute('data-theme', nextTheme)
|
|
if (persist) {
|
|
localStorage.setItem(themeStorageKey, nextTheme)
|
|
}
|
|
}
|
|
|
|
function toggleDrawer() {
|
|
showDrawer.value = !showDrawer.value
|
|
localStorage.setItem('drawer', showDrawer.value ? 'Y' : 'N')
|
|
}
|
|
|
|
function toggleTheme() {
|
|
applyTheme(isDarkTheme.value ? 'light' : 'dark')
|
|
}
|
|
|
|
async function navigateToBrandTarget() {
|
|
let resolvedUser = currentUser.value
|
|
|
|
if (!resolvedUser) {
|
|
try {
|
|
resolvedUser = await fetchCurrentUser()
|
|
currentUser.value = resolvedUser
|
|
} catch {
|
|
resolvedUser = null
|
|
currentUser.value = null
|
|
}
|
|
}
|
|
|
|
const targetRouteName = resolvedUser ? 'Dashboard' : 'Home'
|
|
await router.push({ name: targetRouteName })
|
|
}
|
|
|
|
async function refreshAuthState(options: { force?: boolean } = {}) {
|
|
try {
|
|
currentUser.value = await fetchCurrentUser({ force: options.force === true })
|
|
} catch {
|
|
currentUser.value = null
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
if (isLoggingOut.value) {
|
|
return
|
|
}
|
|
|
|
isLoggingOut.value = true
|
|
|
|
try {
|
|
await logout()
|
|
currentUser.value = null
|
|
await router.replace({ name: 'Login' })
|
|
} catch (error) {
|
|
if (error instanceof AuthRequestError) {
|
|
appBannersStore.pushError(error.message, 'Abmeldung fehlgeschlagen')
|
|
} else {
|
|
appBannersStore.pushError(
|
|
'Abmeldung fehlgeschlagen. Bitte versuche es erneut.',
|
|
'Abmeldung fehlgeschlagen',
|
|
)
|
|
}
|
|
} finally {
|
|
isLoggingOut.value = false
|
|
void refreshAuthState()
|
|
}
|
|
}
|
|
|
|
function dismissBanner(id: string) {
|
|
appBannersStore.dismiss(id)
|
|
}
|
|
|
|
function changeWebsiteTitle() {
|
|
if (activeRoute.value) {
|
|
document.title = `Hoard · ${activeRoute.value.name}`
|
|
return
|
|
}
|
|
|
|
document.title = 'Hoard · Self-hosted Workspace'
|
|
}
|
|
|
|
onMounted(() => {
|
|
const storedTheme = localStorage.getItem(themeStorageKey)
|
|
const validTheme = storedTheme === 'dark' || storedTheme === 'light' ? storedTheme : 'light'
|
|
applyTheme(validTheme, false)
|
|
|
|
const storedDrawer = localStorage.getItem('drawer')
|
|
showDrawer.value = storedDrawer ? storedDrawer.startsWith('Y') : !display.mobile.value
|
|
void refreshAuthState({ force: true })
|
|
})
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
() => {
|
|
changeWebsiteTitle()
|
|
void refreshAuthState()
|
|
if (display.mobile.value) {
|
|
showDrawer.value = false
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<v-app class="hoard-shell">
|
|
<v-app-bar class="hoard-app-bar" elevation="0" height="68">
|
|
<template #prepend>
|
|
<v-app-bar-nav-icon
|
|
v-tooltip="!showDrawer ? 'Menü öffnen' : 'Menü schließen'"
|
|
@click="toggleDrawer()"
|
|
/>
|
|
</template>
|
|
|
|
<button type="button" class="brand-button" @click="navigateToBrandTarget">
|
|
<span class="brand-mark">
|
|
<span class="brand-mark__halo" aria-hidden="true" />
|
|
<img :src="iconImage" alt="Hoard Icon" class="brand-mark__logo" />
|
|
</span>
|
|
<span class="brand-text">
|
|
<span class="brand-title">Hoard</span>
|
|
<span class="brand-subtitle">Self-hosted Workspace</span>
|
|
</span>
|
|
</button>
|
|
|
|
<span class="topbar-divider" aria-hidden="true" />
|
|
|
|
<div class="page-context">
|
|
<p class="page-name">{{ pageName }}</p>
|
|
<p class="page-description">{{ pageDescription }}</p>
|
|
</div>
|
|
|
|
<v-spacer />
|
|
|
|
<div class="topbar-actions">
|
|
<v-tooltip location="bottom">
|
|
<template #activator="{ props }">
|
|
<v-btn
|
|
icon
|
|
variant="text"
|
|
:aria-label="themeLabel"
|
|
v-bind="props"
|
|
@click="toggleTheme"
|
|
>
|
|
<v-icon>{{ themeIcon }}</v-icon>
|
|
</v-btn>
|
|
</template>
|
|
{{ themeLabel }}
|
|
</v-tooltip>
|
|
|
|
<template v-if="isAuthenticated">
|
|
<v-menu
|
|
v-if="!display.smAndDown.value"
|
|
open-on-hover
|
|
:open-on-click="false"
|
|
location="bottom end"
|
|
offset="10"
|
|
>
|
|
<template #activator="{ props }">
|
|
<button
|
|
type="button"
|
|
class="account-pill"
|
|
v-bind="props"
|
|
@click="router.push({ name: 'Dashboard' })"
|
|
>
|
|
<span class="account-pill__avatar">{{ userInitials }}</span>
|
|
<span class="account-pill__label">
|
|
<span class="account-pill__hint">Angemeldet</span>
|
|
<span class="account-pill__name">{{ userLabel }}</span>
|
|
</span>
|
|
<v-icon class="account-pill__chev">mdi-chevron-down</v-icon>
|
|
</button>
|
|
</template>
|
|
|
|
<v-list density="compact" class="account-menu-list">
|
|
<v-list-item
|
|
prepend-icon="mdi-view-dashboard-outline"
|
|
title="Dashboard"
|
|
:to="{ name: 'Dashboard' }"
|
|
/>
|
|
<v-list-item
|
|
prepend-icon="mdi-lock-reset"
|
|
title="Passwort ändern"
|
|
:to="{ name: 'ChangePassword' }"
|
|
/>
|
|
<v-divider />
|
|
<v-list-item
|
|
prepend-icon="mdi-logout"
|
|
:title="isLoggingOut ? 'Abmelden …' : 'Abmelden'"
|
|
:disabled="isLoggingOut"
|
|
@click="handleLogout"
|
|
/>
|
|
</v-list>
|
|
</v-menu>
|
|
|
|
<v-menu v-else location="bottom end" offset="10">
|
|
<template #activator="{ props }">
|
|
<v-btn icon variant="text" v-bind="props" :aria-label="`Angemeldet als ${userLabel}`">
|
|
<span class="account-pill__avatar account-pill__avatar--mobile">{{ userInitials }}</span>
|
|
</v-btn>
|
|
</template>
|
|
|
|
<v-list density="compact" class="account-menu-list">
|
|
<v-list-item
|
|
prepend-icon="mdi-view-dashboard-outline"
|
|
title="Zum Dashboard"
|
|
:to="{ name: 'Dashboard' }"
|
|
/>
|
|
<v-list-item
|
|
prepend-icon="mdi-lock-reset"
|
|
title="Passwort ändern"
|
|
:to="{ name: 'ChangePassword' }"
|
|
/>
|
|
<v-divider />
|
|
<v-list-item
|
|
prepend-icon="mdi-logout"
|
|
:title="isLoggingOut ? 'Abmelden …' : 'Abmelden'"
|
|
:disabled="isLoggingOut"
|
|
@click="handleLogout"
|
|
/>
|
|
</v-list>
|
|
</v-menu>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<v-btn
|
|
v-if="!display.smAndDown.value"
|
|
variant="elevated"
|
|
prepend-icon="mdi-login"
|
|
to="/login"
|
|
>
|
|
Anmelden
|
|
</v-btn>
|
|
|
|
<v-btn v-else icon variant="text" to="/login" aria-label="Zum Login">
|
|
<v-icon>mdi-login</v-icon>
|
|
</v-btn>
|
|
</template>
|
|
</div>
|
|
</v-app-bar>
|
|
|
|
<v-navigation-drawer
|
|
v-model="showDrawer"
|
|
:class="['hoard-drawer', { 'hoard-drawer--mobile': display.mobile.value }]"
|
|
:location="display.mobile.value ? 'bottom' : 'left'"
|
|
:temporary="display.mobile.value"
|
|
:permanent="!display.mobile.value"
|
|
:width="276"
|
|
:elevation="display.mobile.value ? 6 : 0"
|
|
>
|
|
<div class="drawer-top">
|
|
<p class="drawer-kicker hoard-kicker hoard-kicker--xs">Navigation</p>
|
|
</div>
|
|
|
|
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
|
|
<v-list-item
|
|
v-for="item in primarySidebarRoutes"
|
|
:key="item.path"
|
|
:to="item.path"
|
|
:active="route.path === item.path"
|
|
:prepend-icon="item.icon"
|
|
:title="item.name"
|
|
class="hoard-nav-item"
|
|
rounded="md"
|
|
link
|
|
/>
|
|
|
|
<template v-if="adminSidebarRoutes.length > 0">
|
|
<hr class="hoard-divider-soft drawer-section-divider" />
|
|
<div class="drawer-section-head">
|
|
<p class="drawer-kicker hoard-kicker hoard-kicker--xs">Admin</p>
|
|
</div>
|
|
|
|
<v-list-item
|
|
v-for="item in adminSidebarRoutes"
|
|
:key="item.path"
|
|
:to="item.path"
|
|
:active="route.path === item.path"
|
|
:prepend-icon="item.icon"
|
|
:title="item.name"
|
|
class="hoard-nav-item"
|
|
rounded="md"
|
|
link
|
|
/>
|
|
</template>
|
|
</v-list>
|
|
|
|
<template #append>
|
|
<div class="drawer-bottom">
|
|
<div v-if="isAuthenticated" class="drawer-card">
|
|
<span class="drawer-card__avatar">{{ userInitials }}</span>
|
|
<div class="drawer-card__body">
|
|
<p class="drawer-card__hint">Eingeloggt</p>
|
|
<p class="drawer-card__name">{{ userLabel }}</p>
|
|
</div>
|
|
<v-btn
|
|
v-tooltip="'Abmelden'"
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
:aria-label="isLoggingOut ? 'Abmelden …' : 'Abmelden'"
|
|
:disabled="isLoggingOut"
|
|
@click="handleLogout"
|
|
>
|
|
<v-icon>mdi-logout</v-icon>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<v-btn
|
|
v-else
|
|
variant="text"
|
|
prepend-icon="mdi-home-outline"
|
|
to="/welcome"
|
|
:block="display.mobile.value"
|
|
>
|
|
Zur Startseite
|
|
</v-btn>
|
|
</div>
|
|
</template>
|
|
</v-navigation-drawer>
|
|
|
|
<v-main class="hoard-main">
|
|
<div class="main-shell">
|
|
<router-view v-slot="{ Component }">
|
|
<transition name="route-fade" mode="out-in">
|
|
<component :is="Component" />
|
|
</transition>
|
|
</router-view>
|
|
</div>
|
|
|
|
<v-footer
|
|
v-if="shouldShowFooter"
|
|
class="hoard-footer"
|
|
>
|
|
<div class="footer-inner">
|
|
<div class="footer-brand">
|
|
<img :src="iconImage" alt="" class="footer-logo" />
|
|
<span>
|
|
<strong>Hoard</strong>
|
|
<span class="footer-tag"> · {{ currentYear }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<nav class="footer-links" aria-label="Footer-Navigation">
|
|
<v-btn
|
|
v-for="link in footerRoutes"
|
|
:key="link.path"
|
|
:to="link.path"
|
|
variant="text"
|
|
size="small"
|
|
>
|
|
{{ link.name }}
|
|
</v-btn>
|
|
</nav>
|
|
</div>
|
|
</v-footer>
|
|
</v-main>
|
|
|
|
<section
|
|
v-if="banners.length > 0"
|
|
class="app-banner-stack"
|
|
aria-live="polite"
|
|
aria-label="Systemmeldungen"
|
|
>
|
|
<transition-group name="app-banner-transition" tag="div" class="app-banner-list">
|
|
<v-alert
|
|
v-for="banner in banners"
|
|
:key="banner.id"
|
|
:class="['app-banner-item', `app-banner-item--${banner.type}`]"
|
|
:type="banner.type"
|
|
variant="tonal"
|
|
border="start"
|
|
closable
|
|
@click:close="dismissBanner(banner.id)"
|
|
>
|
|
<p v-if="banner.title" class="app-banner-title">{{ banner.title }}</p>
|
|
<p class="app-banner-text">{{ banner.message }}</p>
|
|
</v-alert>
|
|
</transition-group>
|
|
</section>
|
|
</v-app>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.hoard-shell {
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.hoard-app-bar {
|
|
padding-inline: var(--space-3);
|
|
}
|
|
|
|
/* ---------- Brand ---------- */
|
|
.brand-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: 4px 6px 4px 4px;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
color: inherit;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
transition: background-color var(--transition-fast);
|
|
}
|
|
|
|
.brand-button:hover {
|
|
background-color: color-mix(in srgb, var(--color-primary-100) 38%, transparent);
|
|
}
|
|
|
|
.brand-mark {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 44px;
|
|
height: 44px;
|
|
}
|
|
|
|
.brand-mark__halo {
|
|
position: absolute;
|
|
inset: -6px;
|
|
border-radius: var(--radius-full);
|
|
background:
|
|
radial-gradient(
|
|
closest-side,
|
|
color-mix(in srgb, var(--color-accent-lime) 35%, transparent),
|
|
transparent 70%
|
|
);
|
|
opacity: 0.7;
|
|
filter: blur(6px);
|
|
transition: opacity var(--transition-medium);
|
|
}
|
|
|
|
.brand-button:hover .brand-mark__halo {
|
|
opacity: 1;
|
|
}
|
|
|
|
.brand-mark__logo {
|
|
position: relative;
|
|
width: 44px;
|
|
height: 44px;
|
|
object-fit: contain;
|
|
filter: drop-shadow(0 4px 10px rgb(28 101 47 / 22%));
|
|
}
|
|
|
|
.brand-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.brand-title {
|
|
color: var(--color-text);
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.brand-subtitle {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--font-size-xs);
|
|
font-weight: 500;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
/* ---------- Topbar ---------- */
|
|
.topbar-divider {
|
|
display: inline-block;
|
|
width: 1px;
|
|
height: 28px;
|
|
margin-inline: var(--space-4);
|
|
background:
|
|
linear-gradient(180deg, transparent, var(--color-border), transparent);
|
|
}
|
|
|
|
.page-context {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.page-name,
|
|
.page-description {
|
|
margin: 0;
|
|
}
|
|
|
|
.page-name {
|
|
color: var(--color-text);
|
|
font-size: var(--font-size-md);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.page-description {
|
|
max-width: min(380px, 32vw);
|
|
color: var(--color-text-muted);
|
|
font-size: var(--font-size-sm);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.topbar-actions {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.account-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: 4px var(--space-3) 4px 4px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-full);
|
|
background-color: color-mix(in srgb, var(--color-surface) 90%, var(--color-surface-alt) 10%);
|
|
color: var(--color-text);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
transition:
|
|
border-color var(--transition-fast),
|
|
background-color var(--transition-fast),
|
|
transform var(--transition-fast);
|
|
}
|
|
|
|
.account-pill:hover {
|
|
border-color: color-mix(in srgb, var(--color-primary-300) 50%, var(--color-border) 50%);
|
|
background-color: color-mix(in srgb, var(--color-primary-100) 32%, var(--color-surface) 68%);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.account-pill__avatar {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: var(--radius-full);
|
|
background:
|
|
linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
|
|
color: var(--color-text-on-primary);
|
|
font-size: var(--font-size-sm);
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent-lime) 20%, transparent);
|
|
}
|
|
|
|
.account-pill__avatar--mobile {
|
|
width: 28px;
|
|
height: 28px;
|
|
font-size: var(--font-size-xs);
|
|
}
|
|
|
|
.account-pill__label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
line-height: 1.1;
|
|
max-width: 160px;
|
|
}
|
|
|
|
.account-pill__hint {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--font-size-2xs);
|
|
font-weight: 500;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.account-pill__name {
|
|
color: var(--color-text);
|
|
font-size: var(--font-size-sm);
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 160px;
|
|
}
|
|
|
|
.account-pill__chev {
|
|
color: var(--color-text-muted);
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
@media (width <= 1280px) {
|
|
.page-description {
|
|
display: none;
|
|
}
|
|
|
|
.page-context {
|
|
gap: 0;
|
|
}
|
|
}
|
|
|
|
@media (width <= 1180px) {
|
|
.topbar-divider,
|
|
.page-context {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* ---------- Drawer ---------- */
|
|
.hoard-drawer {
|
|
padding-top: var(--space-2);
|
|
}
|
|
|
|
.hoard-drawer--mobile {
|
|
border-top-left-radius: var(--radius-xl);
|
|
border-top-right-radius: var(--radius-xl);
|
|
max-height: min(74vh, 580px);
|
|
}
|
|
|
|
.drawer-top {
|
|
padding: var(--space-3) var(--space-5) var(--space-3);
|
|
}
|
|
|
|
.drawer-kicker {
|
|
margin: 0;
|
|
}
|
|
|
|
.drawer-section-divider {
|
|
margin: var(--space-3) var(--space-4);
|
|
}
|
|
|
|
.drawer-section-head {
|
|
padding: 0 var(--space-5) var(--space-2);
|
|
}
|
|
|
|
:deep(.hoard-nav-item) {
|
|
min-height: 44px;
|
|
}
|
|
|
|
:deep(.hoard-nav-item .v-list-item-title) {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.drawer-bottom {
|
|
padding: var(--space-3) var(--space-3) var(--space-4);
|
|
border-top: 1px solid var(--color-border-subtle);
|
|
}
|
|
|
|
.drawer-card {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-3);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
background-color: var(--color-surface);
|
|
box-shadow: var(--shadow-xs);
|
|
}
|
|
|
|
.drawer-card__avatar {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: var(--radius-full);
|
|
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
|
|
color: var(--color-text-on-primary);
|
|
font-size: var(--font-size-sm);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.drawer-card__body {
|
|
min-width: 0;
|
|
}
|
|
|
|
.drawer-card__hint,
|
|
.drawer-card__name {
|
|
margin: 0;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.drawer-card__hint {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--font-size-2xs);
|
|
font-weight: 500;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.drawer-card__name {
|
|
color: var(--color-text);
|
|
font-size: var(--font-size-sm);
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ---------- Main ---------- */
|
|
.hoard-main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: calc(100vh - 68px);
|
|
}
|
|
|
|
.main-shell {
|
|
flex: 1;
|
|
padding: var(--space-6);
|
|
}
|
|
|
|
.route-fade-enter-active,
|
|
.route-fade-leave-active {
|
|
transition:
|
|
opacity var(--transition-medium),
|
|
transform var(--transition-medium);
|
|
}
|
|
|
|
.route-fade-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
|
|
.route-fade-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
/* ---------- Footer ---------- */
|
|
.hoard-footer {
|
|
flex: 0 0 auto;
|
|
height: auto !important;
|
|
min-height: 0 !important;
|
|
padding: 0 !important;
|
|
}
|
|
|
|
.footer-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-4);
|
|
width: 100%;
|
|
padding: var(--space-3) var(--space-6);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.footer-brand {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
color: var(--color-text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.footer-brand strong {
|
|
color: var(--color-text);
|
|
font-weight: 700;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.footer-tag {
|
|
color: var(--color-text-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.footer-logo {
|
|
width: 22px;
|
|
height: 22px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.footer-links {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 2px;
|
|
}
|
|
|
|
/* ---------- Banner stack ---------- */
|
|
.app-banner-stack {
|
|
position: fixed;
|
|
right: max(var(--space-5), env(safe-area-inset-right));
|
|
bottom: max(var(--space-5), env(safe-area-inset-bottom));
|
|
z-index: 2100;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.app-banner-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
width: min(420px, calc(100vw - (2 * var(--space-4))));
|
|
}
|
|
|
|
:deep(.app-banner-item) {
|
|
pointer-events: auto;
|
|
border: 1px solid var(--color-border) !important;
|
|
box-shadow: var(--shadow-md);
|
|
border-radius: var(--radius-md) !important;
|
|
}
|
|
|
|
:deep(.app-banner-item .v-alert__overlay),
|
|
:deep(.app-banner-item .v-alert__underlay) {
|
|
opacity: 0 !important;
|
|
}
|
|
|
|
:deep(.app-banner-item .v-alert__content) {
|
|
line-height: 1.45;
|
|
}
|
|
|
|
:deep(.app-banner-item--error) {
|
|
background-color: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface) 88%) !important;
|
|
}
|
|
|
|
:deep(.app-banner-item--warning) {
|
|
background-color: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface) 88%) !important;
|
|
}
|
|
|
|
:deep(.app-banner-item--info) {
|
|
background-color: color-mix(in srgb, var(--color-info) 12%, var(--color-surface) 88%) !important;
|
|
}
|
|
|
|
:deep(.app-banner-item--success) {
|
|
background-color: color-mix(in srgb, var(--color-success) 12%, var(--color-surface) 88%) !important;
|
|
}
|
|
|
|
.app-banner-title,
|
|
.app-banner-text {
|
|
margin: 0;
|
|
}
|
|
|
|
.app-banner-title {
|
|
margin-bottom: 2px;
|
|
color: var(--color-text);
|
|
font-size: var(--font-size-sm);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.app-banner-text {
|
|
color: var(--color-text-secondary);
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.app-banner-transition-enter-active,
|
|
.app-banner-transition-leave-active {
|
|
transition: opacity 180ms ease, transform 180ms ease;
|
|
}
|
|
|
|
.app-banner-transition-enter-from,
|
|
.app-banner-transition-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
|
|
.app-banner-transition-move {
|
|
transition: transform 180ms ease;
|
|
}
|
|
|
|
/* ---------- Responsive ---------- */
|
|
@media (width <= 960px) {
|
|
.hoard-app-bar {
|
|
padding-inline:
|
|
max(var(--space-1), env(safe-area-inset-left))
|
|
max(var(--space-1), env(safe-area-inset-right));
|
|
}
|
|
|
|
.brand-button {
|
|
min-width: 0;
|
|
gap: var(--space-2);
|
|
padding: 2px;
|
|
}
|
|
|
|
.brand-mark,
|
|
.brand-mark__logo {
|
|
width: 38px;
|
|
height: 38px;
|
|
}
|
|
|
|
.brand-title {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.brand-subtitle {
|
|
display: none;
|
|
}
|
|
|
|
.topbar-actions {
|
|
gap: 2px;
|
|
}
|
|
|
|
.topbar-divider,
|
|
.page-context {
|
|
display: none;
|
|
}
|
|
|
|
.hoard-drawer {
|
|
padding-top: var(--space-1);
|
|
}
|
|
|
|
.drawer-top {
|
|
padding: var(--space-3) var(--space-4);
|
|
}
|
|
|
|
.drawer-bottom {
|
|
padding: var(--space-3) var(--space-3) calc(var(--space-3) + env(safe-area-inset-bottom));
|
|
}
|
|
|
|
:deep(.drawer-bottom .v-btn) {
|
|
min-height: 44px;
|
|
}
|
|
|
|
.main-shell {
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
.footer-inner {
|
|
justify-content: center;
|
|
padding-inline:
|
|
max(var(--space-3), env(safe-area-inset-left))
|
|
max(var(--space-3), env(safe-area-inset-right));
|
|
}
|
|
|
|
.footer-links {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
.app-banner-stack {
|
|
right: var(--hoard-mobile-safe-right);
|
|
left: var(--hoard-mobile-safe-left);
|
|
bottom: calc(var(--hoard-mobile-safe-bottom) + var(--space-1));
|
|
}
|
|
|
|
.app-banner-list {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
@media (width <= 600px) {
|
|
.main-shell {
|
|
padding: var(--space-3);
|
|
}
|
|
|
|
.brand-title {
|
|
font-size: var(--font-size-md);
|
|
}
|
|
|
|
.footer-links {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
}
|
|
|
|
:deep(.footer-links .v-btn) {
|
|
width: 100%;
|
|
min-height: 44px;
|
|
}
|
|
}
|
|
</style>
|