UI refresh: animation, cards, responsive tweaks

Large frontend modernization: add route fade transition and hoard-soft-enter keyframes with prefers-reduced-motion support; introduce smoother motion tokens and stronger shadow tokens. Update global CSS to use subtle surface gradients, unified transitions, hover lift effects, focus rings and improved button/card/table/overlay styles. Wrap <router-view> in a transition and adjust brand/logo sizing and interactions. Revamp several pages/components (Home, Login, Impressum, 404, Forbidden) with adjusted typography, animated entry for sections and improved card hover states. Admin/Dashboard pages enhanced: AdminUsers gains stats, mobile card list & computed counts; AdminUserDetail and Dashboard show compact summary cards and updated styles. Documentation updated (style.md, codexInfo.md) to reflect the new modernisation rules. No API or backend changes.
This commit is contained in:
Jonas
2026-04-23 22:07:07 +02:00
parent 3f826546ea
commit 10bf4b94ad
13 changed files with 827 additions and 84 deletions
+45 -7
View File
@@ -428,7 +428,11 @@ watch(
<v-main class="hoard-main">
<div class="main-shell">
<router-view />
<router-view v-slot="{ Component }">
<transition name="route-fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
<v-footer
@@ -492,17 +496,29 @@ watch(
color: inherit;
background: transparent;
cursor: pointer;
border-radius: var(--radius-md);
transition:
color var(--transition-fast),
transform var(--transition-fast);
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
transition:
transform var(--transition-fast);
}
.brand-button:hover .brand-mark {
transform: translateY(-1px);
}
.brand-logo {
width: 46px;
height: 46px;
width: 58px;
height: 58px;
object-fit: contain;
}
@@ -603,7 +619,7 @@ watch(
.drawer-top {
padding: var(--space-2) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, var(--color-surface) 15%);
}
.drawer-title {
@@ -619,7 +635,7 @@ watch(
.drawer-bottom {
padding: var(--space-3) var(--space-2) var(--space-4);
border-top: 1px solid color-mix(in srgb, var(--color-border) 90%, white 10%);
border-top: 1px solid color-mix(in srgb, var(--color-border) 90%, var(--color-surface) 10%);
}
.drawer-section-head {
@@ -645,6 +661,23 @@ watch(
padding: var(--space-6);
}
.route-fade-enter-active,
.route-fade-leave-active {
transition:
opacity var(--transition-medium),
transform var(--transition-medium);
}
.route-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.route-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.hoard-footer {
flex: 0 0 auto;
height: auto !important;
@@ -755,8 +788,13 @@ watch(
}
.brand-logo {
width: 40px;
height: 40px;
width: 48px;
height: 48px;
}
.brand-mark {
width: 46px;
height: 46px;
}
.brand-title {
+116 -11
View File
@@ -29,7 +29,8 @@
--radius-lg: 14px;
--shadow-sm: 0 1px 2px rgb(16 24 18 / 6%);
--shadow-md: 0 6px 18px rgb(16 24 18 / 8%);
--shadow-md: 0 8px 22px rgb(16 24 18 / 9%);
--shadow-lg: 0 16px 42px rgb(16 24 18 / 11%);
--space-1: 4px;
--space-2: 8px;
@@ -53,8 +54,9 @@
--line-height-normal: 1.5;
--line-height-loose: 1.65;
--transition-fast: 160ms ease;
--page-bg-glow: rgb(183 227 107 / 14%);
--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;
}
@@ -86,9 +88,10 @@
--color-info: #6aa8de;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 35%);
--shadow-md: 0 6px 18px rgb(0 0 0 / 40%);
--shadow-md: 0 10px 24px rgb(0 0 0 / 38%);
--shadow-lg: 0 18px 44px rgb(0 0 0 / 45%);
--page-bg-glow: rgb(79 145 72 / 7%);
--page-bg-soft: color-mix(in srgb, var(--color-primary-100) 28%, var(--color-bg) 72%);
--scrollbar-thumb: #4f5763;
--scrollbar-track: #171b22;
}
@@ -116,7 +119,9 @@ body {
font-family: var(--font-family-sans);
font-size: var(--font-size-md);
color: var(--color-text);
background: radial-gradient(circle at top right, var(--page-bg-glow), transparent 36%), var(--color-bg);
background:
linear-gradient(180deg, var(--page-bg-soft) 0, var(--color-bg) 280px),
var(--color-bg);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
overflow-y: scroll;
@@ -184,15 +189,21 @@ p {
.v-navigation-drawer .v-list-item {
border-radius: var(--radius-md) !important;
margin: 2px var(--space-2);
transition:
background-color var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
.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);
}
.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);
font-weight: 600;
}
@@ -200,8 +211,17 @@ p {
.v-card {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-lg) !important;
background-color: var(--color-surface) !important;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 92%, var(--color-surface-alt) 8%),
var(--color-surface)
) !important;
box-shadow: var(--shadow-sm) !important;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.v-card-title {
@@ -225,6 +245,12 @@ p {
text-transform: none !important;
border-radius: var(--radius-md) !important;
font-weight: 500 !important;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast) !important;
}
.v-btn--variant-elevated,
@@ -235,6 +261,11 @@ p {
.v-btn--variant-elevated:not(.v-btn--disabled):hover,
.v-btn--variant-flat:not(.v-btn--disabled):hover {
box-shadow: var(--shadow-sm) !important;
transform: translateY(-1px);
}
.v-btn:not(.v-btn--disabled):active {
transform: translateY(0);
}
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled) {
@@ -246,15 +277,32 @@ p {
background-color: var(--color-primary-600) !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;
border-color: var(--color-border) !important;
box-shadow: none !important;
}
.v-btn--variant-outlined {
border-color: var(--color-border-strong) !important;
color: var(--color-text) !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;
}
/* Inputs */
.v-input .v-field {
border-radius: var(--radius-md) !important;
background-color: var(--color-surface) !important;
transition:
background-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.v-input .v-field__outline {
@@ -266,6 +314,10 @@ p {
color: var(--color-primary-600) !important;
}
.v-input.v-input--focused .v-field {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary-300) 28%, 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;
@@ -275,11 +327,19 @@ p {
color: var(--color-text-secondary) !important;
}
.v-overlay .v-list {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-surface) !important;
box-shadow: var(--shadow-lg) !important;
}
/* Tables and list-like content */
.v-table {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-surface) !important;
overflow: hidden;
}
.v-table thead th {
@@ -292,6 +352,10 @@ p {
background-color: color-mix(in srgb, var(--color-primary-100) 35%, var(--color-surface) 65%) !important;
}
.v-table tbody td {
border-bottom-color: color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%) !important;
}
/* Status helpers */
.hoard-status {
display: inline-flex;
@@ -325,10 +389,19 @@ p {
/* Reusable layout helpers for file/productivity pages */
.hoard-panel {
background-color: var(--color-surface);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 93%, var(--color-surface-alt) 7%),
var(--color-surface)
);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.hoard-toolbar {
@@ -347,12 +420,15 @@ 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%, white 15%);
transition: background-color var(--transition-fast);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, var(--color-surface) 15%);
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%);
transform: translateX(2px);
}
.hoard-list-row.is-selected {
@@ -398,7 +474,36 @@ p {
}
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--scrollbar-thumb) 85%, black 15%);
background: color-mix(in srgb, var(--scrollbar-thumb) 85%, var(--color-text) 15%);
}
@keyframes hoard-soft-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
.v-btn,
.v-navigation-drawer .v-list-item,
.hoard-list-row {
transform: none !important;
}
}
@media (width <= 960px) {
+13 -2
View File
@@ -145,7 +145,7 @@ onBeforeUnmount(() => {
h1 {
margin-bottom: var(--space-3);
font-size: clamp(1.8rem, 2vw + 1rem, 2.4rem);
font-size: 2.35rem;
font-weight: 700;
}
@@ -160,6 +160,17 @@ h1 {
align-items: center;
}
@media (prefers-reduced-motion: no-preference) {
.not-found-visual,
.not-found-content {
animation: hoard-soft-enter 260ms both;
}
.not-found-content {
animation-delay: 80ms;
}
}
@media (width <= 960px) {
.not-found-shell {
grid-template-columns: 1fr;
@@ -191,7 +202,7 @@ h1 {
}
h1 {
font-size: clamp(1.5rem, 7vw, 1.9rem);
font-size: 1.85rem;
}
.not-found-text {
+6
View File
@@ -80,4 +80,10 @@ onMounted(() => {
.forbidden-head p {
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: no-preference) {
.forbidden-shell {
animation: hoard-soft-enter 260ms both;
}
}
</style>
+95 -5
View File
@@ -88,7 +88,7 @@ const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3
</div>
</div>
<div class="hero-preview hoard-panel">
<div class="hero-preview">
<header class="preview-head">
<p class="preview-title">Beispielansicht</p>
<span class="preview-pill">Workspace</span>
@@ -199,7 +199,7 @@ const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3
h1 {
margin-bottom: var(--space-4);
max-width: 20ch;
font-size: clamp(2rem, 2.5vw + 1rem, 3rem);
font-size: 3rem;
line-height: 1.08;
}
@@ -235,7 +235,15 @@ h1 {
align-self: center;
padding: var(--space-5);
width: 100%;
background-color: color-mix(in srgb, var(--color-surface-alt) 86%, var(--color-surface) 14%);
border: 1px solid color-mix(in srgb, var(--color-border) 76%, var(--color-surface) 24%);
border-radius: var(--radius-lg);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface-alt) 82%, var(--color-surface) 18%),
var(--color-surface)
);
box-shadow: var(--shadow-sm);
}
.preview-head {
@@ -277,6 +285,16 @@ h1 {
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%);
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);
}
.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);
}
.row-title {
@@ -300,10 +318,38 @@ h1 {
padding: var(--space-5);
}
.value-card,
.workflow-card,
.feature-card {
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.value-card:hover,
.workflow-card:hover,
.feature-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 48%, var(--color-border) 52%);
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: clamp(1.25rem, 1.2vw + 0.8rem, 1.8rem);
font-size: 1.65rem;
}
.value-card p,
@@ -381,6 +427,44 @@ h1 {
background-color: color-mix(in srgb, var(--color-surface-alt) 75%, var(--color-surface) 25%);
}
@media (prefers-reduced-motion: no-preference) {
.hero-copy,
.hero-preview,
.value-card,
.feature-section,
.workflow-section,
.stack-section {
animation: hoard-soft-enter 260ms both;
}
.hero-preview {
animation-delay: 70ms;
}
.value-card:nth-child(2),
.feature-section,
.workflow-section {
animation-delay: 90ms;
}
.value-card:nth-child(3),
.stack-section {
animation-delay: 130ms;
}
.preview-row {
animation: hoard-soft-enter 240ms both;
}
.preview-row:nth-child(2) {
animation-delay: 70ms;
}
.preview-row:nth-child(3) {
animation-delay: 120ms;
}
}
@media (width <= 1100px) {
.hero,
.stack-section {
@@ -419,6 +503,12 @@ h1 {
h1 {
max-width: none;
font-size: 2.25rem;
}
.value-card h2,
.section-head h2 {
font-size: 1.45rem;
}
}
@@ -430,7 +520,7 @@ h1 {
}
h1 {
font-size: clamp(1.6rem, 8vw, 2rem);
font-size: 1.9rem;
}
.hero-lead {
+35 -3
View File
@@ -135,7 +135,7 @@ const legalNotes = [
h1 {
margin-bottom: var(--space-3);
font-size: clamp(1.9rem, 2.2vw + 1rem, 2.5rem);
font-size: 2.45rem;
}
.hero-lead {
@@ -187,7 +187,7 @@ h1 {
h2 {
margin: 0;
font-size: clamp(1.2rem, 1.2vw + 0.85rem, 1.6rem);
font-size: 1.45rem;
}
.detail-list {
@@ -250,6 +250,38 @@ 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;
}
.detail-card:nth-child(2) {
animation-delay: 80ms;
}
.detail-card:nth-child(3),
.notes-section {
animation-delay: 120ms;
}
}
@media (width <= 1080px) {
.details-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -289,7 +321,7 @@ h3 {
}
h1 {
font-size: clamp(1.55rem, 7vw, 1.95rem);
font-size: 1.9rem;
}
.hero-meta {
+57 -1
View File
@@ -93,7 +93,17 @@ onMounted(() => {
<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">
<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>
@@ -186,7 +196,47 @@ onMounted(() => {
}
.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%);
}
.admin-user-detail-profile {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
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%);
}
.admin-user-detail-profile-label {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.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-grid {
@@ -217,6 +267,12 @@ onMounted(() => {
justify-content: flex-end;
}
@media (prefers-reduced-motion: no-preference) {
.admin-user-detail-shell {
animation: hoard-soft-enter 260ms both;
}
}
@media (width <= 600px) {
.admin-user-detail-shell {
padding: var(--space-4);
+263 -48
View File
@@ -13,6 +13,10 @@ const errorMessage = ref('')
const users = ref<AdminUser[]>([])
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,
)
function formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(', ') : 'Keine Rolle'
@@ -68,6 +72,21 @@ onMounted(() => {
<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"
@@ -84,54 +103,112 @@ onMounted(() => {
<p>Aktuell sind keine Konten vorhanden.</p>
</section>
<div v-else 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 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>
</div>
<div class="admin-users-actions hoard-action-row">
@@ -179,8 +256,46 @@ onMounted(() => {
color: var(--color-text-secondary);
}
.admin-users-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.admin-users-stat {
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%);
}
.admin-users-stat-label,
.admin-users-stat-value {
margin: 0;
}
.admin-users-stat-label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-users-stat-value {
color: var(--color-text);
font-size: 1.75rem;
font-weight: 700;
line-height: 1.15;
}
.admin-users-table-wrap {
overflow-x: auto;
border-radius: var(--radius-md);
}
.admin-users-mobile-list {
display: none;
}
.admin-users-table {
@@ -201,9 +316,109 @@ onMounted(() => {
justify-content: flex-end;
}
@media (prefers-reduced-motion: no-preference) {
.admin-users-shell {
animation: hoard-soft-enter 260ms both;
}
}
@media (width <= 600px) {
.admin-users-shell {
padding: var(--space-4);
overflow: hidden;
}
.admin-users-stats {
grid-template-columns: 1fr;
}
.admin-users-table-wrap {
display: none;
}
.admin-users-mobile-list {
display: grid;
gap: var(--space-3);
}
.admin-users-mobile-card {
display: grid;
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-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 58%, var(--color-surface) 42%);
}
.admin-users-mobile-head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
min-width: 0;
}
.admin-users-mobile-avatar {
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%);
}
.admin-users-mobile-label,
.admin-users-mobile-head h2,
.admin-users-mobile-details {
margin: 0;
}
.admin-users-mobile-label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-users-mobile-head h2 {
color: var(--color-text);
font-size: 1.1rem;
overflow-wrap: anywhere;
}
.admin-users-mobile-details {
display: grid;
gap: var(--space-3);
}
.admin-users-mobile-details div {
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%);
}
.admin-users-mobile-details div:last-child {
padding-bottom: 0;
border-bottom: none;
}
.admin-users-mobile-details dt {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-users-mobile-details dd {
margin: 0;
color: var(--color-text);
overflow-wrap: anywhere;
}
.admin-users-actions {
@@ -76,7 +76,7 @@ async function handleSubmit() {
<header class="change-password-head">
<p class="hoard-kicker">Sicherheitsvorgabe</p>
<h1>Passwort ändern</h1>
<p>Hier kannst du ganz bequem dein Password aktualisieren.</p>
<p>Hier kannst du ganz bequem dein Passwort aktualisieren.</p>
</header>
<v-alert
@@ -182,7 +182,7 @@ async function handleSubmit() {
.change-password-divider {
margin-top: var(--space-1);
margin-bottom: 0;
border-color: color-mix(in srgb, var(--color-border) 82%, white 18%);
border-color: color-mix(in srgb, var(--color-border) 82%, var(--color-surface) 18%);
}
.change-password-section-label {
@@ -200,6 +200,12 @@ async function handleSubmit() {
font-size: var(--font-size-sm);
}
@media (prefers-reduced-motion: no-preference) {
.change-password-shell {
animation: hoard-soft-enter 260ms both;
}
}
@media (width <= 600px) {
.change-password-form {
gap: var(--space-2);
+31 -3
View File
@@ -103,7 +103,7 @@ onMounted(() => {
</ul>
</aside>
<v-form class="login-form hoard-panel" @submit.prevent="handleSubmit">
<v-form class="login-form" @submit.prevent="handleSubmit">
<div class="form-head">
<h2>Login</h2>
<p>Melde dich mit deinem bestehenden Konto an.</p>
@@ -169,7 +169,7 @@ onMounted(() => {
h1 {
margin-bottom: var(--space-3);
max-width: 18ch;
font-size: clamp(1.9rem, 2vw + 1rem, 2.6rem);
font-size: 2.5rem;
font-weight: 700;
}
@@ -196,12 +196,29 @@ h1 {
font-weight: 500;
}
.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-form {
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);
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);
}
.form-head h2 {
@@ -227,6 +244,17 @@ h1 {
font-size: var(--font-size-sm);
}
@media (prefers-reduced-motion: no-preference) {
.login-brand,
.login-form {
animation: hoard-soft-enter 260ms both;
}
.login-form {
animation-delay: 80ms;
}
}
@media (width <= 960px) {
.login-shell {
grid-template-columns: 1fr;
@@ -244,7 +272,7 @@ h1 {
@media (width <= 600px) {
h1 {
max-width: none;
font-size: clamp(1.55rem, 7vw, 1.95rem);
font-size: 1.85rem;
}
.login-intro {
+142 -1
View File
@@ -15,6 +15,17 @@ const prettyUser = computed(() => {
return JSON.stringify(user.value, null, 2)
})
const roleLabel = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return 'Keine Rolle'
}
return user.value.roles.join(', ')
})
const accountStateLabel = computed(() => (user.value?.isActive ? 'Aktiv' : 'Inaktiv'))
const passwordStateLabel = computed(() =>
user.value?.mustChangePassword ? 'Erforderlich' : 'Aktuell',
)
async function loadCurrentUser() {
isLoading.value = true
@@ -63,11 +74,45 @@ onMounted(() => {
{{ errorMessage }}
</v-alert>
<article v-else class="dashboard-user hoard-panel">
<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>
<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>
<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>
@@ -117,6 +162,9 @@ onMounted(() => {
.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 {
@@ -125,6 +173,55 @@ onMounted(() => {
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 {
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);
}
@@ -145,9 +242,53 @@ onMounted(() => {
justify-content: flex-end;
}
@media (prefers-reduced-motion: no-preference) {
.dashboard-shell {
animation: hoard-soft-enter 260ms both;
}
}
@media (width <= 600px) {
.dashboard-page,
.dashboard-shell,
.dashboard-user,
.dashboard-summary-grid,
.dashboard-summary-card {
min-width: 0;
max-width: 100%;
}
.dashboard-shell {
padding: var(--space-4);
overflow: hidden;
}
.dashboard-summary-grid {
grid-template-columns: 1fr;
}
.dashboard-user {
padding: var(--space-3);
overflow: hidden;
}
.dashboard-summary-card {
grid-template-columns: auto minmax(0, 1fr);
}
.dashboard-head p,
.dashboard-label {
overflow-wrap: anywhere;
}
.dashboard-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 {
+12 -1
View File
@@ -29,7 +29,7 @@ Die Seite soll sich optisch zwischen Google Drive und einer modernen self-hosted
## 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“.
Die App soll **light-first** gestaltet werden. Ein Dark Mode kann später kommen, aber das Grunddesign wird zuerst für helle Oberflächen optimiert. Das spart Aufwand und hält die UI konsistenter.
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.
## Farbpalette
@@ -88,6 +88,7 @@ Die Typografie soll neutral, gut lesbar und unauffällig modern sein. Keine deko
- Bereichsüberschriften: 1820 px
- kleine Meta-Infos: 1213 px
- Zeilenhöhe großzügig halten, besonders in Dateilisten und Formularen
- Schriftgrößen nicht mit Viewport-Breite skalieren; responsive Größen über feste Werte in Breakpoints setzen
**Schriftgewicht:**
- 400 für normalen Fließtext
@@ -136,6 +137,7 @@ Sehr zurückhaltend einsetzen. Die App soll stabil und ruhig wirken, nicht schwe
- Dropdowns / Modals: etwas stärker, aber nie dramatisch
- keine starken farbigen Schatten im Produktivbereich
- grüner Glow nur höchstens im Branding oder auf Marketing-/Login-Flächen
- Hover-Lift maximal 12 px und nur bei klar interaktiven Flächen oder Demo-Karten
## Komponentenstil
@@ -356,6 +358,7 @@ Die folgenden Regeln bilden den aktuellen Responsive-Standard von Hoard und soll
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.
## Interaktionsprinzipien
@@ -458,3 +461,11 @@ Das passt zur Produktidee, weil:
Wenn du bei einer UI-Entscheidung unsicher bist, gilt:
**Lieber schlichter als spektakulär. Lieber Google-Drive-artig als Dashboard-artig. Lieber ruhige Flächen und klare Listen als visuelle Effekte. Grün ist Identität, nicht Dekoration.**
## Aktuelle Modernisierungsregeln
- Oberflächen dürfen subtile lineare Surface-Verläufe, weichere Schatten und klare Fokusrahmen nutzen.
- Route-Wechsel, Banner und wichtige Seitenbereiche dürfen kurze Fade-/Slide-Animationen verwenden.
- Animationen bleiben ruhig: ca. 160260 ms, keine dauerhaften Bewegungen, `prefers-reduced-motion` beachten.
- Brand- und Navigationsbereiche dürfen etwas hochwertiger wirken, die Arbeitsflächen bleiben funktional und ruhig.
- Zusätzliche UI-Elemente sind erlaubt, solange sie aus vorhandenen Daten entstehen und keine API-Calls oder Funktionen ändern.
- QA immer in Light- und Dark-Mode sowie mobil prüfen; insbesondere Navigation, Formulare, Tabellen und Banner.
+4
View File
@@ -116,9 +116,13 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
- 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.