Files
shd.build/src/routes/isac/+layout.svelte
T

129 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>