feat(admin ui): too many tabs, change display
This commit is contained in:
@@ -32,15 +32,12 @@
|
|||||||
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
|
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
|
||||||
|
|
||||||
interface NavLink {
|
interface NavLink {
|
||||||
href:
|
/**
|
||||||
| '/files'
|
* String rather than a literal union so admin links (which
|
||||||
| '/shared'
|
* include a dynamic path segment) can share the same shape.
|
||||||
| '/shared-with-me'
|
* `resolve()` accepts any string, so no type-level cost.
|
||||||
| '/recent'
|
*/
|
||||||
| '/favorites'
|
href: string;
|
||||||
| '/photos'
|
|
||||||
| '/music'
|
|
||||||
| '/trash';
|
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
/** Stable key driving the per-section icon colour (see sidebar.css). */
|
/** Stable key driving the per-section icon colour (see sidebar.css). */
|
||||||
@@ -69,12 +66,103 @@
|
|||||||
{ href: '/trash', label: t('nav.trash', 'Trash'), icon: 'trash', section: 'trash' }
|
{ href: '/trash', label: t('nav.trash', 'Trash'), icon: 'trash', section: 'trash' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin sidebar — populated when the URL is under /admin. The
|
||||||
|
// admin +page.svelte used to render its own horizontal tab
|
||||||
|
// strip; that was displaced here so the section navigation
|
||||||
|
// scales past ~7 items and matches deep-link URLs from the
|
||||||
|
// address bar.
|
||||||
|
const ADMIN_LINKS: NavLink[] = [
|
||||||
|
{
|
||||||
|
href: '/admin',
|
||||||
|
label: t('admin.dashboard', 'Dashboard'),
|
||||||
|
icon: 'chart-pie',
|
||||||
|
section: 'admin-dashboard'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/users',
|
||||||
|
label: t('admin.users', 'Users'),
|
||||||
|
icon: 'users',
|
||||||
|
section: 'admin-users'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/drives',
|
||||||
|
label: t('admin.drives', 'Drives'),
|
||||||
|
icon: 'folder',
|
||||||
|
section: 'admin-drives'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/mounts',
|
||||||
|
label: t('admin.mounts', 'External Mounts'),
|
||||||
|
icon: 'folder',
|
||||||
|
section: 'admin-mounts'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/oidc',
|
||||||
|
label: t('admin.oidc', 'OIDC / SSO'),
|
||||||
|
icon: 'key',
|
||||||
|
section: 'admin-oidc'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/storage',
|
||||||
|
label: t('admin.storage_tab', 'Storage'),
|
||||||
|
icon: 'database',
|
||||||
|
section: 'admin-storage'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/smtp',
|
||||||
|
label: t('admin.smtp', 'Email (SMTP)'),
|
||||||
|
icon: 'envelope',
|
||||||
|
section: 'admin-smtp'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/plugins',
|
||||||
|
label: t('admin.plugins', 'Plugins'),
|
||||||
|
icon: 'layer-group',
|
||||||
|
section: 'admin-plugins'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/jobs',
|
||||||
|
label: t('admin.jobs.tab', 'Jobs'),
|
||||||
|
icon: 'cogs',
|
||||||
|
section: 'admin-jobs'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const isAdmin = $derived(session.user?.role === 'admin');
|
const isAdmin = $derived(session.user?.role === 'admin');
|
||||||
|
|
||||||
|
// Any URL under /admin swaps the sidebar to admin mode. Uses
|
||||||
|
// startsWith so a trailing slash / query params / hash don't
|
||||||
|
// desync. Root `/admin` counts too (dashboard).
|
||||||
|
const isAdminSection = $derived(page.url.pathname.startsWith('/admin'));
|
||||||
|
const currentLinks = $derived(isAdminSection ? ADMIN_LINKS : LINKS);
|
||||||
|
|
||||||
function active(href: string): boolean {
|
function active(href: string): boolean {
|
||||||
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
|
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar-item active check. Non-admin links use `active()`
|
||||||
|
// (matches href + any subpath). Admin links need a stricter
|
||||||
|
// rule for `/admin` itself — a plain `startsWith('/admin/')`
|
||||||
|
// would light up the Dashboard item on `/admin/drives` too.
|
||||||
|
// So `/admin` matches ONLY the exact path; every other admin
|
||||||
|
// item uses the same startsWith rule as before.
|
||||||
|
function activeLink(href: string): boolean {
|
||||||
|
if (href === '/admin') return page.url.pathname === '/admin';
|
||||||
|
return active(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-driven sidebar links (`LINKS`, `ADMIN_LINKS`) hold
|
||||||
|
* runtime strings, not compile-time route keys. SvelteKit's
|
||||||
|
* typed `resolve()` refuses them; we know they're valid
|
||||||
|
* routes at runtime. Cast at the callsite, one place, so
|
||||||
|
* the template stays clean.
|
||||||
|
*/
|
||||||
|
function navHref(href: string): string {
|
||||||
|
// @ts-expect-error runtime-known route string, not a literal typed key
|
||||||
|
return resolve(href);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sidebar drop targets ─────────────────────────────────────────────────
|
// ── Sidebar drop targets ─────────────────────────────────────────────────
|
||||||
// The row-drag on `/files` (and other resource surfaces) sets a
|
// The row-drag on `/files` (and other resource surfaces) sets a
|
||||||
// `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`.
|
// `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`.
|
||||||
@@ -467,16 +555,50 @@
|
|||||||
<div class="app-name">OxiCloud</div>
|
<div class="app-name">OxiCloud</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}>
|
<nav
|
||||||
{#each LINKS as link (link.href)}
|
class="nav-menu"
|
||||||
{@const isDropTarget = link.href === '/favorites' || link.href === '/trash'}
|
class:nav-menu--admin={isAdminSection}
|
||||||
|
aria-label={isAdminSection
|
||||||
|
? t('nav.admin_sections', 'Admin sections')
|
||||||
|
: t('nav.primary', 'Primary')}
|
||||||
|
>
|
||||||
|
{#if isAdminSection}
|
||||||
|
<!--
|
||||||
|
"Back to app" escape hatch — the sidebar swaps to
|
||||||
|
admin mode when the URL is under /admin/*, so the
|
||||||
|
usual Files/Shared links disappear. A dedicated back
|
||||||
|
entry gives the operator a one-click way out without
|
||||||
|
having to hunt through the user menu.
|
||||||
|
-->
|
||||||
|
<a
|
||||||
|
class="nav-item nav-item--back"
|
||||||
|
href={resolve('/files')}
|
||||||
|
data-testid="appshell-nav-admin-back-link"
|
||||||
|
onclick={() => (sidebarOpen = false)}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" />
|
||||||
|
<span>{t('nav.back_to_app', 'Back to OxiCloud')}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<!--
|
||||||
|
Sidebar links come from data-driven arrays (LINKS,
|
||||||
|
ADMIN_LINKS), so hrefs are runtime strings rather than
|
||||||
|
compile-time-known route keys. `navHref()` internally
|
||||||
|
routes them through `resolve()`, but ESLint's static
|
||||||
|
check can't see through the wrapper. Disabling the
|
||||||
|
rule here rather than sprinkling per-line directives.
|
||||||
|
-->
|
||||||
|
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||||
|
{#each currentLinks as link (link.href)}
|
||||||
|
{@const isDropTarget =
|
||||||
|
!isAdminSection && (link.href === '/favorites' || link.href === '/trash')}
|
||||||
<a
|
<a
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={active(link.href)}
|
class:active={activeLink(link.href)}
|
||||||
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
|
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
|
||||||
href={resolve(link.href)}
|
href={navHref(link.href)}
|
||||||
data-section={link.section}
|
data-section={link.section}
|
||||||
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
|
data-testid={`appshell-nav-${link.section}-link`}
|
||||||
onclick={() => (sidebarOpen = false)}
|
onclick={() => (sidebarOpen = false)}
|
||||||
ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined}
|
ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined}
|
||||||
ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined}
|
ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined}
|
||||||
@@ -485,10 +607,11 @@
|
|||||||
<Icon name={link.icon} />
|
<Icon name={link.icon} />
|
||||||
<span>{link.label}</span>
|
<span>{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{#if link.href === '/files' && !session.isExternalUser}
|
{#if !isAdminSection && link.href === '/files' && !session.isExternalUser}
|
||||||
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
|
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if session.user}
|
{#if session.user}
|
||||||
|
|||||||
+70
-107
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||||
import {
|
import {
|
||||||
@@ -183,7 +184,59 @@
|
|||||||
| 'storage'
|
| 'storage'
|
||||||
| 'smtp'
|
| 'smtp'
|
||||||
| 'jobs';
|
| 'jobs';
|
||||||
let tab = $state<Tab>('dashboard');
|
|
||||||
|
const VALID_TABS: readonly Tab[] = [
|
||||||
|
'dashboard',
|
||||||
|
'users',
|
||||||
|
'drives',
|
||||||
|
'mounts',
|
||||||
|
'plugins',
|
||||||
|
'oidc',
|
||||||
|
'storage',
|
||||||
|
'smtp',
|
||||||
|
'jobs'
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseTab(raw: string | undefined): Tab {
|
||||||
|
return VALID_TABS.includes(raw as Tab) ? (raw as Tab) : 'dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL is the source of truth for the current tab. Navigation
|
||||||
|
// is via the AppShell sidebar (see `ADMIN_LINKS` there);
|
||||||
|
// this page just reads `page.params.tab` and renders the
|
||||||
|
// matching content block. Unidirectional URL→state means no
|
||||||
|
// `$effect` loop is even possible.
|
||||||
|
const tab = $derived<Tab>(parseTab(page.params.tab));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable label for the current section — feeds the
|
||||||
|
* page title (`Admin › Jobs · OxiCloud`) and the h1. Kept in
|
||||||
|
* sync with the `ADMIN_LINKS` labels in AppShell manually
|
||||||
|
* (small list, unlikely to drift). Extracting to a shared
|
||||||
|
* module would be over-engineering for 9 strings.
|
||||||
|
*/
|
||||||
|
const tabLabel = $derived.by<string>(() => {
|
||||||
|
switch (tab) {
|
||||||
|
case 'dashboard':
|
||||||
|
return t('admin.dashboard', 'Dashboard');
|
||||||
|
case 'users':
|
||||||
|
return t('admin.users', 'Users');
|
||||||
|
case 'drives':
|
||||||
|
return t('admin.drives', 'Drives');
|
||||||
|
case 'mounts':
|
||||||
|
return t('admin.mounts', 'External Mounts');
|
||||||
|
case 'plugins':
|
||||||
|
return t('admin.plugins', 'Plugins');
|
||||||
|
case 'oidc':
|
||||||
|
return t('admin.oidc', 'OIDC / SSO');
|
||||||
|
case 'storage':
|
||||||
|
return t('admin.storage_tab', 'Storage');
|
||||||
|
case 'smtp':
|
||||||
|
return t('admin.smtp', 'Email (SMTP)');
|
||||||
|
case 'jobs':
|
||||||
|
return t('admin.jobs.tab', 'Jobs');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
let dashboard = $state<AdminDashboard | null>(null);
|
let dashboard = $state<AdminDashboard | null>(null);
|
||||||
@@ -1504,7 +1557,9 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{t('admin.title', 'Admin')} · OxiCloud</title></svelte:head>
|
<svelte:head>
|
||||||
|
<title>{t('admin.title', 'Admin')} › {tabLabel} · OxiCloud</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
{#snippet envBadge(on: boolean)}
|
{#snippet envBadge(on: boolean)}
|
||||||
{#if on}
|
{#if on}
|
||||||
@@ -1514,92 +1569,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The horizontal tab-bar that used to live here was displaced into
|
||||||
|
the shared AppShell sidebar (context-aware — swaps to admin
|
||||||
|
sections when the URL is under /admin/*). This page now renders
|
||||||
|
ONLY the tab content; navigation is via the sidebar + URL. See
|
||||||
|
`lib/components/AppShell.svelte` (ADMIN_LINKS).
|
||||||
|
-->
|
||||||
<main class="admin">
|
<main class="admin">
|
||||||
<h1>{t('admin.title', 'Admin')}</h1>
|
<!--
|
||||||
|
H1 shows the current section since the sidebar is what
|
||||||
<div class="tabs" role="tablist">
|
communicates which admin area we're in — the plain "Admin"
|
||||||
<button
|
h1 was informationless once the tab bar moved out.
|
||||||
role="tab"
|
-->
|
||||||
data-testid="admin-dashboard-tab"
|
<h1>{tabLabel}</h1>
|
||||||
aria-selected={tab === 'dashboard'}
|
|
||||||
onclick={() => (tab = 'dashboard')}
|
|
||||||
>
|
|
||||||
<Icon name="chart-pie" />
|
|
||||||
{t('admin.dashboard', 'Dashboard')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-users-tab"
|
|
||||||
aria-selected={tab === 'users'}
|
|
||||||
onclick={() => (tab = 'users')}
|
|
||||||
>
|
|
||||||
<Icon name="users" />
|
|
||||||
{t('admin.users', 'Users')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-drives-tab"
|
|
||||||
aria-selected={tab === 'drives'}
|
|
||||||
onclick={() => (tab = 'drives')}
|
|
||||||
>
|
|
||||||
<Icon name="folder" />
|
|
||||||
{t('admin.drives', 'Drives')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-mounts-tab"
|
|
||||||
aria-selected={tab === 'mounts'}
|
|
||||||
onclick={() => (tab = 'mounts')}
|
|
||||||
>
|
|
||||||
<Icon name="folder" />
|
|
||||||
{t('admin.mounts', 'External Mounts')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-oidc-tab"
|
|
||||||
aria-selected={tab === 'oidc'}
|
|
||||||
onclick={() => (tab = 'oidc')}
|
|
||||||
>
|
|
||||||
<Icon name="key" />
|
|
||||||
{t('admin.oidc', 'OIDC / SSO')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-storage-tab"
|
|
||||||
aria-selected={tab === 'storage'}
|
|
||||||
onclick={() => (tab = 'storage')}
|
|
||||||
>
|
|
||||||
<Icon name="database" />
|
|
||||||
{t('admin.storage_tab', 'Storage')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-smtp-tab"
|
|
||||||
aria-selected={tab === 'smtp'}
|
|
||||||
onclick={() => (tab = 'smtp')}
|
|
||||||
>
|
|
||||||
<Icon name="envelope" />
|
|
||||||
{t('admin.smtp', 'Email (SMTP)')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-plugins-tab"
|
|
||||||
aria-selected={tab === 'plugins'}
|
|
||||||
onclick={() => (tab = 'plugins')}
|
|
||||||
>
|
|
||||||
<Icon name="layer-group" />
|
|
||||||
{t('admin.plugins', 'Plugins')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
data-testid="admin-jobs-tab"
|
|
||||||
aria-selected={tab === 'jobs'}
|
|
||||||
onclick={() => (tab = 'jobs')}
|
|
||||||
>
|
|
||||||
<Icon name="cogs" />
|
|
||||||
{t('admin.jobs.tab', 'Jobs')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if tab === 'dashboard'}
|
{#if tab === 'dashboard'}
|
||||||
{#if dashboardError}
|
{#if dashboardError}
|
||||||
@@ -4114,26 +4097,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs button[aria-selected='true'] {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-bottom-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -80,6 +80,8 @@
|
|||||||
"trash": "Trash",
|
"trash": "Trash",
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
|
"admin_sections": "Admin sections",
|
||||||
|
"back_to_app": "Back to OxiCloud",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"shared_with_me": "Shared with me",
|
"shared_with_me": "Shared with me",
|
||||||
"toggle": "Toggle navigation menu"
|
"toggle": "Toggle navigation menu"
|
||||||
|
|||||||
Reference in New Issue
Block a user