fix(frontend): admin section fixes (pagination, actions, navbar)
- Audit log: replace load-more with page-based pagination - Audit log: add all 29 action types to the dropdown - Audit log: fix pagination bar hidden behind footer - Root layout: hide footer navbar on /admin/* routes - Users pages: fix curly-quote parse error in ConfirmDialog messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e052efebf
commit
004ff0b45e
@ -34,11 +34,12 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const isLogin = $derived($page.url.pathname === '/login');
|
const isLogin = $derived($page.url.pathname === '/login');
|
||||||
|
const isAdmin = $derived($page.url.pathname.startsWith('/admin'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
{#if !isLogin}
|
{#if !isLogin && !isAdmin}
|
||||||
<footer>
|
<footer>
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
{@const active = $page.url.pathname.startsWith(item.match)}
|
{@const active = $page.url.pathname.startsWith(item.match)}
|
||||||
|
|||||||
@ -5,20 +5,43 @@
|
|||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
file_create: 'File uploaded',
|
// Auth
|
||||||
file_edit: 'File edited',
|
user_login: 'User logged in',
|
||||||
file_delete: 'File deleted',
|
user_logout: 'User logged out',
|
||||||
file_tag_add: 'Tag added to file',
|
// Files
|
||||||
file_tag_remove: 'Tag removed from file',
|
file_create: 'File uploaded',
|
||||||
tag_create: 'Tag created',
|
file_edit: 'File edited',
|
||||||
tag_edit: 'Tag edited',
|
file_delete: 'File deleted',
|
||||||
tag_delete: 'Tag deleted',
|
file_restore: 'File restored',
|
||||||
pool_create: 'Pool created',
|
file_permanent_delete: 'File permanently deleted',
|
||||||
pool_edit: 'Pool edited',
|
file_replace: 'File replaced',
|
||||||
pool_delete: 'Pool deleted',
|
// Tags
|
||||||
category_create: 'Category created',
|
tag_create: 'Tag created',
|
||||||
category_edit: 'Category edited',
|
tag_edit: 'Tag edited',
|
||||||
category_delete: 'Category deleted',
|
tag_delete: 'Tag deleted',
|
||||||
|
// Categories
|
||||||
|
category_create: 'Category created',
|
||||||
|
category_edit: 'Category edited',
|
||||||
|
category_delete: 'Category deleted',
|
||||||
|
// Pools
|
||||||
|
pool_create: 'Pool created',
|
||||||
|
pool_edit: 'Pool edited',
|
||||||
|
pool_delete: 'Pool deleted',
|
||||||
|
// Relations
|
||||||
|
file_tag_add: 'Tag added to file',
|
||||||
|
file_tag_remove: 'Tag removed from file',
|
||||||
|
file_pool_add: 'File added to pool',
|
||||||
|
file_pool_remove: 'File removed from pool',
|
||||||
|
// ACL
|
||||||
|
acl_change: 'ACL changed',
|
||||||
|
// Admin
|
||||||
|
user_create: 'User created',
|
||||||
|
user_delete: 'User deleted',
|
||||||
|
user_block: 'User blocked',
|
||||||
|
user_unblock: 'User unblocked',
|
||||||
|
user_role_change: 'User role changed',
|
||||||
|
// Sessions
|
||||||
|
session_terminate: 'Session terminated',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Filters ----
|
// ---- Filters ----
|
||||||
@ -32,19 +55,20 @@
|
|||||||
// ---- Data ----
|
// ---- Data ----
|
||||||
let entries = $state<AuditEntry[]>([]);
|
let entries = $state<AuditEntry[]>([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let offset = $state(0);
|
let page = $state(0); // 0-based
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let hasMore = $state(true);
|
|
||||||
let initialLoaded = $state(false);
|
let initialLoaded = $state(false);
|
||||||
|
|
||||||
|
let totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||||
|
|
||||||
// ---- Users for filter dropdown ----
|
// ---- Users for filter dropdown ----
|
||||||
let allUsers = $state<User[]>([]);
|
let allUsers = $state<User[]>([]);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
api.get<UserOffsetPage>('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {});
|
api.get<UserOffsetPage>('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// All distinct action types seen across entries (populated from data)
|
// Unknown action types not in ACTION_LABELS (server may add new ones)
|
||||||
let knownActions = $derived([...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]);
|
let knownActions = $derived([...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]);
|
||||||
|
|
||||||
// ---- Reset on filter change ----
|
// ---- Reset on filter change ----
|
||||||
@ -54,9 +78,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (filterKey !== prevFilterKey) {
|
if (filterKey !== prevFilterKey) {
|
||||||
prevFilterKey = filterKey;
|
prevFilterKey = filterKey;
|
||||||
entries = [];
|
page = 0;
|
||||||
offset = 0;
|
|
||||||
hasMore = true;
|
|
||||||
initialLoaded = false;
|
initialLoaded = false;
|
||||||
error = '';
|
error = '';
|
||||||
}
|
}
|
||||||
@ -67,24 +89,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (loading || !hasMore) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(page * LIMIT) });
|
||||||
if (filterUserId) params.set('user_id', filterUserId);
|
if (filterUserId) params.set('user_id', filterUserId);
|
||||||
if (filterAction) params.set('action', filterAction);
|
if (filterAction) params.set('action', filterAction);
|
||||||
if (filterObjectType) params.set('object_type', filterObjectType);
|
if (filterObjectType) params.set('object_type', filterObjectType);
|
||||||
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
||||||
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
||||||
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
||||||
|
|
||||||
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
||||||
const items = res.items ?? [];
|
entries = res.items ?? [];
|
||||||
entries = offset === 0 ? items : [...entries, ...items];
|
|
||||||
total = res.total ?? entries.length;
|
total = res.total ?? entries.length;
|
||||||
offset = entries.length;
|
|
||||||
hasMore = entries.length < total;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load audit log';
|
error = e instanceof ApiError ? e.message : 'Failed to load audit log';
|
||||||
} finally {
|
} finally {
|
||||||
@ -93,6 +112,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToPage(p: number) {
|
||||||
|
if (p < 0 || p >= totalPages || p === page) return;
|
||||||
|
page = p;
|
||||||
|
initialLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTs(iso: string | undefined | null): string {
|
function formatTs(iso: string | undefined | null): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@ -185,6 +210,7 @@
|
|||||||
{#if error}
|
{#if error}
|
||||||
<p class="msg error" role="alert">{error}</p>
|
<p class="msg error" role="alert">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="content-area">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -228,9 +254,18 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hasMore && !loading}
|
{#if totalPages > 1}
|
||||||
<button class="load-more-btn" onclick={load}>Load more</button>
|
<div class="pagination">
|
||||||
|
<button class="page-btn" onclick={() => goToPage(page - 1)} disabled={page === 0 || loading}>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span class="page-info">Page {page + 1} of {totalPages}</span>
|
||||||
|
<button class="page-btn" onclick={() => goToPage(page + 1)} disabled={page >= totalPages - 1 || loading}>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -316,8 +351,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Table ---- */
|
/* ---- Table ---- */
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
@ -424,24 +468,43 @@
|
|||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
.load-more-btn {
|
.pagination {
|
||||||
align-self: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 20px;
|
padding: 0 14px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-btn:hover {
|
.page-btn:hover:not(:disabled) {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.msg.error {
|
.msg.error {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
|
|||||||
@ -172,7 +172,7 @@
|
|||||||
|
|
||||||
{#if confirmDeleteUser}
|
{#if confirmDeleteUser}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
message="Delete user "{confirmDeleteUser.name}"? This cannot be undone."
|
message="Delete user “{confirmDeleteUser.name}”? This cannot be undone."
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
danger
|
danger
|
||||||
onConfirm={() => deleteUser(confirmDeleteUser!)}
|
onConfirm={() => deleteUser(confirmDeleteUser!)}
|
||||||
|
|||||||
@ -150,7 +150,7 @@
|
|||||||
|
|
||||||
{#if confirmDelete && user}
|
{#if confirmDelete && user}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
message="Delete user "{user.name}"? This cannot be undone."
|
message="Delete user “{user.name}”? This cannot be undone."
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
danger
|
danger
|
||||||
onConfirm={doDelete}
|
onConfirm={doDelete}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user