129 lines
11 KiB
Svelte
129 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { onMount } from 'svelte';
|
||
import MutationConsequence from '$lib/components/MutationConsequence.svelte';
|
||
let { data, children } = $props();
|
||
const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission);
|
||
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
|
||
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
|
||
const guidelinesPage = $derived(page.url.pathname === '/isac/contributor-guidelines');
|
||
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage);
|
||
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
|
||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||
let readOnlyBlocked = $state(false);
|
||
const dataSections = [
|
||
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
||
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
|
||
{ href: '/isac/specializations', label: 'Specializations', module: 'specializations' },
|
||
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
|
||
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
|
||
{ href: '/isac/brands', label: 'Brand sets', module: 'brands' },
|
||
{ href: '/isac/gear', label: 'Gear', module: 'gear' },
|
||
{ href: '/isac/weapons', label: 'Weapons', module: 'weapons' },
|
||
{ href: '/isac/weaponmods', label: 'Weapon Mods', module: 'weaponmods' },
|
||
{ href: '/isac/gearmods', label: 'Gear Mods', module: 'gearmods' },
|
||
{ href: '/isac/protocols', label: 'OS Protocols', module: 'protocols' },
|
||
{ href: '/isac/skillchips', label: 'Skill Chips', module: 'skillchips' }
|
||
];
|
||
const dataModulePage = $derived(dataSections.some((section) => page.url.pathname === section.href));
|
||
const userSections = [
|
||
{ href: '/isac/users', label: 'User operations', permission: 'users.view' },
|
||
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
|
||
];
|
||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
||
const oversightSections: Array<{ href: string; label: string; permission: string; reviewKey?: 'data' | 'avatars' }> = [
|
||
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
|
||
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
|
||
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
|
||
];
|
||
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
|
||
const systemSections = [
|
||
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
|
||
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
|
||
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
|
||
];
|
||
const visibleSystemSections = $derived(systemSections.filter((section) => can(section.permission)));
|
||
type SidebarGroup = 'users' | 'data' | 'oversight' | 'system';
|
||
let moduleQuery = $state('');
|
||
let groupOpen = $state<Record<SidebarGroup, boolean>>({ users: false, data: false, oversight: false, system: false });
|
||
const matchesModuleQuery = (label: string) => label.toLocaleLowerCase().includes(moduleQuery.trim().toLocaleLowerCase());
|
||
const filteredUserSections = $derived(visibleUserSections.filter((section) => matchesModuleQuery(section.label)));
|
||
const filteredDataSections = $derived(dataSections.filter((section) => matchesModuleQuery(section.label)));
|
||
const filteredOversightSections = $derived(visibleOversightSections.filter((section) => matchesModuleQuery(section.label)));
|
||
const filteredSystemSections = $derived(visibleSystemSections.filter((section) => matchesModuleQuery(section.label)));
|
||
const activeGroup = (pathname: string): SidebarGroup =>
|
||
pathname === '/isac' ? (visibleUserSections.length ? 'users' : canSeeData ? 'data' : visibleOversightSections.length ? 'oversight' : 'system')
|
||
: userSections.some((section) => pathname.startsWith(section.href)) ? 'users'
|
||
: dataSections.some((section) => pathname === section.href) || pathname === '/isac/contributor-guidelines' ? 'data'
|
||
: oversightSections.some((section) => pathname.startsWith(section.href)) ? 'oversight'
|
||
: 'system';
|
||
const setGroupOpen = (group: SidebarGroup, open: boolean) => {
|
||
groupOpen = { ...groupOpen, [group]: open };
|
||
localStorage.setItem('shd.isac.sidebar-groups', JSON.stringify(groupOpen));
|
||
};
|
||
onMount(() => {
|
||
try {
|
||
const remembered = JSON.parse(localStorage.getItem('shd.isac.sidebar-groups') ?? '{}');
|
||
groupOpen = { ...groupOpen, ...remembered, [activeGroup(page.url.pathname)]: true };
|
||
} catch {
|
||
groupOpen = { ...groupOpen, [activeGroup(page.url.pathname)]: true };
|
||
}
|
||
});
|
||
const selectNumericValue = (event: FocusEvent) => {
|
||
const input = event.target;
|
||
if (input instanceof HTMLInputElement && input.type === 'number' && !input.disabled) input.select();
|
||
};
|
||
</script>
|
||
|
||
<main class="admin-page">
|
||
<header class="admin-command">
|
||
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
|
||
<div class="command-status compact-status" aria-label="ISAC system status"><span><i></i>SYSTEM <strong>ONLINE</strong></span><span>ACCESS <strong>AUTHORIZED</strong></span><span>ENV <strong>LOCAL</strong></span><span class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:oversight={dataModeClass === 'oversight'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}>MODE <strong>{dataMode}</strong></span></div>
|
||
</header>
|
||
<div class="admin-dashboard">
|
||
<aside class="admin-sidebar">
|
||
<label class="sidebar-module-search">
|
||
<span>FIND MODULE</span>
|
||
<input type="search" bind:value={moduleQuery} placeholder="Search ISAC…" aria-label="Search authorized ISAC modules"/>
|
||
</label>
|
||
{#if visibleUserSections.length && (!moduleQuery || filteredUserSections.length)}
|
||
<button class="sidebar-label" type="button" aria-expanded={groupOpen.users || Boolean(moduleQuery)} aria-controls="isac-nav-users" onclick={() => setGroupOpen('users', !groupOpen.users)}><span>USER OPERATIONS</span><b>{String(visibleUserSections.length).padStart(2, '0')} <i>{groupOpen.users || moduleQuery ? '−' : '+'}</i></b></button>
|
||
{#if groupOpen.users || moduleQuery}<nav id="isac-nav-users" aria-label="User operations">{#each filteredUserSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><span>{section.label}</span><b>›</b></a>{/each}</nav>{/if}
|
||
{/if}
|
||
{#if canSeeData && (!moduleQuery || filteredDataSections.length)}
|
||
<button class="sidebar-label" class:section-label={visibleUserSections.length > 0} type="button" aria-expanded={groupOpen.data || Boolean(moduleQuery)} aria-controls="isac-nav-data" onclick={() => setGroupOpen('data', !groupOpen.data)}><span>DATA MANAGEMENT</span><b>{String(dataSections.length).padStart(2, '0')} <i>{groupOpen.data || moduleQuery ? '−' : '+'}</i></b></button>
|
||
{#if groupOpen.data || moduleQuery}<nav id="isac-nav-data" aria-label="Data management">
|
||
{#each filteredDataSections as section}<a class:active={page.url.pathname === section.href} class:has-pending={Boolean(data.pendingProposals[section.module])} href={section.href}><span>{section.label}</span>{#if data.pendingProposals[section.module]}<em aria-label={`${data.pendingProposals[section.module]} pending change proposal${data.pendingProposals[section.module] === 1 ? '' : 's'}`} title="Pending change proposals">{data.pendingProposals[section.module]}</em>{/if}<b>›</b></a>{/each}
|
||
</nav>{/if}
|
||
{/if}
|
||
{#if visibleOversightSections.length && (!moduleQuery || filteredOversightSections.length)}
|
||
<button class="sidebar-label section-label" type="button" aria-expanded={groupOpen.oversight || Boolean(moduleQuery)} aria-controls="isac-nav-oversight" onclick={() => setGroupOpen('oversight', !groupOpen.oversight)}><span>SYSTEM OVERSIGHT</span><b>{String(visibleOversightSections.length).padStart(2, '0')} <i>{groupOpen.oversight || moduleQuery ? '−' : '+'}</i></b></button>
|
||
{#if groupOpen.oversight || moduleQuery}<nav id="isac-nav-oversight" aria-label="System oversight">{#each filteredOversightSections as section}
|
||
{@const reviewCount = section.reviewKey ? Number(data.oversightReviewCounts[section.reviewKey] ?? 0) : 0}
|
||
<a class:active={page.url.pathname.startsWith(section.href)} class:has-pending={reviewCount > 0} href={section.href}><span>{section.label}</span>{#if reviewCount}<em aria-label={`${reviewCount} item${reviewCount === 1 ? '' : 's'} awaiting review`} title="Awaiting review">{reviewCount}</em>{/if}<b>›</b></a>
|
||
{/each}</nav>{/if}
|
||
{/if}
|
||
{#if visibleSystemSections.length && (!moduleQuery || filteredSystemSections.length)}
|
||
<button class="sidebar-label section-label" type="button" aria-expanded={groupOpen.system || Boolean(moduleQuery)} aria-controls="isac-nav-system" onclick={() => setGroupOpen('system', !groupOpen.system)}><span>SYSTEM CONFIGURATION</span><b>{String(visibleSystemSections.length).padStart(2, '0')} <i>{groupOpen.system || moduleQuery ? '−' : '+'}</i></b></button>
|
||
{#if groupOpen.system || moduleQuery}<nav id="isac-nav-system" aria-label="System configuration">{#each filteredSystemSections as section}
|
||
<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><span>{section.label}</span><b>›</b></a>
|
||
{/each}</nav>{/if}
|
||
{/if}
|
||
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
|
||
</aside>
|
||
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
|
||
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
|
||
event.preventDefault();
|
||
readOnlyBlocked = true;
|
||
}
|
||
}}>
|
||
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
|
||
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
|
||
{:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
|
||
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
|
||
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
|
||
{@render children()}
|
||
</section>
|
||
</div>
|
||
</main>
|