Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

@@ -0,0 +1,3 @@
@inherits LayoutComponentBase
@Body
@@ -0,0 +1,653 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject UserSessionService UserSession
@inject IAuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@if (IsPublicPage())
{
<main id="main-content">
@Body
</main>
}
else
{
@switch (state)
{
case AuthState.Loading:
<div class="auth-loading" role="status" aria-label="Lade Anwendung" aria-live="polite">
<div class="spinner-border" aria-hidden="true"></div>
<p>Lade...</p>
</div>
break;
case AuthState.Authenticated:
<div class="app-layout">
<header class="app-header" role="banner">
<div class="header-container">
<a href="/" class="header-brand" aria-label="Reifen Späth - Zur Startseite">
<span class="brand-name">REIFEN SPÄTH</span>
<span class="brand-sub">Lagerverwaltung</span>
</a>
<nav class="header-nav" role="navigation" aria-label="Hauptnavigation">
@* Worker and Admin see: Lager, Einlagern, Abholen *@
@if (UserSession.IsWorkerOrAdmin)
{
<a href="/tire-storage" class="nav-item @(IsCurrentPage("/tire-storage") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-storage") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Lager</span>
</a>
<a href="/tire-checkin" class="nav-item @(IsCurrentPage("/tire-checkin") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-checkin") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M12 5v14M5 12h14"></path>
</svg>
<span>Einlagern</span>
</a>
<a href="/tire-checkout" class="nav-item @(IsCurrentPage("/tire-checkout") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-checkout") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M5 12h14M12 5l7 7-7 7"></path>
</svg>
<span>Abholen</span>
</a>
<a href="/dashboard" class="nav-item @(IsCurrentPage("/dashboard") ? "active" : "")" aria-current="@(IsCurrentPage("/dashboard") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Dashboard</span>
</a>
}
@* Admin only sees: Kunden, Plätze, Benutzer, Status *@
@if (UserSession.IsAdmin)
{
<a href="/customers" class="nav-item @(IsCurrentPage("/customers") ? "active" : "")" aria-current="@(IsCurrentPage("/customers") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<span>Kunden</span>
</a>
<a href="/storage-locations" class="nav-item @(IsCurrentPage("/storage-locations") ? "active" : "")" aria-current="@(IsCurrentPage("/storage-locations") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<span>Plätze</span>
</a>
<a href="/users" class="nav-item @(IsCurrentPage("/users") ? "active" : "")" aria-current="@(IsCurrentPage("/users") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span>Benutzer</span>
</a>
<a href="/status" class="nav-item @(IsCurrentPage("/status") ? "active" : "")" aria-current="@(IsCurrentPage("/status") ? "page" : null)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span>Status</span>
</a>
}
</nav>
<div class="header-user">
<div class="user-dropdown">
<button type="button"
class="user-dropdown-toggle"
@onclick="ToggleUserMenu"
@onclick:stopPropagation
aria-expanded="@showUserMenu"
aria-haspopup="menu"
aria-controls="user-menu"
aria-label="Benutzermenü öffnen">
<div class="user-info">
<span class="user-name">@UserSession.DisplayName</span>
<span class="user-role">@UserSession.RoleName</span>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="@(showUserMenu ? "rotated" : "")" aria-hidden="true" focusable="false">
<path d="M6 9l6 6 6-6"></path>
</svg>
</button>
@if (showUserMenu)
{
<div class="user-dropdown-menu" id="user-menu" role="menu" aria-label="Benutzeroptionen">
<button type="button" class="dropdown-item" @onclick="OpenPasswordModal" role="menuitem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
Passwort ändern
</button>
<div class="dropdown-divider" role="separator"></div>
<button type="button" class="dropdown-item dropdown-item-danger" @onclick="OpenLogoutModal" role="menuitem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Abmelden
</button>
</div>
}
</div>
</div>
<button type="button"
class="mobile-menu-toggle"
@onclick="ToggleMobileMenu"
aria-expanded="@showMobileMenu"
aria-controls="mobile-menu"
aria-label="@(showMobileMenu ? "Menü schließen" : "Menü öffnen")">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
@if (showMobileMenu)
{
<nav class="mobile-menu" id="mobile-menu" role="navigation" aria-label="Mobile Navigation">
@* Worker and Admin see: Lager, Einlagern, Abholen, Dashboard *@
@if (UserSession.IsWorkerOrAdmin)
{
<a href="/tire-storage" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📊</span> Lager</a>
<a href="/tire-checkin" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">➕</span> Einlagern</a>
<a href="/tire-checkout" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📤</span> Abholen</a>
<a href="/dashboard" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📋</span> Dashboard</a>
}
@* Admin only sees: Kunden, Plätze, Benutzer, Status *@
@if (UserSession.IsAdmin)
{
<a href="/customers" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">👥</span> Kunden</a>
<a href="/storage-locations" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📍</span> Lagerplätze</a>
<a href="/users" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">👤</span> Benutzer</a>
<a href="/status" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📈</span> Status</a>
}
<div class="mobile-user-info">
<span>@UserSession.DisplayName</span>
<div class="mobile-user-actions">
<button type="button" class="btn-password-mobile" @onclick="OpenPasswordModal" title="Passwort ändern" aria-label="Passwort ändern">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</button>
<button type="button" class="btn-logout-mobile" @onclick="OpenLogoutModal">Abmelden</button>
</div>
</div>
</nav>
}
</header>
<main id="main-content" class="app-main" role="main">
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent Context="ex">
<div class="blazor-error-boundary" role="alert">
<p><strong>Ein Fehler ist aufgetreten.</strong></p>
<p>Die Seite kann m&ouml;glicherweise nicht korrekt angezeigt werden. Bitte laden Sie die Anwendung neu.</p>
<button type="button" class="btn btn-primary" @onclick="ReloadPage">Neu laden</button>
</div>
</ErrorContent>
</ErrorBoundary>
</main>
</div>
break;
case AuthState.NotAuthenticated:
<main class="auth-redirect" role="main">
<div class="auth-icon" aria-hidden="true">🔒</div>
<h1>Anmeldung erforderlich</h1>
<p>Sie müssen angemeldet sein, um auf diese Seite zuzugreifen.</p>
<a href="/login" class="btn btn-primary">Zum Login</a>
</main>
break;
}
}
@* Logout Modal *@
@if (showLogoutModal)
{
<div class="global-modal-backdrop" @onclick="CloseAllModals" aria-hidden="true"></div>
<div class="global-modal-container" @onclick="CloseAllModals" role="dialog" aria-modal="true" aria-labelledby="logout-title" aria-describedby="logout-desc">
<div class="global-modal-dialog" @onclick:stopPropagation>
<div class="global-modal-header">
<h2 id="logout-title">Abmelden</h2>
<button type="button" class="global-modal-close" @onclick="CloseAllModals" aria-label="Dialog schließen">
<span aria-hidden="true">✕</span>
</button>
</div>
<div class="global-modal-body">
<div class="global-modal-icon global-modal-icon-warning" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" focusable="false">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</div>
<p id="logout-desc">Möchten Sie sich wirklich abmelden?</p>
<p class="text-muted">Sie werden zur Startseite weitergeleitet.</p>
</div>
<div class="global-modal-footer">
<button type="button" class="global-modal-btn global-modal-btn-secondary" @onclick="CloseAllModals">Abbrechen</button>
<button type="button" class="global-modal-btn global-modal-btn-danger" @onclick="ExecuteLogout" disabled="@isLoggingOut" aria-busy="@isLoggingOut">
@if (isLoggingOut)
{
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden">Abmeldung läuft...</span>
}
else
{
<span>Abmelden</span>
}
</button>
</div>
</div>
</div>
}
@* Password Change Modal *@
@if (showPasswordModal)
{
<div class="global-modal-backdrop" @onclick="CloseAllModals" aria-hidden="true"></div>
<div class="global-modal-container" @onclick="CloseAllModals" role="dialog" aria-modal="true" aria-labelledby="password-title">
<div class="global-modal-dialog global-modal-dialog-lg" @onclick:stopPropagation>
<div class="global-modal-header">
<h2 id="password-title">Passwort ändern</h2>
<button type="button" class="global-modal-close" @onclick="CloseAllModals" aria-label="Dialog schließen">
<span aria-hidden="true">✕</span>
</button>
</div>
<div class="global-modal-body">
@if (!string.IsNullOrEmpty(passwordError))
{
<div class="global-modal-alert global-modal-alert-danger" role="alert" aria-live="assertive">@passwordError</div>
}
@if (!string.IsNullOrEmpty(passwordSuccess))
{
<div class="global-modal-alert global-modal-alert-success" role="status" aria-live="polite">@passwordSuccess</div>
}
<div class="global-modal-form-group">
<label class="global-modal-form-label" for="current-password">Aktuelles Passwort</label>
<input type="password"
id="current-password"
class="global-modal-form-control"
@bind="currentPassword"
@bind:event="oninput"
placeholder="Aktuelles Passwort eingeben"
disabled="@isChangingPassword"
autocomplete="current-password"
required
aria-required="true" />
</div>
<div class="global-modal-form-group">
<label class="global-modal-form-label" for="new-password">Neues Passwort</label>
<input type="password"
id="new-password"
class="global-modal-form-control"
@bind="newPassword"
@bind:event="oninput"
placeholder="Neues Passwort eingeben"
disabled="@isChangingPassword"
autocomplete="new-password"
required
aria-required="true"
aria-describedby="password-hint" />
<small id="password-hint" class="global-modal-form-hint">Mindestens 4 Zeichen</small>
</div>
<div class="global-modal-form-group">
<label class="global-modal-form-label" for="confirm-password">Neues Passwort bestätigen</label>
<input type="password"
id="confirm-password"
class="global-modal-form-control"
@bind="confirmPassword"
@bind:event="oninput"
placeholder="Neues Passwort wiederholen"
disabled="@isChangingPassword"
autocomplete="new-password"
required
aria-required="true" />
</div>
</div>
<div class="global-modal-footer">
<button type="button" class="global-modal-btn global-modal-btn-secondary" @onclick="CloseAllModals" disabled="@isChangingPassword">Abbrechen</button>
<button type="button" class="global-modal-btn global-modal-btn-primary" @onclick="ChangePassword" disabled="@(isChangingPassword || !CanChangePassword)" aria-busy="@isChangingPassword">
@if (isChangingPassword)
{
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden">Passwort wird geändert...</span>
}
else
{
<span>Passwort ändern</span>
}
</button>
</div>
</div>
</div>
}
@code {
private enum AuthState { Loading, Authenticated, NotAuthenticated }
private AuthState state = AuthState.Loading;
private bool showMobileMenu;
private bool showUserMenu;
private bool showLogoutModal;
private bool showPasswordModal;
private bool isLoggingOut;
// Password change form
private string currentPassword = "";
private string newPassword = "";
private string confirmPassword = "";
private string? passwordError;
private string? passwordSuccess;
private bool isChangingPassword;
private bool CanChangePassword =>
!string.IsNullOrWhiteSpace(currentPassword) &&
!string.IsNullOrWhiteSpace(newPassword) &&
newPassword.Length >= 4 &&
newPassword == confirmPassword;
private ErrorBoundary? errorBoundary;
private DotNetObjectReference<MainLayout>? dotNetRef;
private bool userMenuClickOutsideRegistered;
private bool lastAuthMarker;
protected override void OnInitialized()
{
UserSession.OnChange += HandleUserChanged;
if (UserSession.IsAuthenticated)
{
state = AuthState.Authenticated;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetRef = DotNetObjectReference.Create(this);
if (!IsPublicPage() && state == AuthState.Loading)
{
await RestoreSessionAsync();
}
}
// Keep <body data-authenticated> in sync so app-interop.js can
// auto-start/stop the idle watcher.
var isAuthed = state == AuthState.Authenticated;
if (isAuthed != lastAuthMarker)
{
lastAuthMarker = isAuthed;
try
{
await JS.InvokeVoidAsync("document.body.setAttribute", "data-authenticated", isAuthed ? "true" : "false");
if (!isAuthed)
{
await JS.InvokeVoidAsync("appInterop.stopIdleWatcher");
}
}
catch { /* circuit may be mid-reconnect */ }
}
// Register / unregister click-outside for the user dropdown.
try
{
if (showUserMenu && !userMenuClickOutsideRegistered && dotNetRef != null)
{
await JS.InvokeVoidAsync("appInterop.registerClickOutside", dotNetRef, nameof(CloseUserMenuFromJs), ".user-dropdown");
userMenuClickOutsideRegistered = true;
}
else if (!showUserMenu && userMenuClickOutsideRegistered)
{
await JS.InvokeVoidAsync("appInterop.unregisterClickOutside");
userMenuClickOutsideRegistered = false;
}
}
catch { /* non-fatal */ }
}
[JSInvokable]
public Task CloseUserMenuFromJs()
{
if (showUserMenu)
{
showUserMenu = false;
StateHasChanged();
}
return Task.CompletedTask;
}
private void ReloadPage()
{
errorBoundary?.Recover();
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private async Task RestoreSessionAsync()
{
try
{
var userId = await JS.InvokeAsync<string?>("localStorage.getItem", "userId");
if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid))
{
var user = await AuthService.GetUserByIdAsync(guid);
if (user != null && user.IsActive)
{
UserSession.SetUser(user);
state = AuthState.Authenticated;
await InvokeAsync(StateHasChanged);
return;
}
// Clear invalid session
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
}
}
catch (Exception)
{
// Continue to not authenticated state
}
state = AuthState.NotAuthenticated;
await InvokeAsync(StateHasChanged);
}
private void HandleUserChanged()
{
state = UserSession.IsAuthenticated ? AuthState.Authenticated : AuthState.NotAuthenticated;
InvokeAsync(StateHasChanged);
}
private bool IsPublicPage()
{
var uri = Navigation.ToBaseRelativePath(Navigation.Uri).ToLowerInvariant();
return string.IsNullOrEmpty(uri) ||
uri == "/" ||
uri.StartsWith("login") ||
uri.StartsWith("impressum") ||
uri.StartsWith("datenschutz");
}
private bool IsCurrentPage(string path)
{
var uri = "/" + Navigation.ToBaseRelativePath(Navigation.Uri);
return uri.Equals(path, StringComparison.OrdinalIgnoreCase);
}
private void ToggleMobileMenu()
{
showMobileMenu = !showMobileMenu;
showUserMenu = false;
}
private void CloseMobileMenu() => showMobileMenu = false;
private void ToggleUserMenu() => showUserMenu = !showUserMenu;
private void OpenLogoutModal()
{
CloseMenus();
showLogoutModal = true;
}
private void OpenPasswordModal()
{
CloseMenus();
ResetPasswordForm();
showPasswordModal = true;
}
private void CloseMenus()
{
showUserMenu = false;
showMobileMenu = false;
}
private void CloseAllModals()
{
showLogoutModal = false;
showPasswordModal = false;
showUserMenu = false;
showMobileMenu = false;
ResetPasswordForm();
}
private void ResetPasswordForm()
{
currentPassword = "";
newPassword = "";
confirmPassword = "";
passwordError = null;
passwordSuccess = null;
isChangingPassword = false;
}
private async Task ChangePassword()
{
passwordError = null;
passwordSuccess = null;
if (string.IsNullOrWhiteSpace(currentPassword))
{
passwordError = "Bitte geben Sie Ihr aktuelles Passwort ein.";
return;
}
var validation = AuthService.ValidatePassword(newPassword);
if (!validation.IsValid)
{
passwordError = validation.ErrorMessage;
return;
}
if (newPassword != confirmPassword)
{
passwordError = "Die Passwörter stimmen nicht überein.";
return;
}
isChangingPassword = true;
StateHasChanged();
try
{
var user = UserSession.CurrentUser;
if (user == null)
{
passwordError = "Benutzer nicht gefunden.";
return;
}
var verifiedUser = await AuthService.AuthenticateAsync(user.Username, currentPassword);
if (verifiedUser == null)
{
passwordError = "Das aktuelle Passwort ist falsch.";
await PlayFeedback("error");
return;
}
var success = await AuthService.ChangePasswordAsync(user.Id, newPassword);
if (success)
{
passwordSuccess = "Passwort wurde erfolgreich geändert!";
currentPassword = "";
newPassword = "";
confirmPassword = "";
await PlayFeedback("success");
StateHasChanged();
await Task.Delay(1500);
CloseAllModals();
}
else
{
passwordError = "Fehler beim Ändern des Passworts.";
await PlayFeedback("error");
}
}
catch (Exception ex)
{
passwordError = $"Fehler: {ex.Message}";
await PlayFeedback("error");
}
finally
{
isChangingPassword = false;
StateHasChanged();
}
}
private async Task ExecuteLogout()
{
isLoggingOut = true;
StateHasChanged();
try
{
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
}
catch { }
UserSession.ClearUser();
showLogoutModal = false;
Navigation.NavigateTo("/", forceLoad: true);
}
private async Task PlayFeedback(string type)
{
try
{
await JS.InvokeVoidAsync("qrScanner.playSound", type);
}
catch { }
}
public void Dispose()
{
UserSession.OnChange -= HandleUserChanged;
dotNetRef?.Dispose();
}
}
@@ -0,0 +1,492 @@
/* ========================================
MainLayout - Apple HIG Admin Interface
Uses global CSS variables from app.css
======================================== */
.page {
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
/* ========================================
Auth States
======================================== */
.auth-icon {
font-family: var(--font-family-emoji);
font-size: 3rem;
line-height: 1;
margin-bottom: var(--space-4);
}
/* ========================================
App Layout
======================================== */
.app-layout {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-bg);
}
/* ========================================
App Header - Responsive
======================================== */
.app-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: rgba(0, 0, 0, 0.85);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--border-default);
}
.header-container {
max-width: 1600px;
margin: 0 auto;
padding: 0 var(--space-2);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
@media (min-width: 480px) {
.header-container {
padding: 0 var(--space-4);
gap: var(--space-4);
}
}
@media (min-width: 768px) {
.header-container {
height: 60px;
gap: var(--space-6);
}
}
@media (min-width: 1024px) {
.header-container {
padding: 0 var(--space-6);
height: 64px;
}
}
.header-brand {
display: flex;
flex-direction: column;
text-decoration: none;
flex-shrink: 0;
transition: opacity var(--duration-fast) var(--ease-out);
}
.header-brand:hover {
opacity: 0.9;
}
.header-brand:active {
opacity: 0.7;
}
.header-brand .brand-name {
font-family: var(--font-family-heading);
font-weight: var(--font-weight-bold);
font-size: clamp(0.9rem, 2.5vw, 1.2rem);
color: var(--text-primary);
line-height: var(--line-height-tight);
}
.header-brand .brand-sub {
font-family: var(--font-family-base);
font-size: clamp(0.6rem, 1.5vw, 0.7rem);
color: var(--text-secondary);
}
/* ========================================
Header Navigation - Responsive
======================================== */
.header-nav {
display: none;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.06);
padding: 4px;
border-radius: var(--radius-lg);
width: fit-content;
}
@media (min-width: 768px) {
.header-nav {
display: flex;
}
}
@media (min-width: 1200px) {
.header-nav {
gap: 6px;
}
}
.nav-item {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 10px;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--text-secondary);
font-family: var(--font-family-base);
font-weight: var(--font-weight-medium);
font-size: 0.8rem;
transition: all var(--duration-fast) var(--ease-out);
white-space: nowrap;
}
@media (min-width: 768px) {
.nav-item {
padding: 8px 12px;
font-size: 0.85rem;
}
}
@media (min-width: 1024px) {
.nav-item {
padding: 10px 16px;
gap: 6px;
font-size: 0.9rem;
}
}
@media (min-width: 1200px) {
.nav-item {
padding: 10px 18px;
font-size: 0.95rem;
}
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.08);
text-decoration: none;
}
.nav-item:active {
transform: scale(0.96);
}
.nav-item.active {
color: var(--text-primary);
background: var(--surface-secondary);
box-shadow: var(--shadow-sm);
}
.nav-item svg {
flex-shrink: 0;
width: 14px;
height: 14px;
}
@media (min-width: 1024px) {
.nav-item svg {
width: 16px;
height: 16px;
}
}
/* Hide nav text on smaller tablets */
@media (min-width: 768px) and (max-width: 899px) {
.nav-item span {
display: none;
}
.nav-item {
padding: 10px 12px;
}
.nav-item svg {
width: 18px;
height: 18px;
}
}
/* ========================================
User Dropdown - Responsive
======================================== */
.header-user {
display: none;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
@media (min-width: 768px) {
.header-user {
display: flex;
}
}
.user-dropdown {
position: relative;
}
.user-dropdown-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.06);
border: 0.5px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
}
.user-dropdown-toggle:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.user-dropdown-toggle:active {
transform: scale(0.97);
}
.user-dropdown-toggle .user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
}
.user-dropdown-toggle .user-name {
font-family: var(--font-family-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
font-size: 0.9rem;
line-height: var(--line-height-tight);
}
.user-dropdown-toggle .user-role {
font-family: var(--font-family-base);
font-size: 0.7rem;
color: var(--text-secondary);
background: none;
padding: 0;
}
.user-dropdown-toggle svg {
color: var(--text-secondary);
width: 14px;
height: 14px;
transition: transform var(--duration-fast) var(--ease-out);
}
.user-dropdown-toggle svg.rotated {
transform: rotate(180deg);
}
.user-dropdown-menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 220px;
background: var(--surface-primary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 0.5px solid var(--border-default);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
z-index: var(--z-dropdown);
overflow: hidden;
animation: dropdownIn var(--duration-normal) var(--ease-spring);
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 18px;
background: transparent;
border: none;
color: var(--text-primary);
font-family: var(--font-family-base);
font-size: 1rem;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
text-align: left;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.dropdown-item:active {
background: rgba(255, 255, 255, 0.1);
}
.dropdown-item svg {
width: 18px;
height: 18px;
color: var(--text-secondary);
}
.dropdown-item-danger {
color: var(--color-error-light);
background: rgba(220, 38, 38, 0.08);
border-top: 0.5px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.dropdown-item-danger:hover {
background: rgba(220, 38, 38, 0.15);
}
.dropdown-item-danger svg {
color: var(--color-error-light);
}
.dropdown-divider {
height: 0.5px;
background: var(--border-default);
margin: 6px 0;
}
/* ========================================
Mobile Menu - Enhanced Responsive
======================================== */
.mobile-menu-toggle {
display: flex;
align-items: center;
justify-content: center;
width: var(--touch-target);
height: var(--touch-target);
background: transparent;
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
-webkit-tap-highlight-color: transparent;
}
.mobile-menu-toggle:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.1);
}
@media (min-width: 768px) {
.mobile-menu-toggle {
display: none;
}
}
.mobile-menu {
background: rgba(0, 0, 0, 0.98);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: var(--space-4);
border-top: 0.5px solid var(--border-default);
animation: mobileMenuIn var(--duration-normal) var(--ease-out);
max-height: calc(100vh - 60px);
max-height: calc(100dvh - 60px);
overflow-y: auto;
}
@keyframes mobileMenuIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.mobile-nav-item {
display: flex;
align-items: center;
padding: 14px 16px;
color: var(--text-primary);
text-decoration: none;
font-family: var(--font-family-emoji), var(--font-family-base);
font-weight: var(--font-weight-medium);
font-size: 1rem;
border-radius: var(--radius-lg);
transition: background var(--duration-fast) var(--ease-out);
-webkit-tap-highlight-color: transparent;
min-height: 48px;
}
@media (min-width: 480px) {
.mobile-nav-item {
padding: 16px 18px;
font-size: 1.05rem;
}
}
.mobile-nav-item:active {
background: rgba(255, 255, 255, 0.08);
}
.mobile-user-info {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-6) var(--space-4);
margin-top: var(--space-2);
border-top: 0.5px solid var(--border-default);
color: var(--text-secondary);
font-family: var(--font-family-base);
font-size: 1rem;
}
.mobile-user-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ========================================
Main Content - Responsive
======================================== */
.app-main {
padding: 0;
}
/* ========================================
Form Controls - Responsive
======================================== */
.form-group {
margin-bottom: var(--space-4);
}
.form-group:last-child {
margin-bottom: 0;
}
/* Reduced motion: inherited from app.css (section 17) */
Whitespace-only changes.