Modernize frontend: new design system, redesign all pages

- Convert codexInfo.md to CLAUDE.md as the central project context.
- Rewrite GUI/style.md with a modernized, file-first direction (layered
  surfaces, refined typography, motion budget, ambient gradients).
- Refresh global tokens in global.css, page-layouts.css and
  surface-patterns.css: extended palette (primary 050/800, surface
  elevated, border subtle), four-tier shadow system, dark-mode parity,
  new utilities (hoard-chip, hoard-icon-tile, hoard-spotlight,
  hoard-section-head, hoard-divider-soft, status pulse dot).
- Rebuild Layout.vue: premium app shell with brand halo, animated
  active-indicator, account pill with avatar/initials, drawer footer
  card, refined banner stack and footer.
- Redesign every route while preserving routing and API contracts:
  Home, Login, ChangePassword, Dashboard, AdminUsers, AdminUserDetail,
  Impressum, 404, Forbidden. Adds search/admin stats, password hint
  list, dashboard greeting with avatar, modernized hero/spotlight
  treatments and consistent mobile layouts (safe-areas, 44/48px tap
  targets).
- Drop @fontsource/roboto import in vuetify.ts and load Inter via the
  rsms.me CSS in index.html; update Vuetify defaults and palette to
  match the new tokens.
This commit is contained in:
Claude
2026-04-26 15:24:52 +00:00
parent 10bf4b94ad
commit 6740038e9a
18 changed files with 3478 additions and 1881 deletions
+396 -166
View File
@@ -124,7 +124,7 @@ const activeRoute = computed(() => {
const pageName = computed(() => activeRoute.value?.name ?? 'Hoard')
const pageDescription = computed(
() => activeRoute.value?.description ?? 'Self-hosted Dateiablage im Browser',
() => activeRoute.value?.description ?? 'Self-hosted Datei-Workspace im Browser',
)
const shouldShowFooter = computed(() => activeRoute.value?.disableFooter !== true)
const isDarkTheme = computed(() => theme.global.name.value === 'dark')
@@ -134,6 +134,26 @@ const themeLabel = computed(() =>
)
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
@@ -192,7 +212,10 @@ async function handleLogout() {
if (error instanceof AuthRequestError) {
appBannersStore.pushError(error.message, 'Abmeldung fehlgeschlagen')
} else {
appBannersStore.pushError('Abmeldung fehlgeschlagen. Bitte versuche es erneut.', 'Abmeldung fehlgeschlagen')
appBannersStore.pushError(
'Abmeldung fehlgeschlagen. Bitte versuche es erneut.',
'Abmeldung fehlgeschlagen',
)
}
} finally {
isLoggingOut.value = false
@@ -206,11 +229,11 @@ function dismissBanner(id: string) {
function changeWebsiteTitle() {
if (activeRoute.value) {
document.title = `Hoard | ${activeRoute.value.name}`
document.title = `Hoard · ${activeRoute.value.name}`
return
}
document.title = 'Hoard'
document.title = 'Hoard · Self-hosted Workspace'
}
onMounted(() => {
@@ -238,7 +261,7 @@ watch(
<template>
<v-app class="hoard-shell">
<v-app-bar class="hoard-app-bar" elevation="0" height="64">
<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'"
@@ -248,15 +271,16 @@ watch(
<button type="button" class="brand-button" @click="navigateToBrandTarget">
<span class="brand-mark">
<img :src="iconImage" alt="Hoard Icon" class="brand-logo" />
<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">Dateien zuerst</span>
<span class="brand-subtitle">Self-hosted Workspace</span>
</span>
</button>
<v-divider vertical class="mx-3 topbar-context-divider" />
<span class="topbar-divider" aria-hidden="true" />
<div class="page-context">
<p class="page-name">{{ pageName }}</p>
@@ -270,6 +294,7 @@ watch(
<template #activator="{ props }">
<v-btn
icon
variant="text"
:aria-label="themeLabel"
v-bind="props"
@click="toggleTheme"
@@ -286,21 +311,30 @@ watch(
open-on-hover
:open-on-click="false"
location="bottom end"
offset="8"
offset="10"
>
<template #activator="{ props }">
<v-btn
class="account-button"
variant="outlined"
prepend-icon="mdi-account-check-outline"
:to="{ name: 'Dashboard' }"
<button
type="button"
class="account-pill"
v-bind="props"
@click="router.push({ name: 'Dashboard' })"
>
{{ userLabel }}
</v-btn>
<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"
@@ -309,21 +343,17 @@ watch(
<v-divider />
<v-list-item
prepend-icon="mdi-logout"
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
:title="isLoggingOut ? 'Abmelden' : 'Abmelden'"
:disabled="isLoggingOut"
@click="handleLogout"
/>
</v-list>
</v-menu>
<v-menu
v-else
location="bottom end"
offset="8"
>
<v-menu v-else location="bottom end" offset="10">
<template #activator="{ props }">
<v-btn icon v-bind="props" :aria-label="`Angemeldet als ${userLabel}`">
<v-icon>mdi-account-check-outline</v-icon>
<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>
@@ -341,7 +371,7 @@ watch(
<v-divider />
<v-list-item
prepend-icon="mdi-logout"
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
:title="isLoggingOut ? 'Abmelden' : 'Abmelden'"
:disabled="isLoggingOut"
@click="handleLogout"
/>
@@ -350,23 +380,18 @@ watch(
</template>
<template v-else>
<v-tooltip v-if="!display.smAndDown.value" location="bottom">
<template #activator="{ props }">
<v-btn variant="outlined" prepend-icon="mdi-account-circle-outline" to="/login" v-bind="props">
Konto
</v-btn>
</template>
Zum Login
</v-tooltip>
<v-btn
v-if="!display.smAndDown.value"
variant="elevated"
prepend-icon="mdi-login"
to="/login"
>
Anmelden
</v-btn>
<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-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>
@@ -377,7 +402,7 @@ watch(
:location="display.mobile.value ? 'bottom' : 'left'"
:temporary="display.mobile.value"
:permanent="!display.mobile.value"
:width="268"
:width="276"
:elevation="display.mobile.value ? 6 : 0"
>
<div class="drawer-top">
@@ -385,20 +410,20 @@ watch(
</div>
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
<template v-for="item in primarySidebarRoutes" :key="item.path">
<v-list-item
:to="item.path"
:active="route.path === item.path"
:prepend-icon="item.icon"
:title="item.name"
class="hoard-nav-item"
rounded="lg"
link
/>
</template>
<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">
<v-divider class="my-2 mx-2" />
<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>
@@ -411,15 +436,40 @@ watch(
:prepend-icon="item.icon"
:title="item.name"
class="hoard-nav-item"
rounded="lg"
rounded="md"
link
/>
</template>
</v-list>
<template #append>
<div v-if="!isAuthenticated" class="drawer-bottom">
<v-btn variant="text" prepend-icon="mdi-home" to="/welcome" :block="display.mobile.value">
<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>
@@ -437,19 +487,29 @@ watch(
<v-footer
v-if="shouldShowFooter"
class="hoard-footer d-flex align-center justify-space-between ga-2 flex-wrap py-3"
class="hoard-footer"
>
<div class="footer-links">
<v-btn
v-for="link in footerRoutes"
:key="link.path"
:to="link.path"
:text="link.name"
variant="text"
rounded
/>
<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>
<p class="footer-copy">{{ currentYear }} - <strong>Hoard</strong></p>
</v-footer>
</v-main>
@@ -484,42 +544,61 @@ watch(
}
.hoard-app-bar {
padding-inline: var(--space-2);
padding-inline: var(--space-3);
}
/* ---------- Brand ---------- */
.brand-button {
display: inline-flex;
align-items: center;
gap: var(--space-3);
padding: 0;
padding: 4px 6px 4px 4px;
border: none;
border-radius: var(--radius-md);
color: inherit;
background: transparent;
cursor: pointer;
border-radius: var(--radius-md);
transition:
color var(--transition-fast),
transform var(--transition-fast);
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: 54px;
height: 54px;
transition:
transform var(--transition-fast);
width: 44px;
height: 44px;
}
.brand-button:hover .brand-mark {
transform: translateY(-1px);
.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-logo {
width: 58px;
height: 58px;
.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 {
@@ -531,14 +610,26 @@ watch(
.brand-title {
color: var(--color-text);
font-size: 16px;
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 {
@@ -548,31 +639,8 @@ watch(
min-width: 0;
}
.topbar-context-divider {
display: flex;
}
.topbar-actions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.account-button {
max-width: 220px;
}
:deep(.account-button .v-btn__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-name,
.page-description,
.drawer-kicker,
.drawer-text,
.footer-copy {
.page-description {
margin: 0;
}
@@ -583,7 +651,7 @@ watch(
}
.page-description {
max-width: min(360px, 32vw);
max-width: min(380px, 32vw);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
white-space: nowrap;
@@ -591,7 +659,90 @@ watch(
text-overflow: ellipsis;
}
@media (width <= 1360px) {
.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;
}
@@ -602,44 +753,37 @@ watch(
}
@media (width <= 1180px) {
.topbar-context-divider {
.topbar-divider,
.page-context {
display: none;
}
}
/* ---------- Drawer ---------- */
.hoard-drawer {
padding-top: var(--space-2);
}
.hoard-drawer--mobile {
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
max-height: min(72vh, 560px);
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
max-height: min(74vh, 580px);
}
.drawer-top {
padding: var(--space-2) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, var(--color-surface) 15%);
padding: var(--space-3) var(--space-5) var(--space-3);
}
.drawer-title {
margin: var(--space-2) 0 var(--space-1);
color: var(--color-text);
font-size: var(--font-size-lg);
.drawer-kicker {
margin: 0;
}
.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%, var(--color-surface) 10%);
.drawer-section-divider {
margin: var(--space-3) var(--space-4);
}
.drawer-section-head {
padding: var(--space-2) var(--space-3) var(--space-1);
padding: 0 var(--space-5) var(--space-2);
}
:deep(.hoard-nav-item) {
@@ -650,10 +794,68 @@ watch(
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 - 64px);
min-height: calc(100vh - 68px);
}
.main-shell {
@@ -678,29 +880,60 @@ watch(
transform: translateY(-4px);
}
/* ---------- Footer ---------- */
.hoard-footer {
flex: 0 0 auto;
height: auto !important;
min-height: 0 !important;
max-height: 88px;
padding-inline: var(--space-6);
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: var(--space-1);
}
.footer-copy {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
gap: 2px;
}
/* ---------- Banner stack ---------- */
.app-banner-stack {
position: fixed;
right: max(var(--space-4), env(safe-area-inset-right));
bottom: max(var(--space-4), env(safe-area-inset-bottom));
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;
}
@@ -716,6 +949,7 @@ watch(
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),
@@ -728,19 +962,19 @@ watch(
}
:deep(.app-banner-item--error) {
background-color: color-mix(in srgb, var(--color-danger) 10%, var(--color-surface) 90%) !important;
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) 10%, var(--color-surface) 90%) !important;
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) 10%, var(--color-surface) 90%) !important;
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) 10%, var(--color-surface) 90%) !important;
background-color: color-mix(in srgb, var(--color-success) 12%, var(--color-surface) 88%) !important;
}
.app-banner-title,
@@ -775,6 +1009,7 @@ watch(
transition: transform 180ms ease;
}
/* ---------- Responsive ---------- */
@media (width <= 960px) {
.hoard-app-bar {
padding-inline:
@@ -785,27 +1020,28 @@ watch(
.brand-button {
min-width: 0;
gap: var(--space-2);
padding: 2px;
}
.brand-logo {
width: 48px;
height: 48px;
}
.brand-mark {
width: 46px;
height: 46px;
.brand-mark,
.brand-mark__logo {
width: 38px;
height: 38px;
}
.brand-title {
font-size: 15px;
font-size: 16px;
}
.brand-subtitle {
display: none;
}
.topbar-actions {
gap: 2px;
}
.topbar-context-divider,
.topbar-divider,
.page-context {
display: none;
}
@@ -819,7 +1055,7 @@ watch(
}
.drawer-bottom {
padding: var(--space-2) var(--space-3) calc(var(--space-3) + env(safe-area-inset-bottom));
padding: var(--space-3) var(--space-3) calc(var(--space-3) + env(safe-area-inset-bottom));
}
:deep(.drawer-bottom .v-btn) {
@@ -830,22 +1066,16 @@ watch(
padding: var(--space-4);
}
.brand-subtitle {
display: none;
}
.hoard-footer {
justify-content: center !important;
gap: var(--space-2);
.footer-inner {
justify-content: center;
padding-inline:
max(var(--space-2), env(safe-area-inset-left))
max(var(--space-2), env(safe-area-inset-right));
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;
gap: var(--space-2);
}
.app-banner-stack {
@@ -870,7 +1100,7 @@ watch(
.footer-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
:deep(.footer-links .v-btn) {