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:
@@ -0,0 +1,106 @@
|
||||
# CLAUDE.md – Projektkontext für Hoard
|
||||
|
||||
Diese Datei gibt Claude (und allen weiteren Beitragenden) den nötigen Kontext, um an Hoard sinnvoll und konsistent weiterzubauen. Sie ersetzt die alte `codexInfo.md`.
|
||||
|
||||
## Projektidee
|
||||
Hoard ist eine self-hosted Web-App, die sich funktional zwischen Google Drive, Notion und Obsidian einordnet. Der Schwerpunkt liegt auf einer **Google-Drive-artigen Oberfläche** mit Dateien und Ordnern. Markdown-Dateien sollen direkt im Browser bearbeitet werden, andere Dateien gespeichert und – wenn möglich – als Vorschau angezeigt werden.
|
||||
|
||||
## Ziel des Projekts
|
||||
Hoard ist ein bewusst einfach gehaltenes Solo-Projekt neben einer Ausbildung. Es soll mehrere Benutzer unterstützen, dabei aber technisch und funktional schlank bleiben. Wichtig ist ein realistisches MVP, das sauber läuft und später erweitert werden kann.
|
||||
|
||||
## Tech-Stack
|
||||
- **Frontend:** Vue 3 + TypeScript + Vite + Vuetify 4 + Pinia + Vue Router
|
||||
- **Markdown-Editor:** md-editor-v3 (geplant)
|
||||
- **Backend:** ASP.NET Core (C#)
|
||||
- **Datenbank:** PostgreSQL (EF Core, Migrationen beim Start)
|
||||
- **Identity:** ASP.NET Identity, Cookie-basierte Auth, Rollenmodell (`admin`)
|
||||
- **Dateispeicher:** MinIO als S3-kompatibler Storage (geplant)
|
||||
- **Deployment:** Self-hosted; Frontend-Build geht direkt nach `API/wwwroot`
|
||||
|
||||
## Kernfunktionen für das MVP
|
||||
- Login mit bestehenden Accounts (kein öffentliches Registrieren)
|
||||
- Initialer Admin-Account, weitere Benutzer manuell durch Admins
|
||||
- Dateien und Ordner anlegen, hochladen, öffnen, navigieren
|
||||
- Dateiliste + Vorschau als Hauptansicht
|
||||
- Markdown direkt im Browser bearbeiten
|
||||
- PDFs und Bilder als Vorschau
|
||||
- Andere Dateien speichern, herunterladen oder öffnen
|
||||
|
||||
## Was bewusst nicht im MVP ist
|
||||
- Keine offene Registrierung
|
||||
- Kein Teilen oder Freigeben
|
||||
- Keine Suche, keine Versionierung
|
||||
- Keine Echtzeit-Zusammenarbeit
|
||||
- Keine Desktop-/Mobile-Apps
|
||||
- Keine komplexe Rechteverwaltung
|
||||
- Kein JWT/OAuth/SSO
|
||||
|
||||
## Sprachregel für UI-Texte
|
||||
- Umlaute ausdrücklich erwünscht (`ä`, `ö`, `ü`, `Ä`, `Ö`, `Ü`).
|
||||
- Keine Umschreibungen mit `ae`, `oe`, `ue` in sichtbaren deutschen Texten.
|
||||
|
||||
## Design-Quelle
|
||||
- Maßgeblich ist `GUI/style.md`. Dort liegen Farbpalette, Typografie, Spacing, Radien, Schatten, Komponentenregeln, Motion- und Responsive-Standards.
|
||||
- Die App ist **light-first**, dateiorientiert und nutzt **Grün als kontrollierte Markenfarbe** statt Dauerakzent.
|
||||
- Der modernisierte Look (siehe `GUI/style.md` → „Modernisierungs-Direktive") darf hochwertiger und visuell präziser wirken, bleibt aber ruhig, klar und produktiv – kein Gaming-, Glassmorphism- oder Marketing-Look.
|
||||
|
||||
## Globale CSS-Basis
|
||||
- `GUI/src/global.css` hält alle Design-Tokens (`:root`/`[data-theme='dark']`), Basis-Resets, Vuetify-Anpassungen und wiederverwendbare Patterns (`hoard-panel`, `hoard-toolbar`, `hoard-list-row`, `hoard-empty-state`, `hoard-status`, …).
|
||||
- `GUI/src/styles/global/page-layouts.css` enthält Page-Shells (`hoard-page`, `hoard-page--centered`, `hoard-shell-grid`).
|
||||
- `GUI/src/styles/global/surface-patterns.css` enthält wiederkehrende Surface-/Inhaltsbausteine (`hoard-kicker`, `hoard-action-row`, `hoard-panel-gradient`, `hoard-chip`, `hoard-spotlight` …).
|
||||
- Alle drei werden in `GUI/src/main.ts` einmalig importiert.
|
||||
- Neue Layouts/Patterns immer **zuerst** dort ergänzen, nicht in `scoped`-Styles duplizieren.
|
||||
|
||||
## Anleitung: CSS-Patterns verwenden
|
||||
- Neue Seiten standardmäßig mit `hoard-page` aufbauen; für zentrierte Vollhöhen-Ansichten zusätzlich `hoard-page--centered`.
|
||||
- Karten-/Shell-Container als `hoard-shell-grid hoard-panel` verwenden; Breite/Abstände pro Seite über CSS-Variablen setzen (`--hoard-shell-width`, `--hoard-shell-gap`, `--hoard-shell-padding`).
|
||||
- Wiederkehrende Headlines/Kicker mit `hoard-kicker` (+ ggf. `--wide`/`--xs`).
|
||||
- Aktionszeilen mit `hoard-action-row` bauen statt eigene Flex-Definitionen pro Seite.
|
||||
- Gradient-Flächen über `hoard-panel-gradient` + Variablen steuern.
|
||||
- Lokales `scoped` CSS nur für wirklich seitenspezifische Sonderfälle.
|
||||
|
||||
## Aktueller Stand (Identity-Branch)
|
||||
- `GUI/src/Layout.vue` ist die zentrale App-Shell: Topbar, Sidebar, Footer, responsiver Drawer, globaler Banner-Stack.
|
||||
- Light-/Dark-Mode global integriert (Toggle in der Topbar, Persistenz in `localStorage`, Theme-Tokens in CSS und Vuetify).
|
||||
- Öffentliche Kernseiten im Hoard-Stil: `Home.vue` (Landing), `Login.vue`, `404NotFound.vue`, `Impressum.vue`, `Forbidden.vue`.
|
||||
- Geschützter Bereich: `Dashboard.vue`, `ChangePassword.vue`, `AdminUsers.vue`, `AdminUserDetail.vue`.
|
||||
- Sidebar berücksichtigt Auth-/Rollen-Status; Admin-Bereich ist visuell abgesetzt.
|
||||
- Mobile Touch-Optimierung (Safe-Area, 44/48px Mindestgrößen, Bottom-Drawer) ist aktiv.
|
||||
- Backend-API: Health (`GET /api/health`), Auth (`POST /auth/login`, `POST /auth/logout`, `GET /auth/me`, `POST /auth/password`), Admin-User (`GET /auth/user`, `GET /auth/user/{id}`).
|
||||
- Swagger nur in Development (`/swagger`).
|
||||
- Frontend-Build (`npm run build` in `GUI`) schreibt nach `API/wwwroot`; das Backend liefert SPA + statische Assets.
|
||||
- PostgreSQL via `ConnectionStrings:Postgres`, EF Core, automatische Migrationen beim Start.
|
||||
- ASP.NET Identity mit `AppUser` (Guid-Key, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`); Admin-Rechte über Rolle `admin`.
|
||||
- `IdentitySeedService` legt nach Migrationen die `admin`-Rolle an und sichert einen initialen Admin-Account.
|
||||
- Lokale Entwicklung: `API/Dev/docker-compose.yml` mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`).
|
||||
- API lädt optional `API/appsettings.custom.json` (in `.gitignore`).
|
||||
- Strukturierte Console-/HTTP-Logs aktiv.
|
||||
- Frontend-Auth: Login → `/auth/login`, Dashboard auf `/`, Router-Guards prüfen Auth/Rollen, erzwingen Passwortwechsel bei `mustChangePassword=true`.
|
||||
- Public Landingpage liegt auf `/welcome`; 404/Impressum referenzieren entsprechend.
|
||||
- Topbar zeigt User-Menü (Dashboard, Passwort ändern, Abmelden).
|
||||
- Globaler Banner-Stack am unteren Rand (stapelbar, manuell schließbar, opake Hintergründe, kontrolliertes `z-index`).
|
||||
|
||||
## Konventionen für Beitragende (auch Claude)
|
||||
- **Erst Patterns, dann Custom-CSS:** Globale Klassen aus `global.css`/`page-layouts.css`/`surface-patterns.css` nutzen, bevor neue Stile lokal entstehen.
|
||||
- **Tokens statt Hex:** Farben/Spacings/Radien immer aus CSS-Variablen ziehen, damit Light-/Dark-Mode automatisch funktionieren.
|
||||
- **Mobile QA Pflicht** auf `360x800`, `390x844`, `768x1024`, `1024x768` und `>=1280`. Light- und Dark-Mode jeweils mindestens auf Desktop und Mobile prüfen.
|
||||
- **Animationen:** kurz (160–280 ms), `prefers-reduced-motion` immer respektieren.
|
||||
- **API-Kontrakte stabil halten:** Frontend-Refactorings dürfen Backend-Endpunkte oder Auth-Flow nicht brechen.
|
||||
|
||||
## Befehle
|
||||
```bash
|
||||
# Frontend dev
|
||||
cd GUI && npm install && npm run dev
|
||||
|
||||
# Frontend build (geht nach API/wwwroot)
|
||||
cd GUI && npm run build
|
||||
|
||||
# Type-Check ohne Build
|
||||
cd GUI && npm run type-check
|
||||
|
||||
# Lokale Datenbank
|
||||
cd API/Dev && docker compose up -d
|
||||
```
|
||||
|
||||
## Projektbeschreibung für eine KI
|
||||
Hoard ist eine self-hosted Web-App für mehrere Benutzer, die eine Google-Drive-artige Dateiverwaltung mit einfacher Markdown-Bearbeitung kombiniert. Benutzer navigieren durch Ordner und Dateien, sehen Bilder und PDFs in einer Vorschau und bearbeiten Markdown direkt im Browser. Es gibt keine offene Registrierung, kein Teilen, keine Suche und keine Versionierung. Benutzerkonten werden manuell angelegt, beginnend mit einem initialen Admin-Account. Tech-Stack: Vue 3 (TypeScript, Vuetify, Pinia) im Frontend, ASP.NET Core mit C# im Backend, PostgreSQL für Metadaten, MinIO als S3-kompatibler Dateispeicher, Cookie-basierte Authentifizierung. Design: light-first, dateiorientiert, ruhig, mit kontrolliertem Grün als Markenfarbe – modernisiert und visuell präzise, aber bewusst nicht spektakulär.
|
||||
+10
-6
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (160–240 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: 14–15 px
|
||||
- UI-Haupttext in Listen und Tabellen: 14 px
|
||||
- Seitenüberschriften: 24–28 px
|
||||
- Bereichsüberschriften: 18–20 px
|
||||
- kleine Meta-Infos: 12–13 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` 1120–1200px, 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 1–2 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. 60–64 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. 240–280 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 268–284 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. 160–260 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.**
|
||||
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
# Projektübersicht – self-hosted Datei- und Markdown-App
|
||||
|
||||
## Projektidee
|
||||
Ich baue eine einfache self-hosted Web-App, die sich funktional zwischen Google Drive, Notion und Obsidian einordnet. Der Schwerpunkt liegt aber klar auf einer Google-Drive-artigen Oberfläche mit Dateien und Ordnern. Markdown-Dateien sollen direkt im Browser bearbeitet werden können, andere Dateien sollen gespeichert und – wenn möglich – als Vorschau angezeigt werden.
|
||||
|
||||
## Ziel des Projekts
|
||||
Das Projekt ist ein kleines, bewusst einfach gehaltenes Solo-Projekt neben meiner Ausbildung. Es soll mehrere Benutzer unterstützen, aber technisch und funktional schlank bleiben. Wichtig ist ein realistisches MVP, das sauber läuft und später erweitert werden kann.
|
||||
|
||||
## Geplanter Tech-Stack
|
||||
- Frontend: Vue 3
|
||||
- Markdown-Editor: md-editor-v3
|
||||
- Backend: ASP.NET Core mit C#
|
||||
- Datenbank: PostgreSQL
|
||||
- Dateispeicher: MinIO als S3-kompatibler Storage
|
||||
- Authentifizierung: klassische Cookie-basierte Authentifizierung, keine JWTs
|
||||
- Deployment: self-hosted Web-App auf meinem eigenen Server
|
||||
|
||||
## Kernfunktionen für das MVP
|
||||
- Login mit bestehenden Accounts
|
||||
- Kein öffentliches Registrieren
|
||||
- Ein initialer Admin-Account wird zuerst erstellt
|
||||
- Weitere Benutzer werden später nur manuell durch den Admin angelegt
|
||||
- Dateien und Ordner anlegen, hochladen und öffnen
|
||||
- Durch Ordnerstrukturen navigieren
|
||||
- Google-Drive-artige Hauptansicht mit Dateiliste und Vorschau
|
||||
- Markdown-Dateien direkt im Browser bearbeiten
|
||||
- PDFs und Bilder als Vorschau anzeigen
|
||||
- Andere Dateien einfach speichern und bei Bedarf herunterladen oder öffnen
|
||||
|
||||
## Was bewusst nicht Teil des MVP ist
|
||||
- Keine Registrierung für normale Nutzer
|
||||
- Kein Teilen oder Freigeben von Dateien
|
||||
- Keine Suche
|
||||
- Keine Versionierung oder Dateihistorie
|
||||
- Keine Echtzeit-Zusammenarbeit
|
||||
- Keine Desktop-App oder Mobile-App
|
||||
- Keine komplizierte Rechteverwaltung
|
||||
- Keine JWT-, OAuth- oder SSO-Lösung
|
||||
|
||||
## Gewünschter Stil der Anwendung
|
||||
Die Oberfläche soll sich eher an Google Drive orientieren als an Notion oder Obsidian. Wichtig sind Übersicht, einfache Navigation und ein klarer Fokus auf Dateien, Ordner, Vorschau und Bearbeitung. Die App soll schlicht, pragmatisch und gut allein umsetzbar sein.
|
||||
|
||||
## Sprachregel für UI-Texte
|
||||
- Umlaute sind ausdrücklich erwünscht (`ä`, `ö`, `ü`, `Ä`, `Ö`, `Ü`).
|
||||
- Keine Umschreibungen mit `ae`, `oe`, `ue` in sichtbaren deutschen Texten.
|
||||
|
||||
## Wie die App später wirken soll
|
||||
Die Anwendung soll wie eine einfache Dateiverwaltung im Browser wirken. Man meldet sich an, sieht seine Ordner und Dateien, kann sich durch die Struktur klicken, PDFs und Bilder direkt ansehen und Markdown-Dateien öffnen und bearbeiten. Der Fokus liegt auf Einfachheit statt auf vielen Sonderfunktionen.
|
||||
|
||||
## Technische Leitidee
|
||||
Das Projekt soll möglichst einfach aufgebaut werden. Dateimetadaten liegen in PostgreSQL, die eigentlichen Dateien in MinIO. Das Backend verwaltet Login, Benutzer, Ordner, Dateien und Vorschau-Informationen. Das Frontend bildet hauptsächlich die Dateiverwaltung, Vorschau und Markdown-Bearbeitung ab. Die gesamte Architektur soll bewusst schlank bleiben, damit sie für ein Solo-Projekt realistisch ist.
|
||||
|
||||
## Projektbeschreibung für eine KI
|
||||
Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für mehrere Benutzer. Die App kombiniert eine Google-Drive-artige Dateiverwaltung mit einfacher Markdown-Bearbeitung. Benutzer sollen durch Ordner und Dateien navigieren können, Bilder und PDFs in einer Vorschau sehen und Markdown-Dateien direkt im Browser bearbeiten. Es gibt keine öffentliche Registrierung, kein Teilen, keine Suche und keine Versionierung. Benutzerkonten werden manuell angelegt, beginnend mit einem initialen Admin-Account. Der Tech-Stack besteht aus Vue 3 im Frontend, md-editor-v3 als Markdown-Editor, ASP.NET Core mit C# im Backend, PostgreSQL für Metadaten, MinIO als S3-kompatiblen Dateispeicher und Cookie-basierter Authentifizierung.
|
||||
|
||||
## Frontend-Designquelle (Style Guide)
|
||||
- Es gibt einen zentralen Design-Guide unter `GUI/style.md`.
|
||||
- Dieser Guide definiert die visuelle Richtung für Hoard: light-first, dateiorientiert, ruhige Flächen, gezielte Verwendung von Grün als Markenfarbe.
|
||||
- Enthalten sind Farbpalette, Typografie, Spacing, Border-Radien, Schatten, Komponentenregeln und Interaktionsprinzipien.
|
||||
|
||||
## Angelegte globale CSS-Basis
|
||||
- Statt `app.css` wurde eine zentrale globale Datei `GUI/src/global.css` angelegt und verwendet.
|
||||
- Diese Datei wird in `GUI/src/main.ts` über `import './global.css'` eingebunden.
|
||||
- Zusätzlich wurden modulare globale CSS-Dateien angelegt: `GUI/src/styles/global/page-layouts.css` und `GUI/src/styles/global/surface-patterns.css`.
|
||||
- Beide Module werden ebenfalls zentral in `GUI/src/main.ts` eingebunden und bündeln wiederkehrende Layout-/Surface-Patterns.
|
||||
- Inhaltlich stellt `global.css` bereit:
|
||||
- Design-Tokens als CSS-Variablen (`:root`) für Farben, Spacing, Radius, Schatten, Typografie und Statusfarben.
|
||||
- Globale Basisstile für `html`, `body`, Links, Überschriften, Fokuszustände und Scrollbars.
|
||||
- Vuetify-nahe globale Anpassungen für App-Shell und Standardkomponenten (Topbar, Sidebar, Cards, Buttons, Inputs, Tabellen).
|
||||
- Wiederverwendbare Utility-/Pattern-Klassen für Hoard-Seiten, z. B. `hoard-panel`, `hoard-toolbar`, `hoard-list-row`, `hoard-empty-state`, `hoard-status`.
|
||||
- Responsive Verhalten für kleinere Viewports per Media Query.
|
||||
|
||||
## Anleitung: CSS-Patterns verwenden
|
||||
- Neue Seiten standardmäßig mit `hoard-page` aufbauen; für zentrierte Vollhöhen-Ansichten zusätzlich `hoard-page--centered`.
|
||||
- Karten-/Shell-Container als `hoard-shell-grid hoard-panel` verwenden; Breite/Abstände pro Seite über CSS-Variablen setzen (`--hoard-shell-width`, `--hoard-shell-gap`, `--hoard-shell-padding`).
|
||||
- Wiederkehrende Headlines/Kicker mit `hoard-kicker` nutzen, Varianten bei Bedarf mit `hoard-kicker--wide` oder `hoard-kicker--xs`.
|
||||
- Button-/Link-Aktionszeilen mit `hoard-action-row` bauen statt pro Seite eigene Flex-Definitionen zu duplizieren.
|
||||
- Gradient-Flächen über `hoard-panel-gradient` + Variablen steuern (`--hoard-gradient-angle`, `--hoard-gradient-start`, `--hoard-gradient-end`, `--hoard-gradient-end-stop`), nicht pro Seite komplett neu definieren.
|
||||
- Lokales `scoped` CSS nur für wirklich seitenspezifische Styles verwenden; alles Wiederverwendbare zuerst in `GUI/src/styles/global/page-layouts.css` oder `GUI/src/styles/global/surface-patterns.css` ergänzen.
|
||||
|
||||
## Aktueller Stand
|
||||
- `GUI/src/Layout.vue` bildet die zentrale App-Shell mit Topbar, Sidebar, Footer, Routen-Kontext und responsivem Drawer-Verhalten.
|
||||
- Darkmode (`light`/`dark`) ist global integriert (Toggle in der Topbar, Persistenz in `localStorage`, Theme-Tokens in CSS/Vuetify).
|
||||
- Öffentliche Kernseiten sind im einheitlichen Hoard-Stil umgesetzt: `Home.vue` (Landingpage), `Login.vue`, `404NotFound.vue`, `Impressum.vue`.
|
||||
- Das Topbar-Branding nutzt das App-Icon aus `GUI/src/assets/images/icon.svg`.
|
||||
- Globale CSS-Struktur ist aktiv: `GUI/src/global.css` (Tokens/Basis) sowie `GUI/src/styles/global/page-layouts.css` und `GUI/src/styles/global/surface-patterns.css` für wiederverwendbare Patterns.
|
||||
- Sidebar-Sichtbarkeit unterstützt `Visibility.Route` mit optionalem `visibilityRoute` in `GUI/src/plugins/routesLayout.ts`.
|
||||
- Mobile-Touch-Optimierung ist für alle aktuellen öffentlichen Oberflächen aktiv (Shell, Home, Login, Impressum, 404), inklusive Safe-Area-Unterstützung.
|
||||
- Desktop-Ansicht bleibt unverändert, da alle neuen Anpassungen ausschließlich in mobilen Breakpoints (`<= 960px`, Feinschliff `<= 600px`) umgesetzt sind.
|
||||
- Backend-API enthält aktuell Basis-Endpunkte für Health (`GET /api/health`) und Auth (`POST /auth/login`, `POST /auth/logout`, `GET /auth/me`).
|
||||
- Swagger/OpenAPI ist im Backend nur im Development-Modus aktiv (`/swagger`).
|
||||
- Frontend-Build (`npm run build` im `GUI`-Projekt) schreibt direkt nach `API/wwwroot`; das Backend liefert die SPA und statische Assets aus.
|
||||
- Backend nutzt jetzt PostgreSQL über `ConnectionStrings:Postgres` mit EF Core (`ApplicationDbContext`) und führt Migrationen beim Start automatisch aus.
|
||||
- Backend nutzt ASP.NET Identity mit `AppUser` (Guid-Key, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`) über PostgreSQL; Admin-Rechte laufen über Rollen (`admin`) statt User-Flag.
|
||||
- `ApplicationDbContext` basiert auf `IdentityDbContext`; Identity-Tabellen sind auf `Users`, `Roles`, `UserRoles`, `UserClaims`, `UserLogins`, `RoleClaims` und `UserTokens` gemappt.
|
||||
- Migration `InitIdentity` (`API/Migrations/20260418192723_InitIdentity.cs`) erstellt das Identity-Schema und wird beim Start automatisch angewendet.
|
||||
- Temporäre Test-Entity und Test-CRUD-Endpunkt (`api/test-items`) wurden wieder entfernt.
|
||||
- Nach den Migrationen wird per `IdentitySeedService` die Rolle `admin` sichergestellt und einem initialen Admin-Account zugewiesen, falls noch kein Admin in dieser Rolle existiert.
|
||||
- Für lokale Entwicklung liegt unter `API/Dev/docker-compose.yml` ein Stack mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`).
|
||||
- API lädt optional `API/appsettings.custom.json`; wenn vorhanden, überschreibt sie Werte aus `appsettings.json`.
|
||||
- `API/appsettings.custom.json` ist in `.gitignore` hinterlegt, damit lokale Konfigurationswerte nicht versehentlich committed werden.
|
||||
- Backend-Logging ist aktiviert mit strukturierter Console-Ausgabe (inkl. Zeitstempel) sowie HTTP-Request-Logging.
|
||||
- Beim Identity-Seeding wird explizit geloggt, wenn ein Admin-Account neu angelegt wurde.
|
||||
- Frontend-Auth ist jetzt aktiv: Login-Form (`GUI/src/routes/authentication/Login.vue`) sendet an `POST /auth/login` und zeigt API-Fehler als sichtbare Meldung.
|
||||
- Dashboard ist als geschützte Route auf `/` umgesetzt (`GUI/src/routes/dashboard/Dashboard.vue`) und zeigt die Antwort von `GET /auth/me`.
|
||||
- Router-Guards in `GUI/src/router/index.ts` leiten nicht eingeloggte Nutzer von geschützten Routen auf `/login` um und leiten eingeloggte Nutzer von `/login` zurück aufs Dashboard.
|
||||
- Öffentliche Landingpage wurde auf `/welcome` verschoben; `404`- und Impressum-Links zur Startseite zeigen entsprechend auf `/welcome`.
|
||||
- Sidebar-Navigation berücksichtigt Auth-Status differenziert: `Startseite` wird nur unangemeldet angezeigt (inkl. unterem Drawer-Link), `Dash` nur angemeldet.
|
||||
- Topbar zeigt bei aktiver Anmeldung den Benutzernamen; die Abmelden-Aktion erscheint im Hover-Menü unter dem Benutzernamen (Desktop) bzw. im Account-Menü (Mobile).
|
||||
- Fehler werden jetzt global über einen Banner-Stack am unteren Seitenrand angezeigt (routeübergreifend, stapelbar, manuell schließbar).
|
||||
- Der globale Banner-Stack im Layout nutzt einen kontrollierten `z-index` und opake Hintergründe, damit Fehlermeldungen im Vordergrund bleiben und kaum durchscheinende Inhalte zeigen.
|
||||
- Klick auf Logo/Branding in der Topbar führt abhängig vom Auth-Status: angemeldet auf `Dashboard`, unangemeldet auf `Welcome`.
|
||||
- 404-Seite löst den Auth-Status auf und leitet automatisch weiter: angemeldet zu `Dashboard`, unangemeldet zu `Welcome`; primärer CTA ist entsprechend dynamisch.
|
||||
- Brand-Klick ist gegen Auth-Timing abgesichert: bei vorhandener Session führt Logo/Titel auch bei noch nicht synchronisiertem Layout-Status zuverlässig auf `Dashboard`.
|
||||
- Admin-Benutzerverwaltung zeigt jetzt echte Kontodaten: `/admin/users` lädt die Benutzerliste aus `GET /auth/user`, `/admin/users/:userId` zeigt read-only Details aus `GET /auth/user/{id}`.
|
||||
- Der Router erzwingt Passwortwechsel global: bei `mustChangePassword=true` erfolgt vor normaler Navigation ein Redirect auf `/password/change`; nach erfolgreicher Änderung führt der Flow auf Login zurück.
|
||||
- Im Account-Menü der Topbar gibt es jetzt zusätzlich zur Abmeldung einen direkten Einstieg auf `Passwort ändern` (Desktop und Mobile).
|
||||
- Sidebar-Navigation trennt adminpflichtige Seiten jetzt in einem eigenen Abschnitt `Admin` (gleicher grüner Kicker-Stil wie `Navigation`), sodass z. B. `Benutzer` nicht mehr direkt neben dem Dashboard steht.
|
||||
- Frontend-Modernisierung ist aktiv: globale Surface-/Motion-Tokens, kurze Route-/Page-Animationen mit `prefers-reduced-motion`, Dark-/Light-taugliche Farbmischungen sowie responsive Dashboard-/Admin-Übersichten ohne neue API-Calls.
|
||||
- Topbar-Branding nutzt das App-Icon jetzt größer und ohne gerahmten Icon-Container.
|
||||
- Mobile Auth-Ansichten wurden nachgeschärft: Dashboard bricht JSON-/Statusinhalte ohne horizontales Überlaufen um, Benutzerverwaltung nutzt mobil Karten statt breiter Tabelle; Desktop bleibt unverändert.
|
||||
|
||||
## Änderungen durch Codex
|
||||
- Frontend/UI: App-Shell, globale Design-Patterns und öffentliche Seiten wurden konsolidiert; Auth-Routing mit Guard-Logik, Banner-Stack sowie rollenbasierte Navigation inkl. Dashboard-, 403- und Admin-User-Oberflächen ist umgesetzt.
|
||||
- Backend/API: Basis-Endpunkte für Health und Auth wurden auf ASP.NET Identity mit Rollenmodell (admin), `/auth/me` mit `roles`, Admin-User-Listen/Details sowie Passwortwechsel mit Session-Invalidierung erweitert.
|
||||
- Infrastruktur/Build: Frontend-Build liefert nach `API/wwwroot` mit sauberem SPA-Fallback, PostgreSQL läuft über EF-Core-Migrationen beim Start (inkl. Dev-Docker-Stack), und lokale Overrides via `appsettings.custom.json` plus strukturiertes Logging/Swagger (Development) sind integriert.
|
||||
- Frontend/UI-Review: Layout, globale Styles, öffentliche Seiten und Admin-/Dashboard-Ansichten wurden optisch modernisiert; `GUI/style.md` dokumentiert die neuen Modernisierungs-, Motion- und Dark-/Light-Regeln.
|
||||
|
||||
Reference in New Issue
Block a user