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
+10 -6
View File
@@ -1,10 +1,14 @@
<!DOCTYPE html>
<html lang="">
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1c652f" />
<meta name="description" content="Hoard self-hosted Datei- und Markdown-Workspace im Browser." />
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<title>Hoard · Self-hosted Workspace</title>
</head>
<body>
<div id="app"></div>
+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) {
+307 -91
View File
@@ -1,85 +1,143 @@
/* =============================================================================
Hoard Globale Tokens, Resets und Vuetify-Anpassungen
Quelle: GUI/style.md
============================================================================= */
:root {
color-scheme: light;
--color-bg: #f6f8f5;
/* Surfaces */
--color-bg: #f5f8f2;
--color-bg-tint: #eef3ea;
--color-surface: #ffffff;
--color-surface-alt: #f1f4ef;
--color-border: #dce4d8;
--color-border-strong: #c7d2c2;
--color-surface-alt: #f1f4ee;
--color-surface-elevated: #fbfcf8;
--color-text: #1f2a21;
--color-text-secondary: #5f6e62;
--color-text-muted: #7d8a80;
--color-border: #dce3d6;
--color-border-strong: #c5cfbe;
--color-border-subtle: #e8ede2;
/* Text */
--color-text: #1a2a1e;
--color-text-secondary: #5a6a5e;
--color-text-muted: #7b897f;
--color-text-on-primary: #ffffff;
/* Brand */
--color-primary-800: #10421e;
--color-primary-700: #1c652f;
--color-primary-600: #2e7d32;
--color-primary-500: #3c8f42;
--color-primary-300: #a8d5a2;
--color-primary-100: #eaf5e8;
--color-primary-050: #f4faf1;
--color-accent-lime: #b7e36b;
/* Status */
--color-success: #2e7d32;
--color-warning: #b7791f;
--color-danger: #c0392b;
--color-info: #2f6fb3;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
/* Radius */
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--radius-xl: 22px;
--radius-full: 999px;
--shadow-sm: 0 1px 2px rgb(16 24 18 / 6%);
--shadow-md: 0 8px 22px rgb(16 24 18 / 9%);
--shadow-lg: 0 16px 42px rgb(16 24 18 / 11%);
/* Shadows */
--shadow-xs: 0 1px 1px rgb(20 30 22 / 4%);
--shadow-sm: 0 2px 6px rgb(20 30 22 / 6%);
--shadow-md: 0 8px 22px rgb(20 30 22 / 8%);
--shadow-lg: 0 18px 44px rgb(20 30 22 / 12%);
--shadow-glow: 0 12px 36px rgb(28 101 47 / 18%);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Typography */
--font-family-sans:
Inter, 'Segoe UI', Roboto, system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue',
Arial, sans-serif;
'Inter', 'Segoe UI', Roboto, system-ui, -apple-system, BlinkMacSystemFont,
'Helvetica Neue', Arial, sans-serif;
--font-family-mono:
'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo,
'SF Mono', Consolas, 'Liberation Mono', monospace;
--font-size-2xs: 11px;
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-md: 14px;
--font-size-lg: 18px;
--font-size-xl: 24px;
--font-size-lg: 16px;
--font-size-xl: 20px;
--font-size-2xl: 26px;
--font-size-3xl: 32px;
--font-size-display: 44px;
--line-height-tight: 1.3;
--line-height-tight: 1.2;
--line-height-normal: 1.5;
--line-height-loose: 1.65;
/* Motion */
--transition-fast: 160ms cubic-bezier(0.2, 0, 0, 1);
--transition-medium: 220ms cubic-bezier(0.2, 0, 0, 1);
--page-bg-soft: color-mix(in srgb, var(--color-primary-100) 36%, var(--color-bg) 64%);
--scrollbar-thumb: #a6b3a2;
--scrollbar-track: #e8ede5;
--transition-slow: 320ms cubic-bezier(0.2, 0, 0, 1);
/* Atmospheric helpers */
--page-bg-ambient:
radial-gradient(
90% 60% at 12% -10%,
color-mix(in srgb, var(--color-primary-100) 75%, transparent) 0%,
transparent 60%
),
radial-gradient(
80% 60% at 100% 0%,
color-mix(in srgb, var(--color-accent-lime) 12%, transparent) 0%,
transparent 55%
),
linear-gradient(180deg, var(--color-bg-tint) 0%, var(--color-bg) 360px);
--scrollbar-thumb: #b1bcab;
--scrollbar-thumb-hover: #95a290;
--scrollbar-track: transparent;
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #101215;
--color-surface: #171a1f;
--color-surface-alt: #1d2229;
--color-border: #2c333d;
--color-border-strong: #3a4350;
--color-bg: #0e1115;
--color-bg-tint: #11161c;
--color-surface: #161a20;
--color-surface-alt: #1b2028;
--color-surface-elevated: #1f252e;
--color-text: #ebeff3;
--color-text-secondary: #c5ccd4;
--color-text-muted: #95a0ad;
--color-border: #2a323d;
--color-border-strong: #3a4452;
--color-border-subtle: #232a34;
--color-text: #e9eff3;
--color-text-secondary: #b6bec8;
--color-text-muted: #8b94a0;
--color-text-on-primary: #08120a;
--color-primary-800: #8be194;
--color-primary-700: #5fb968;
--color-primary-600: #4ea758;
--color-primary-500: #3f9148;
--color-primary-300: #2e6a37;
--color-primary-100: #202822;
--color-primary-100: #1f2922;
--color-primary-050: #161e19;
--color-accent-lime: #b7e36b;
--color-success: #5fb968;
@@ -87,15 +145,30 @@
--color-danger: #e07a7a;
--color-info: #6aa8de;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 35%);
--shadow-md: 0 10px 24px rgb(0 0 0 / 38%);
--shadow-lg: 0 18px 44px rgb(0 0 0 / 45%);
--shadow-xs: 0 1px 2px rgb(0 0 0 / 30%);
--shadow-sm: 0 2px 6px rgb(0 0 0 / 35%);
--shadow-md: 0 10px 26px rgb(0 0 0 / 40%);
--shadow-lg: 0 18px 44px rgb(0 0 0 / 48%);
--shadow-glow: 0 14px 40px rgb(95 185 104 / 22%);
--page-bg-soft: color-mix(in srgb, var(--color-primary-100) 28%, var(--color-bg) 72%);
--scrollbar-thumb: #4f5763;
--scrollbar-track: #171b22;
--page-bg-ambient:
radial-gradient(
90% 60% at 12% -10%,
color-mix(in srgb, var(--color-primary-700) 18%, transparent) 0%,
transparent 60%
),
radial-gradient(
80% 60% at 100% 0%,
color-mix(in srgb, var(--color-accent-lime) 6%, transparent) 0%,
transparent 55%
),
linear-gradient(180deg, var(--color-bg-tint) 0%, var(--color-bg) 360px);
--scrollbar-thumb: #404a57;
--scrollbar-thumb-hover: #5a6573;
}
/* ---------- Reset / base ---------- */
*,
*::before,
*::after {
@@ -119,16 +192,15 @@ body {
font-family: var(--font-family-sans);
font-size: var(--font-size-md);
color: var(--color-text);
background:
linear-gradient(180deg, var(--page-bg-soft) 0, var(--color-bg) 280px),
var(--color-bg);
background: var(--page-bg-ambient);
background-attachment: fixed;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
overflow-y: scroll;
}
::selection {
background-color: color-mix(in srgb, var(--color-primary-300) 38%, transparent);
background-color: color-mix(in srgb, var(--color-primary-300) 42%, transparent);
color: var(--color-text);
}
@@ -151,6 +223,13 @@ h6 {
margin: 0 0 var(--space-4);
line-height: var(--line-height-tight);
color: var(--color-text);
font-weight: 600;
letter-spacing: -0.01em;
}
h1 {
font-weight: 700;
letter-spacing: -0.02em;
}
p {
@@ -159,12 +238,18 @@ p {
line-height: var(--line-height-loose);
}
code,
pre {
font-family: var(--font-family-mono);
}
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
/* App shell */
/* ---------- App shell ---------- */
.v-application {
font-family: var(--font-family-sans) !important;
color: var(--color-text) !important;
@@ -176,45 +261,75 @@ p {
}
.v-app-bar {
background-color: var(--color-surface) !important;
background-color: color-mix(in srgb, var(--color-surface) 92%, transparent) !important;
backdrop-filter: saturate(120%) blur(8px);
border-bottom: 1px solid var(--color-border) !important;
box-shadow: var(--shadow-sm) !important;
box-shadow: var(--shadow-xs) !important;
}
.v-navigation-drawer {
background-color: var(--color-surface-alt) !important;
border-right: 1px solid var(--color-border) !important;
background-image: linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface-alt) 96%, var(--color-primary-100) 4%),
var(--color-surface-alt) 220px
);
}
.v-navigation-drawer .v-list-item {
position: relative;
border-radius: var(--radius-md) !important;
margin: 2px var(--space-2);
min-height: 44px;
transition:
background-color var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
color var(--transition-fast);
}
.v-navigation-drawer .v-list-item::before {
content: '';
position: absolute;
left: 4px;
top: 50%;
width: 3px;
height: 0;
background-color: var(--color-primary-600);
border-radius: var(--radius-full);
transform: translateY(-50%);
transition: height var(--transition-medium);
}
.v-navigation-drawer .v-list-item:hover {
background-color: color-mix(in srgb, var(--color-primary-100) 45%, var(--color-surface-alt) 55%) !important;
transform: translateX(2px);
background-color: color-mix(
in srgb,
var(--color-primary-100) 50%,
var(--color-surface-alt) 50%
) !important;
}
.v-navigation-drawer .v-list-item--active {
color: var(--color-primary-700) !important;
background-color: var(--color-primary-100) !important;
box-shadow: inset 3px 0 0 var(--color-primary-600);
background-color: color-mix(
in srgb,
var(--color-primary-100) 75%,
var(--color-surface) 25%
) !important;
font-weight: 600;
}
/* Surface components */
.v-navigation-drawer .v-list-item--active::before {
height: 60%;
}
/* ---------- Cards / surfaces ---------- */
.v-card {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-lg) !important;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 92%, var(--color-surface-alt) 8%),
color-mix(in srgb, var(--color-surface) 96%, var(--color-surface-alt) 4%),
var(--color-surface)
) !important;
box-shadow: var(--shadow-sm) !important;
@@ -226,8 +341,9 @@ p {
.v-card-title {
color: var(--color-text) !important;
font-size: var(--font-size-lg) !important;
font-size: var(--font-size-xl) !important;
font-weight: 600 !important;
letter-spacing: -0.01em;
}
.v-card-text {
@@ -236,14 +352,14 @@ p {
.v-footer {
border-top: 1px solid var(--color-border);
background-color: color-mix(in srgb, var(--color-surface) 90%, var(--color-bg) 10%) !important;
background-color: color-mix(in srgb, var(--color-surface) 92%, var(--color-bg) 8%) !important;
}
/* Buttons */
/* ---------- Buttons ---------- */
.v-btn {
letter-spacing: 0 !important;
text-transform: none !important;
border-radius: var(--radius-md) !important;
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
transition:
background-color var(--transition-fast),
@@ -255,7 +371,7 @@ p {
.v-btn--variant-elevated,
.v-btn--variant-flat {
box-shadow: none !important;
box-shadow: var(--shadow-xs) !important;
}
.v-btn--variant-elevated:not(.v-btn--disabled):hover,
@@ -268,19 +384,32 @@ p {
transform: translateY(0);
}
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled) {
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled),
.v-btn.v-btn--variant-flat:not(.v-btn--disabled) {
color: var(--color-text-on-primary) !important;
background-color: var(--color-primary-700) !important;
background:
linear-gradient(
180deg,
var(--color-primary-600),
var(--color-primary-700)
) !important;
}
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled):hover {
background-color: var(--color-primary-600) !important;
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled):hover,
.v-btn.v-btn--variant-flat:not(.v-btn--disabled):hover {
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary-600) 90%, var(--color-accent-lime) 10%),
var(--color-primary-700)
) !important;
}
.v-btn.v-btn--disabled {
opacity: 1 !important;
color: var(--color-text-muted) !important;
background-color: color-mix(in srgb, var(--color-surface-alt) 82%, var(--color-surface) 18%) !important;
background:
color-mix(in srgb, var(--color-surface-alt) 80%, var(--color-surface) 20%) !important;
border-color: var(--color-border) !important;
box-shadow: none !important;
}
@@ -288,15 +417,29 @@ p {
.v-btn--variant-outlined {
border-color: var(--color-border-strong) !important;
color: var(--color-text) !important;
background-color: color-mix(
in srgb,
var(--color-surface) 88%,
var(--color-surface-alt) 12%
) !important;
}
.v-btn--variant-outlined:not(.v-btn--disabled):hover,
.v-btn--variant-text:not(.v-btn--disabled):hover {
background-color: color-mix(in srgb, var(--color-primary-100) 38%, transparent) !important;
border-color: color-mix(in srgb, var(--color-primary-600) 52%, var(--color-border-strong) 48%) !important;
background-color: color-mix(
in srgb,
var(--color-primary-100) 38%,
transparent
) !important;
border-color: color-mix(
in srgb,
var(--color-primary-600) 50%,
var(--color-border-strong) 50%
) !important;
color: var(--color-primary-700) !important;
}
/* Inputs */
/* ---------- Inputs ---------- */
.v-input .v-field {
border-radius: var(--radius-md) !important;
background-color: var(--color-surface) !important;
@@ -315,10 +458,9 @@ p {
}
.v-input.v-input--focused .v-field {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary-300) 28%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary-300) 32%, transparent);
}
/* Vuetify steuert den Fokuszustand bereits am Feld; verhindert doppelten Fokusrahmen im Input */
.v-input .v-field :is(input, textarea, select):focus-visible {
outline: none !important;
}
@@ -330,11 +472,11 @@ p {
.v-overlay .v-list {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-surface) !important;
background-color: var(--color-surface-elevated) !important;
box-shadow: var(--shadow-lg) !important;
}
/* Tables and list-like content */
/* ---------- Tables ---------- */
.v-table {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
@@ -346,25 +488,43 @@ p {
color: var(--color-text-secondary) !important;
font-weight: 600 !important;
background-color: var(--color-surface-alt) !important;
font-size: var(--font-size-xs) !important;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.v-table tbody tr:hover td {
background-color: color-mix(in srgb, var(--color-primary-100) 35%, var(--color-surface) 65%) !important;
background-color: color-mix(
in srgb,
var(--color-primary-100) 32%,
var(--color-surface) 68%
) !important;
}
.v-table tbody td {
border-bottom-color: color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%) !important;
border-bottom-color: var(--color-border-subtle) !important;
}
/* Status helpers */
/* ---------- Status pills ---------- */
.hoard-status {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
padding: 3px var(--space-2);
border-radius: var(--radius-xs);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
}
.hoard-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background-color: currentcolor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentcolor 22%, transparent);
}
.hoard-status--success {
@@ -374,25 +534,35 @@ p {
.hoard-status--warning {
color: var(--color-warning);
background-color: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface) 85%);
background-color: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface) 84%);
}
.hoard-status--danger {
color: var(--color-danger);
background-color: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface) 88%);
background-color: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface) 86%);
}
.hoard-status--info {
color: var(--color-info);
background-color: color-mix(in srgb, var(--color-info) 12%, var(--color-surface) 88%);
background-color: color-mix(in srgb, var(--color-info) 14%, var(--color-surface) 86%);
}
/* Reusable layout helpers for file/productivity pages */
.hoard-status--muted {
color: var(--color-text-muted);
background-color: color-mix(in srgb, var(--color-surface-alt) 70%, var(--color-surface) 30%);
}
.hoard-status--muted::before {
background-color: var(--color-text-muted);
}
/* ---------- Reusable surfaces ---------- */
.hoard-panel {
position: relative;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 93%, var(--color-surface-alt) 7%),
color-mix(in srgb, var(--color-surface) 95%, var(--color-surface-alt) 5%),
var(--color-surface)
);
border: 1px solid var(--color-border);
@@ -404,14 +574,28 @@ p {
transform var(--transition-fast);
}
.hoard-panel--elevated {
background-color: var(--color-surface-elevated);
box-shadow: var(--shadow-md);
}
.hoard-panel--ghost {
background:
color-mix(in srgb, var(--color-surface-alt) 60%, var(--color-surface) 40%);
border-color: var(--color-border-subtle);
box-shadow: none;
}
.hoard-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-alt);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.hoard-list-row {
@@ -420,20 +604,28 @@ p {
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, var(--color-surface) 15%);
border-bottom: 1px solid var(--color-border-subtle);
transition:
background-color var(--transition-fast),
transform var(--transition-fast);
}
.hoard-list-row:hover {
background-color: color-mix(in srgb, var(--color-primary-100) 35%, var(--color-surface) 65%);
background-color: color-mix(
in srgb,
var(--color-primary-100) 32%,
var(--color-surface) 68%
);
transform: translateX(2px);
}
.hoard-list-row.is-selected {
color: var(--color-primary-700);
background-color: var(--color-primary-100);
background-color: color-mix(
in srgb,
var(--color-primary-100) 70%,
var(--color-surface) 30%
);
}
.hoard-meta {
@@ -442,18 +634,23 @@ p {
}
.hoard-empty-state {
padding: var(--space-8) var(--space-6);
padding: var(--space-10) var(--space-6);
text-align: center;
color: var(--color-text-secondary);
}
.hoard-empty-state h2 {
margin-bottom: var(--space-3);
font-size: 20px;
margin-bottom: var(--space-2);
font-size: var(--font-size-xl);
font-weight: 600;
}
/* Scrollbar refinement */
.hoard-empty-state p {
margin: 0 auto;
max-width: 44ch;
}
/* ---------- Scrollbar ---------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
@@ -470,13 +667,17 @@ p {
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 10px;
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--scrollbar-thumb) 85%, var(--color-text) 15%);
background-color: var(--scrollbar-thumb-hover);
background-clip: content-box;
}
/* ---------- Animations ---------- */
@keyframes hoard-soft-enter {
from {
opacity: 0;
@@ -489,6 +690,20 @@ p {
}
}
@keyframes hoard-pulse-ring {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary-300) 35%, transparent);
}
70% {
box-shadow: 0 0 0 12px color-mix(in srgb, var(--color-primary-300) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -506,6 +721,7 @@ p {
}
}
/* ---------- Responsive ---------- */
@media (width <= 960px) {
:root {
--hoard-mobile-safe-left: max(var(--space-2), env(safe-area-inset-left));
@@ -545,7 +761,7 @@ p {
}
.hoard-empty-state {
padding: var(--space-6) var(--space-4);
padding: var(--space-8) var(--space-4);
}
.v-navigation-drawer {
+39 -12
View File
@@ -1,10 +1,4 @@
import 'vuetify/styles'
import '@fontsource/roboto/100.css'
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto/900.css'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
@@ -14,6 +8,33 @@ import { aliases, mdi } from 'vuetify/iconsets/mdi'
export default createVuetify({
components,
directives,
defaults: {
VBtn: {
rounded: 'md',
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VTextarea: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VAlert: {
variant: 'tonal',
border: 'start',
},
VCard: {
rounded: 'lg',
},
},
theme: {
defaultTheme: 'light',
themes: {
@@ -21,9 +42,12 @@ export default createVuetify({
dark: false,
colors: {
primary: '#1C652F',
secondary: '#5F6E62',
background: '#F6F8F5',
'primary-darken-1': '#10421E',
secondary: '#5A6A5E',
accent: '#B7E36B',
background: '#F5F8F2',
surface: '#FFFFFF',
'surface-variant': '#F1F4EE',
success: '#2E7D32',
warning: '#B7791F',
error: '#C0392B',
@@ -33,10 +57,13 @@ export default createVuetify({
dark: {
dark: true,
colors: {
primary: '#4EA758',
secondary: '#A7B0BC',
background: '#101215',
surface: '#171A1F',
primary: '#5FB968',
'primary-darken-1': '#4EA758',
secondary: '#B6BEC8',
accent: '#B7E36B',
background: '#0E1115',
surface: '#161A20',
'surface-variant': '#1B2028',
success: '#5FB968',
warning: '#D0A34E',
error: '#E07A7A',
+79 -37
View File
@@ -32,7 +32,7 @@ function scheduleAutoRedirect() {
autoRedirectTimer = setTimeout(() => {
void router.replace({ name: redirectRouteName.value })
}, 2000)
}, 4000)
}
async function resolveRedirectTarget() {
@@ -73,32 +73,43 @@ onBeforeUnmount(() => {
<template>
<v-container fluid class="not-found-page hoard-page hoard-page--centered">
<section class="not-found-shell hoard-panel hoard-shell-grid hoard-panel-gradient">
<section class="not-found-shell hoard-panel hoard-panel-gradient hoard-spotlight hoard-shell-grid">
<div class="not-found-visual">
<div class="image-frame">
<img :src="notFoundImage" alt="Illustration für eine nicht gefundene Seite" class="not-found-image" />
<img
:src="notFoundImage"
alt="Illustration für eine nicht gefundene Seite"
class="not-found-image"
/>
</div>
</div>
<div class="not-found-content">
<p class="not-found-kicker hoard-kicker hoard-kicker--wide">Fehler 404</p>
<h1>Seite nicht gefunden</h1>
<p class="not-found-code">404</p>
<p class="hoard-kicker hoard-kicker--wide">Seite nicht gefunden</p>
<h1>Diese Spur führt ins Leere.</h1>
<p class="not-found-text">
Der Link ist ungültig oder die Seite wurde verschoben. Du kannst direkt zur
{{ autoRedirectTargetLabel }} weitergehen oder die vorherige Ansicht öffnen.
Du wirst automatisch dorthin weitergeleitet.
Der Link ist ungültig oder die Seite wurde verschoben. Wir leiten dich gleich zur
{{ autoRedirectTargetLabel }} weiter oder du nutzt direkt einen der Buttons unten.
</p>
<div class="not-found-actions hoard-action-row">
<v-btn
color="primary"
variant="elevated"
:prepend-icon="redirectButtonIcon"
@click="navigateToPrimaryTarget"
>
{{ redirectButtonLabel }}
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">Zurück</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">
Zurück
</v-btn>
</div>
<p class="not-found-hint">
<v-icon icon="mdi-clock-time-four-outline" size="14" />
Auto-Redirect zur {{ autoRedirectTargetLabel }} in wenigen Sekunden.
</p>
</div>
</section>
</v-container>
@@ -106,13 +117,15 @@ onBeforeUnmount(() => {
<style scoped>
.not-found-shell {
--hoard-shell-width: 980px;
--hoard-gradient-angle: 180deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-surface) 94%, var(--color-primary-100) 6%);
--hoard-gradient-end: color-mix(in srgb, var(--color-surface) 82%, var(--color-surface-alt) 18%);
--hoard-gradient-end-stop: 100%;
--hoard-shell-width: 1040px;
--hoard-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 65%;
grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr);
border-radius: var(--radius-xl);
align-items: center;
}
.not-found-visual {
@@ -122,13 +135,20 @@ onBeforeUnmount(() => {
}
.image-frame {
width: min(100%, 360px);
position: relative;
width: min(100%, 380px);
aspect-ratio: 1 / 1;
padding: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--color-surface-alt) 84%, var(--color-surface) 16%);
box-shadow: var(--shadow-sm);
border-radius: var(--radius-xl);
background:
radial-gradient(
120% 80% at 0% 100%,
color-mix(in srgb, var(--color-primary-100) 50%, transparent),
transparent 70%
),
color-mix(in srgb, var(--color-surface-alt) 86%, var(--color-surface) 14%);
box-shadow: var(--shadow-md);
}
.not-found-image {
@@ -143,31 +163,59 @@ onBeforeUnmount(() => {
justify-content: center;
}
h1 {
margin-bottom: var(--space-3);
font-size: 2.35rem;
.not-found-code {
margin: 0 0 var(--space-3);
font-family: var(--font-family-mono);
font-size: 88px;
font-weight: 700;
letter-spacing: -0.05em;
line-height: 1;
background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-500) 60%, var(--color-accent-lime) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
h1 {
margin: 0 0 var(--space-3);
font-size: clamp(1.8rem, 1.4rem + 1vw, 2.4rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.not-found-text {
margin-bottom: var(--space-6);
max-width: 44ch;
margin: 0 0 var(--space-5);
max-width: 48ch;
color: var(--color-text-secondary);
font-size: 15px;
font-size: var(--font-size-md);
line-height: 1.6;
}
.not-found-actions {
align-items: center;
}
.not-found-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-5) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.not-found-hint .v-icon {
color: var(--color-primary-700);
}
@media (prefers-reduced-motion: no-preference) {
.not-found-visual,
.not-found-content {
animation: hoard-soft-enter 260ms both;
animation: hoard-soft-enter 320ms both;
}
.not-found-content {
animation-delay: 80ms;
animation-delay: 100ms;
}
}
@@ -196,23 +244,17 @@ h1 {
}
@media (width <= 600px) {
.not-found-shell {
--hoard-shell-padding-block-mobile-xs: var(--space-4);
--hoard-shell-padding-inline-mobile-xs: var(--space-3);
}
h1 {
font-size: 1.85rem;
.not-found-code {
font-size: 64px;
}
.not-found-text {
margin-bottom: var(--space-4);
font-size: var(--font-size-md);
}
.image-frame {
width: min(100%, 260px);
padding: var(--space-3);
padding: var(--space-4);
}
.not-found-actions {
+98 -14
View File
@@ -8,6 +8,9 @@ const isLoading = ref(true)
const isAuthenticated = ref(false)
const primaryActionLabel = computed(() => (isAuthenticated.value ? 'Zum Dashboard' : 'Zum Login'))
const primaryActionIcon = computed(() =>
isAuthenticated.value ? 'mdi-view-dashboard-outline' : 'mdi-login',
)
async function resolveAuthState() {
try {
@@ -29,6 +32,15 @@ async function navigatePrimaryAction() {
await router.replace({ name: 'Login' })
}
async function navigateBack() {
if (window.history.length > 1) {
router.back()
return
}
await router.replace({ name: isAuthenticated.value ? 'Dashboard' : 'Home' })
}
onMounted(() => {
void resolveAuthState()
})
@@ -36,23 +48,34 @@ onMounted(() => {
<template>
<v-container fluid class="forbidden-page hoard-page hoard-page--centered">
<section class="forbidden-shell hoard-shell-grid hoard-panel">
<section class="forbidden-shell hoard-panel hoard-panel-gradient hoard-spotlight">
<div class="forbidden-icon">
<span class="forbidden-icon__halo" aria-hidden="true" />
<v-icon icon="mdi-shield-alert-outline" size="40" />
</div>
<header class="forbidden-head">
<p class="hoard-kicker">Fehlende Berechtigung</p>
<h1>Kein Zugriff</h1>
<p>Dein Konto hat keine ausreichende Rolle für diese Seite.</p>
<p class="hoard-kicker hoard-kicker--wide">Fehlende Berechtigung</p>
<h1>Kein Zugriff.</h1>
<p>
Dein Konto hat aktuell keine ausreichende Rolle, um diese Seite zu sehen. Falls das ein Fehler ist,
wende dich an einen Admin oder wechsle zurück in deinen freigegebenen Bereich.
</p>
</header>
<div class="forbidden-actions hoard-action-row">
<v-btn
color="primary"
prepend-icon="mdi-arrow-right"
variant="elevated"
:prepend-icon="primaryActionIcon"
:loading="isLoading"
:disabled="isLoading"
@click="navigatePrimaryAction"
>
{{ primaryActionLabel }}
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">
Zurück
</v-btn>
</div>
</section>
</v-container>
@@ -60,30 +83,91 @@ onMounted(() => {
<style scoped>
.forbidden-page {
--hoard-shell-width: min(640px, 100%);
--hoard-centered-offset: 200px;
}
.forbidden-shell {
gap: var(--space-4);
--hoard-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-warning) 14%, var(--color-surface) 86%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 65%;
display: flex;
flex-direction: column;
gap: var(--space-5);
align-items: center;
text-align: center;
width: min(640px, 100%);
padding: var(--space-10) var(--space-7);
border-radius: var(--radius-xl);
}
.forbidden-head h1,
.forbidden-head p {
margin: 0;
.forbidden-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background:
linear-gradient(135deg, var(--color-warning), color-mix(in srgb, var(--color-warning) 60%, var(--color-danger) 40%));
color: #fff;
box-shadow:
var(--shadow-md),
inset 0 0 0 2px color-mix(in srgb, var(--color-accent-lime) 18%, transparent);
}
.forbidden-icon__halo {
position: absolute;
inset: -10px;
border-radius: var(--radius-full);
background:
radial-gradient(
closest-side,
color-mix(in srgb, var(--color-warning) 38%, transparent),
transparent 70%
);
filter: blur(12px);
opacity: 0.6;
}
.forbidden-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
margin: 0 0 var(--space-2);
font-size: clamp(1.8rem, 1.4rem + 1vw, 2.4rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.forbidden-head p {
margin: 0 auto;
max-width: 50ch;
color: var(--color-text-secondary);
line-height: 1.6;
}
.forbidden-actions {
justify-content: center;
}
@media (prefers-reduced-motion: no-preference) {
.forbidden-shell {
animation: hoard-soft-enter 260ms both;
animation: hoard-soft-enter 280ms both;
}
}
@media (width <= 600px) {
.forbidden-shell {
padding: var(--space-7) var(--space-5);
}
.forbidden-actions {
width: 100%;
}
:deep(.forbidden-actions .v-btn) {
width: 100%;
min-height: 44px;
}
}
</style>
+429 -204
View File
@@ -1,19 +1,21 @@
<script setup lang="ts">
import iconImage from '@/assets/images/icon.svg'
const valueProps = [
{
icon: 'mdi-folder-multiple-outline',
title: 'Dateien zuerst',
text: 'Ordner, Dateiliste und Vorschau sind der Kern. Kein überladenes Dashboard-Gefühl.',
text: 'Ordner, Dateiliste und Vorschau bilden den Kern kein überladenes Dashboard, keine Ablenkung.',
},
{
icon: 'mdi-file-document-edit-outline',
title: 'Markdown direkt im Browser',
text: 'Dokumente bearbeiten, lesen und strukturieren ohne Tool-Wechsel.',
text: 'Notizen, Doku und Konzepte ohne Tool-Wechsel öffnen, lesen und sauber strukturieren.',
},
{
icon: 'mdi-server-outline',
title: 'Self-hosted Kontrolle',
text: 'Dateimetadaten in PostgreSQL, Dateien in MinIO, alles auf deinem Server.',
text: 'Metadaten in PostgreSQL, Dateien in MinIO. Volle Datenhoheit auf deinem eigenen Server.',
},
]
@@ -26,17 +28,17 @@ const coreFeatures = [
{
icon: 'mdi-image-outline',
title: 'PDF- und Bildvorschau',
text: 'Dateien öffnen und direkt einsehen, ohne externe Viewer oder Downloads.',
text: 'Dateien direkt einsehen, ohne externe Viewer oder ständige Downloads.',
},
{
icon: 'mdi-account-lock-outline',
title: 'Klare Benutzerlogik',
text: 'Keine offene Registrierung. Accounts werden bewusst und kontrolliert verwaltet.',
text: 'Keine offene Registrierung. Konten werden bewusst und kontrolliert verwaltet.',
},
{
icon: 'mdi-lightning-bolt-outline',
title: 'Schlankes MVP-Setup',
text: 'Fokus auf das Wesentliche: stabil, wartbar und realistisch für Solo-Entwicklung.',
text: 'Fokus auf das Wesentliche stabil, wartbar und realistisch für Solo-Entwicklung.',
},
]
@@ -44,12 +46,12 @@ const workflowSteps = [
{
number: '01',
title: 'Anmelden',
text: 'Melde dich mit einem vorhandenen Konto an und starte direkt in deiner Dateiablage.',
text: 'Mit deinem bestehenden Konto einsteigen und direkt in deiner Dateiablage starten.',
},
{
number: '02',
title: 'Dateien strukturieren',
text: 'Lege Ordner an, lade Dateien hoch und halte deine Arbeitsbereiche aufgeräumt.',
text: 'Ordner anlegen, Inhalte hochladen und Arbeitsbereiche aufgeräumt halten.',
},
{
number: '03',
@@ -58,93 +60,159 @@ const workflowSteps = [
},
]
const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3', 'Cookie Auth']
const techStack = [
{ label: 'Vue 3', icon: 'mdi-vuejs' },
{ label: 'ASP.NET Core', icon: 'mdi-dot-net' },
{ label: 'PostgreSQL', icon: 'mdi-database-outline' },
{ label: 'MinIO', icon: 'mdi-cloud-outline' },
{ label: 'md-editor-v3', icon: 'mdi-language-markdown-outline' },
{ label: 'Cookie-Auth', icon: 'mdi-cookie-outline' },
]
</script>
<template>
<v-container fluid class="landing-page hoard-page">
<section class="hero hoard-panel hoard-panel-gradient">
<section class="hero hoard-panel hoard-panel-gradient hoard-spotlight">
<div class="hero-copy">
<p class="hero-kicker hoard-kicker">Self-hosted Datei-Workspace</p>
<h1>Hoard ist deine ruhige Startseite für Dateien, Ordner und Markdown.</h1>
<p class="hero-kicker hoard-kicker hoard-kicker--wide">Self-hosted Datei-Workspace</p>
<h1>
Eine ruhige Heimat für deine
<span class="hero-accent">Dateien, Ordner und Notizen.</span>
</h1>
<p class="hero-lead">
Eine einfache, Google-Drive-inspirierte Web-App für Teams, die volle Kontrolle über
Daten, Struktur und Workflow behalten wollen.
Hoard ist eine Google-Drive-inspirierte Web-App für Teams, die volle Kontrolle über
Daten, Struktur und Workflow behalten wollen ohne Cloud-Lock-in, ohne SaaS-Abo.
</p>
<div class="hero-actions hoard-action-row">
<v-btn color="primary" size="large" prepend-icon="mdi-login" to="/login">
Zum Login
<v-btn variant="elevated" size="large" prepend-icon="mdi-login" to="/login">
Anmelden
</v-btn>
<v-btn variant="outlined" size="large" prepend-icon="mdi-file-document-outline" to="/impressum">
<v-btn variant="outlined" size="large" prepend-icon="mdi-arrow-right" to="/impressum">
Mehr erfahren
</v-btn>
</div>
<div class="hero-tags">
<span class="hero-tag">Light-first UX</span>
<span class="hero-tag">Mehrbenutzerfähig</span>
<span class="hero-tag">Ohne SaaS-Abhängigkeit</span>
<span class="hoard-chip hoard-chip--brand">
<v-icon icon="mdi-shield-check-outline" size="14" /> Datenhoheit
</span>
<span class="hoard-chip">
<v-icon icon="mdi-account-multiple-outline" size="14" /> Mehrbenutzerfähig
</span>
<span class="hoard-chip">
<v-icon icon="mdi-weather-night" size="14" /> Light- und Dark-Mode
</span>
</div>
</div>
<div class="hero-preview">
<header class="preview-head">
<p class="preview-title">Beispielansicht</p>
<span class="preview-pill">Workspace</span>
</header>
<div class="preview-list">
<article class="preview-row">
<v-icon icon="mdi-folder-outline" size="18" />
<div>
<p class="row-title">Dokumentation</p>
<p class="row-meta">Ordner · vor 3 Tagen aktualisiert</p>
<aside class="hero-preview" aria-hidden="true">
<div class="preview-window">
<header class="preview-window__head">
<span class="preview-window__dots">
<span /><span /><span />
</span>
<span class="preview-window__path">
hoard / dokumentation
</span>
</header>
<div class="preview-window__body">
<header class="preview-toolbar">
<span class="preview-toolbar__title">Dokumentation</span>
<span class="hoard-chip hoard-chip--brand">
<v-icon icon="mdi-folder-outline" size="14" /> 3 Ordner · 12 Dateien
</span>
</header>
<div class="preview-list">
<article class="preview-row">
<span class="preview-row__icon">
<v-icon icon="mdi-folder-outline" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">Konzepte</p>
<p class="preview-row__meta">Ordner · vor 2 Tagen</p>
</div>
<span class="hoard-status hoard-status--muted">Geteilt</span>
</article>
<article class="preview-row preview-row--active">
<span class="preview-row__icon">
<v-icon icon="mdi-language-markdown-outline" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">roadmap.md</p>
<p class="preview-row__meta">Markdown · 18 KB</p>
</div>
<span class="hoard-status hoard-status--success">Editor</span>
</article>
<article class="preview-row">
<span class="preview-row__icon">
<v-icon icon="mdi-file-pdf-box" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">api-reference.pdf</p>
<p class="preview-row__meta">PDF · 1,2 MB</p>
</div>
<span class="hoard-status hoard-status--info">Vorschau</span>
</article>
<article class="preview-row">
<span class="preview-row__icon">
<v-icon icon="mdi-image-outline" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">screen-2026-04.png</p>
<p class="preview-row__meta">Bild · 480 KB</p>
</div>
<span class="hoard-status hoard-status--muted">Bereit</span>
</article>
</div>
</article>
<article class="preview-row">
<v-icon icon="mdi-file-document-outline" size="18" />
<div>
<p class="row-title">roadmap.md</p>
<p class="row-meta">Markdown · 18 KB</p>
</div>
</article>
<article class="preview-row">
<v-icon icon="mdi-file-pdf-box" size="18" />
<div>
<p class="row-title">api-reference.pdf</p>
<p class="row-meta">PDF · 1.2 MB</p>
</div>
</article>
</div>
</div>
</div>
<div class="preview-glow" aria-hidden="true">
<img :src="iconImage" alt="" class="preview-glow__logo" />
</div>
</aside>
</section>
<section class="value-grid">
<article v-for="item in valueProps" :key="item.title" class="value-card hoard-panel">
<v-icon :icon="item.icon" size="22" />
<span class="hoard-icon-tile hoard-icon-tile--lg">
<v-icon :icon="item.icon" size="22" />
</span>
<h2>{{ item.title }}</h2>
<p>{{ item.text }}</p>
</article>
</section>
<section class="feature-section hoard-panel">
<header class="section-head">
<p class="section-kicker hoard-kicker">Für den Produktivalltag</p>
<h2>Weniger Tool-Chaos, mehr Fokus auf Inhalte</h2>
<header class="hoard-section-head">
<p class="hoard-kicker">Für den Produktivalltag</p>
<h2>Weniger Tool-Chaos, mehr Fokus auf Inhalte.</h2>
<p>Hoard bündelt Datei- und Markdown-Workflows in einer ruhigen Oberfläche, die nicht ablenkt.</p>
</header>
<div class="feature-grid">
<article v-for="feature in coreFeatures" :key="feature.title" class="feature-card">
<v-icon :icon="feature.icon" size="20" />
<h3>{{ feature.title }}</h3>
<p>{{ feature.text }}</p>
<span class="hoard-icon-tile">
<v-icon :icon="feature.icon" size="20" />
</span>
<div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.text }}</p>
</div>
</article>
</div>
</section>
<section class="workflow-section">
<header class="section-head">
<p class="section-kicker hoard-kicker">So funktioniert Hoard</p>
<h2>In drei klaren Schritten produktiv starten</h2>
<header class="hoard-section-head">
<p class="hoard-kicker">So funktioniert Hoard</p>
<h2>In drei klaren Schritten produktiv starten.</h2>
</header>
<div class="workflow-grid">
<article v-for="step in workflowSteps" :key="step.number" class="workflow-card hoard-panel">
@@ -155,17 +223,20 @@ const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3
</div>
</section>
<section class="stack-section hoard-panel">
<section class="stack-section hoard-panel hoard-panel-gradient">
<div class="stack-copy">
<p class="section-kicker hoard-kicker">Technische Basis</p>
<h2>Schlank gebaut für ein realistisches MVP</h2>
<p class="stack-text">
<p class="hoard-kicker">Technische Basis</p>
<h2>Schlank gebaut für ein realistisches MVP.</h2>
<p>
Hoard kombiniert einen modernen Frontend-Stack mit einem pragmatischen Backend-Setup,
damit Weiterentwicklung und Betrieb auch solo gut machbar bleiben.
damit Weiterentwicklung und Betrieb auch solo machbar bleiben.
</p>
</div>
<div class="stack-list">
<span v-for="item in techStack" :key="item" class="stack-pill">{{ item }}</span>
<span v-for="item in techStack" :key="item.label" class="stack-pill">
<v-icon :icon="item.icon" size="16" />
{{ item.label }}
</span>
</div>
</section>
</v-container>
@@ -173,45 +244,59 @@ const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3
<style scoped>
.landing-page {
--hoard-page-width: 1180px;
--hoard-page-width: 1200px;
}
/* ---------- Hero ---------- */
.hero {
--hoard-gradient-angle: 120deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 34%, var(--color-surface) 66%);
--hoard-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 52%;
--hoard-gradient-end-stop: 60%;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--space-6);
padding: var(--space-8);
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: var(--space-10);
padding: var(--space-12);
border-radius: var(--radius-xl);
overflow: hidden;
}
.workflow-number,
.preview-title,
.row-title,
.row-meta,
.stack-text {
margin: 0;
.hero-copy {
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
z-index: 1;
}
h1 {
margin-bottom: var(--space-4);
max-width: 20ch;
font-size: 3rem;
line-height: 1.08;
max-width: 18ch;
font-size: clamp(2.4rem, 1.6rem + 1.6vw, 3.2rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.025em;
}
.hero-accent {
background: linear-gradient(120deg, var(--color-primary-700) 30%, var(--color-primary-500) 70%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-lead {
margin-bottom: var(--space-5);
max-width: 50ch;
margin-bottom: var(--space-6);
max-width: 56ch;
color: var(--color-text-secondary);
font-size: 15px;
font-size: var(--font-size-lg);
line-height: 1.6;
}
.hero-actions {
margin-bottom: var(--space-4);
margin-bottom: var(--space-5);
}
.hero-tags {
@@ -220,56 +305,89 @@ h1 {
gap: var(--space-2);
}
.hero-tag {
display: inline-flex;
align-items: center;
padding: 5px var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: 999px;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
/* ---------- Hero preview ---------- */
.hero-preview {
position: relative;
align-self: center;
z-index: 1;
}
.hero-preview {
align-self: center;
padding: var(--space-5);
width: 100%;
border: 1px solid color-mix(in srgb, var(--color-border) 76%, var(--color-surface) 24%);
.preview-window {
position: relative;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface-alt) 82%, var(--color-surface) 18%),
color-mix(in srgb, var(--color-surface) 96%, var(--color-surface-alt) 4%),
var(--color-surface)
);
box-shadow: var(--shadow-sm);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.preview-head {
.preview-window__head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-alt);
}
.preview-window__dots {
display: inline-flex;
gap: 6px;
}
.preview-window__dots span {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background-color: color-mix(in srgb, var(--color-border-strong) 80%, var(--color-text-muted) 20%);
}
.preview-window__dots span:first-child {
background-color: color-mix(in srgb, var(--color-danger) 60%, var(--color-border-strong) 40%);
}
.preview-window__dots span:nth-child(2) {
background-color: color-mix(in srgb, var(--color-warning) 60%, var(--color-border-strong) 40%);
}
.preview-window__dots span:last-child {
background-color: color-mix(in srgb, var(--color-success) 60%, var(--color-border-strong) 40%);
}
.preview-window__path {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
letter-spacing: 0.02em;
}
.preview-window__body {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.preview-title {
.preview-toolbar__title {
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
}
.preview-pill {
display: inline-flex;
align-items: center;
padding: 4px var(--space-2);
border-radius: 999px;
color: var(--color-primary-700);
font-size: var(--font-size-xs);
font-weight: 600;
background-color: var(--color-primary-100);
}
.preview-list {
display: flex;
flex-direction: column;
@@ -278,44 +396,91 @@ h1 {
.preview-row {
display: grid;
grid-template-columns: auto 1fr;
grid-template-columns: auto 1fr auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: var(--color-surface);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
background-color var(--transition-fast);
}
.preview-row:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 52%, var(--color-border) 48%);
box-shadow: var(--shadow-sm);
transform: translateX(3px);
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) 28%, var(--color-surface) 72%);
}
.row-title {
.preview-row--active {
border-color: color-mix(in srgb, var(--color-primary-500) 60%, var(--color-border) 40%);
background-color: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
}
.preview-row__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--color-surface-alt) 70%, var(--color-surface) 30%);
color: var(--color-text-secondary);
}
.preview-row--active .preview-row__icon {
background-color: color-mix(in srgb, var(--color-primary-100) 80%, var(--color-surface) 20%);
color: var(--color-primary-700);
}
.preview-row__title,
.preview-row__meta {
margin: 0;
}
.preview-row__title {
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 500;
}
.row-meta {
.preview-row__meta {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
}
.preview-glow {
position: absolute;
inset: -10% -10% auto auto;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
filter: blur(0.6px);
opacity: 0.16;
}
.preview-glow__logo {
width: 70%;
height: 70%;
object-fit: contain;
}
/* ---------- Value grid ---------- */
.value-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
gap: var(--space-5);
}
.value-card {
padding: var(--space-5);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.value-card,
@@ -330,42 +495,28 @@ h1 {
.value-card:hover,
.workflow-card:hover,
.feature-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 48%, var(--color-border) 52%);
border-color: color-mix(in srgb, var(--color-primary-300) 50%, var(--color-border) 50%);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
:deep(.value-card > .v-icon),
:deep(.feature-card > .v-icon) {
display: inline-flex;
width: 36px;
height: 36px;
padding: var(--space-2);
border-radius: var(--radius-md);
color: var(--color-primary-700);
background-color: color-mix(in srgb, var(--color-primary-100) 82%, var(--color-surface) 18%);
}
.value-card h2,
.section-head h2 {
margin: var(--space-3) 0 var(--space-2);
font-size: 1.65rem;
.value-card h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 600;
letter-spacing: -0.01em;
}
.value-card p,
.feature-card p,
.workflow-card p,
.stack-text {
.workflow-card p {
margin: 0;
color: var(--color-text-secondary);
}
.feature-section,
.stack-section {
padding: var(--space-6);
}
.section-head {
margin-bottom: var(--space-5);
/* ---------- Feature section ---------- */
.feature-section {
padding: var(--space-8);
}
.feature-grid {
@@ -375,40 +526,88 @@ h1 {
}
.feature-card {
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%);
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-4);
align-items: flex-start;
padding: var(--space-5);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface) 80%, var(--color-surface-alt) 20%);
background-color: var(--color-surface);
}
.feature-card h3,
.workflow-card h3,
.stack-copy h2 {
margin: var(--space-2) 0;
.workflow-card h3 {
margin: 0 0 var(--space-1);
font-size: var(--font-size-lg);
font-weight: 600;
letter-spacing: -0.01em;
}
/* ---------- Workflow ---------- */
.workflow-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
gap: var(--space-5);
}
.workflow-card {
padding: var(--space-5);
position: relative;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.workflow-card::before {
content: '';
position: absolute;
left: var(--space-6);
top: var(--space-6);
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
filter: blur(8px);
opacity: 0.7;
z-index: 0;
}
.workflow-number {
position: relative;
margin: 0;
color: var(--color-primary-700);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: 700;
letter-spacing: 0.05em;
letter-spacing: 0.06em;
z-index: 1;
}
/* ---------- Stack ---------- */
.stack-section {
--hoard-gradient-angle: 110deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 50%, var(--color-surface) 50%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 70%;
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: var(--space-6);
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-8);
align-items: center;
padding: var(--space-8);
}
.stack-copy h2 {
margin: 0 0 var(--space-3);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.stack-copy p {
margin: 0;
color: var(--color-text-secondary);
max-width: 56ch;
}
.stack-list {
@@ -418,15 +617,30 @@ h1 {
}
.stack-pill {
padding: 6px var(--space-3);
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 8px var(--space-4);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
border-radius: var(--radius-full);
background-color: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: 500;
background-color: color-mix(in srgb, var(--color-surface-alt) 75%, var(--color-surface) 25%);
box-shadow: var(--shadow-xs);
transition: transform var(--transition-fast), border-color var(--transition-fast);
}
.stack-pill:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--color-primary-300) 60%, var(--color-border-strong) 40%);
}
.stack-pill .v-icon {
color: var(--color-primary-700);
}
/* ---------- Animations ---------- */
@media (prefers-reduced-motion: no-preference) {
.hero-copy,
.hero-preview,
@@ -434,22 +648,28 @@ h1 {
.feature-section,
.workflow-section,
.stack-section {
animation: hoard-soft-enter 260ms both;
animation: hoard-soft-enter 320ms both;
}
.hero-preview {
animation-delay: 70ms;
animation-delay: 80ms;
}
.value-card:nth-child(2),
.feature-section,
.workflow-section {
animation-delay: 90ms;
.value-card:nth-child(2) {
animation-delay: 80ms;
}
.value-card:nth-child(3),
.feature-section {
animation-delay: 140ms;
}
.workflow-section {
animation-delay: 180ms;
}
.stack-section {
animation-delay: 130ms;
animation-delay: 220ms;
}
.preview-row {
@@ -457,42 +677,53 @@ h1 {
}
.preview-row:nth-child(2) {
animation-delay: 70ms;
animation-delay: 60ms;
}
.preview-row:nth-child(3) {
animation-delay: 120ms;
}
.preview-row:nth-child(4) {
animation-delay: 180ms;
}
}
/* ---------- Responsive ---------- */
@media (width <= 1100px) {
.hero,
.stack-section {
.hero {
grid-template-columns: 1fr;
gap: var(--space-8);
padding: var(--space-8);
}
.hero-preview {
max-width: 720px;
}
.stack-section {
grid-template-columns: 1fr;
gap: var(--space-6);
}
}
@media (width <= 960px) {
.hero,
.feature-section,
.stack-section {
padding: var(--space-5);
}
.hero-actions {
gap: var(--space-2);
padding: var(--space-6);
}
.hero-preview {
padding: var(--space-4);
max-width: none;
}
.preview-window__body {
padding: var(--space-3);
}
.preview-row {
padding: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.value-grid,
@@ -503,29 +734,26 @@ h1 {
h1 {
max-width: none;
font-size: 2.25rem;
}
.value-card h2,
.section-head h2 {
font-size: 1.45rem;
.feature-card {
padding: var(--space-4);
}
}
@media (width <= 600px) {
.hero,
.feature-section,
.stack-section {
padding: var(--space-4);
}
h1 {
font-size: 1.9rem;
.stack-section,
.feature-card,
.value-card,
.workflow-card {
padding: var(--space-5);
}
.hero-lead {
margin-bottom: var(--space-4);
font-size: var(--font-size-md);
margin-bottom: var(--space-5);
}
.hero-actions {
@@ -541,20 +769,17 @@ h1 {
gap: 6px;
}
.hero-tag {
padding: 4px var(--space-2);
font-size: var(--font-size-xs);
}
.value-card,
.workflow-card,
.feature-card {
padding: var(--space-4);
}
.stack-pill {
width: 100%;
text-align: center;
justify-content: center;
}
.preview-row {
grid-template-columns: auto 1fr;
}
.preview-row .hoard-status {
display: none;
}
}
</style>
+138 -129
View File
@@ -19,18 +19,22 @@ const contactDetails = [
const legalNotes = [
{
icon: 'mdi-account-voice',
title: 'Verantwortlich für den Inhalt',
text: 'Julia Beispiel, Musterstraße 42, 12345 Musterstadt, Deutschland (Testdaten).',
},
{
icon: 'mdi-scale-balance',
title: 'EU-Streitbeilegung',
text: 'Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit: https://ec.europa.eu/consumers/odr/. Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.',
text: 'Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit. Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.',
},
{
icon: 'mdi-shield-check-outline',
title: 'Haftung für Inhalte',
text: 'Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Für fremde Inhalte, auf die wir verweisen, übernehmen wir keine Gewähr. Dieses Impressum enthält ausschließlich Demo-Angaben.',
},
{
icon: 'mdi-link-variant',
title: 'Haftung für Links',
text: 'Unsere Seiten enthalten Links zu externen Webseiten Dritter. Für deren Inhalte ist stets der jeweilige Anbieter verantwortlich. Bei Bekanntwerden von Rechtsverletzungen werden derartige Links umgehend entfernt.',
},
@@ -39,30 +43,39 @@ const legalNotes = [
<template>
<v-container fluid class="impressum-page hoard-page">
<section class="impressum-hero hoard-panel hoard-panel-gradient">
<div class="hero-copy">
<p class="hero-kicker hoard-kicker">Rechtliche Angaben</p>
<section class="impressum-hero hoard-panel hoard-panel-gradient hoard-spotlight">
<div class="impressum-hero__copy">
<p class="hoard-kicker hoard-kicker--wide">Rechtliche Angaben</p>
<h1>Impressum</h1>
<p class="hero-lead">
Diese Seite ist im Hoard-Design aufgebaut und mit Testdaten gefüllt. Ersetze die Angaben
vor einem produktiven Einsatz mit deinen echten Unternehmensdaten.
<p class="impressum-hero__lead">
Diese Seite ist im Hoard-Design aufgebaut und mit Testdaten gefüllt. Vor produktivem
Einsatz bitte alle Angaben durch echte Unternehmensdaten ersetzen.
</p>
<div class="hero-meta">
<span class="meta-pill">Testdaten</span>
<span class="meta-text">Stand: 17. April 2026</span>
<div class="impressum-hero__meta">
<span class="hoard-chip hoard-chip--brand">
<v-icon icon="mdi-information-outline" size="14" /> Testdaten
</span>
<span class="hoard-chip">
<v-icon icon="mdi-calendar-month-outline" size="14" /> Stand: 26. April 2026
</span>
</div>
</div>
<div class="hero-actions hoard-action-row">
<v-btn color="primary" prepend-icon="mdi-home" to="/welcome">Zur Startseite</v-btn>
<div class="impressum-hero__actions hoard-action-row">
<v-btn variant="elevated" prepend-icon="mdi-home-outline" to="/welcome">
Zur Startseite
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-login" to="/login">Zum Login</v-btn>
</div>
</section>
<section class="details-grid">
<article class="detail-card hoard-panel">
<h2>Anbieterangaben</h2>
<header class="detail-card__head">
<span class="hoard-icon-tile"><v-icon icon="mdi-domain" size="20" /></span>
<h2>Anbieterangaben</h2>
</header>
<dl class="detail-list">
<div v-for="entry in companyDetails" :key="entry.label" class="detail-item">
<dt>{{ entry.label }}</dt>
@@ -72,7 +85,10 @@ const legalNotes = [
</article>
<article class="detail-card hoard-panel">
<h2>Kontakt</h2>
<header class="detail-card__head">
<span class="hoard-icon-tile"><v-icon icon="mdi-email-outline" size="20" /></span>
<h2>Kontakt</h2>
</header>
<dl class="detail-list">
<div v-for="entry in contactDetails" :key="entry.label" class="detail-item">
<dt>{{ entry.label }}</dt>
@@ -84,7 +100,10 @@ const legalNotes = [
</article>
<article class="detail-card hoard-panel">
<h2>Register und Steuer</h2>
<header class="detail-card__head">
<span class="hoard-icon-tile"><v-icon icon="mdi-clipboard-text-outline" size="20" /></span>
<h2>Register &amp; Steuer</h2>
</header>
<dl class="detail-list">
<div v-for="entry in registerDetails" :key="entry.label" class="detail-item">
<dt>{{ entry.label }}</dt>
@@ -95,15 +114,21 @@ const legalNotes = [
</section>
<section class="notes-section hoard-panel">
<header class="notes-head">
<p class="notes-kicker hoard-kicker">Rechtliche Hinweise</p>
<header class="hoard-section-head">
<p class="hoard-kicker">Rechtliche Hinweise</p>
<h2>Wichtige Zusatzinformationen</h2>
<p>Standardklauseln, die im Produktivbetrieb durch eine juristische Prüfung ersetzt werden sollten.</p>
</header>
<div class="notes-grid">
<article v-for="note in legalNotes" :key="note.title" class="note-card">
<h3>{{ note.title }}</h3>
<p>{{ note.text }}</p>
<span class="hoard-icon-tile">
<v-icon :icon="note.icon" size="20" />
</span>
<div>
<h3>{{ note.title }}</h3>
<p>{{ note.text }}</p>
</div>
</article>
</div>
</section>
@@ -112,66 +137,50 @@ const legalNotes = [
<style scoped>
.impressum-page {
--hoard-page-width: 1120px;
--hoard-page-width: 1180px;
}
/* ---------- Hero ---------- */
.impressum-hero {
--hoard-gradient-angle: 125deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 40%, var(--color-surface) 60%);
--hoard-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 56%;
--hoard-gradient-end-stop: 65%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1.2fr) auto;
gap: var(--space-6);
align-items: end;
padding: var(--space-8);
padding: var(--space-10) var(--space-8);
border-radius: var(--radius-xl);
}
.hero-lead,
.meta-text {
.impressum-hero__copy h1 {
margin: 0 0 var(--space-3);
font-size: clamp(2rem, 1.4rem + 1.4vw, 2.6rem);
letter-spacing: -0.02em;
}
.impressum-hero__lead {
margin: 0;
}
h1 {
margin-bottom: var(--space-3);
font-size: 2.45rem;
}
.hero-lead {
max-width: 64ch;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
line-height: 1.6;
}
.hero-meta {
.impressum-hero__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
gap: var(--space-2);
margin-top: var(--space-5);
}
.meta-pill {
display: inline-flex;
align-items: center;
padding: 4px var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-primary-300) 60%, var(--color-border) 40%);
border-radius: 999px;
color: var(--color-primary-700);
font-size: var(--font-size-sm);
font-weight: 600;
background-color: color-mix(in srgb, var(--color-primary-100) 82%, var(--color-surface) 18%);
}
.meta-text {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.hero-actions {
.impressum-hero__actions {
justify-content: flex-end;
}
/* ---------- Detail cards ---------- */
.details-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -181,13 +190,30 @@ h1 {
.detail-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
gap: var(--space-4);
padding: var(--space-6);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
h2 {
.detail-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 50%, var(--color-border) 50%);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.detail-card__head {
display: inline-flex;
align-items: center;
gap: var(--space-3);
}
.detail-card h2 {
margin: 0;
font-size: 1.45rem;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.detail-list {
@@ -199,7 +225,7 @@ h2 {
.detail-item {
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%);
border-bottom: 1px solid var(--color-border-subtle);
}
.detail-item:last-child {
@@ -210,21 +236,21 @@ h2 {
dt {
margin-bottom: var(--space-1);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
dd {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-md);
}
/* ---------- Notes ---------- */
.notes-section {
padding: var(--space-6);
}
.notes-head {
margin-bottom: var(--space-5);
padding: var(--space-7);
}
.notes-grid {
@@ -234,15 +260,31 @@ dd {
}
.note-card {
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 76%, var(--color-surface) 24%);
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-4);
align-items: flex-start;
padding: var(--space-5);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 72%, var(--color-surface) 28%);
background-color: color-mix(in srgb, var(--color-surface-alt) 60%, var(--color-surface) 40%);
transition:
border-color var(--transition-fast),
background-color var(--transition-fast),
transform var(--transition-fast);
}
h3 {
margin: 0 0 var(--space-2);
font-size: 1.03rem;
.note-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 40%, var(--color-border) 60%);
background-color: var(--color-surface);
transform: translateY(-2px);
}
.note-card h3 {
margin: 0 0 var(--space-1);
font-size: var(--font-size-lg);
font-weight: 600;
letter-spacing: -0.01em;
}
.note-card p {
@@ -250,36 +292,16 @@ h3 {
color: var(--color-text-secondary);
}
.detail-card,
.note-card {
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.detail-card:hover,
.note-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 46%, var(--color-border) 54%);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
@media (prefers-reduced-motion: no-preference) {
.impressum-hero,
.detail-card,
.notes-section {
animation: hoard-soft-enter 260ms both;
animation: hoard-soft-enter 280ms both;
}
.detail-card:nth-child(2) {
animation-delay: 80ms;
}
.detail-card:nth-child(3),
.notes-section {
animation-delay: 120ms;
}
.detail-card:nth-child(2) { animation-delay: 80ms; }
.detail-card:nth-child(3) { animation-delay: 140ms; }
.notes-section { animation-delay: 200ms; }
}
@media (width <= 1080px) {
@@ -289,19 +311,14 @@ h3 {
}
@media (width <= 960px) {
.impressum-hero,
.notes-section {
padding: var(--space-5);
}
.impressum-hero {
grid-template-columns: 1fr;
align-items: start;
padding: var(--space-7);
}
.hero-actions {
.impressum-hero__actions {
justify-content: flex-start;
gap: var(--space-2);
}
.details-grid,
@@ -309,40 +326,32 @@ h3 {
grid-template-columns: 1fr;
}
:deep(.hero-actions .v-btn) {
min-height: 44px;
.notes-section {
padding: var(--space-6);
}
}
@media (width <= 600px) {
.impressum-hero,
.notes-section {
padding: var(--space-4);
}
h1 {
font-size: 1.9rem;
}
.hero-meta {
margin-top: var(--space-4);
gap: var(--space-2);
}
.hero-actions {
width: 100%;
}
:deep(.hero-actions .v-btn) {
width: 100%;
}
.notes-section,
.detail-card {
padding: var(--space-4);
padding: var(--space-5);
}
.impressum-hero__actions {
width: 100%;
}
:deep(.impressum-hero__actions .v-btn) {
width: 100%;
}
.impressum-hero__meta {
margin-top: var(--space-4);
}
.note-card {
padding: var(--space-3);
padding: var(--space-4);
}
}
</style>
+210 -121
View File
@@ -18,6 +18,27 @@ const routeUserId = computed(() => {
return typeof value === 'string' ? value : ''
})
const userInitials = computed(() => {
const name = user.value?.userName?.trim() ?? ''
if (name.length === 0) {
return '·'
}
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()
})
const rolesText = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return 'Keine Rolle'
@@ -75,78 +96,14 @@ onMounted(() => {
<template>
<v-container fluid class="admin-user-detail-page hoard-page">
<section class="admin-user-detail-shell hoard-panel">
<header class="admin-user-detail-head">
<p class="hoard-kicker">Adminbereich</p>
<header class="admin-user-detail-head hoard-panel hoard-panel-gradient">
<div class="admin-user-detail-head__copy">
<p class="hoard-kicker hoard-kicker--wide">Adminbereich</p>
<h1>Benutzerdetails</h1>
<p>Read-only Ansicht des ausgewählten Kontos.</p>
</header>
<p>Read-only Ansicht des ausgewählten Kontos. Änderungen erfolgen aktuell außerhalb der App.</p>
</div>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
border="start"
>
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-user-detail-loading">Benutzerdetails werden geladen...</p>
<article v-else-if="user" class="admin-user-detail-card">
<div class="admin-user-detail-profile">
<span class="admin-user-detail-avatar">
<v-icon icon="mdi-account-outline" size="24" />
</span>
<div>
<p class="admin-user-detail-profile-label">Ausgewähltes Konto</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
</div>
</div>
<dl class="admin-user-detail-grid">
<div class="admin-user-detail-item">
<dt>ID</dt>
<dd>{{ user.id }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Benutzername</dt>
<dd>{{ user.userName || '(ohne Benutzername)' }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Rollen</dt>
<dd>{{ rolesText }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Aktiv</dt>
<dd>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
</dd>
</div>
<div class="admin-user-detail-item">
<dt>Passwortwechsel</dt>
<dd>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Nicht erforderlich' }}
</span>
</dd>
</div>
</dl>
</article>
<div class="admin-user-detail-actions hoard-action-row">
<div class="admin-user-detail-head__actions hoard-action-row">
<v-btn
variant="outlined"
prepend-icon="mdi-arrow-left"
@@ -155,7 +112,7 @@ onMounted(() => {
Zurück zur Liste
</v-btn>
<v-btn
variant="outlined"
variant="elevated"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@@ -164,126 +121,258 @@ onMounted(() => {
Neu laden
</v-btn>
</div>
</section>
</header>
<v-alert v-if="errorMessage" type="error" density="comfortable">
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-user-detail-loading">Benutzerdetails werden geladen </p>
<article v-else-if="user" class="admin-user-detail-card hoard-panel">
<header class="admin-user-detail-card__head">
<span class="admin-user-detail-avatar">{{ userInitials }}</span>
<div>
<p class="hoard-kicker hoard-kicker--xs">Konto</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
<p class="admin-user-detail-card__id">{{ user.id }}</p>
</div>
<div class="admin-user-detail-card__pills">
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Passwort wechseln' : 'Passwort aktuell' }}
</span>
</div>
</header>
<hr class="hoard-divider-soft" />
<dl class="admin-user-detail-grid">
<div class="admin-user-detail-item">
<dt>ID</dt>
<dd class="admin-user-detail-item__mono">{{ user.id }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Benutzername</dt>
<dd>{{ user.userName || '(ohne Benutzername)' }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Rollen</dt>
<dd>{{ rolesText }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Konto aktiv</dt>
<dd>{{ user.isActive ? 'Ja' : 'Nein' }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Passwortwechsel</dt>
<dd>{{ user.mustChangePassword ? 'Erforderlich' : 'Nicht erforderlich' }}</dd>
</div>
</dl>
</article>
</v-container>
</template>
<style scoped>
.admin-user-detail-page {
--hoard-page-width: 980px;
--hoard-page-width: 1080px;
}
.admin-user-detail-shell {
.admin-user-detail-head {
--hoard-gradient-angle: 120deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 55%, var(--color-surface) 45%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 65%;
display: grid;
gap: var(--space-4);
padding: var(--space-6);
grid-template-columns: minmax(0, 1.1fr) auto;
gap: var(--space-5);
align-items: end;
padding: var(--space-7) var(--space-8);
border-radius: var(--radius-xl);
}
.admin-user-detail-head__copy h1 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.admin-user-detail-head__copy p {
margin: 0;
color: var(--color-text-secondary);
}
.admin-user-detail-head h1,
.admin-user-detail-head p,
.admin-user-detail-loading {
margin: 0;
}
.admin-user-detail-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.admin-user-detail-head p,
.admin-user-detail-loading {
color: var(--color-text-secondary);
}
.admin-user-detail-card {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 54%, var(--color-surface) 46%);
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
}
.admin-user-detail-profile {
.admin-user-detail-card__head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
grid-template-columns: auto 1fr auto;
gap: var(--space-4);
align-items: center;
min-width: 0;
}
.admin-user-detail-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
color: var(--color-primary-700);
background-color: color-mix(in srgb, var(--color-primary-100) 82%, var(--color-surface) 18%);
width: 64px;
height: 64px;
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: 22px;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow:
var(--shadow-sm),
inset 0 0 0 2px color-mix(in srgb, var(--color-accent-lime) 22%, transparent);
}
.admin-user-detail-profile-label {
.admin-user-detail-card__head h2 {
margin: var(--space-1) 0;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.admin-user-detail-card__id {
margin: 0;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
overflow-wrap: anywhere;
}
.admin-user-detail-profile h2 {
margin: var(--space-1) 0 0;
color: var(--color-text);
font-size: 1.35rem;
overflow-wrap: anywhere;
.admin-user-detail-card__pills {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: flex-end;
}
.admin-user-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: var(--space-4);
gap: var(--space-4) var(--space-6);
margin: 0;
}
.admin-user-detail-item {
display: grid;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.admin-user-detail-item:nth-last-child(-n + 2) {
border-bottom: none;
padding-bottom: 0;
}
.admin-user-detail-item dt {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-user-detail-item dd {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-md);
word-break: break-word;
}
.admin-user-detail-actions {
justify-content: flex-end;
.admin-user-detail-item__mono {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm) !important;
}
@media (prefers-reduced-motion: no-preference) {
.admin-user-detail-shell {
animation: hoard-soft-enter 260ms both;
.admin-user-detail-head,
.admin-user-detail-card {
animation: hoard-soft-enter 280ms both;
}
.admin-user-detail-card {
animation-delay: 80ms;
}
}
@media (width <= 960px) {
.admin-user-detail-head {
grid-template-columns: 1fr;
align-items: flex-start;
padding: var(--space-6);
}
.admin-user-detail-head__actions {
width: 100%;
justify-content: flex-start;
}
.admin-user-detail-card__head {
grid-template-columns: auto 1fr;
}
.admin-user-detail-card__pills {
grid-column: 1 / -1;
flex-direction: row;
align-items: flex-start;
flex-wrap: wrap;
}
}
@media (width <= 600px) {
.admin-user-detail-shell {
padding: var(--space-4);
.admin-user-detail-head {
padding: var(--space-5);
}
.admin-user-detail-card {
padding: var(--space-5);
}
.admin-user-detail-card__head {
grid-template-columns: auto 1fr;
gap: var(--space-3);
}
.admin-user-detail-grid {
grid-template-columns: 1fr;
}
.admin-user-detail-actions {
justify-content: stretch;
.admin-user-detail-item:nth-last-child(-n + 2) {
border-bottom: 1px solid var(--color-border-subtle);
padding-bottom: var(--space-3);
}
.admin-user-detail-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
</style>
+440 -199
View File
@@ -11,12 +11,59 @@ const appBannersStore = useAppBannersStore()
const isLoading = ref(true)
const errorMessage = ref('')
const users = ref<AdminUser[]>([])
const searchQuery = ref('')
const hasUsers = computed(() => users.value.length > 0)
const activeUserCount = computed(() => users.value.filter((user) => user.isActive).length)
const passwordChangeCount = computed(
() => users.value.filter((user) => user.mustChangePassword).length,
)
const adminCount = computed(
() =>
users.value.filter((user) =>
user.roles.some((role) => role.toLowerCase() === 'admin'),
).length,
)
const filteredUsers = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (query.length === 0) {
return users.value
}
return users.value.filter((user) => {
if (user.userName.toLowerCase().includes(query)) {
return true
}
if (user.id.toLowerCase().includes(query)) {
return true
}
return user.roles.some((role) => role.toLowerCase().includes(query))
})
})
function userInitials(user: AdminUser) {
const name = user.userName?.trim() ?? ''
if (name.length === 0) {
return '·'
}
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 formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(', ') : 'Keine Rolle'
@@ -65,155 +112,26 @@ onMounted(() => {
<template>
<v-container fluid class="admin-users-page hoard-page">
<section class="admin-users-shell hoard-panel">
<header class="admin-users-head">
<p class="hoard-kicker">Adminbereich</p>
<header class="admin-users-header hoard-panel hoard-panel-gradient">
<div class="admin-users-header__copy">
<p class="hoard-kicker hoard-kicker--wide">Adminbereich</p>
<h1>Benutzerverwaltung</h1>
<p>Alle App-Konten mit Rollen, Status und Passwortwechselpflicht.</p>
</header>
<section v-if="!isLoading && !errorMessage" class="admin-users-stats" aria-label="Benutzerübersicht">
<article class="admin-users-stat">
<p class="admin-users-stat-label">Konten</p>
<p class="admin-users-stat-value">{{ users.length }}</p>
</article>
<article class="admin-users-stat">
<p class="admin-users-stat-label">Aktiv</p>
<p class="admin-users-stat-value">{{ activeUserCount }}</p>
</article>
<article class="admin-users-stat">
<p class="admin-users-stat-label">Passwortwechsel</p>
<p class="admin-users-stat-value">{{ passwordChangeCount }}</p>
</article>
</section>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
border="start"
>
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-users-loading">Benutzer werden geladen...</p>
<section v-else-if="!hasUsers" class="hoard-empty-state">
<h2>Keine Benutzer gefunden</h2>
<p>Aktuell sind keine Konten vorhanden.</p>
</section>
<div v-else class="admin-users-list-region">
<div class="admin-users-table-wrap">
<v-table class="admin-users-table">
<thead>
<tr>
<th>Benutzername</th>
<th>Rollen</th>
<th>Aktiv</th>
<th>Passwortwechsel</th>
<th class="admin-users-col-actions">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="admin-users-cell-user">{{ user.userName || '(ohne Benutzername)' }}</td>
<td>{{ formatRoles(user.roles) }}</td>
<td>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
</td>
<td>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Nein' }}
</span>
</td>
<td class="admin-users-col-actions">
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-account-details-outline"
@click="openUserDetail(user.id)"
>
Details
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</div>
<div class="admin-users-mobile-list" aria-label="Benutzerliste">
<article v-for="user in users" :key="user.id" class="admin-users-mobile-card">
<header class="admin-users-mobile-head">
<span class="admin-users-mobile-avatar">
<v-icon icon="mdi-account-outline" size="20" />
</span>
<div>
<p class="admin-users-mobile-label">Benutzer</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
</div>
</header>
<dl class="admin-users-mobile-details">
<div>
<dt>Rollen</dt>
<dd>{{ formatRoles(user.roles) }}</dd>
</div>
<div>
<dt>Aktiv</dt>
<dd>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
</dd>
</div>
<div>
<dt>Passwortwechsel</dt>
<dd>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Nein' }}
</span>
</dd>
</div>
</dl>
<v-btn
variant="outlined"
prepend-icon="mdi-account-details-outline"
block
@click="openUserDetail(user.id)"
>
Details
</v-btn>
</article>
</div>
<p>Alle Hoard-Konten mit Rollen, Status und Passwortwechselpflicht read-only.</p>
</div>
<div class="admin-users-actions hoard-action-row">
<v-btn
<div class="admin-users-header__actions hoard-action-row">
<v-text-field
v-model="searchQuery"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-magnify"
placeholder="Benutzer, Rollen oder ID suchen"
hide-details
clearable
class="admin-users-search"
/>
<v-btn
variant="elevated"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@@ -222,76 +140,315 @@ onMounted(() => {
Neu laden
</v-btn>
</div>
</header>
<section v-if="!isLoading && !errorMessage" class="admin-users-stats" aria-label="Benutzerübersicht">
<article class="admin-users-stat">
<span class="hoard-icon-tile">
<v-icon icon="mdi-account-group-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Konten</p>
<p class="admin-users-stat__value">{{ users.length }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="hoard-icon-tile">
<v-icon icon="mdi-account-check-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Aktiv</p>
<p class="admin-users-stat__value">{{ activeUserCount }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="hoard-icon-tile">
<v-icon icon="mdi-shield-account-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Admins</p>
<p class="admin-users-stat__value">{{ adminCount }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="hoard-icon-tile">
<v-icon icon="mdi-lock-reset" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Passwortwechsel</p>
<p class="admin-users-stat__value">{{ passwordChangeCount }}</p>
</div>
</article>
</section>
<v-alert v-if="errorMessage" type="error">
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-users-loading">Benutzer werden geladen </p>
<section v-else-if="!hasUsers" class="hoard-panel hoard-empty-state">
<span class="hoard-icon-tile hoard-icon-tile--lg">
<v-icon icon="mdi-account-question-outline" size="22" />
</span>
<h2>Keine Benutzer gefunden</h2>
<p>Aktuell sind keine Konten vorhanden. Ein neuer Account muss vom Admin manuell angelegt werden.</p>
</section>
<section v-else class="admin-users-listing hoard-panel">
<header class="admin-users-listing__head hoard-toolbar">
<div>
<p class="admin-users-listing__title">Benutzer</p>
<p class="admin-users-listing__meta">{{ filteredUsers.length }} von {{ users.length }} angezeigt</p>
</div>
</header>
<p v-if="filteredUsers.length === 0" class="admin-users-empty-search">
Keine Treffer für {{ searchQuery }}".
</p>
<div v-else class="admin-users-table-wrap">
<v-table class="admin-users-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rollen</th>
<th>Aktiv</th>
<th>Passwort</th>
<th class="admin-users-col-actions">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>
<div class="admin-users-cell-user">
<span class="admin-users-cell-user__avatar">{{ userInitials(user) }}</span>
<div>
<p class="admin-users-cell-user__name">{{ user.userName || '(ohne Benutzername)' }}</p>
<p class="admin-users-cell-user__id">{{ user.id }}</p>
</div>
</div>
</td>
<td>{{ formatRoles(user.roles) }}</td>
<td>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
</td>
<td>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Aktuell' }}
</span>
</td>
<td class="admin-users-col-actions">
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-arrow-right"
@click="openUserDetail(user.id)"
>
Details
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</div>
<div v-if="filteredUsers.length > 0" class="admin-users-mobile-list" aria-label="Benutzerliste">
<article v-for="user in filteredUsers" :key="user.id" class="admin-users-mobile-card">
<header class="admin-users-mobile-head">
<span class="admin-users-mobile-avatar">{{ userInitials(user) }}</span>
<div>
<p class="admin-users-mobile-label">Benutzer</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
<p class="admin-users-mobile-id">{{ user.id }}</p>
</div>
</header>
<dl class="admin-users-mobile-details">
<div>
<dt>Rollen</dt>
<dd>{{ formatRoles(user.roles) }}</dd>
</div>
<div>
<dt>Aktiv</dt>
<dd>
<span
:class="[
'hoard-status',
user.isActive ? 'hoard-status--success' : 'hoard-status--danger',
]"
>
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</span>
</dd>
</div>
<div>
<dt>Passwortwechsel</dt>
<dd>
<span
:class="[
'hoard-status',
user.mustChangePassword ? 'hoard-status--warning' : 'hoard-status--info',
]"
>
{{ user.mustChangePassword ? 'Erforderlich' : 'Nein' }}
</span>
</dd>
</div>
</dl>
<v-btn
variant="outlined"
prepend-icon="mdi-arrow-right"
block
@click="openUserDetail(user.id)"
>
Details öffnen
</v-btn>
</article>
</div>
</section>
</v-container>
</template>
<style scoped>
.admin-users-page {
--hoard-page-width: 1120px;
--hoard-page-width: 1200px;
}
.admin-users-shell {
/* ---------- Header ---------- */
.admin-users-header {
--hoard-gradient-angle: 120deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 55%, var(--color-surface) 45%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 65%;
display: grid;
gap: var(--space-4);
padding: var(--space-6);
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-6);
align-items: end;
padding: var(--space-7) var(--space-8);
border-radius: var(--radius-xl);
}
.admin-users-head h1,
.admin-users-head p,
.admin-users-loading {
.admin-users-header__copy h1 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.admin-users-header__copy p {
margin: 0;
}
.admin-users-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.admin-users-head p {
color: var(--color-text-secondary);
}
.admin-users-loading {
color: var(--color-text-secondary);
.admin-users-header__actions {
justify-content: flex-end;
align-items: center;
}
.admin-users-search {
flex: 1 1 280px;
min-width: 220px;
max-width: 380px;
}
/* ---------- Stats ---------- */
.admin-users-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.admin-users-stat {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 58%, var(--color-surface) 42%);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 95%, var(--color-surface-alt) 5%),
var(--color-surface)
);
box-shadow: var(--shadow-xs);
}
.admin-users-stat-label,
.admin-users-stat-value {
.admin-users-stat__label,
.admin-users-stat__value {
margin: 0;
}
.admin-users-stat-label {
.admin-users-stat__label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.04em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-users-stat-value {
.admin-users-stat__value {
color: var(--color-text);
font-size: 1.75rem;
font-size: var(--font-size-2xl);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
line-height: 1.1;
}
/* ---------- Listing ---------- */
.admin-users-listing {
padding: 0;
overflow: hidden;
}
.admin-users-listing__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-users-listing__title {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
}
.admin-users-listing__meta {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.admin-users-empty-search {
margin: 0;
padding: var(--space-6);
color: var(--color-text-secondary);
text-align: center;
}
.admin-users-loading {
margin: 0;
color: var(--color-text-secondary);
}
.admin-users-table-wrap {
overflow-x: auto;
border-radius: var(--radius-md);
}
.admin-users-mobile-list {
@@ -299,12 +456,48 @@ onMounted(() => {
}
.admin-users-table {
min-width: 900px;
min-width: 920px;
border: 0;
border-radius: 0;
}
.admin-users-cell-user {
font-weight: 600;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
min-width: 0;
}
.admin-users-cell-user__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-xs);
font-weight: 700;
letter-spacing: 0.02em;
}
.admin-users-cell-user__name,
.admin-users-cell-user__id {
margin: 0;
}
.admin-users-cell-user__name {
color: var(--color-text);
font-weight: 600;
}
.admin-users-cell-user__id {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-2xs);
overflow-wrap: anywhere;
}
.admin-users-col-actions {
@@ -312,26 +505,65 @@ onMounted(() => {
white-space: nowrap;
}
.admin-users-actions {
justify-content: flex-end;
.hoard-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
@media (prefers-reduced-motion: no-preference) {
.admin-users-shell {
animation: hoard-soft-enter 260ms both;
.admin-users-header,
.admin-users-stat,
.admin-users-listing {
animation: hoard-soft-enter 280ms both;
}
.admin-users-stat:nth-child(2) { animation-delay: 60ms; }
.admin-users-stat:nth-child(3) { animation-delay: 120ms; }
.admin-users-stat:nth-child(4) { animation-delay: 180ms; }
.admin-users-listing { animation-delay: 220ms; }
}
@media (width <= 1100px) {
.admin-users-header {
grid-template-columns: 1fr;
align-items: flex-start;
}
.admin-users-header__actions {
justify-content: flex-start;
}
.admin-users-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 960px) {
.admin-users-header {
padding: var(--space-6);
}
}
@media (width <= 600px) {
.admin-users-shell {
padding: var(--space-4);
overflow: hidden;
.admin-users-header {
padding: var(--space-5);
}
.admin-users-search {
flex: 1 1 100%;
max-width: none;
}
.admin-users-stats {
grid-template-columns: 1fr;
}
.admin-users-listing {
overflow: hidden;
}
.admin-users-table-wrap {
display: none;
}
@@ -339,6 +571,7 @@ onMounted(() => {
.admin-users-mobile-list {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
}
.admin-users-mobile-card {
@@ -346,9 +579,10 @@ onMounted(() => {
gap: var(--space-4);
min-width: 0;
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-surface) 20%);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 58%, var(--color-surface) 42%);
background-color: var(--color-surface);
box-shadow: var(--shadow-xs);
}
.admin-users-mobile-head {
@@ -363,30 +597,41 @@ onMounted(() => {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
color: var(--color-primary-700);
background-color: color-mix(in srgb, var(--color-primary-100) 82%, var(--color-surface) 18%);
width: 44px;
height: 44px;
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;
}
.admin-users-mobile-label,
.admin-users-mobile-head h2,
.admin-users-mobile-id,
.admin-users-mobile-details {
margin: 0;
}
.admin-users-mobile-label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.04em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-users-mobile-head h2 {
color: var(--color-text);
font-size: 1.1rem;
font-size: var(--font-size-lg);
font-weight: 600;
overflow-wrap: anywhere;
}
.admin-users-mobile-id {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-2xs);
overflow-wrap: anywhere;
}
@@ -399,7 +644,7 @@ onMounted(() => {
display: grid;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 78%, var(--color-surface) 22%);
border-bottom: 1px solid var(--color-border-subtle);
}
.admin-users-mobile-details div:last-child {
@@ -420,9 +665,5 @@ onMounted(() => {
color: var(--color-text);
overflow-wrap: anywhere;
}
.admin-users-actions {
justify-content: stretch;
}
}
</style>
+153 -65
View File
@@ -27,6 +27,33 @@ const submitDisabled = computed(() => {
)
})
const passwordsMatch = computed(() => {
if (newPassword.value.length === 0 || newPasswordConfirm.value.length === 0) {
return null
}
return newPassword.value === newPasswordConfirm.value
})
const passwordHints = computed(() => [
{
label: 'Mindestens 8 Zeichen',
valid: newPassword.value.length >= 8,
},
{
label: 'Buchstabe enthalten',
valid: /[A-Za-zÄÖÜäöüß]/.test(newPassword.value),
},
{
label: 'Ziffer enthalten',
valid: /\d/.test(newPassword.value),
},
{
label: 'Übereinstimmung mit Bestätigung',
valid: passwordsMatch.value === true,
},
])
async function handleSubmit() {
if (submitDisabled.value) {
errorMessage.value = 'Bitte alle Passwortfelder ausfüllen.'
@@ -74,70 +101,94 @@ async function handleSubmit() {
<v-container fluid class="change-password-page hoard-page hoard-page--centered">
<section class="change-password-shell hoard-panel hoard-shell-grid">
<header class="change-password-head">
<p class="hoard-kicker">Sicherheitsvorgabe</p>
<h1>Passwort ändern</h1>
<p>Hier kannst du ganz bequem dein Passwort aktualisieren.</p>
<span class="hoard-icon-tile hoard-icon-tile--lg">
<v-icon icon="mdi-shield-key-outline" size="24" />
</span>
<div>
<p class="hoard-kicker hoard-kicker--xs">Sicherheitsvorgabe</p>
<h1>Passwort ändern</h1>
<p>Aktualisiere dein Hoard-Passwort. Nach der Änderung wirst du erneut zur Anmeldung weitergeleitet.</p>
</div>
</header>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
border="start"
density="comfortable"
>
{{ errorMessage }}
</v-alert>
<v-form class="change-password-form" @submit.prevent="handleSubmit">
<v-text-field
v-model="oldPassword"
label="Altes Passwort"
:type="showOldPassword ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showOldPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showOldPassword = !showOldPassword"
/>
<section class="change-password-section">
<p class="hoard-kicker hoard-kicker--xs hoard-kicker--plain">Aktuell</p>
<v-text-field
v-model="oldPassword"
label="Altes Passwort"
:type="showOldPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showOldPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showOldPassword = !showOldPassword"
/>
</section>
<v-divider class="change-password-divider" />
<p class="change-password-section-label">Neues Passwort</p>
<hr class="hoard-divider-soft" />
<v-text-field
v-model="newPassword"
label="Neues Passwort"
:type="showNewPassword ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-reset"
:append-inner-icon="showNewPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
@click:append-inner="showNewPassword = !showNewPassword"
/>
<section class="change-password-section">
<p class="hoard-kicker hoard-kicker--xs hoard-kicker--plain">Neues Passwort</p>
<v-text-field
v-model="newPasswordConfirm"
label="Neues Passwort bestätigen"
:type="showNewPasswordConfirm ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-check-outline"
:append-inner-icon="showNewPasswordConfirm ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
@click:append-inner="showNewPasswordConfirm = !showNewPasswordConfirm"
/>
<v-text-field
v-model="newPassword"
label="Neues Passwort"
:type="showNewPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-reset"
:append-inner-icon="showNewPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
@click:append-inner="showNewPassword = !showNewPassword"
/>
<v-text-field
v-model="newPasswordConfirm"
label="Neues Passwort bestätigen"
:type="showNewPasswordConfirm ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-check-outline"
:append-inner-icon="showNewPasswordConfirm ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
:error="passwordsMatch === false"
:error-messages="
passwordsMatch === false ? 'Bestätigung stimmt nicht mit dem neuen Passwort überein.' : undefined
"
@click:append-inner="showNewPasswordConfirm = !showNewPasswordConfirm"
/>
<ul class="password-hints">
<li
v-for="hint in passwordHints"
:key="hint.label"
:class="['password-hint', { 'password-hint--valid': hint.valid }]"
>
<v-icon :icon="hint.valid ? 'mdi-check-circle' : 'mdi-circle-outline'" size="14" />
{{ hint.label }}
</li>
</ul>
</section>
<p class="change-password-hint">
Nach erfolgreicher Änderung wirst du automatisch abgemeldet und meldest dich mit dem neuen Passwort wieder an.
<v-icon icon="mdi-information-outline" size="14" />
Nach erfolgreicher Änderung wirst du automatisch abgemeldet und meldest dich anschließend mit deinem
neuen Passwort wieder an.
</p>
<v-btn
type="submit"
color="primary"
variant="elevated"
size="large"
prepend-icon="mdi-content-save-outline"
:loading="isSubmitting"
@@ -157,58 +208,95 @@ async function handleSubmit() {
}
.change-password-shell {
gap: var(--space-4);
gap: var(--space-5);
}
.change-password-head h1,
.change-password-head p {
margin: 0;
.change-password-head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-4);
align-items: flex-start;
}
.change-password-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
margin: var(--space-1) 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.change-password-head p {
margin: 0;
color: var(--color-text-secondary);
}
.change-password-form {
display: grid;
gap: var(--space-4);
}
.change-password-section {
display: grid;
gap: var(--space-3);
}
.change-password-divider {
margin-top: var(--space-1);
margin-bottom: 0;
border-color: color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%);
.password-hints {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
padding: 0;
margin: 0;
list-style: none;
}
.change-password-section-label {
margin: 0;
color: var(--color-primary-700);
.password-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.3;
transition: color var(--transition-fast);
}
.password-hint--valid {
color: var(--color-primary-700);
}
.password-hint .v-icon {
flex: 0 0 auto;
}
.change-password-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: var(--space-3);
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--color-surface-alt) 80%, var(--color-surface) 20%);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.change-password-hint .v-icon {
color: var(--color-primary-700);
flex: 0 0 auto;
}
@media (prefers-reduced-motion: no-preference) {
.change-password-shell {
animation: hoard-soft-enter 260ms both;
animation: hoard-soft-enter 280ms both;
}
}
@media (width <= 600px) {
.change-password-form {
gap: var(--space-2);
.change-password-head {
grid-template-columns: 1fr;
}
.password-hints {
grid-template-columns: 1fr;
}
}
</style>
+204 -105
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import iconImage from '@/assets/images/icon.svg'
import { AuthRequestError, login } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
@@ -79,104 +81,186 @@ onMounted(() => {
<template>
<v-container fluid class="login-page hoard-page hoard-page--centered">
<section class="login-shell hoard-panel hoard-shell-grid hoard-panel-gradient">
<section class="login-shell hoard-panel hoard-panel-gradient hoard-spotlight">
<aside class="login-brand">
<p class="login-kicker hoard-kicker hoard-kicker--wide">Willkommen bei Hoard</p>
<h1>Anmelden und weiterarbeiten</h1>
<div class="login-brand__logo">
<span class="login-brand__halo" aria-hidden="true" />
<img :src="iconImage" alt="Hoard Icon" />
</div>
<p class="hoard-kicker hoard-kicker--wide">Willkommen bei Hoard</p>
<h1>
Deine Dateien.<br />
<span class="login-brand__accent">Aufgeräumt.</span>
</h1>
<p class="login-intro">
Deine Dateiablage bleibt aufgeräumt, schnell und direkt im Browser bedienbar.
Hoard ist deine ruhige, self-hosted Dateiablage schnell, übersichtlich und direkt im Browser.
Melde dich an und mach weiter, wo du aufgehört hast.
</p>
<ul class="login-points">
<li>
<v-icon icon="mdi-folder-outline" size="18" />
Ordner und Dateien zentral verwalten
<span class="hoard-icon-tile"><v-icon icon="mdi-folder-outline" size="18" /></span>
<div>
<p class="login-points__title">Ordner und Dateien</p>
<p class="login-points__text">Zentral organisieren, schnell finden, sauber strukturieren.</p>
</div>
</li>
<li>
<v-icon icon="mdi-file-document-edit-outline" size="18" />
Markdown-Dateien sofort bearbeiten
<span class="hoard-icon-tile"><v-icon icon="mdi-language-markdown-outline" size="18" /></span>
<div>
<p class="login-points__title">Markdown direkt im Browser</p>
<p class="login-points__text">Notizen lesen und bearbeiten, ohne externes Tool.</p>
</div>
</li>
<li>
<v-icon icon="mdi-image-outline" size="18" />
Bilder und PDFs direkt als Vorschau ansehen
<span class="hoard-icon-tile"><v-icon icon="mdi-shield-check-outline" size="18" /></span>
<div>
<p class="login-points__title">Self-hosted &amp; sicher</p>
<p class="login-points__text">Cookie-Auth, Rollenmodell, deine Infrastruktur.</p>
</div>
</li>
</ul>
</aside>
<v-form class="login-form" @submit.prevent="handleSubmit">
<div class="form-head">
<h2>Login</h2>
<p>Melde dich mit deinem bestehenden Konto an.</p>
<header class="login-form__head">
<p class="hoard-kicker hoard-kicker--xs">Login</p>
<h2>Anmelden</h2>
<p>Melde dich mit deinem bestehenden Hoard-Konto an.</p>
</header>
<div class="login-form__fields">
<v-text-field
v-model="userName"
label="Benutzername"
type="text"
prepend-inner-icon="mdi-account-outline"
autocomplete="username"
required
:disabled="isSubmitting"
/>
<v-text-field
v-model="password"
label="Passwort"
:type="showPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showPassword = !showPassword"
/>
</div>
<v-text-field
v-model="userName"
label="Benutzername"
type="text"
variant="outlined"
prepend-inner-icon="mdi-account-outline"
autocomplete="username"
required
:disabled="isSubmitting"
/>
<v-text-field
v-model="password"
label="Passwort"
:type="showPassword ? 'text' : 'password'"
variant="outlined"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showPassword = !showPassword"
/>
<div class="form-meta">
<p>Anmeldung erfolgt per sicherem Session-Cookie.</p>
</div>
<p class="login-form__hint">
<v-icon icon="mdi-cookie-outline" size="14" />
Anmeldung erfolgt per sicherem Session-Cookie. Keine Tokens, keine Drittanbieter.
</p>
<v-btn
type="submit"
color="primary"
variant="elevated"
block
size="large"
prepend-icon="mdi-login"
prepend-icon="mdi-arrow-right"
:loading="isSubmitting"
:disabled="submitDisabled"
>
Anmelden
</v-btn>
<v-btn variant="outlined" block to="/welcome" prepend-icon="mdi-home">Zur Startseite</v-btn>
<v-btn variant="text" block to="/welcome" prepend-icon="mdi-home-outline" size="small">
Zurück zur Startseite
</v-btn>
</v-form>
</section>
</v-container>
</template>
<style scoped>
.login-shell {
--hoard-shell-width: 1040px;
--hoard-gradient-angle: 115deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 45%, var(--color-surface) 55%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 52%;
.login-page {
--hoard-centered-offset: 200px;
}
grid-template-columns: minmax(280px, 1fr) minmax(320px, 430px);
.login-shell {
--hoard-shell-width: 1080px;
--hoard-shell-padding: 0;
--hoard-shell-gap: 0;
--hoard-gradient-angle: 120deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 60%;
grid-template-columns: minmax(280px, 1.1fr) minmax(320px, 460px);
border-radius: var(--radius-xl);
overflow: hidden;
}
.login-brand {
position: relative;
padding: var(--space-10) var(--space-8);
display: flex;
flex-direction: column;
justify-content: center;
z-index: 1;
}
.login-brand__logo {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: var(--space-5);
}
.login-brand__halo {
position: absolute;
inset: -12px;
border-radius: var(--radius-full);
background:
radial-gradient(
closest-side,
color-mix(in srgb, var(--color-accent-lime) 38%, transparent),
transparent 70%
);
filter: blur(10px);
opacity: 0.7;
}
.login-brand__logo img {
position: relative;
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 8px 18px rgb(28 101 47 / 30%));
}
h1 {
margin-bottom: var(--space-3);
max-width: 18ch;
font-size: 2.5rem;
margin: 0 0 var(--space-3);
max-width: 16ch;
font-size: clamp(2rem, 1.4rem + 1.5vw, 2.8rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.025em;
}
.login-brand__accent {
background: linear-gradient(120deg, var(--color-primary-700), var(--color-primary-500));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.login-intro {
margin-bottom: var(--space-5);
max-width: 44ch;
margin: 0 0 var(--space-6);
max-width: 46ch;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
line-height: 1.6;
}
.login-points {
@@ -189,65 +273,90 @@ h1 {
}
.login-points li {
display: flex;
align-items: center;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
color: var(--color-text-secondary);
font-weight: 500;
align-items: flex-start;
}
.login-points :deep(.v-icon) {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
color: var(--color-primary-700);
background-color: color-mix(in srgb, var(--color-primary-100) 78%, var(--color-surface) 22%);
.login-points__title,
.login-points__text {
margin: 0;
}
.login-points__title {
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.3;
}
.login-points__text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.login-form {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-surface) 20%);
border-radius: var(--radius-lg);
padding: var(--space-8);
border-left: 1px solid var(--color-border);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 96%, var(--color-surface-alt) 4%),
var(--color-surface)
);
box-shadow: var(--shadow-md);
z-index: 1;
}
.form-head h2 {
margin-bottom: var(--space-1);
font-size: 1.45rem;
.login-form__head {
display: flex;
flex-direction: column;
gap: 2px;
}
.form-head p {
.login-form__head h2 {
margin: 0;
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.login-form__head p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
}
.form-meta {
display: flex;
.login-form__fields {
display: grid;
gap: var(--space-3);
}
.login-form__hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: var(--space-3);
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--color-surface-alt) 80%, var(--color-surface) 20%);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.form-meta p {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
.login-form__hint .v-icon {
color: var(--color-primary-700);
flex: 0 0 auto;
}
@media (prefers-reduced-motion: no-preference) {
.login-brand,
.login-form {
animation: hoard-soft-enter 260ms both;
animation: hoard-soft-enter 320ms both;
}
.login-form {
@@ -260,44 +369,34 @@ h1 {
grid-template-columns: 1fr;
}
.login-form {
padding: var(--space-5);
.login-brand {
padding: var(--space-7) var(--space-6) 0;
}
.form-meta {
flex-wrap: wrap;
.login-form {
padding: var(--space-6);
border-left: none;
border-top: 1px solid var(--color-border);
}
}
@media (width <= 600px) {
h1 {
max-width: none;
font-size: 1.85rem;
.login-brand {
padding: var(--space-6) var(--space-5) 0;
}
.login-form {
padding: var(--space-5);
}
.login-intro {
margin-bottom: var(--space-4);
}
.login-points {
gap: var(--space-2);
margin-bottom: var(--space-5);
}
.login-points li {
align-items: flex-start;
}
.login-form {
gap: var(--space-3);
padding: var(--space-4);
}
.form-meta {
flex-direction: column;
align-items: stretch;
gap: var(--space-2);
}
:deep(.login-form .v-btn) {
min-height: 44px;
}
+350 -187
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AuthRequestError, fetchCurrentUser, type CurrentUser } from '@/services/authSession'
const router = useRouter()
@@ -15,6 +16,28 @@ const prettyUser = computed(() => {
return JSON.stringify(user.value, null, 2)
})
const userInitials = computed(() => {
const name = user.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()
})
const roleLabel = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return 'Keine Rolle'
@@ -22,9 +45,20 @@ const roleLabel = computed(() => {
return user.value.roles.join(', ')
})
const accountStateLabel = computed(() => (user.value?.isActive ? 'Aktiv' : 'Inaktiv'))
const passwordStateLabel = computed(() =>
user.value?.mustChangePassword ? 'Erforderlich' : 'Aktuell',
const roleChips = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return [] as string[]
}
return user.value.roles
})
const accountStatusVariant = computed(() => (user.value?.isActive ? 'success' : 'danger'))
const accountStatusLabel = computed(() => (user.value?.isActive ? 'Aktiv' : 'Inaktiv'))
const passwordStatusVariant = computed(() => (user.value?.mustChangePassword ? 'warning' : 'info'))
const passwordStatusLabel = computed(() =>
user.value?.mustChangePassword ? 'Wechsel erforderlich' : 'Aktuell',
)
async function loadCurrentUser() {
@@ -50,6 +84,10 @@ async function loadCurrentUser() {
}
}
function goToChangePassword() {
void router.push({ name: 'ChangePassword' })
}
onMounted(() => {
void loadCurrentUser()
})
@@ -57,247 +95,372 @@ onMounted(() => {
<template>
<v-container fluid class="dashboard-page hoard-page">
<section class="dashboard-shell hoard-panel">
<header class="dashboard-head">
<p class="hoard-kicker">Geschützter Bereich</p>
<h1>Dashboard</h1>
<p>Hier siehst du aktuell direkt die Antwort von <code>/auth/me</code>.</p>
</header>
<header class="dashboard-greeting hoard-panel hoard-panel-gradient hoard-spotlight">
<div class="dashboard-greeting__copy">
<p class="hoard-kicker hoard-kicker--wide">Geschützter Bereich</p>
<h1>
<template v-if="user">Hallo, {{ user.userName || 'willkommen' }}.</template>
<template v-else>Dashboard</template>
</h1>
<p>
Hier siehst du den aktuellen Status deines Kontos. Sobald die Datei- und Markdown-Module live
gehen, wird dieser Bereich dein Startpunkt in den Workspace.
</p>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="comfortable"
border="start"
>
{{ errorMessage }}
</v-alert>
<div class="dashboard-greeting__chips">
<span v-for="role in roleChips" :key="role" class="hoard-chip hoard-chip--brand">
<v-icon icon="mdi-shield-account-outline" size="14" />
{{ role }}
</span>
<span v-if="roleChips.length === 0" class="hoard-chip">
<v-icon icon="mdi-account-outline" size="14" /> Keine Rollen
</span>
</div>
</div>
<article v-else class="dashboard-user">
<template v-if="isLoading">
<p class="dashboard-loading">Benutzerdaten werden geladen...</p>
</template>
<template v-else>
<div class="dashboard-summary-grid">
<article class="dashboard-summary-card">
<span class="dashboard-summary-icon">
<v-icon icon="mdi-account-outline" size="20" />
</span>
<div>
<p class="dashboard-summary-label">Konto</p>
<p class="dashboard-summary-value">{{ user?.userName || 'Unbekannt' }}</p>
</div>
</article>
<div v-if="user" class="dashboard-greeting__avatar">
<span class="dashboard-avatar">{{ userInitials }}</span>
<p class="dashboard-avatar__caption">{{ user.userName }}</p>
</div>
</header>
<article class="dashboard-summary-card">
<span class="dashboard-summary-icon">
<v-icon icon="mdi-shield-account-outline" size="20" />
</span>
<div>
<p class="dashboard-summary-label">Rollen</p>
<p class="dashboard-summary-value">{{ roleLabel }}</p>
</div>
</article>
<v-alert
v-if="errorMessage"
type="error"
density="comfortable"
>
{{ errorMessage }}
</v-alert>
<article class="dashboard-summary-card">
<span class="dashboard-summary-icon">
<v-icon icon="mdi-lock-check-outline" size="20" />
</span>
<div>
<p class="dashboard-summary-label">Status</p>
<p class="dashboard-summary-value">
{{ accountStateLabel }} · Passwort {{ passwordStateLabel }}
</p>
</div>
</article>
</div>
<p class="dashboard-label">Antwort von <code>GET /auth/me</code>:</p>
<pre class="dashboard-json">{{ prettyUser }}</pre>
</template>
<section v-else class="dashboard-grid">
<article class="dashboard-stat hoard-panel">
<div class="dashboard-stat__head">
<span class="hoard-icon-tile">
<v-icon icon="mdi-account-outline" size="20" />
</span>
<p class="dashboard-stat__label">Konto</p>
</div>
<p class="dashboard-stat__value">{{ user?.userName || '—' }}</p>
<p class="dashboard-stat__hint">Angemeldet als interner Benutzer</p>
</article>
<div class="dashboard-actions">
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadCurrentUser"
>
Neu laden
</v-btn>
</div>
<article class="dashboard-stat hoard-panel">
<div class="dashboard-stat__head">
<span class="hoard-icon-tile">
<v-icon icon="mdi-shield-account-outline" size="20" />
</span>
<p class="dashboard-stat__label">Rollen</p>
</div>
<p class="dashboard-stat__value">{{ roleLabel }}</p>
<p class="dashboard-stat__hint">Definiert deine sichtbaren Bereiche</p>
</article>
<article class="dashboard-stat hoard-panel">
<div class="dashboard-stat__head">
<span class="hoard-icon-tile">
<v-icon icon="mdi-pulse" size="20" />
</span>
<p class="dashboard-stat__label">Status</p>
</div>
<div class="dashboard-stat__pill-row">
<span :class="['hoard-status', `hoard-status--${accountStatusVariant}`]">
{{ accountStatusLabel }}
</span>
<span :class="['hoard-status', `hoard-status--${passwordStatusVariant}`]">
{{ passwordStatusLabel }}
</span>
</div>
<p class="dashboard-stat__hint">Konto- und Passwortzustand</p>
</article>
</section>
<section v-if="!errorMessage" class="dashboard-detail hoard-panel">
<header class="dashboard-detail__head">
<div>
<p class="hoard-kicker">Auth-Antwort</p>
<h2>Aktueller Session-Snapshot</h2>
<p>Direkt aus <code>GET /auth/me</code> nützlich zum Debuggen und für Plausibilitätschecks.</p>
</div>
<div class="dashboard-detail__actions hoard-action-row">
<v-btn
variant="outlined"
prepend-icon="mdi-lock-reset"
@click="goToChangePassword"
>
Passwort ändern
</v-btn>
<v-btn
variant="elevated"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadCurrentUser"
>
Neu laden
</v-btn>
</div>
</header>
<p v-if="isLoading" class="dashboard-detail__loading">Benutzerdaten werden geladen </p>
<pre v-else class="dashboard-detail__json">{{ prettyUser }}</pre>
</section>
</v-container>
</template>
<style scoped>
.dashboard-page {
--hoard-page-width: 980px;
--hoard-page-width: 1120px;
}
.dashboard-shell {
/* ---------- Greeting ---------- */
.dashboard-greeting {
--hoard-gradient-angle: 120deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 65%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-6);
align-items: center;
padding: var(--space-8);
border-radius: var(--radius-xl);
overflow: hidden;
}
.dashboard-greeting__copy {
position: relative;
z-index: 1;
}
h1 {
margin: 0 0 var(--space-3);
font-size: clamp(1.8rem, 1.4rem + 1vw, 2.4rem);
letter-spacing: -0.02em;
}
.dashboard-greeting__copy p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
max-width: 64ch;
}
.dashboard-greeting__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-4);
}
.dashboard-greeting__avatar {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
z-index: 1;
}
.dashboard-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 84px;
height: 84px;
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: 28px;
font-weight: 700;
letter-spacing: -0.01em;
box-shadow:
var(--shadow-glow),
inset 0 0 0 2px color-mix(in srgb, var(--color-accent-lime) 25%, transparent);
}
.dashboard-avatar__caption {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------- Stats grid ---------- */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.dashboard-stat {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
}
.dashboard-stat__head {
display: inline-flex;
align-items: center;
gap: var(--space-3);
}
.dashboard-stat__label {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.dashboard-stat__value {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-xl);
font-weight: 600;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.dashboard-stat__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.dashboard-stat__pill-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* ---------- Detail panel ---------- */
.dashboard-detail {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
}
.dashboard-head h1,
.dashboard-head p,
.dashboard-label,
.dashboard-loading {
.dashboard-detail__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.dashboard-detail__head h2 {
margin: 0 0 var(--space-1);
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.dashboard-detail__head p {
margin: 0;
}
.dashboard-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.dashboard-head p {
color: var(--color-text-secondary);
}
.dashboard-user {
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 58%, var(--color-surface) 42%);
}
.dashboard-label {
margin-bottom: var(--space-2);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.dashboard-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.dashboard-summary-card {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
min-width: 0;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 78%, var(--color-surface) 22%);
border-radius: var(--radius-md);
background-color: var(--color-surface);
}
.dashboard-summary-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
color: var(--color-primary-700);
background-color: color-mix(in srgb, var(--color-primary-100) 82%, var(--color-surface) 18%);
}
.dashboard-summary-label,
.dashboard-summary-value {
.dashboard-detail__loading {
margin: 0;
}
.dashboard-summary-label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dashboard-summary-value {
color: var(--color-text);
font-weight: 600;
line-height: 1.35;
overflow-wrap: anywhere;
}
.dashboard-loading {
color: var(--color-text-secondary);
}
.dashboard-json {
.dashboard-detail__json {
margin: 0;
overflow: auto;
padding: var(--space-4);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 70%, var(--color-surface) 30%);
color: var(--color-text);
background-color: var(--color-surface-alt);
font-size: 0.88rem;
line-height: 1.5;
}
.dashboard-actions {
display: flex;
justify-content: flex-end;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: 1.55;
}
@media (prefers-reduced-motion: no-preference) {
.dashboard-shell {
animation: hoard-soft-enter 260ms both;
.dashboard-greeting,
.dashboard-stat,
.dashboard-detail {
animation: hoard-soft-enter 280ms both;
}
.dashboard-stat:nth-child(2) {
animation-delay: 60ms;
}
.dashboard-stat:nth-child(3) {
animation-delay: 120ms;
}
.dashboard-detail {
animation-delay: 160ms;
}
}
@media (width <= 960px) {
.dashboard-greeting {
grid-template-columns: 1fr;
text-align: left;
padding: var(--space-6);
}
.dashboard-greeting__avatar {
align-items: flex-start;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.dashboard-detail {
padding: var(--space-5);
}
}
@media (width <= 600px) {
.dashboard-page,
.dashboard-shell,
.dashboard-user,
.dashboard-summary-grid,
.dashboard-summary-card {
min-width: 0;
max-width: 100%;
.dashboard-greeting {
padding: var(--space-5);
}
.dashboard-shell {
.dashboard-stat,
.dashboard-detail {
padding: var(--space-4);
overflow: hidden;
}
.dashboard-summary-grid {
grid-template-columns: 1fr;
.dashboard-detail__head {
flex-direction: column;
align-items: stretch;
}
.dashboard-user {
padding: var(--space-3);
overflow: hidden;
.dashboard-detail__actions {
width: 100%;
}
.dashboard-summary-card {
grid-template-columns: auto minmax(0, 1fr);
:deep(.dashboard-detail__actions .v-btn) {
width: 100%;
min-height: 44px;
}
.dashboard-head p,
.dashboard-label {
overflow-wrap: anywhere;
}
.dashboard-json {
.dashboard-detail__json {
max-width: 100%;
overflow-x: hidden;
padding: var(--space-3);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
font-size: var(--font-size-xs);
}
.dashboard-actions {
justify-content: stretch;
}
:deep(.dashboard-actions .v-btn) {
width: 100%;
min-height: 44px;
}
}
</style>
+58 -20
View File
@@ -1,13 +1,16 @@
/* Shared layout primitives for route-level page shells */
/* =============================================================================
Hoard Page-Layout-Primitives
============================================================================= */
.hoard-page {
display: flex;
flex-direction: column;
gap: var(--hoard-page-gap, var(--space-6));
margin-inline: auto;
width: min(100%, var(--hoard-page-width, 1120px));
width: min(100%, var(--hoard-page-width, 1180px));
padding-block:
var(--hoard-page-padding-start, var(--space-4))
var(--hoard-page-padding-end, var(--space-8));
var(--hoard-page-padding-start, var(--space-5))
var(--hoard-page-padding-end, var(--space-12));
}
.hoard-page--centered {
@@ -15,41 +18,76 @@
margin-inline: 0;
align-items: center;
justify-content: center;
min-height: calc(100vh - var(--hoard-centered-offset, 210px));
padding: var(--hoard-centered-padding, var(--space-8) var(--space-4));
min-height: calc(100vh - var(--hoard-centered-offset, 220px));
padding: var(--hoard-centered-padding, var(--space-10) var(--space-4));
}
.hoard-shell-grid {
display: grid;
gap: var(--hoard-shell-gap, var(--space-8));
width: min(100%, var(--hoard-shell-width, 1040px));
width: min(100%, var(--hoard-shell-width, 1080px));
padding: var(--hoard-shell-padding, var(--space-8));
}
.hoard-page-header {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 78ch;
}
.hoard-page-header > h1 {
margin: 0;
font-size: var(--font-size-3xl);
letter-spacing: -0.02em;
}
.hoard-page-header > p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-lg);
line-height: var(--line-height-loose);
}
.hoard-page-header__meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
}
@media (width <= 960px) {
.hoard-page {
width: 100%;
gap: var(--hoard-page-gap-mobile, var(--space-5));
padding-inline:
var(--hoard-page-padding-inline-start-mobile, max(var(--space-2), env(safe-area-inset-left)))
var(--hoard-page-padding-inline-end-mobile, max(var(--space-2), env(safe-area-inset-right)));
var(--hoard-page-padding-inline-start-mobile, max(var(--space-3), env(safe-area-inset-left)))
var(--hoard-page-padding-inline-end-mobile, max(var(--space-3), env(safe-area-inset-right)));
padding-block:
var(--hoard-page-padding-start-mobile, var(--space-2))
var(--hoard-page-padding-end-mobile, var(--space-6));
var(--hoard-page-padding-start-mobile, var(--space-3))
var(--hoard-page-padding-end-mobile, var(--space-8));
}
.hoard-page--centered {
width: 100%;
min-height: calc(100vh - var(--hoard-centered-offset-mobile, 180px));
padding: var(--hoard-centered-padding-mobile, var(--space-5) var(--space-2));
min-height: calc(100vh - var(--hoard-centered-offset-mobile, 200px));
padding: var(--hoard-centered-padding-mobile, var(--space-6) var(--space-3));
}
.hoard-shell-grid {
width: 100%;
gap: var(--hoard-shell-gap-mobile, var(--space-5));
padding:
var(--hoard-shell-padding-block-mobile, var(--space-5))
var(--hoard-shell-padding-inline-mobile, var(--space-4));
var(--hoard-shell-padding-block-mobile, var(--space-6))
var(--hoard-shell-padding-inline-mobile, var(--space-5));
}
.hoard-page-header > h1 {
font-size: var(--font-size-2xl);
}
.hoard-page-header > p {
font-size: var(--font-size-md);
}
}
@@ -58,18 +96,18 @@
gap: var(--hoard-page-gap-mobile-xs, var(--space-4));
padding-block:
var(--hoard-page-padding-start-mobile-xs, var(--space-2))
var(--hoard-page-padding-end-mobile-xs, var(--space-5));
var(--hoard-page-padding-end-mobile-xs, var(--space-6));
}
.hoard-page--centered {
min-height: calc(100vh - var(--hoard-centered-offset-mobile-xs, 164px));
padding: var(--hoard-centered-padding-mobile-xs, var(--space-4) var(--space-2));
min-height: calc(100vh - var(--hoard-centered-offset-mobile-xs, 180px));
padding: var(--hoard-centered-padding-mobile-xs, var(--space-5) var(--space-3));
}
.hoard-shell-grid {
gap: var(--hoard-shell-gap-mobile-xs, var(--space-4));
padding:
var(--hoard-shell-padding-block-mobile-xs, var(--space-4))
var(--hoard-shell-padding-inline-mobile-xs, var(--space-3));
var(--hoard-shell-padding-block-mobile-xs, var(--space-5))
var(--hoard-shell-padding-inline-mobile-xs, var(--space-4));
}
}
+191 -14
View File
@@ -1,46 +1,219 @@
/* Shared surface and content patterns */
/* =============================================================================
Hoard Surface- und Inhaltspattern
============================================================================= */
.hoard-kicker {
margin: 0 0 var(--space-2);
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-3);
color: var(--color-primary-700);
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.05em;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hoard-kicker::before {
content: '';
width: 18px;
height: 1px;
background-color: currentcolor;
opacity: 0.6;
}
.hoard-kicker--wide {
letter-spacing: 0.06em;
letter-spacing: 0.12em;
}
.hoard-kicker--xs {
font-size: var(--font-size-xs);
letter-spacing: 0.04em;
margin-bottom: var(--space-2);
font-size: var(--font-size-2xs);
letter-spacing: 0.1em;
}
.hoard-kicker--plain::before {
display: none;
}
.hoard-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.hoard-action-row--end {
justify-content: flex-end;
}
.hoard-panel-gradient {
background: linear-gradient(
var(--hoard-gradient-angle, 120deg),
var(--hoard-gradient-start, color-mix(in srgb, var(--color-primary-100) 34%, var(--color-surface) 66%))
0%,
var(--hoard-gradient-end, var(--color-surface)) var(--hoard-gradient-end-stop, 52%)
);
background:
radial-gradient(
120% 80% at 100% 0%,
color-mix(in srgb, var(--color-accent-lime) 14%, transparent) 0%,
transparent 60%
),
linear-gradient(
var(--hoard-gradient-angle, 130deg),
var(--hoard-gradient-start, color-mix(in srgb, var(--color-primary-100) 65%, var(--color-surface) 35%))
0%,
var(--hoard-gradient-end, var(--color-surface)) var(--hoard-gradient-end-stop, 60%)
);
}
.hoard-spotlight {
position: relative;
overflow: hidden;
isolation: isolate;
}
.hoard-spotlight::before,
.hoard-spotlight::after {
content: '';
position: absolute;
z-index: -1;
pointer-events: none;
border-radius: var(--radius-full);
filter: blur(60px);
}
.hoard-spotlight::before {
inset: -10% auto auto -10%;
width: 360px;
height: 360px;
background: color-mix(in srgb, var(--color-primary-300) 40%, transparent);
opacity: 0.55;
}
.hoard-spotlight::after {
inset: auto -10% -10% auto;
width: 320px;
height: 320px;
background: color-mix(in srgb, var(--color-accent-lime) 35%, transparent);
opacity: 0.35;
}
[data-theme='dark'] .hoard-spotlight::before {
background: color-mix(in srgb, var(--color-primary-700) 50%, transparent);
opacity: 0.45;
}
[data-theme='dark'] .hoard-spotlight::after {
background: color-mix(in srgb, var(--color-primary-600) 30%, transparent);
opacity: 0.4;
}
.hoard-chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 5px var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
background-color: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-alt) 12%);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 500;
letter-spacing: 0.01em;
white-space: nowrap;
}
.hoard-chip--brand {
border-color: color-mix(in srgb, var(--color-primary-300) 60%, var(--color-border) 40%);
background-color: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
color: var(--color-primary-700);
}
.hoard-chip--ghost {
border-color: var(--color-border-subtle);
background-color: transparent;
}
.hoard-chip > .v-icon {
width: 14px;
height: 14px;
}
.hoard-icon-tile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: var(--radius-md);
color: var(--color-primary-700);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary-100) 90%, var(--color-surface) 10%),
color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%)
);
border: 1px solid color-mix(in srgb, var(--color-primary-300) 35%, var(--color-border) 65%);
flex: 0 0 auto;
}
.hoard-icon-tile--lg {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
}
.hoard-icon-tile--ghost {
color: var(--color-text-secondary);
background: var(--color-surface);
border-color: var(--color-border);
}
.hoard-divider-soft {
border: 0;
height: 1px;
background:
linear-gradient(
90deg,
transparent,
var(--color-border-subtle) 12%,
var(--color-border) 50%,
var(--color-border-subtle) 88%,
transparent
);
}
.hoard-section-head {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-5);
max-width: 70ch;
}
.hoard-section-head > h2 {
margin: 0;
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.hoard-section-head > p {
margin: 0;
color: var(--color-text-secondary);
}
@media (width <= 960px) {
.hoard-action-row {
gap: var(--space-2);
}
.hoard-spotlight::before,
.hoard-spotlight::after {
width: 240px;
height: 240px;
filter: blur(48px);
}
}
@media (width <= 600px) {
.hoard-kicker {
margin-bottom: var(--space-1);
margin-bottom: var(--space-2);
}
.hoard-action-row {
@@ -52,4 +225,8 @@
.hoard-action-row > * {
width: 100%;
}
.hoard-section-head > h2 {
font-size: var(--font-size-xl);
}
}
+270 -383
View File
@@ -1,471 +1,358 @@
# Hoard Style Guide
## Zielbild
Hoard soll wirken wie eine ruhige, moderne Dateiverwaltung im Browser: klar, aufgeräumt, produktiv und leicht verständlich. Nicht verspielt, nicht luxuriös, nicht wie ein komplexes Notion-Klon-System. Die Oberfläche soll in erster Linie Ordnung vermitteln und den Fokus auf Dateien, Ordner, Vorschau und Markdown-Bearbeitung legen.
Hoard soll wirken wie eine **moderne, produktive Dateiverwaltung im Browser** ruhig, klar und mit hoher visueller Sorgfalt. Die Grundphilosophie bleibt dateiorientiert: Inhalte, Dateinamen, Pfade und Aktionen stehen im Vordergrund, nicht UI-Effekte. Gleichzeitig darf sich Hoard hochwertig und präzise anfühlen „polished" statt „fancy", näher an einer modernen Linear-/Vercel-/Notion-Sidebar als an einer alten Admin-UI.
Die Gestaltung orientiert sich an drei Prinzipien:
Die Gestaltung folgt drei Prinzipien:
1. **Dateien zuerst** Inhalte, Dateinamen, Pfade und Aktionen stehen optisch im Vordergrund.
2. **Ruhige Oberfläche** wenig visuelle Unruhe, viel Weißraum, zurückhaltende Farben.
3. **Grün als Identität, nicht als Dauerfeuer** die Markenfarbe wird gezielt für Auswahl, Primäraktionen und Status genutzt, nicht flächendeckend.
1. **Dateien zuerst** Inhalte, Namen, Strukturen und Aktionen sind optisch dominant, nicht die Chrome.
2. **Ruhe mit Charakter** viel Weißraum, klare Flächen, ausgewählte Akzente. Wenig Elemente, aber jedes mit Sorgfalt.
3. **Grün als Identität, nicht als Dauerfeuer** die Markenfarbe wird gezielt für Auswahl, Primäraktionen, Status und Branding eingesetzt nicht flächendeckend.
## Stilrichtung
Die Seite soll sich optisch zwischen Google Drive und einer modernen self-hosted Admin-Oberfläche bewegen.
## Modernisierungs-Direktive
Beim Redesign gilt zusätzlich:
**So soll es wirken:**
- sachlich und sauber
- freundlich, aber nicht verspielt
- modern, aber bewusst einfach
- produktiv statt marketing-lastig
- leicht technisch, ohne kalt zu sein
- **Hochwertige App-Shell:** Sidebar und Topbar dürfen sich „premium" anfühlen (klare Hierarchie, animierter Active-Indicator, gradient-tinged Brand-Bereich).
- **Layered Surfaces:** Statt einer einzigen Surface-Farbe gibt es eine kleine Surface-Hierarchie (`surface`, `surface-alt`, `surface-elevated`) mit subtil gestaffelten Schatten.
- **Microinteractions:** kurze, präzise Hover-/Active-/Focus-Animationen (160240 ms). Keine wabernden, dauerhaften Bewegungen.
- **Ambient Gradients:** Hero-/Brand-Bereiche dürfen sehr weiche, breit gestreute Verläufe in Markenfarbe haben. Arbeitsflächen bleiben funktional und ruhig.
- **Typografische Präzision:** klare Größen-Hierarchie, ruhige Headlines, viel `letter-spacing` Disziplin, keine dekorativen Schriften.
- **Konsistente Iconographie:** ausschließlich MDI Outline-Icons mit gleicher Größe und Farbverhalten.
- **Light- und Dark-Mode gleichwertig:** alle neuen Stile müssen in beiden Modi funktional und ästhetisch tragen, ausschließlich über Tokens (`color-mix`, CSS-Variablen) gelöst.
**So soll es nicht wirken:**
- kein Neon- oder Gaming-Look
- kein Glassmorphism
- keine harten Kontraste überall
- keine überladenen Kartenlayouts
- keine bunte Mischung vieler Akzentfarben
**Was weiterhin tabu ist:**
- kein Glassmorphism (echte Backdrop-Blur-Karten),
- kein Neon-/Gaming-Look,
- keine harten, durchgängigen Kontraste,
- keine Mehrfarbigkeit aus dem Akzent-Setup,
- keine dauerhaften Animationen oder Parallax-Spielereien.
## Visuelle Identität
Die Markenwirkung basiert auf neutralen Flächen mit einem kontrollierten Grün als Wiedererkennungsmerkmal. Das Grün kommt aus dem Logo und steht für Ablage, Struktur, Ruhe und „self-hosted tool statt „Social App.
Markenwirkung basiert auf neutralen, leicht warmen Flächen mit kontrolliertem Grün. Das Grün stammt aus dem Logo (Stapel-/Ordner-Idee) und steht für Ablage, Struktur und „self-hosted tool" statt „Social App". Akzent-Lime nur als gezielter Akzent-Glow im Branding und in Marketing-Sektionen.
Die App bleibt **light-first**, aber Light- und Dark-Mode sind gleichwertig zu pflegen. Neue Komponenten müssen ihre Farben aus den Design-Tokens beziehen, damit beide Modi ohne Sonderlogik funktionieren.
Die App ist **light-first**, Dark-Mode ist gleichwertig zu pflegen. Neue Komponenten beziehen Farben aus Design-Tokens.
## Farbpalette
### Primärfarben
- **Primary 700:** `#1C652F`
Für Primärbuttons, aktive Icons, Fokusrahmen, Links in aktiven Zuständen.
- **Primary 600:** `#2E7D32`
Für Hover-Zustände und aktive Navigation.
- **Primary 500:** `#3C8F42`
Für ausgewählte Einträge, Badges, bestätigende States.
- **Primary 300:** `#A8D5A2`
Für weiche Hintergründe von Auswahlflächen.
- **Primary 100:** `#EAF5E8`
Für sehr subtile Hervorhebungen.
### Primärfarben (Light)
- **Primary 800:** `#10421E` tiefster Markenton, Texte auf Light-Surface.
- **Primary 700:** `#1C652F` Primärbuttons, aktive Icons, Fokusrahmen.
- **Primary 600:** `#2E7D32` Hover-Zustände, aktive Navigation.
- **Primary 500:** `#3C8F42` ausgewählte Einträge, Badges, bestätigende States.
- **Primary 300:** `#A8D5A2` weiche Hintergründe für Auswahlflächen.
- **Primary 100:** `#EAF5E8` sehr subtile Hervorhebungen, Tints.
- **Primary 050:** `#F4FAF1` fast unsichtbare Pflasterfläche für Hero-Glows.
### Akzentfarbe
- **Accent Lime:** `#B7E36B`
Nur sehr sparsam einsetzen, z. B. kleiner Glow im Logo-Bereich, leichtere Highlights, Upload-Fortschritt oder ausgewählte Illustrationsdetails. Nicht für normalen Text.
### Akzent
- **Accent Lime:** `#B7E36B` sparsam: Logo-Glow, kleine Highlights, Upload-Fortschritt.
### Neutrale Farben
- **Background:** `#F6F8F5`
Hauptseitenhintergrund.
- **Surface 1:** `#FFFFFF`
Karten, Panels, Modals, Dialoge.
- **Surface 2:** `#F1F4EF`
Sekundäre Flächen, Toolbar-Hintergründe, Tabellenkopf.
- **Border:** `#DCE4D8`
Standard-Border.
- **Border Strong:** `#C7D2C2`
Stärkere Abgrenzung bei Panels und Inputs.
### Neutrale (Light)
- **Background:** `#F5F8F2`
- **Background Tint:** `#EEF3EA` ambient Hintergrund-Verlauf.
- **Surface:** `#FFFFFF` Karten, Panels, Dialoge.
- **Surface Alt:** `#F1F4EE` Toolbar, Tabellenkopf, Sekundärflächen.
- **Surface Elevated:** `#FBFCF8` höhere Layer (Drawer-Inhalt, Dropdowns).
- **Border:** `#DCE3D6`
- **Border Strong:** `#C5CFBE`
- **Border Subtle:** `#E8EDE2` innere Trennlinien.
### Textfarben
- **Text Primary:** `#1F2A21`
- **Text Secondary:** `#5F6E62`
- **Text Muted:** `#7D8A80`
### Text (Light)
- **Text Primary:** `#1A2A1E`
- **Text Secondary:** `#5A6A5E`
- **Text Muted:** `#7B897F`
- **Text On Primary:** `#FFFFFF`
### Statusfarben
Schlicht halten, nicht zu bunt.
### Dark Mode
Dark-Mode wird vollständig über Tokens gespiegelt. Eckwerte:
- Background: `#0E1115`
- Surface: `#161A20`
- Surface Alt: `#1B2028`
- Surface Elevated: `#1F252E`
- Border: `#2A323D`
- Border Strong: `#3A4452`
- Text Primary: `#E9EFF3`
- Text Secondary: `#B6BEC8`
- Primary 700 (dark): `#5FB968`
- Primary 600 (dark): `#4EA758`
- Primary 100 (dark): `#1F2922`
### Status
- **Success:** `#2E7D32`
- **Warning:** `#B7791F`
- **Danger:** `#C0392B`
- **Info:** `#2F6FB3`
## Typografie
Die Typografie soll neutral, gut lesbar und unauffällig modern sein. Keine dekorativen Schriften.
Neutral, gut lesbar, unauffällig modern keine dekorativen Schriften.
**Empfohlene Schriftfamilie:**
- `Inter`
- Fallback: `system-ui, sans-serif`
**Stack:** `Inter, "Segoe UI", Roboto, system-ui, sans-serif`
**Typografische Regeln:**
- normale Lesetexte: 1415 px
- UI-Haupttext in Listen und Tabellen: 14 px
- Seitenüberschriften: 2428 px
- Bereichsüberschriften: 1820 px
- kleine Meta-Infos: 1213 px
- Zeilenhöhe großzügig halten, besonders in Dateilisten und Formularen
- Schriftgrößen nicht mit Viewport-Breite skalieren; responsive Größen über feste Werte in Breakpoints setzen
**Skala (rem-orientiert, fixe px-Breakpoints, keine viewport-Skalierung):**
- `--font-size-2xs`: 11px Status-Labels, Mini-Meta.
- `--font-size-xs`: 12px Tabellenmeta, Helper-Text.
- `--font-size-sm`: 13px sekundärer UI-Text.
- `--font-size-md`: 14px Standard-UI-Text, Listen.
- `--font-size-lg`: 16px akzentuierte UI-Hervorhebung.
- `--font-size-xl`: 20px Bereichsüberschriften.
- `--font-size-2xl`: 26px Seitenüberschriften.
- `--font-size-3xl`: 32px Display-Headlines (Hero).
- `--font-size-display`: 44px sehr seltene Marketing-Display-Texte.
**Schriftgewicht:**
- 400 für normalen Fließtext
- 500 für UI-Text und Labels
- 600 für Titel und aktive Elemente
- 700 nur sehr gezielt
**Gewichte:**
- 400 für Fließtext.
- 500 für UI-Text und Labels.
- 600 für Titel, Kicker, aktive Items.
- 700 nur sehr gezielt (Display-Headlines, Logo-Wortmarke).
## Layoutprinzip
Das Layout soll stark an eine Dateiverwaltung erinnern.
**Letter-Spacing-Disziplin:**
- Display-Headlines: leicht negatives Tracking (`-0.01em` bis `-0.02em`).
- Kicker/Labels (uppercase): `0.05em` bis `0.08em`.
- Standard: `0`.
### Grundaufbau
- **Topbar** für Logo, Breadcrumbs, Kontextaktionen, Benutzer-Menü
- **linke Sidebar** für Navigation
- **Hauptbereich** für Dateiliste oder Grid
- **rechte Vorschau / Detailansicht** optional als Panel oder getrennte Ansicht
## Spacing
Modulare Skala in 4er-Schritten:
### Seitenbreite und Abstand
- großzügige horizontale Abstände
- Hauptinhalte nicht zu schmal machen
- Panels mit genug Luft, aber ohne Dashboard-Overdesign
- Standard-Abstandssystem in 4er- oder 8er-Schritten
```
--space-1: 4px
--space-2: 8px
--space-3: 12px
--space-4: 16px
--space-5: 20px
--space-6: 24px
--space-7: 28px
--space-8: 32px
--space-10: 40px
--space-12: 48px
--space-16: 64px
```
**Spacing-Skala:**
- 4 px
- 8 px
- 12 px
- 16 px
- 20 px
- 24 px
- 32 px
- 40 px
Hauptseiten: `--hoard-page-width` 11201200px, Padding orientiert sich an `--space-6`/`--space-8`.
## Formensprache
Die Formensprache soll weich, aber nicht rundgelutscht sein.
## Formensprache (Border-Radien)
- `--radius-xs`: 6px kleine Pills, Status-Badges.
- `--radius-sm`: 10px Buttons, kleine Controls.
- `--radius-md`: 14px Inputs, Dropdowns, Listzeilen.
- `--radius-lg`: 18px Cards, Panels.
- `--radius-xl`: 22px Hero-Shells, Modals.
- `--radius-full`: 999px nur für Avatar/Tag-Pills.
- kleine Controls: `8px` Radius
- Panels, Inputs, Dropdowns: `10px`
- Modals und größere Karten: `14px`
- keine pillenförmigen Vollflächen als Grundstil
Keine pillenförmigen Vollflächen als Grundstil.
## Schatten und Tiefe
Sehr zurückhaltend einsetzen. Die App soll stabil und ruhig wirken, nicht schwebend.
## Schatten & Tiefe
Mehrstufiges, sehr ruhiges Schatten-System:
**Standard-Schatten:**
- Panels: leichter, weicher Schatten
- Dropdowns / Modals: etwas stärker, aber nie dramatisch
- keine starken farbigen Schatten im Produktivbereich
- grüner Glow nur höchstens im Branding oder auf Marketing-/Login-Flächen
- Hover-Lift maximal 12 px und nur bei klar interaktiven Flächen oder Demo-Karten
```
--shadow-xs: 0 1px 1px rgba(20, 30, 22, 0.04);
--shadow-sm: 0 2px 6px rgba(20, 30, 22, 0.06);
--shadow-md: 0 8px 22px rgba(20, 30, 22, 0.08);
--shadow-lg: 0 18px 44px rgba(20, 30, 22, 0.12);
--shadow-glow: 0 12px 36px rgba(28, 101, 47, 0.18);
```
## Komponentenstil
Regeln:
- Panels: `--shadow-sm`.
- Hover-Lift max. 2 px, nur bei klar interaktiven Elementen.
- Dropdowns/Modals: `--shadow-md` bis `--shadow-lg`.
- `--shadow-glow` ausschließlich für Brand-Bereiche (Login-Hero, Welcome-Hero).
- Keine farbigen Schatten in Arbeitsflächen.
## Motion
- Standard-Easing: `cubic-bezier(0.2, 0, 0, 1)`.
- `--transition-fast`: 160 ms (Hover, Buttons, Listzeilen).
- `--transition-medium`: 220 ms (Route-Wechsel, Banner).
- `--transition-slow`: 320 ms (Hero-Enter, Drawer).
- Globale Page-Enter-Animation: weicher Y-Slide + Fade (≤ 8 px).
- `prefers-reduced-motion`: alle Transitions auf 1 ms reduzieren, keine Translates.
## App-Shell
### Topbar
Die Topbar ist ruhig und funktional.
- Höhe ca. 6064 px
- heller Hintergrund oder leicht abgesetzte Surface-Farbe
- Logo links
- Breadcrumbs klar lesbar, nicht zu klein
- Kontextaktionen rechts davon oder am rechten Rand
- dünne Unterkante oder subtile Shadow-Abgrenzung
- Höhe ca. 64 px, opaker `surface`-Hintergrund, dünne Bottom-Border.
- Branding links: Icon + Wortmarke + dezenter Subtext („Self-hosted Workspace").
- Page-Kontext (Name + Beschreibung) als sekundäre Info, nur ab `>1180px`.
- Rechts: Theme-Toggle, Account-Menü oder Login-Button.
- Subtile bottom shadow (`--shadow-xs`).
### Sidebar
Die Sidebar ist funktional, nicht dominant.
- feste Breite, ca. 240280 px
- leicht abgesetzte Hintergrundfläche
- aktive Einträge mit heller grüner Fläche und dunklerem Text
- Icons schlicht und einheitlich
- Navigation in logische Gruppen, aber ohne zu viele Sektionen
- Breite 268284 px, Hintergrund `surface-alt`, leicht abgesetzt.
- Active-Indicator: linker, animierter, vertikaler Strich (`--color-primary-600`) + grüne Tint-Fläche + dunklerer Text.
- Hover: leichte Tint-Fläche, kein Scaling.
- Sektionen mit Kicker-Labels (`Navigation`, `Admin`).
- Mindesthöhe pro Item: 44 px (Desktop), 48 px (Mobile).
- Mobile = Bottom-Sheet-Drawer mit abgerundeten Top-Ecken.
### Dateiliste
Die Dateiliste ist das Herzstück der App.
### Footer
- Sehr ruhig, kompakt, `surface` mit dünner oberer Border.
- Links nur auf rechtlich/strukturell wichtige Routen (Impressum etc.).
- Mobile: in Grid mit Tap-Größen ≥ 44 px.
**Darstellung:**
- standardmäßig Listenansicht
- klare Spalten für Name, Typ, Größe, geändert am
- Zeilenhöhe eher luftig statt kompakt gepresst
- Hover nur leicht hervorheben
- ausgewählte Zeile mit heller grüner Tönung
- Doppelklick oder klarer Primärklick zum Öffnen
- Dateisymbole farblich dezent
## Komponenten
**Wichtig:**
Die Liste soll strukturierter und ruhiger wirken als ein typisches Admin-Grid. Nicht wie eine Datenbanktabelle, sondern wie eine echte Dateiverwaltung.
### Karten / Panels
Karten nur dort einsetzen, wo es fachlich Sinn ergibt:
- Vorschau-Panel
- Datei-Infos
- Upload-Status
- Modals
Nicht jede Seite künstlich in zehn Karten aufteilen.
### Cards / Panels
- Hintergrund: `surface` mit subtilem 180°-Verlauf zu `surface-alt`.
- Border: `--color-border`.
- Radius: `--radius-lg`.
- Padding: `--space-6` (Standard), kleinere Inhalte `--space-4`.
- Hover (nur klar interaktive Cards): Border in Primary-300-Mix, `--shadow-md`, 2 px Y-Lift.
### Buttons
Buttons sollen schlicht und klar sein.
**Primärbutton:**
- grüner Hintergrund
- weiße Schrift
- mittlere Höhe
- klar erkennbare Hover- und Disabled-Zustände
**Sekundärbutton:**
- helle Fläche mit Border
- dunkler Text
- kein zu aggressiver Kontrast
**Tertiärbutton / Icon-Button:**
- für Zeilenaktionen, Toolbar, Preview-Aktionen
- Hover mit leichter Surface-Abhebung
**Regel:**
Es sollte pro Bereich meist genau eine klare Primäraktion geben.
- **Primary:** grüner Hintergrund (`--color-primary-700`), weiße Schrift, `--shadow-xs`. Hover: `--color-primary-600` + leichter Glow. Active: ohne Lift.
- **Outlined:** transparenter Hintergrund, `--color-border-strong`, dunkler Text. Hover: leichte Primary-Tint-Fläche, Border zu Primary-Mix.
- **Text/Tertiary:** für Zeilenaktionen, Toolbar, Nav. Hover: Surface-Tint.
- **Icon-Buttons:** mind. 40×40 (Desktop), 44×44 (Mobile).
- Letter-Spacing 0, kein Uppercase.
- Pro Bereich genau **eine Primäraktion**.
### Inputs
- weiße Fläche
- klarer Border
- Fokuszustand mit grünem Ring oder grün betonter Border
- keine dunklen, schweren Inputs
- Labels oberhalb statt Placeholder-only
- Outline-Variante, `surface` Hintergrund.
- Border `--color-border-strong`, Focus-Ring 3 px Primary-300-Tint + Primary-600-Border.
- Labels oberhalb (Vuetify-Outlined-Label), keine reinen Placeholder.
- Mind. 44 px Höhe auf Mobile.
### Modals und Dialoge
- kompakt und funktional
- deutlicher Titel
- klare Primär- und Sekundäraktion
- nicht zu breit
- Löschen-Aktionen visuell bewusst neutral mit Danger-Akzent nur am Button
### Listen / Tabellen
- Tabelle: `surface` Hintergrund, abgesetzter Tabellen-Header (`surface-alt`).
- Hover: sehr subtile Primary-Tint-Tönung.
- Selected Row: Primary-100 Hintergrund, `--color-primary-700` Text.
- Border-Bottom in `--color-border-subtle`.
- `hoard-list-row` (Datei-/Item-Zeilen): luftiges Padding, klare Spalten, `transform: translateX(2px)` beim Hover.
### Dropdowns und Kontextmenüs
- schlicht, hell, sauber getrennte Einträge
- Icons optional, aber konsistent
- Hover klar sichtbar
- kritische Aktionen unten gruppieren
### Status-Pills (`hoard-status`)
- Kompakt, `--radius-xs`, mit dezentem Text/Background-Tint pro Status (Success/Info/Warning/Danger/Neutral).
- Optional kleines Punktindikator-Dot vor dem Text.
## Vorschau-Bereich
Der Vorschau-Bereich ist ein zentraler Teil von Hoard und soll hochwertig, aber ruhig wirken.
### Banner-Stack
- Position: fixiert unten rechts (Desktop), unten zentriert (Mobile).
- Stapelbar, einzeln dismissable.
- Opake Hintergründe, `--shadow-md`, sauber lesbar auf jedem Inhalt.
- Enter/Leave: 180 ms Fade + 10 px Y.
### PDF-Vorschau
- heller neutraler Hintergrund
- PDF sitzt auf einer weißen „Papier“-Fläche
- genug Rand um die Seite herum
- Controls minimal und funktional
## Hero-/Marketing-Bereiche
- Erlauben einen **ambient Verlauf** (`hoard-panel-gradient` + Variablen).
- Optional dezenter „Spotlight"-Glow (radialer Verlauf, `--color-primary-300` mit niedriger Opazität, blur).
- Inhaltsbreite max ~64ch.
- Display-Headlines mit `--font-size-3xl` oder `--font-size-display` (nur Hero).
- Hero-Tags (`hoard-chip`/`hoard-tag`) als kompakte, dezent gerahmte Pills.
### Bildvorschau
- dunklerer neutraler Viewer-Hintergrund ist okay, wenn das Bild dadurch besser wirkt
- umgebende UI trotzdem konsistent mit dem restlichen Produkt halten
- keine übertriebene Galerie-Optik
## Vorschau-Bereich (Files)
- **PDF-Vorschau:** neutraler Hintergrund, weiße „Papier"-Fläche mit `--shadow-md`, genug Rand. Controls minimal.
- **Bildvorschau:** dunklerer neutraler Viewer-Hintergrund nur, wenn das Bild dadurch besser wirkt; UI-Chrome konsistent.
- **Markdown-Editor/Reader:** wirkt wie ein Arbeitsdokument, nicht wie Blogpost. Lesbreite ~70ch, klare Heading-Hierarchie, sehr ruhige Codeblöcke.
### Markdown-Ansicht / Editor
Markdown-Dateien sollen wie Arbeitsdokumente wirken, nicht wie Blogposts.
**Vorgaben:**
- gute Textbreite
- klare Hierarchie bei Überschriften
- dezente Codeblock-Gestaltung
- sehr gute Lesbarkeit
- Editor und Preview optisch zur restlichen App passend, auch wenn `md-editor-v3` eigene Defaults mitbringt
## Tabellen- und Listenverhalten
Da der Kern deiner App Listen, Dateiansichten und Metadaten sind, muss das Verhalten konsistent sein.
- Hover ist immer subtil
- Auswahl ist immer über denselben Grünton markiert
- aktive Navigation und aktive Listelemente nutzen dieselbe semantische Farbe
- Sortierung, falls später vorhanden, visuell zurückhaltend markieren
- Bulk-Actions nur anzeigen, wenn wirklich etwas ausgewählt ist
## Icons
Empfohlen ist ein schlanker, moderner Icon-Stil mit einheitlicher Konturstärke.
Geeignet wären z. B.:
- Lucide
- Heroicons
**Regeln:**
- möglichst Outline-Icons
- nur wenige gefüllte Icons
- Dateityp-Icons dürfen leicht differenziert sein, aber nicht bunt explodieren
- Ordner-Icon in gedecktem Grün oder neutralem Grau
## Status und Feedback
Feedback soll klar sein, aber nicht laut.
### Toasts
- kurze Texte
- kein unnötiger Fließtext
- success, error, info klar unterscheidbar
- am besten oben rechts oder unten rechts, aber konsistent
### Ladezustände
- Skeletons oder sehr schlichte Loader
- lieber ruhige Platzhalter statt hektische Spinner überall
### Leere Zustände
Leere Zustände sollen freundlich, aber nüchtern sein.
- kleines Icon oder einfache Illustration
- ein klarer Satz
- eine eindeutige Folgeaktion
## Login-Seite
Die Login-Seite darf minimal etwas mehr Branding zeigen als die Haupt-App.
**Empfehlung:**
- zentrierte Login-Card
- Logo sichtbar
- neutraler Hintergrund mit sehr leichter grüner Stimmung
- keine starke Hero-Sektion nötig
- Fokus auf schnellem Einstieg
## Empty-/Loading-States
- Empty: kleines Outline-Icon in Primary-Tint, kurzer Titel, ein Satz Begründung, eine klare CTA.
- Loading: Skeletons (graue Pulslinien) bevorzugt, ansonsten ein kleiner zentraler Spinner mit beschreibendem Text. Keine flackernden Spinner-Felder.
## Responsive Verhalten
Desktop ist der Hauptfokus. Mobile muss funktionieren, aber nicht die Priorität des MVP sein.
Desktop ist Hauptfokus, Mobile muss aber sauber funktionieren.
### Desktop
- volle Sidebar
- großzügige Dateiliste
- Preview neben Liste möglich
### Breakpoints
- `@media (width <= 1180px)` Topbar-Kontextleiste verkürzen.
- `@media (width <= 960px)` Sidebar wird Bottom-Drawer; Karten-/Grid-Bereiche werden einspaltig; Spacing wird reduziert.
- `@media (width <= 600px)` CTAs full-width; Schriften minimal kompakter; Footer als Grid; Status-Pills behalten Lesbarkeit.
### Tablet
- Sidebar einklappbar
- Preview eher als Overlay oder eigene Ansicht
### Mobile
- eher einfache Stapelansicht
- Fokus auf Navigation und Öffnen
- Bearbeitung von Markdown darf reduziert sein, solange Lesen und einfache Bedienung sauber funktionieren
### Umsetzungsstandard Responsivität (verbindlich)
Die folgenden Regeln bilden den aktuellen Responsive-Standard von Hoard und sollen bei allen kommenden UI-Aufgaben eingehalten werden.
1. **Desktop-first, Mobile-only Overrides**
- Desktop-Styles bleiben Basis.
- Mobile-Anpassungen ausschließlich in Media Queries, keine Änderungen an Desktop-Baseregeln.
2. **Breakpoints**
- `@media (width <= 960px)` für Tablet/Mobile-Umbruch (Layout-Stacks, Spacing-Reduktion, Drawer-/Footer-Verhalten).
- `@media (width <= 600px)` für Phone-Feinschliff (volle Breite für CTAs, kompaktere Typo/Abstände).
3. **Globale Responsive-Patterns zuerst nutzen**
- Wiederverwendbare Anpassungen immer zuerst in den globalen Dateien pflegen:
- `GUI/src/global.css`
- `GUI/src/styles/global/page-layouts.css`
- `GUI/src/styles/global/surface-patterns.css`
- Seiten-spezifisches `scoped` CSS nur für wirklich lokale Sonderfälle.
4. **Touch-Zielgrößen und Bedienbarkeit**
- Interaktive Elemente mobil mit klarer Daumen-Bedienbarkeit:
- Buttons mindestens `44px` Höhe.
- Icon-Buttons mindestens `44x44px`.
- Navigations-Listeneinträge mindestens `48px` Höhe.
- Aktionszeilen (`hoard-action-row`) auf kleinen Geräten vertikal stapeln, damit Primäraktionen gut erreichbar sind.
5. **Safe-Area-Unterstützung**
- Bei mobilen Außenabständen `env(safe-area-inset-*)` berücksichtigen (iOS/Android).
- Muster: `max(var(--space-x), env(safe-area-inset-...))` als Fallback-sicherer Abstand.
- Besonders relevant für App-Bar-Ränder, Seiten-Padding und Bottom-Bereiche (Drawer/Footer).
6. **App-Shell-Muster**
- Mobile Navigation bleibt als Bottom-Sheet-Drawer.
- Desktop-Navigation bleibt unverändert (keine visuelle Regression auf großen Viewports).
- Footer-Links auf kleinen Screens umbauen (Wrap/Grid), mit ausreichend großen Tap-Flächen.
7. **Seiten-Muster**
- Content-Änderungen vermeiden; nur Layout/Bedienung anpassen.
- Karten-/Grid-Bereiche bei `<= 960px` in Einspalten-Layout überführen.
- Primäre CTAs bei `<= 600px` in voller Breite anzeigen.
8. **Responsive QA vor Abschluss**
- Pflicht-Viewports: `360x800`, `390x844`, `768x1024`, `1024x768`, `>=1280`.
- Prüfen: Navigation, Scroll-Verhalten, CTA-Erreichbarkeit, Formular-Bedienbarkeit.
- Light- und Dark-Mode jeweils mindestens auf Desktop und Mobile prüfen.
- Desktop-Regression-Check: bei `>=1024` darf sich das gewollte Desktop-Erscheinungsbild nicht ändern.
### Pflicht
- Desktop-Baseregeln nicht anfassen, nur Mobile-Overrides per Media Query.
- Touch-Größen: Buttons ≥ 44 px, Icon-Buttons ≥ 44×44, Nav-Items ≥ 48 px.
- Safe-Areas via `env(safe-area-inset-*)` (Topbar-Padding, Footer/Drawer Bottom).
- Banner-Stack respektiert Safe-Area Bottom.
- Pflicht-Viewports im QA: `360x800`, `390x844`, `768x1024`, `1024x768`, `>=1280` jeweils Light + Dark.
## Interaktionsprinzipien
- Primäraktionen immer klar sichtbar
- destruktive Aktionen nie zu nah an Standardaktionen
- Dateizeilen sollen sich klickbar anfühlen, ohne wie Buttons auszusehen
- Hover, Active und Selected Zustände deutlich unterscheiden
- Fokuszustände für Tastaturbedienung sauber sichtbar machen
## Stil für konkrete Bereiche
### Ordnernavigation
- Breadcrumbs schlicht, klickbar, gut lesbar
- aktueller Ordner klar markiert
- Pfad nie visuell dominanter als der Inhalt
### Upload-Bereich
- Uploads funktional anzeigen, nicht dramatisch
- Fortschritt mit ruhiger grüner Progressbar
- Fehlerfälle klar lesbar
- Upload-Liste eher kompakt halten
### Datei-Details
- Metadaten in sauberem Zwei-Spalten-Raster oder kompakter Liste
- Labels und Werte klar unterscheidbar
- Aktionen wie Download, Umbenennen, Löschen klar getrennt
- Primäraktionen klar sichtbar.
- Destruktives nie direkt neben Standardaktion.
- Dateizeilen sind klickbar, dürfen aber nicht wie Buttons aussehen.
- Hover, Active und Selected klar unterscheidbar (unterschiedliche Tints/Outlines).
- Fokus für Tastaturbedienung immer sichtbar (`outline: 2px solid var(--color-primary-500); outline-offset: 2px`).
## Designregeln für die Umsetzung
### Immer tun
- viel Weißraum lassen
- Grün nur gezielt einsetzen
- Borders und Surface-Unterschiede subtil halten
- Listen und Dateiansichten priorisieren
- Text gut lesbar und eher neutral halten
### Immer
- Tokens nutzen (Farben, Spacing, Radius, Shadow).
- Patterns wiederverwenden (`hoard-panel`, `hoard-page`, `hoard-action-row`, `hoard-kicker`, `hoard-status`, `hoard-chip`, `hoard-spotlight`).
- Light- und Dark-Mode gleichwertig prüfen.
- Animationen kurz halten und `prefers-reduced-motion` respektieren.
### Vermeiden
- zu viele Karten
- zu viele Farbflächen
- übertriebene Animationen
- mehrere konkurrierende Akzentfarben
- rein dekorative UI-Elemente ohne Nutzen
- zu viele Karten ohne Funktion,
- zu viele Akzentfarben gleichzeitig,
- harte 1:1-Kontraste,
- echte Glasflächen / Backdrop-Blur,
- dauernde Bewegungen,
- Schriftgrößen, die mit der Viewport-Breite skalieren.
## Beispiel für Design Tokens
Diese Tokens können später direkt in CSS-Variablen oder ein Theme übernommen werden.
## Beispiel: Design-Tokens
```css
:root {
--color-bg: #F6F8F5;
--color-surface: #FFFFFF;
--color-surface-alt: #F1F4EF;
--color-border: #DCE4D8;
--color-border-strong: #C7D2C2;
/* Surfaces */
--color-bg: #f5f8f2;
--color-bg-tint: #eef3ea;
--color-surface: #ffffff;
--color-surface-alt: #f1f4ee;
--color-surface-elevated: #fbfcf8;
--color-text: #1F2A21;
--color-text-secondary: #5F6E62;
--color-text-muted: #7D8A80;
--color-border: #dce3d6;
--color-border-strong: #c5cfbe;
--color-border-subtle: #e8ede2;
--color-primary-700: #1C652F;
--color-primary-600: #2E7D32;
--color-primary-500: #3C8F42;
--color-primary-300: #A8D5A2;
--color-primary-100: #EAF5E8;
--color-accent-lime: #B7E36B;
/* Text */
--color-text: #1a2a1e;
--color-text-secondary: #5a6a5e;
--color-text-muted: #7b897f;
--color-text-on-primary: #ffffff;
--color-success: #2E7D32;
--color-warning: #B7791F;
--color-danger: #C0392B;
--color-info: #2F6FB3;
/* Brand */
--color-primary-800: #10421e;
--color-primary-700: #1c652f;
--color-primary-600: #2e7d32;
--color-primary-500: #3c8f42;
--color-primary-300: #a8d5a2;
--color-primary-100: #eaf5e8;
--color-primary-050: #f4faf1;
--color-accent-lime: #b7e36b;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
/* Status */
--color-success: #2e7d32;
--color-warning: #b7791f;
--color-danger: #c0392b;
--color-info: #2f6fb3;
--shadow-sm: 0 1px 2px rgba(16, 24, 18, 0.06);
--shadow-md: 0 6px 18px rgba(16, 24, 18, 0.08);
/* Radius */
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--radius-xl: 22px;
--radius-full: 999px;
/* Shadows */
--shadow-xs: 0 1px 1px rgba(20, 30, 22, 0.04);
--shadow-sm: 0 2px 6px rgba(20, 30, 22, 0.06);
--shadow-md: 0 8px 22px rgba(20, 30, 22, 0.08);
--shadow-lg: 0 18px 44px rgba(20, 30, 22, 0.12);
--shadow-glow: 0 12px 36px rgba(28, 101, 47, 0.18);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Motion */
--transition-fast: 160ms cubic-bezier(0.2, 0, 0, 1);
--transition-medium: 220ms cubic-bezier(0.2, 0, 0, 1);
--transition-slow: 320ms cubic-bezier(0.2, 0, 0, 1);
}
```
## Abschlussentscheidung für Hoard
Für Hoard ist ein **ruhiger, light-first, dateiorientierter Produktivstil mit neutralen Flächen und kontrolliertem Grün als Markenfarbe** die passendste Richtung.
Das passt zur Produktidee, weil:
- die App primär eine Dateiverwaltung ist
- Markdown-Bearbeitung und Vorschau im Vordergrund stehen
- die Oberfläche einfach und wartbar bleiben soll
- der Stil gut allein umsetzbar ist
- die UI professionell wirkt, ohne nach großem SaaS-Produkt aussehen zu müssen
## Kurzfassung als Design-Leitlinie
Wenn du bei einer UI-Entscheidung unsicher bist, gilt:
Wenn du bei einer UI-Entscheidung unsicher bist:
**Lieber schlichter als spektakulär. Lieber Google-Drive-artig als Dashboard-artig. Lieber ruhige Flächen und klare Listen als visuelle Effekte. Grün ist Identität, nicht Dekoration.**
## Aktuelle Modernisierungsregeln
- Oberflächen dürfen subtile lineare Surface-Verläufe, weichere Schatten und klare Fokusrahmen nutzen.
- Route-Wechsel, Banner und wichtige Seitenbereiche dürfen kurze Fade-/Slide-Animationen verwenden.
- Animationen bleiben ruhig: ca. 160260 ms, keine dauerhaften Bewegungen, `prefers-reduced-motion` beachten.
- Brand- und Navigationsbereiche dürfen etwas hochwertiger wirken, die Arbeitsflächen bleiben funktional und ruhig.
- Zusätzliche UI-Elemente sind erlaubt, solange sie aus vorhandenen Daten entstehen und keine API-Calls oder Funktionen ändern.
- QA immer in Light- und Dark-Mode sowie mobil prüfen; insbesondere Navigation, Formulare, Tabellen und Banner.
**Lieber präzise als spektakulär. Lieber ruhige Flächen mit Charakter als visuelle Effekte. Grün ist Identität, nicht Dekoration. Jedes Element muss eine Funktion haben sonst fliegt es raus.**