feat(manacore): add UX improvements across dashboard

Breadcrumbs:
- New Breadcrumbs component for hierarchical navigation
- Added to Settings > My Data page

Keyboard shortcuts:
- Press ? anywhere to open shortcuts modal
- Shows Ctrl+1-8 nav, Esc, and ? shortcuts

Session timeout warning:
- Yellow banner appears when session expires in < 5 minutes
- "Sitzung verlangern" button to refresh token

Pagination:
- Admin users table now paginated (20 per page)
- Page controls with Zuruck/Weiter buttons
- Resets to page 1 on search

Better error messages:
- German HTTP status messages (401, 403, 404, 429, 500, 502, 503)
- "Sitzung abgelaufen" instead of "Authentication failed"
- "Keine Berechtigung" instead of generic 403

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 10:00:27 +01:00
parent 3075e515bd
commit 623ce1f051
7 changed files with 392 additions and 30 deletions

View file

@ -54,6 +54,23 @@ function isNetworkError(error: Error): boolean {
);
}
/**
* Human-readable HTTP status messages
*/
function httpStatusMessage(status: number): string {
const messages: Record<number, string> = {
400: 'Ungultige Anfrage — bitte Eingaben prufen',
404: 'Nicht gefunden',
409: 'Konflikt — Daten wurden zwischenzeitlich geandert',
422: 'Eingaben konnten nicht verarbeitet werden',
429: 'Zu viele Anfragen — bitte kurz warten',
500: 'Interner Server-Fehler',
502: 'Service vorubergehend nicht erreichbar',
503: 'Service wird gewartet',
};
return messages[status] || `Fehler (HTTP ${status})`;
}
/**
* Fetch with authentication and retry logic
*
@ -86,23 +103,27 @@ export async function fetchWithRetry<T>(
if (!response.ok) {
// Don't retry on auth errors
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
return {
data: null,
error: `Authentication failed (${response.status})`,
error: 'Sitzung abgelaufen — bitte neu anmelden',
};
}
if (response.status === 403) {
return {
data: null,
error: 'Keine Berechtigung fur diese Aktion',
};
}
// Don't retry on client errors (except rate limiting)
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
const errorBody = await response.json().catch(() => ({ message: 'Request failed' }));
return {
data: null,
error: errorBody.message || `HTTP ${response.status}`,
};
const errorBody = await response.json().catch(() => ({ message: '' }));
const msg = errorBody.message || httpStatusMessage(response.status);
return { data: null, error: msg };
}
throw new Error(`HTTP ${response.status}`);
throw new Error(`Server-Fehler (${response.status})`);
}
const data = await response.json();

View file

@ -0,0 +1,51 @@
<script lang="ts">
interface Crumb {
label: string;
href?: string;
}
let { items }: { items: Crumb[] } = $props();
</script>
<nav aria-label="Breadcrumb" class="breadcrumbs">
{#each items as crumb, i}
{#if i > 0}
<span class="separator">/</span>
{/if}
{#if crumb.href && i < items.length - 1}
<a href={crumb.href} class="crumb-link">{crumb.label}</a>
{:else}
<span class="crumb-current">{crumb.label}</span>
{/if}
{/each}
</nav>
<style>
.breadcrumbs {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
margin-bottom: 4px;
}
.separator {
color: var(--color-muted-foreground, #94a3b8);
font-size: 11px;
}
.crumb-link {
color: var(--color-muted-foreground, #94a3b8);
text-decoration: none;
transition: color 0.15s;
}
.crumb-link:hover {
color: var(--color-primary, #6366f1);
}
.crumb-current {
color: var(--color-foreground, #f1f5f9);
font-weight: 500;
}
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
let { open = false, onclose }: { open: boolean; onclose: () => void } = $props();
const shortcuts = [
{ keys: ['Ctrl', '1-8'], description: 'Navigation (Home, Dashboard, Observatory, ...)' },
{ keys: ['Esc'], description: 'Modal/Panel schliessen' },
{ keys: ['?'], description: 'Tastaturkurzel anzeigen' },
];
function handleKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<button type="button" class="modal-backdrop" onclick={onclose} tabindex="-1" aria-label="Close"
></button>
<div class="modal" role="dialog" aria-label="Tastaturkurzel">
<div class="modal-header">
<h2 class="modal-title">Tastaturkurzel</h2>
<button type="button" class="close-btn" onclick={onclose} aria-label="Close">&times;</button>
</div>
<div class="shortcut-list">
{#each shortcuts as shortcut}
<div class="shortcut-row">
<div class="shortcut-keys">
{#each shortcut.keys as key}
<kbd class="key">{key}</kbd>
{/each}
</div>
<span class="shortcut-desc">{shortcut.description}</span>
</div>
{/each}
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.4);
border: none;
cursor: default;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 55;
background: var(--color-card, #1e293b);
border: 1px solid var(--color-border, rgba(148, 163, 184, 0.15));
border-radius: 12px;
padding: 24px;
min-width: 320px;
max-width: 90vw;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: var(--color-foreground, #f1f5f9);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--color-muted-foreground, #94a3b8);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.close-btn:hover {
background: var(--color-muted, rgba(148, 163, 184, 0.1));
}
.shortcut-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.shortcut-keys {
display: flex;
gap: 4px;
}
.key {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--color-muted, rgba(148, 163, 184, 0.1));
border: 1px solid var(--color-border, rgba(148, 163, 184, 0.15));
border-radius: 5px;
font-size: 12px;
font-family: system-ui, sans-serif;
color: var(--color-foreground, #e2e8f0);
font-weight: 500;
}
.shortcut-desc {
font-size: 13px;
color: var(--color-muted-foreground, #94a3b8);
}
</style>

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
let showWarning = $state(false);
let secondsLeft = $state(0);
let interval: ReturnType<typeof setInterval> | undefined;
// Check token expiry every 30 seconds
onMount(() => {
interval = setInterval(checkSession, 30000);
return () => {
if (interval) clearInterval(interval);
};
});
function checkSession() {
if (!authStore.isAuthenticated) return;
// Try to get token expiry from JWT
const token = authStore.getAccessTokenSync?.();
if (!token) return;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000;
const remaining = exp - Date.now();
if (remaining < 5 * 60 * 1000 && remaining > 0) {
// Less than 5 minutes remaining
secondsLeft = Math.ceil(remaining / 1000);
showWarning = true;
} else {
showWarning = false;
}
} catch {
// Can't parse token, ignore
}
}
async function handleRefresh() {
try {
await authStore.refreshToken?.();
showWarning = false;
} catch {
// Refresh failed, user will be logged out
}
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
{#if showWarning}
<div class="session-warning" role="alert">
<span class="warning-text">
Sitzung lauft in {formatTime(secondsLeft)} ab
</span>
<button type="button" class="refresh-btn" onclick={handleRefresh}> Sitzung verlangern </button>
</div>
{/if}
<style>
.session-warning {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 45;
display: flex;
align-items: center;
gap: 12px;
background: rgba(245, 158, 11, 0.95);
color: #1e293b;
padding: 10px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(20px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
.warning-text {
white-space: nowrap;
}
.refresh-btn {
background: rgba(0, 0, 0, 0.15);
border: none;
color: #1e293b;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.refresh-btn:hover {
background: rgba(0, 0, 0, 0.25);
}
</style>

View file

@ -3,6 +3,8 @@
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
@ -30,6 +32,7 @@
let loading = $state(true);
let isCollapsed = $state(false);
let showShortcuts = $state(false);
// Get theme state
let isDark = $derived(theme.isDark);
@ -106,6 +109,13 @@
return;
}
// ? key opens keyboard shortcuts
if (event.key === '?' && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
showShortcuts = !showShortcuts;
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
@ -254,5 +264,11 @@
{@render children()}
</div>
</main>
<!-- Session expiry warning -->
<SessionWarning />
<!-- Keyboard shortcuts modal -->
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
</div>
{/if}

View file

@ -15,6 +15,8 @@
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let currentPage = $state(1);
const pageSize = 20;
let filteredUsers = $derived(
searchQuery
@ -26,6 +28,17 @@
: users
);
let totalPages = $derived(Math.max(1, Math.ceil(filteredUsers.length / pageSize)));
let paginatedUsers = $derived(
filteredUsers.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
// Reset to page 1 when search changes
$effect(() => {
searchQuery;
currentPage = 1;
});
onMount(async () => {
try {
// TODO: Replace with actual API call
@ -112,7 +125,34 @@
</div>
<!-- User Table -->
<UserTable users={filteredUsers} {loading} />
<UserTable users={paginatedUsers} {loading} />
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between pt-4">
<span class="text-sm text-muted-foreground">
Seite {currentPage} von {totalPages}
</span>
<div class="flex gap-2">
<button
type="button"
onclick={() => (currentPage = Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
class="rounded-md border px-3 py-1.5 text-sm transition-colors disabled:opacity-40 hover:bg-muted"
>
Zuruck
</button>
<button
type="button"
onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
class="rounded-md border px-3 py-1.5 text-sm transition-colors disabled:opacity-40 hover:bg-muted"
>
Weiter
</button>
</div>
</div>
{/if}
{#if error}
<div

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { Card, PageHeader } from '@manacore/shared-ui';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import StatCard from '$lib/components/admin/StatCard.svelte';
import ProjectDataCard from '$lib/components/admin/ProjectDataCard.svelte';
import DeleteConfirmationModal from '$lib/components/my-data/DeleteConfirmationModal.svelte';
@ -100,27 +101,14 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<a
href="/settings"
class="p-2 rounded-lg hover:bg-muted transition-colors"
aria-label="Zuruck zu Einstellungen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</a>
<div>
<h1 class="text-2xl font-bold">Meine Daten</h1>
<p class="text-muted-foreground">
Ubersicht uber alle deine gespeicherten Daten (GDPR/DSGVO)
</p>
</div>
<div>
<Breadcrumbs
items={[{ label: 'Einstellungen', href: '/settings' }, { label: 'Meine Daten' }]}
/>
<h1 class="text-2xl font-bold">Meine Daten</h1>
<p class="text-muted-foreground">
Ubersicht uber alle deine gespeicherten Daten (GDPR/DSGVO)
</p>
</div>
{#if userData}
<div class="flex items-center gap-2">