225 lines
10 KiB
Svelte
225 lines
10 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import BrandLockup from '$lib/components/BrandLockup.svelte';
|
||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||
import { onMount, tick } from 'svelte';
|
||
import type { Snippet } from 'svelte';
|
||
|
||
let { user, isacAccess, notificationSummary, siteAccess, children }: {
|
||
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
|
||
isacAccess: boolean;
|
||
notificationSummary: { unread: number };
|
||
siteAccess: { comingSoonEnabled: boolean; sections: Record<string, boolean> };
|
||
children: Snippet;
|
||
} = $props();
|
||
|
||
const allLinks = [
|
||
{ href: '/news', label: 'News', key: 'news' },
|
||
{ href: '/database', label: 'Database', key: 'database' },
|
||
{ href: '/builds', label: 'Builds', key: 'builds' },
|
||
{ href: '/guides', label: 'Guides', key: 'guides' },
|
||
{ href: '/builder', label: 'Builder', key: 'builder' }
|
||
];
|
||
const links = $derived(allLinks.filter((link) => siteAccess.sections[link.key]));
|
||
const launchSupportPages = ['/contact', '/dmca', '/terms', '/sign-in'];
|
||
const homeHref = $derived(
|
||
siteAccess.comingSoonEnabled && launchSupportPages.some((path) => page.url.pathname === path || page.url.pathname.startsWith(`${path}/`))
|
||
? '/coming-soon'
|
||
: '/'
|
||
);
|
||
|
||
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={homeHref} aria-label="SHD.build home">
|
||
<BrandLockup />
|
||
</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' : 'Open'} navigation${unreadNotifications ? `, ${unreadNotifications} unread notification${unreadNotifications === 1 ? '' : 's'}` : ''}`}
|
||
aria-expanded={mobileOpen}
|
||
aria-controls="mobile-navigation"
|
||
onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}
|
||
>
|
||
<span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b>
|
||
{#if unreadNotifications}
|
||
<em class="mobile-nav-unread" aria-hidden="true">{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>
|
||
{/if}
|
||
</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 NETWORK</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
|
||
<a class:active={page.url.pathname === '/' || page.url.pathname === '/coming-soon'} href={homeHref}><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={homeHref} aria-label="SHD.build home">
|
||
<BrandLockup size="footer" />
|
||
</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="https://buymeacoffee.com/shd.build" target="_blank" rel="noreferrer">BUY ME A COFFEE ↗</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}
|