feat(admin): responsive entity nav and collapsible list panels (Phase 9)
EntityNav: hidden on mobile, 48px icon strip at tablet (md), full labels+counts at desktop (lg). Each list panel collapses to a 32px handle via localStorage-persisted state; auto-collapses when navigating to the "+New" route. Mobile routing hides the list panel when a detail route is active. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,57 +8,104 @@ type Group = {
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
let { groups }: { groups: Group[] } = $props();
|
||||
let {
|
||||
groups,
|
||||
autocollapse = false
|
||||
}: {
|
||||
groups: Group[];
|
||||
autocollapse?: boolean;
|
||||
} = $props();
|
||||
|
||||
let isCollapsed = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true'
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (autocollapse) isCollapsed = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('admin_list_collapsed', String(isCollapsed));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center justify-between border-b border-line px-3 py-2">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.admin_groups_list_title()}
|
||||
</span>
|
||||
<a
|
||||
href="/admin/groups/new"
|
||||
class="inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||
title={m.admin_btn_new_group()}
|
||||
aria-label={m.admin_btn_new_group()}
|
||||
{#if isCollapsed}
|
||||
<!-- Collapsed handle: 32px -->
|
||||
<button
|
||||
onclick={() => (isCollapsed = false)}
|
||||
aria-label={m.admin_btn_expand_list()}
|
||||
class="flex w-8 flex-shrink-0 flex-col items-center gap-2 border-r border-line bg-surface pt-2 hover:bg-muted"
|
||||
>
|
||||
<span class="text-sm font-bold text-ink-2">›</span>
|
||||
<span
|
||||
class="text-[8px] font-extrabold tracking-widest text-ink-3 uppercase"
|
||||
style="writing-mode: vertical-rl; transform: rotate(180deg);"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{m.admin_btn_new_group()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable group list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if groups.length === 0}
|
||||
<p class="px-4 py-6 text-center text-xs text-ink-3">
|
||||
{m.admin_groups_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
{#each groups as group (group.id)}
|
||||
{@const isActive = page.url.pathname.startsWith('/admin/groups/' + group.id)}
|
||||
{m.admin_tab_groups()}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
|
||||
>
|
||||
<!-- Panel header -->
|
||||
<div class="flex items-center justify-between border-b border-line px-3 py-2">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.admin_groups_list_title()}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<a
|
||||
href="/admin/groups/{group.id}"
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
|
||||
? 'border-primary bg-primary/10 dark:bg-primary/15'
|
||||
: 'border-transparent hover:bg-muted'}"
|
||||
href="/admin/groups/new"
|
||||
class="inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||
title={m.admin_btn_new_group()}
|
||||
aria-label={m.admin_btn_new_group()}
|
||||
>
|
||||
<div class="text-sm font-bold text-ink">{group.name}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{m.admin_groups_permission_count({ count: group.permissions.length })}
|
||||
</div>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (isCollapsed = true)}
|
||||
aria-label={m.admin_btn_collapse_list()}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-sm text-xs font-bold text-ink-2 transition-colors hover:bg-muted"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable group list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if groups.length === 0}
|
||||
<p class="px-4 py-6 text-center text-xs text-ink-3">
|
||||
{m.admin_groups_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
{#each groups as group (group.id)}
|
||||
{@const isActive = page.url.pathname.startsWith('/admin/groups/' + group.id)}
|
||||
<a
|
||||
href="/admin/groups/{group.id}"
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
|
||||
? 'border-primary bg-primary/10 dark:bg-primary/15'
|
||||
: 'border-transparent hover:bg-muted'}"
|
||||
>
|
||||
<div class="text-sm font-bold text-ink">{group.name}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{m.admin_groups_permission_count({ count: group.permissions.length })}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user