Revamp app layout, theming and navigation
Refactor Layout.vue to implement a redesigned, responsive shell: new branding (SVG icon, title & subtitle), computed sidebar/footer routes, route-aware page title & description, improved footer visibility, and a mobile-friendly navigation drawer. Theme handling now uses a data-theme attribute, persistent storage, accessible labels and toggle controls. Added new image assets (icon.svg, icon.png, 404NotFound.png), global.css and documentation files (style.md, codexInfo.md), updated related plugins/routes/components, and removed the old logo.png. Also updated the favicon.
This commit is contained in:
+344
-50
@@ -1,19 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import iconImage from '@/assets/images/icon.svg'
|
||||
import { Visibility, routes } from '@/plugins/routesLayout'
|
||||
|
||||
const display = useDisplay()
|
||||
const theme = useTheme()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const showDrawer = ref(true)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const themeStorageKey = 'theme'
|
||||
|
||||
function changeTheme() {
|
||||
const nextTheme = theme.global.name.value === 'dark' ? 'light' : 'dark'
|
||||
function normalizeRoutePath(path: string) {
|
||||
if (!path || path === '/') {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path
|
||||
}
|
||||
|
||||
function isWithinRoutePath(currentPath: string, targetPath: string) {
|
||||
const normalizedCurrentPath = normalizeRoutePath(currentPath)
|
||||
const normalizedTargetPath = normalizeRoutePath(targetPath)
|
||||
|
||||
if (normalizedTargetPath === '/') {
|
||||
return normalizedCurrentPath === '/'
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedCurrentPath === normalizedTargetPath ||
|
||||
normalizedCurrentPath.startsWith(`${normalizedTargetPath}/`)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveVisibilityRoutes(path: string, visibilityRoute?: string | string[]) {
|
||||
if (Array.isArray(visibilityRoute)) {
|
||||
return visibilityRoute.map((entry) => entry.trim()).filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
const normalizedVisibilityRoute = visibilityRoute?.trim()
|
||||
if (normalizedVisibilityRoute && normalizedVisibilityRoute.length > 0) {
|
||||
return [normalizedVisibilityRoute]
|
||||
}
|
||||
|
||||
return [path]
|
||||
}
|
||||
|
||||
const sidebarRoutes = computed(() =>
|
||||
routes.filter((item) => {
|
||||
if (item.visible === Visibility.Public) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (item.visible !== Visibility.Route) {
|
||||
return false
|
||||
}
|
||||
|
||||
return resolveVisibilityRoutes(item.path, item.visibilityRoute).some((targetPath) =>
|
||||
isWithinRoutePath(route.path, targetPath),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const footerRoutes = computed(() => routes.filter((x) => x.visible === Visibility.Footer))
|
||||
|
||||
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 Dateiablage 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',
|
||||
)
|
||||
|
||||
function applyTheme(nextTheme: 'light' | 'dark', persist = true) {
|
||||
theme.global.name.value = nextTheme
|
||||
localStorage.setItem('theme', nextTheme)
|
||||
document.documentElement.setAttribute('data-theme', nextTheme)
|
||||
if (persist) {
|
||||
localStorage.setItem(themeStorageKey, nextTheme)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
@@ -21,10 +99,13 @@ function toggleDrawer() {
|
||||
localStorage.setItem('drawer', showDrawer.value ? 'Y' : 'N')
|
||||
}
|
||||
|
||||
function changeWebsiteTitle(path: string) {
|
||||
const currentPageInfo = routes.find((x) => x.path === path)
|
||||
if (currentPageInfo) {
|
||||
document.title = `Hoard | ${currentPageInfo.name}`
|
||||
function toggleTheme() {
|
||||
applyTheme(isDarkTheme.value ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function changeWebsiteTitle() {
|
||||
if (activeRoute.value) {
|
||||
document.title = `Hoard | ${activeRoute.value.name}`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,96 +113,309 @@ function changeWebsiteTitle(path: string) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const storedTheme = localStorage.getItem('theme')
|
||||
const fallbackTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
theme.global.name.value = storedTheme === 'dark' || storedTheme === 'light' ? storedTheme : fallbackTheme
|
||||
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') : true
|
||||
showDrawer.value = storedDrawer ? storedDrawer.startsWith('Y') : !display.mobile.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
changeWebsiteTitle(newPath)
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
changeWebsiteTitle()
|
||||
if (display.mobile.value) {
|
||||
showDrawer.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-app-bar elevation="1">
|
||||
<v-app class="hoard-shell">
|
||||
<v-app-bar class="hoard-app-bar" elevation="0" height="64">
|
||||
<template #prepend>
|
||||
<v-app-bar-nav-icon
|
||||
v-tooltip="!showDrawer ? 'Menue oeffnen' : 'Menue schliessen'"
|
||||
v-tooltip="!showDrawer ? 'Menü öffnen' : 'Menü schließen'"
|
||||
@click="toggleDrawer()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-app-bar-title class="title" @click="router.push({ name: 'Home' })">
|
||||
<span class="pointer">Hoard</span>
|
||||
</v-app-bar-title>
|
||||
<button type="button" class="brand-button" @click="router.push({ name: 'Home' })">
|
||||
<span class="brand-mark">
|
||||
<img :src="iconImage" alt="Hoard Icon" class="brand-logo" />
|
||||
</span>
|
||||
<span class="brand-text">
|
||||
<span class="brand-title">Hoard</span>
|
||||
<span class="brand-subtitle">Dateien zuerst</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<v-tooltip v-if="!$vuetify.display.mobile">
|
||||
<v-divider vertical class="mx-4 d-none d-md-flex" />
|
||||
|
||||
<div class="page-context d-none d-md-flex">
|
||||
<p class="page-name">{{ pageName }}</p>
|
||||
<p class="page-description">{{ pageDescription }}</p>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon v-bind="props" to="/login">
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<v-btn
|
||||
icon
|
||||
:aria-label="themeLabel"
|
||||
v-bind="props"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<v-icon>{{ themeIcon }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
Account
|
||||
{{ themeLabel }}
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip>
|
||||
<v-tooltip v-if="!display.smAndDown.value" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon @click="changeTheme()" v-bind="props">
|
||||
<v-icon>mdi-brightness-6</v-icon>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-account-circle-outline" to="/login" v-bind="props">
|
||||
Konto
|
||||
</v-btn>
|
||||
</template>
|
||||
{{ theme.global.name.value === 'dark' ? 'Hellen Modus aktivieren' : 'Dunklen Modus aktivieren' }}
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-else location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon to="/login" v-bind="props" aria-label="Zum Login">
|
||||
<v-icon>mdi-account-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
</v-app-bar>
|
||||
|
||||
<v-navigation-drawer
|
||||
v-model="showDrawer"
|
||||
:location="$vuetify.display.mobile ? 'bottom' : undefined"
|
||||
:permanent="!$vuetify.display.mobile"
|
||||
class="hoard-drawer"
|
||||
:location="display.mobile.value ? 'bottom' : 'left'"
|
||||
:temporary="display.mobile.value"
|
||||
:permanent="!display.mobile.value"
|
||||
:width="268"
|
||||
:elevation="display.mobile.value ? 6 : 0"
|
||||
>
|
||||
<v-list>
|
||||
<div class="drawer-top">
|
||||
<p class="drawer-kicker">Navigation</p>
|
||||
<h2 class="drawer-title">Dateiverwaltung</h2>
|
||||
<p class="drawer-text">Ordner, Dateien und Ansichten schnell erreichen.</p>
|
||||
</div>
|
||||
|
||||
<v-list nav density="comfortable" class="px-1">
|
||||
<v-list-item
|
||||
v-for="item in routes.filter((x) => x.visible === Visibility.Public)"
|
||||
v-for="item in sidebarRoutes"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:active="route.path === item.path"
|
||||
link
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
class="rounded-lg mr-1 ml-1"
|
||||
class="hoard-nav-item"
|
||||
rounded="lg"
|
||||
link
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<template #append>
|
||||
<div class="drawer-bottom">
|
||||
<v-btn variant="text" prepend-icon="mdi-home" to="/">Zur Startseite</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main>
|
||||
<router-view />
|
||||
<v-footer class="d-flex align-center justify-center ga-2 flex-wrap flex-grow-1 py-3">
|
||||
<v-btn
|
||||
v-for="link in routes.filter((x) => x.visible === Visibility.Footer)"
|
||||
:key="link.path"
|
||||
:to="link.path"
|
||||
:text="link.name"
|
||||
variant="text"
|
||||
rounded
|
||||
/>
|
||||
<div class="flex-1-0-100 text-center mt-2">
|
||||
{{ new Date().getFullYear() }} - <strong>Hoard</strong>
|
||||
<v-main class="hoard-main">
|
||||
<div class="main-shell">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<v-footer
|
||||
v-if="shouldShowFooter"
|
||||
class="hoard-footer d-flex align-center justify-space-between ga-2 flex-wrap py-3"
|
||||
>
|
||||
<div class="footer-links">
|
||||
<v-btn
|
||||
v-for="link in footerRoutes"
|
||||
:key="link.path"
|
||||
:to="link.path"
|
||||
:text="link.name"
|
||||
variant="text"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
<p class="footer-copy">{{ currentYear }} - <strong>Hoard</strong></p>
|
||||
</v-footer>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pointer {
|
||||
.hoard-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hoard-app-bar {
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.brand-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: var(--color-text);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-context {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-name,
|
||||
.page-description,
|
||||
.drawer-kicker,
|
||||
.drawer-text,
|
||||
.footer-copy {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
max-width: min(360px, 32vw);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hoard-drawer {
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.drawer-top {
|
||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%);
|
||||
}
|
||||
|
||||
.drawer-kicker {
|
||||
color: var(--color-primary-700);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
margin: var(--space-2) 0 var(--space-1);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.drawer-text {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.drawer-bottom {
|
||||
padding: var(--space-3) var(--space-2) var(--space-4);
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 90%, white 10%);
|
||||
}
|
||||
|
||||
:deep(.hoard-nav-item) {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
:deep(.hoard-nav-item .v-list-item-title) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hoard-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.main-shell {
|
||||
flex: 1;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.hoard-footer {
|
||||
padding-inline: var(--space-6);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.main-shell {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hoard-footer {
|
||||
justify-content: center !important;
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user