feat: add prelaunch site controls and refresh branding
This commit is contained in:
@@ -1,23 +1,32 @@
|
||||
<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, children }: {
|
||||
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 links = [
|
||||
{ href: '/news', label: 'News' },
|
||||
{ href: '/database', label: 'Database' },
|
||||
{ href: '/builds', label: 'Builds' },
|
||||
{ href: '/guides', label: 'Guides' },
|
||||
{ href: '/builder', label: 'Builder' }
|
||||
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);
|
||||
@@ -102,8 +111,8 @@
|
||||
<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 class="brand" href={homeHref} aria-label="SHD.build home">
|
||||
<BrandLockup />
|
||||
</a>
|
||||
<nav aria-label="Main navigation">
|
||||
{#each links as link}
|
||||
@@ -162,8 +171,8 @@
|
||||
<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>
|
||||
<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}
|
||||
@@ -186,9 +195,8 @@
|
||||
|
||||
<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 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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
let { size = '100%' }: { size?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg class="brand-emblem" viewBox="0 0 100 100" aria-hidden="true" style:width={size} style:height={size}>
|
||||
<g fill="none" stroke="#f6a821" stroke-width="12" stroke-linecap="butt">
|
||||
<path d="M73 20A38 38 0 0 0 15 63"/>
|
||||
<path d="M85 37A38 38 0 0 1 55 88"/>
|
||||
<path d="M42 87A38 38 0 0 1 18 70"/>
|
||||
</g>
|
||||
<rect x="72" y="23" width="10" height="10" fill="#f6a821"/>
|
||||
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="#ecebe5" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.brand-emblem {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import BrandEmblem from '$lib/components/BrandEmblem.svelte';
|
||||
|
||||
let {
|
||||
size = 'header',
|
||||
descriptor = 'RESURGENCE DATA & TOOLS'
|
||||
}: {
|
||||
size?: 'header' | 'footer' | 'launch';
|
||||
descriptor?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<span class="brand-lockup brand-lockup--{size}">
|
||||
<span class="brand-lockup__emblem"><BrandEmblem /></span>
|
||||
<span class="brand-lockup__copy">
|
||||
<span class="brand-lockup__name"><span>SHD</span><b>.build</b></span>
|
||||
<small>{descriptor}</small>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.brand-lockup {
|
||||
--brand-emblem-size: 38px;
|
||||
--brand-name-size: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.brand-lockup__emblem {
|
||||
display: block;
|
||||
width: var(--brand-emblem-size);
|
||||
height: var(--brand-emblem-size);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-lockup__copy {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.brand-lockup__name {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: var(--shd-text);
|
||||
font-size: var(--brand-name-size);
|
||||
font-weight: 900;
|
||||
letter-spacing: -.025em;
|
||||
line-height: .82;
|
||||
}
|
||||
.brand-lockup__name b {
|
||||
color: var(--shd-orange);
|
||||
font-weight: inherit;
|
||||
}
|
||||
.brand-lockup small {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .13em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-lockup--footer {
|
||||
--brand-emblem-size: 36px;
|
||||
--brand-name-size: 18px;
|
||||
}
|
||||
.brand-lockup--launch {
|
||||
--brand-emblem-size: 42px;
|
||||
--brand-name-size: 22px;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.brand-lockup--header,
|
||||
.brand-lockup--launch {
|
||||
--brand-emblem-size: 36px;
|
||||
}
|
||||
.brand-lockup--header .brand-lockup__copy,
|
||||
.brand-lockup--launch .brand-lockup__copy {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const databaseRow = vi.hoisted(() => ({ value: undefined as Record<string, unknown> | undefined }));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => ({
|
||||
prepare: vi.fn(async () => ({
|
||||
get: vi.fn(async () => databaseRow.value)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
import {
|
||||
getSiteAccessBlock,
|
||||
getSiteAccessSettings,
|
||||
invalidateSiteAccessSettings,
|
||||
isLaunchGateExempt,
|
||||
siteSectionForPath,
|
||||
type SiteAccessSettings,
|
||||
type SiteSectionKey
|
||||
} from './site-access';
|
||||
|
||||
function accessSettings(
|
||||
overrides: Partial<Pick<SiteAccessSettings, 'comingSoonEnabled' | 'accountCreationEnabled'>> & {
|
||||
sections?: Partial<SiteAccessSettings['sections']>;
|
||||
} = {}
|
||||
): SiteAccessSettings {
|
||||
return {
|
||||
comingSoonEnabled: overrides.comingSoonEnabled ?? false,
|
||||
accountCreationEnabled: overrides.accountCreationEnabled ?? true,
|
||||
sections: {
|
||||
news: true,
|
||||
database: true,
|
||||
builds: true,
|
||||
guides: true,
|
||||
builder: true,
|
||||
...overrides.sections
|
||||
},
|
||||
updatedAt: '2026-07-27T00:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
describe('siteSectionForPath', () => {
|
||||
it.each([
|
||||
['/news', 'news'],
|
||||
['/news/latest', 'news'],
|
||||
['/database', 'database'],
|
||||
['/database/weapons/first-wave-vector', 'database'],
|
||||
['/builds', 'builds'],
|
||||
['/builds/shared-loadout', 'builds'],
|
||||
['/guides', 'guides'],
|
||||
['/guides/combat-medic', 'guides'],
|
||||
['/builder', 'builder'],
|
||||
['/builder/loadout-id', 'builder']
|
||||
])('maps %s to the %s launch section', (pathname, section) => {
|
||||
expect(siteSectionForPath(pathname)).toBe(section);
|
||||
});
|
||||
|
||||
it.each(['/', '/newsroom', '/database-tools', '/build', '/guide', '/builder-tools', '/isac'])(
|
||||
'does not treat %s as a gated section',
|
||||
(pathname) => {
|
||||
expect(siteSectionForPath(pathname)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('isLaunchGateExempt', () => {
|
||||
it.each([
|
||||
'/coming-soon',
|
||||
'/sign-in',
|
||||
'/terms',
|
||||
'/dmca',
|
||||
'/contact',
|
||||
'/auth/discord',
|
||||
'/auth/discord/callback',
|
||||
'/api/health/database',
|
||||
'/api/internal/notifications',
|
||||
'/_app/immutable/entry/start.js',
|
||||
'/favicon.svg'
|
||||
])('keeps %s reachable while the launch gate is active', (pathname) => {
|
||||
expect(isLaunchGateExempt(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/',
|
||||
'/news',
|
||||
'/isac',
|
||||
'/coming-soon/preview',
|
||||
'/sign-in/help',
|
||||
'/auth',
|
||||
'/api/health',
|
||||
'/api/internal',
|
||||
'/favicon.png'
|
||||
])('does not exempt %s', (pathname) => {
|
||||
expect(isLaunchGateExempt(pathname)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSiteAccessBlock', () => {
|
||||
it('gives the global launch gate priority over a disabled section for anonymous visitors', () => {
|
||||
const settings = accessSettings({
|
||||
comingSoonEnabled: true,
|
||||
sections: { builder: false }
|
||||
});
|
||||
|
||||
expect(getSiteAccessBlock(settings, '/builder', false)).toEqual({ kind: 'launch' });
|
||||
});
|
||||
|
||||
it('lets authenticated users bypass the global launch gate when the requested section is enabled', () => {
|
||||
const settings = accessSettings({ comingSoonEnabled: true });
|
||||
|
||||
expect(getSiteAccessBlock(settings, '/builder', true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/news', 'news'],
|
||||
['/database/weapons', 'database'],
|
||||
['/builds/agent-loadout', 'builds'],
|
||||
['/guides/combat-medic', 'guides'],
|
||||
['/builder', 'builder']
|
||||
] satisfies Array<[string, SiteSectionKey]>)(
|
||||
'blocks %s for authenticated and anonymous visitors when the %s section is disabled',
|
||||
(pathname, section) => {
|
||||
const settings = accessSettings({
|
||||
sections: { [section]: false }
|
||||
});
|
||||
|
||||
expect(getSiteAccessBlock(settings, pathname, false)).toEqual({ kind: 'section', section });
|
||||
expect(getSiteAccessBlock(settings, pathname, true)).toEqual({ kind: 'section', section });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['/coming-soon', '/sign-in', '/terms', '/dmca', '/contact', '/auth/discord/callback'])(
|
||||
'keeps exempt route %s available while the global gate is active',
|
||||
(pathname) => {
|
||||
const settings = accessSettings({ comingSoonEnabled: true });
|
||||
|
||||
expect(getSiteAccessBlock(settings, pathname, false)).toBeNull();
|
||||
expect(getSiteAccessBlock(settings, pathname, true)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('allows an enabled section when the global launch gate is off', () => {
|
||||
const settings = accessSettings();
|
||||
|
||||
expect(getSiteAccessBlock(settings, '/news', false)).toBeNull();
|
||||
expect(getSiteAccessBlock(settings, '/news', true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSiteAccessSettings', () => {
|
||||
beforeEach(() => {
|
||||
databaseRow.value = undefined;
|
||||
invalidateSiteAccessSettings();
|
||||
});
|
||||
|
||||
it('fails safely to the launch page while leaving registration and sections staged on a new database', async () => {
|
||||
await expect(getSiteAccessSettings()).resolves.toEqual({
|
||||
comingSoonEnabled: true,
|
||||
accountCreationEnabled: true,
|
||||
sections: {
|
||||
news: true,
|
||||
database: true,
|
||||
builds: true,
|
||||
guides: true,
|
||||
builder: true
|
||||
},
|
||||
updatedAt: '2026-07-27T00:00:00.000Z'
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes persisted integer flags into the public settings shape', async () => {
|
||||
databaseRow.value = {
|
||||
coming_soon_enabled: 0,
|
||||
account_creation_enabled: 0,
|
||||
news_enabled: 1,
|
||||
database_enabled: 0,
|
||||
builds_enabled: 1,
|
||||
guides_enabled: 0,
|
||||
builder_enabled: 1,
|
||||
updated_at: '2026-08-01T12:30:00.000Z'
|
||||
};
|
||||
|
||||
await expect(getSiteAccessSettings()).resolves.toEqual({
|
||||
comingSoonEnabled: false,
|
||||
accountCreationEnabled: false,
|
||||
sections: {
|
||||
news: true,
|
||||
database: false,
|
||||
builds: true,
|
||||
guides: false,
|
||||
builder: true
|
||||
},
|
||||
updatedAt: '2026-08-01T12:30:00.000Z'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
|
||||
export const SITE_SECTION_KEYS = ['news', 'database', 'builds', 'guides', 'builder'] as const;
|
||||
export type SiteSectionKey = (typeof SITE_SECTION_KEYS)[number];
|
||||
export type SiteAccessSettings = {
|
||||
comingSoonEnabled: boolean;
|
||||
accountCreationEnabled: boolean;
|
||||
sections: Record<SiteSectionKey, boolean>;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SiteAccessBlock =
|
||||
| { kind: 'launch' }
|
||||
| { kind: 'section'; section: SiteSectionKey };
|
||||
|
||||
const DEFAULT_SETTINGS: SiteAccessSettings = {
|
||||
comingSoonEnabled: true,
|
||||
accountCreationEnabled: true,
|
||||
sections: { news: true, database: true, builds: true, guides: true, builder: true },
|
||||
updatedAt: '2026-07-27T00:00:00.000Z'
|
||||
};
|
||||
|
||||
let cached: { expiresAt: number; value: SiteAccessSettings } | null = null;
|
||||
|
||||
function normalized(row: Record<string, unknown> | undefined): SiteAccessSettings {
|
||||
if (!row) return structuredClone(DEFAULT_SETTINGS);
|
||||
return {
|
||||
comingSoonEnabled: Boolean(row.coming_soon_enabled),
|
||||
accountCreationEnabled: Boolean(row.account_creation_enabled),
|
||||
sections: {
|
||||
news: Boolean(row.news_enabled),
|
||||
database: Boolean(row.database_enabled),
|
||||
builds: Boolean(row.builds_enabled),
|
||||
guides: Boolean(row.guides_enabled),
|
||||
builder: Boolean(row.builder_enabled)
|
||||
},
|
||||
updatedAt: String(row.updated_at ?? DEFAULT_SETTINGS.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateSiteAccessSettings() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export async function getSiteAccessSettings() {
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
const database = await getDatabase();
|
||||
const row = await (await database.prepare("SELECT * FROM site_launch_settings WHERE id='primary'")).get();
|
||||
const value = normalized(row);
|
||||
cached = { value, expiresAt: Date.now() + 5_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
export function siteSectionForPath(pathname: string): SiteSectionKey | null {
|
||||
for (const section of SITE_SECTION_KEYS) {
|
||||
if (pathname === `/${section}` || pathname.startsWith(`/${section}/`)) return section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isLaunchGateExempt(pathname: string) {
|
||||
return pathname === '/coming-soon' ||
|
||||
pathname === '/sign-in' ||
|
||||
pathname === '/terms' ||
|
||||
pathname === '/dmca' ||
|
||||
pathname === '/contact' ||
|
||||
pathname.startsWith('/auth/') ||
|
||||
pathname.startsWith('/api/health/') ||
|
||||
pathname.startsWith('/api/internal/') ||
|
||||
pathname.startsWith('/_app/') ||
|
||||
pathname === '/favicon.svg';
|
||||
}
|
||||
|
||||
export function getSiteAccessBlock(
|
||||
settings: SiteAccessSettings,
|
||||
pathname: string,
|
||||
authenticated: boolean
|
||||
): SiteAccessBlock | null {
|
||||
if (isLaunchGateExempt(pathname)) return null;
|
||||
if (!authenticated && settings.comingSoonEnabled) return { kind: 'launch' };
|
||||
|
||||
const section = siteSectionForPath(pathname);
|
||||
if (section && !settings.sections[section]) return { kind: 'section', section };
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user