Files
shd.build/src/lib/components/AppShell.svelte
T

205 lines
9.4 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 PendingForm from '$lib/components/PendingForm.svelte';
import { onMount, tick } from 'svelte';
import type { Snippet } from 'svelte';
let { user, isacAccess, notificationSummary, children }: {
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
isacAccess: boolean;
notificationSummary: { unread: number };
children: Snippet;
} = $props();
const links = [
{ href: '/news', label: 'News' },
{ href: '/database', label: 'Database' },
{ href: '/builds', label: 'Builds' },
{ href: '/guides', label: 'Guides' },
{ href: '/builder', label: 'Builder' }
];
let showCookieNotice = $state(false);
let mobileOpen = $state(false);
let mobilePanel = $state<HTMLElement | null>(null);
let accountOpen = $state(false);
let accountButton = $state<HTMLButtonElement | null>(null);
let accountMenu = $state<HTMLElement | null>(null);
let unreadNotifications = $state(0);
$effect(() => { unreadNotifications = notificationSummary.unread; });
onMount(() => {
showCookieNotice = localStorage.getItem('shd-essential-cookie-notice') !== 'dismissed';
if (!user) return;
const poll = async () => {
try {
const response = await fetch('/api/notifications/unread');
if (!response.ok) return;
const result = await response.json();
unreadNotifications = Number(result.count ?? 0);
if (!('Notification' in window) || Notification.permission !== 'granted') return;
const shown = new Set<string>(JSON.parse(localStorage.getItem('shd-browser-notifications') ?? '[]'));
for (const item of result.notifications as Array<{ id: string; title: string; body: string; link?: string }>) {
if (shown.has(item.id)) continue;
const alert = new Notification(item.title, { body: item.body, icon: '/favicon.svg', tag: `shd-${item.id}` });
alert.onclick = () => { window.focus(); location.href = item.link || '/account/notifications'; alert.close(); };
shown.add(item.id);
}
localStorage.setItem('shd-browser-notifications', JSON.stringify([...shown].slice(-100)));
} catch { /* A transient poll failure must not interrupt navigation. */ }
};
void poll();
const interval = window.setInterval(poll, 30_000);
return () => window.clearInterval(interval);
});
function dismissCookieNotice() {
localStorage.setItem('shd-essential-cookie-notice', 'dismissed');
showCookieNotice = false;
}
async function openMobileNavigation() {
mobileOpen = true;
await tick();
mobilePanel?.querySelector<HTMLElement>('a')?.focus();
}
function closeMobileNavigation() {
mobileOpen = false;
}
function handleNavigationKey(event: KeyboardEvent) {
if (event.key === 'Escape' && mobileOpen) {
event.preventDefault();
closeMobileNavigation();
}
if (event.key === 'Escape' && accountOpen) {
event.preventDefault();
accountOpen = false;
accountButton?.focus();
}
}
async function toggleAccountMenu() {
accountOpen = !accountOpen;
if (accountOpen) {
await tick();
accountMenu?.querySelector<HTMLElement>('a')?.focus();
}
}
function closeAccountFromOutside(event: MouseEvent) {
if (accountOpen && !(event.target as Element).closest('.desktop-agent-menu')) accountOpen = false;
}
$effect(() => {
page.url.pathname;
mobileOpen = false;
accountOpen = false;
});
</script>
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
<header class="site-header">
<a class="brand" href="/" aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span><span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
</a>
<nav aria-label="Main navigation">
{#each links as link}
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
{/each}
{#if isacAccess}
<a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
{/if}
</nav>
{#if user}
<div class="account">
<div class="desktop-agent-menu">
<button
class="desktop-agent-trigger"
type="button"
aria-haspopup="menu"
aria-expanded={accountOpen}
aria-controls="desktop-agent-menu"
bind:this={accountButton}
onclick={toggleAccountMenu}
>
<img src={user.avatarUrl} alt=""/>
<span><small><i></i> AGENT ONLINE</small><b>{user.displayName}</b></span>
{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{/if}
<strong aria-hidden="true">{accountOpen ? '−' : '+'}</strong>
</button>
{#if accountOpen}
<div id="desktop-agent-menu" class="desktop-agent-panel" role="menu" bind:this={accountMenu}>
<header><img src={user.avatarUrl} alt=""/><div><small>ACTIVE AGENT</small><b>{user.displayName}</b><span>{user.role.toUpperCase()} ACCESS</span></div></header>
<a role="menuitem" class:active={page.url.pathname === '/account'} href="/account"><span>MY PROFILE</span><b>AGENT DOSSIER →</b></a>
<a role="menuitem" class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><span>NOTIFICATIONS</span><b>{unreadNotifications ? `${unreadNotifications > 99 ? '99+' : unreadNotifications} UNREAD` : 'INBOX CLEAR'} →</b></a>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button role="menuitem" class="desktop-agent-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
</div>
{/if}
</div>
</div>
{:else}
<a class="sign-in" href="/sign-in">SIGN IN →</a>
{/if}
<button class="mobile-nav-toggle" type="button" aria-label={mobileOpen ? 'Close navigation' : 'Open navigation'} aria-expanded={mobileOpen} aria-controls="mobile-navigation" onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}><span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b></button>
</header>
{#if mobileOpen}
<div class="mobile-nav-layer">
<button class="mobile-nav-backdrop" type="button" aria-label="Close navigation" onclick={closeMobileNavigation}></button>
<nav id="mobile-navigation" class="mobile-navigation" aria-label="Mobile navigation" bind:this={mobilePanel}>
<div class="mobile-nav-heading"><div><span>SHD COMMUNITY</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
<a class:active={page.url.pathname === '/'} href="/"><small>00</small><span>Home</span><b>›</b></a>
{#each links as link, index}
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
{/each}
{#if user}
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Notifications</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
<a class:active={page.url.pathname === '/account'} href="/account"><small>{String(links.length + 2).padStart(2, '0')}</small><span>My Profile</span><b>›</b></a>
{:else}
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Sign In</span><b>›</b></a>
{/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>{String(links.length + 3).padStart(2, '0')}</small><span>ISAC</span><b>›</b></a>{/if}
{#if user}
<div class="mobile-agent-state"><img src={user.avatarUrl} alt=""/><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
{/if}
</nav>
</div>
{/if}
{@render children()}
<footer class="site-footer">
<div>
<a class="brand footer-brand" href="/" aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span>
<span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
</a>
<p>Independent builds, guides, game data, news, and community intelligence for The Division Resurgence.</p>
</div>
<div class="footer-links">
<a class="footer-discord" href="https://discord.gg/dhpd33VwQK" target="_blank" rel="noreferrer">
<img src="/discord-symbol.svg" alt="" aria-hidden="true" />
<span><small>COMMUNITY NETWORK</small>JOIN SHD DISCORD ↗</span>
</a>
<nav class="footer-nav" aria-label="Support and legal">
<a href="/contact">CONTACT US</a><a href="/dmca">DMCA & COPYRIGHT</a><a href="/terms">TERMS & PRIVACY</a><a href="/terms#cookies">COOKIE POLICY</a><a href="/terms#retention">DATA RETENTION</a>
</nav>
</div>
<p class="fan-disclaimer">UNOFFICIAL FAN SITE · NOT AFFILIATED WITH OR ENDORSED BY UBISOFT. TOM CLANCY’S THE DIVISION, THE DIVISION RESURGENCE, UBISOFT, AND RELATED INTELLECTUAL PROPERTY ARE OWNED BY UBISOFT AND/OR ITS LICENSORS.</p>
</footer>
{#if showCookieNotice}
<aside class="cookie-notice" aria-label="Essential cookie notice">
<div>
<p class="eyebrow"><span></span> ESSENTIAL SYSTEM NOTICE</p>
<p>SHD.build uses essential cookies for authentication and security. We do not use analytics or advertising cookies.</p>
<a href="/terms#cookies">VIEW COOKIE POLICY →</a>
</div>
<button type="button" onclick={dismissCookieNotice}>GOT IT</button>
</aside>
{/if}