mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
3075e515bd
commit
623ce1f051
7 changed files with 392 additions and 30 deletions
|
|
@ -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();
|
||||
|
|
|
|||
51
apps/manacore/apps/web/src/lib/components/Breadcrumbs.svelte
Normal file
51
apps/manacore/apps/web/src/lib/components/Breadcrumbs.svelte
Normal 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>
|
||||
|
|
@ -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">×</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>
|
||||
116
apps/manacore/apps/web/src/lib/components/SessionWarning.svelte
Normal file
116
apps/manacore/apps/web/src/lib/components/SessionWarning.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue