Checkpoint SHD dashboard design system

This commit is contained in:
JB
2026-07-24 21:31:35 -04:00
commit 5e113454ee
52 changed files with 3128 additions and 0 deletions
+54
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { page } from '$app/state';
import type { Snippet } from 'svelte';
let { user, children }: {
user: { username: string; role: string } | null;
children: Snippet;
} = $props();
const links = [
{ href: '/builds', label: 'Discover' },
{ href: '/builder', label: 'Builder' },
{ href: '/guides', label: 'Guides' }
];
</script>
<header class="site-header">
<a class="brand" href="/" aria-label="SHD.build home">
<span class="brand-mark">SHD</span><span>SHD<b>.build</b></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 user}
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
{/if}
{#if user?.role === 'admin'}
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin/stats">Admin</a>
{/if}
</nav>
{#if user}
<div class="account">
<a href="/account"><small>AGENT</small>{user.username}</a>
<form method="POST" action="/sign-in?/logout"><button>SIGN OUT →</button></form>
</div>
{:else}
<a class="sign-in" href="/sign-in">SIGN IN →</a>
{/if}
</header>
{@render children()}
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { Snippet } from 'svelte';
let { action, method = 'POST', class: className = '', children }: { action: string; method?: 'POST'; class?: string; children: Snippet<[boolean]> } = $props();
let pending = $state(false);
</script>
<form {action} {method} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
</form>
<style>fieldset{border:0;padding:0;margin:0;min-width:0}fieldset[aria-busy=true]{opacity:.7}</style>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+12
View File
@@ -0,0 +1,12 @@
import type { AuthUser } from './session';
import { error } from '@sveltejs/kit';
/**
* Every admin page, action, and endpoint must call this server-side.
* The capability-based permission checks will replace the broad admin role
* as the permission editor is introduced.
*/
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
if (!user) error(401, 'Sign in required');
if (user.role !== 'admin') error(403, 'Administrator permission required');
}
+18
View File
@@ -0,0 +1,18 @@
import { hash, verify, argon2id } from 'argon2';
/**
* Password hashes use the PHC string format, which embeds the unique salt,
* algorithm version, and work parameters alongside the derived hash.
*/
export function hashPassword(password: string): Promise<string> {
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
}
export function verifyPassword(passwordHash: string, candidate: string): Promise<boolean> {
return verify(passwordHash, candidate);
}
+82
View File
@@ -0,0 +1,82 @@
import { dev } from '$app/environment';
import { getDatabase } from '$lib/server/database';
import type { Cookies } from '@sveltejs/kit';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
const COOKIE_NAME = 'shd_session';
const SESSION_SECONDS = 60 * 60 * 24 * 30;
export type AuthUser = {
id: string;
username: string;
role: string;
discordLinked: boolean;
};
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function createSession(cookies: Cookies, userId: string): Promise<void> {
const database = await getDatabase();
const token = randomBytes(32).toString('base64url');
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
const insert = await database.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)'
);
await insert.run(
randomUUID(),
userId,
hashToken(token),
expiresAt.toISOString(),
now.toISOString()
);
cookies.set(COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: SESSION_SECONDS
});
}
export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null> {
const token = cookies.get(COOKIE_NAME);
if (!token) return null;
const database = await getDatabase();
const statement = await database.prepare(`
SELECT users.id, users.username, users.role, users.discord_id
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token_hash = ? AND sessions.expires_at > ?
`);
const user = await statement.get(hashToken(token), new Date().toISOString());
if (!user) {
cookies.delete(COOKIE_NAME, { path: '/' });
return null;
}
return {
id: String(user.id),
username: String(user.username),
role: String(user.role),
discordLinked: typeof user.discord_id === 'string'
};
}
export async function destroySession(cookies: Cookies): Promise<void> {
const token = cookies.get(COOKIE_NAME);
if (token) {
const database = await getDatabase();
const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?');
await statement.run(hashToken(token));
}
cookies.delete(COOKIE_NAME, { path: '/' });
}
+21
View File
@@ -0,0 +1,21 @@
import { connect, type Database } from '@tursodatabase/database';
import { env } from '$env/dynamic/private';
import { resolve } from 'node:path';
let databasePromise: Promise<Database> | undefined;
/**
* Returns the process-wide embedded Turso connection.
*
* This module lives under $lib/server so SvelteKit prevents it from being
* imported into browser bundles.
*/
export function getDatabase(): Promise<Database> {
const configuredPath = env.TDR_DATABASE_PATH || './data/shd-build.db';
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
}
return databasePromise;
}
+6
View File
@@ -0,0 +1,6 @@
import { getSessionUser } from '$lib/server/auth/session';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => ({
user: await getSessionUser(cookies)
});
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import '../app.css';
import AppShell from '$lib/components/AppShell.svelte';
let { data, children } = $props();
</script>
<svelte:head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</svelte:head>
<AppShell user={data.user}>{@render children()}</AppShell>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
const builds = [
{ title: 'Striker Critical Rush', author: 'AgentNova', mode: 'PVE', rating: 4.9, votes: 128, tags: ['AR', 'Critical', 'Solo'], color: '#f6a821' },
{ title: 'Ironclad Vanguard', author: 'KelsoMain', mode: 'PVP', rating: 4.8, votes: 94, tags: ['Tank', 'Shield', 'Team'], color: '#61b7d7' },
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7, votes: 76, tags: ['Skill', 'Status', 'Support'], color: '#d36b5c' }
];
</script>
<svelte:head><title>SHD.build — Division Resurgence Loadouts</title><meta name="description" content="Create, optimize, and share loadouts for The Division Resurgence." /></svelte:head>
<main>
<section class="hero">
<div class="hero-copy"><p class="eyebrow"><span></span> SHD LOADOUT NETWORK</p><h1>BUILD SMARTER.<br /><em>HIT HARDER.</em></h1><p class="lede">Plan every stat. Test every synergy. Share the loadout that gets your agent through the toughest fights in Manhattan.</p><div class="hero-actions"><a class="primary" href="/builder">START A BUILD <span>+</span></a><a class="secondary" href="/builds">EXPLORE BUILDS ↓</a></div><div class="metrics"><div><strong>2,847</strong><span>PUBLIC BUILDS</span></div><div><strong>14.2K</strong><span>AGENTS</span></div><div><strong>8,931</strong><span>RATINGS</span></div></div></div>
<div class="loadout-preview"><div class="scan-lines"></div><div class="preview-top"><span>FEATURED LOADOUT</span><b>01 / 03</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['MASK','VEST','PACK','GLOVES'] as label,i}<div class="gear-node n-{i}"><small>{label}</small><b>{['42','38','39','6.5%'][i]}</b></div>{/each}</div><div class="preview-name"><small>META // PVE</small><strong>STRIKER CRITICAL RUSH</strong><span>BY AGENTNOVA</span></div></div>
</section>
<section class="featured"><div class="section-heading"><div><p class="eyebrow"><span></span> FIELD-TESTED</p><h2>FEATURED BUILDS</h2></div><a href="/builds">VIEW ALL BUILDS →</a></div><div class="build-grid">{#each builds as build}<article style={`--build-color:${build.color}`}><div class="card-top"><span>{build.mode}</span><span>★ {build.rating}</span></div><div class="weapon-art"><div></div><span>SHD LOADOUT</span></div><div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="tags">{#each build.tags as tag}<span>{tag}</span>{/each}</div><div class="author"><span class="avatar">{build.author.slice(0,1)}</span><span>BY <strong>{build.author}</strong></span><span>{build.votes} RATINGS</span></div></div></article>{/each}</div></section>
</main>
+47
View File
@@ -0,0 +1,47 @@
import { createSession, getSessionUser } from '$lib/server/auth/session';
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id)
]);
return { account: { builds, guides, favorites, ratings }, chooseUsername: url.searchParams.has('choose_username'), discordLinked: url.searchParams.has('discord_linked'), oauthError: url.searchParams.has('oauth_error') };
};
export const actions: Actions = {
changeUsername: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim();
if (username.length < 3 || username.length > 24 || !/^[\p{L}\p{N}_-]+$/u.test(username)) return fail(400, { usernameError: 'Use 3–24 letters, numbers, underscores, or hyphens.' });
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { usernameError: 'That username is reserved.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { usernameError: 'That username is already in use.' });
await (await database.prepare('UPDATE users SET username = ? WHERE id = ?')).run(username, user.id);
return { usernameChanged: true };
},
changePassword: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const current = String(form.get('currentPassword') ?? ''), next = String(form.get('newPassword') ?? ''), confirm = String(form.get('confirmPassword') ?? '');
if (next.length < 12 || next.length > 128) return fail(400, { passwordError: 'Use between 12 and 128 characters.' });
if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' });
const database = await getDatabase();
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id);
if (typeof credential?.password_hash !== 'string' || !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id);
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
await createSession(cookies, user.id);
return { passwordChanged: true };
}
};
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">let { data, form } = $props(); const user = $derived(data.user!);</script>
<svelte:head><title>{user.username} — SHD.build</title></svelte:head>
<main class="page"><div class="page-head"><div><p class="eyebrow">AGENT PROFILE</p><h1>{user.username}</h1><span class="accent">{user.role.toUpperCase()}</span></div><div><b>{data.account.builds.length}</b> builds · <b>{data.account.guides.length}</b> guides · <b>{data.account.favorites.length}</b> favorites</div></div>
{#if data.chooseUsername}<p class="notice">Choose the unique SHD.build username other agents will see.</p>{/if}{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}{#if data.oauthError}<p class="error">Discord linking could not be completed.</p>{/if}
<div class="grid" style="grid-template-columns:1fr 380px">
<section class="grid"><div class="panel"><div class="page-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>My builds</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>{#if data.account.builds.length}{#each data.account.builds as build}<div class="table-row"><b>{build.title}</b><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><span></span><button>OPEN</button></div>{/each}{:else}<p class="muted">Your saved and published loadouts will appear here.</p>{/if}</div><div class="panel"><h2>Favorites and ratings</h2><p class="muted">{data.account.favorites.length} favorited · {data.account.ratings.length} rated</p></div></section>
<aside class="panel"><p class="eyebrow">ACCOUNT SETTINGS</p><h2>Username</h2><form method="POST" action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<div class="form-actions"><button class="primary">UPDATE</button></div></form><hr /><h2>Sign-in methods</h2><a class="secondary" href="/auth/discord?mode=link">LINK DISCORD</a><hr /><h2>Change password</h2><form method="POST" action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<div class="form-actions"><button class="primary">UPDATE PASSWORD</button></div></form></aside>
</div></main>
+9
View File
@@ -0,0 +1,9 @@
import { getSessionUser } from '$lib/server/auth/session';
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
if (user.role !== 'admin') redirect(303, '/account');
return {};
};
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import { page } from '$app/state';
let { children } = $props();
const sections = [
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR' },
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
{ href: '/admin/equipment', label: 'Equipment types', code: 'EQPT', pending: true },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', pending: true }
];
</script>
<main class="admin-page">
<header class="admin-command">
<div class="command-title"><p class="eyebrow"><span></span> SHD DATA OPERATIONS</p><h1>ADMIN CONSOLE</h1><p>Embedded game-data control surface</p></div>
<div class="command-status"><div><i></i><span>DATABASE</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>ADMIN</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
</header>
<div class="admin-dashboard">
<aside class="admin-sidebar">
<div class="sidebar-label"><span>DATA MANAGEMENT</span><b>04 MODULES</b></div>
<nav aria-label="Admin sections">
{#each sections as section}
<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}>
<small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}
</a>
{/each}
</nav>
<div class="sidebar-foot"><span>SHD NODE</span><strong>NYC-01</strong><small>Schema synchronized</small></div>
</aside>
<section class="admin-content">{@render children()}</section>
</div>
</main>
+2
View File
@@ -0,0 +1,2 @@
import { redirect } from '@sveltejs/kit';
export const load = () => redirect(303, '/admin/stats');
+35
View File
@@ -0,0 +1,35 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requireAdmin } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
return { stats: await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all() };
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? 'flat');
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; };
let values: Array<number|null>;
try { values = ['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax','capValue'].map(number); } catch { return fail(400, { error: 'Ranges must be positive numbers.' }); }
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' });
const now = new Date().toISOString();
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(randomUUID(),name,type,category,calculation,unit,...values,now,now);
return { saved: true };
},
archive: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies)); const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id); return { archived: true };
}
};
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props();
let editing = $state<any>(null);
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'%',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
</script>
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
<div class="module-kpis"><div><span>TOTAL RECORDS</span><strong>{data.stats.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.stats.filter((item)=>Number(item.active)===1).length}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Offensive').length}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Defensive').length}</strong><small>Survival-oriented</small></div></div>
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><label class="field">TYPE<select name="type" bind:value={editing.type}><option>Offensive</option><option>Defensive</option></select></label><label class="field">CATEGORY<select name="category" bind:value={editing.category}><option>Weapon</option><option>Skill</option><option>Character</option></select></label><label class="field">CALCULATION<select name="calculationType" bind:value={editing.calculation_type}><option>Additive</option><option>Multiplicative</option></select></label><label class="field">UNIT<input name="unit" bind:value={editing.unit}/></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
+59
View File
@@ -0,0 +1,59 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requireAdmin } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const db = await getDatabase();
const [talents, tiers, triggers, links, effects, stats] = await Promise.all([
(await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(),
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
(await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
(await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
(await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
]);
return { talents: talents.map((talent) => ({...talent, tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({...tier, triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)), effects: effects.filter((effect) => effect.talent_tier_id === tier.id)}))})), triggers, stats };
};
const parseEffects = (raw: FormDataEntryValue | null) => {
const value = JSON.parse(String(raw ?? '[]')) as Array<{statId:string;value:string|number}>;
if (!Array.isArray(value)) throw new Error();
return value.map((effect) => ({statId:String(effect.statId),value:Number(effect.value)}));
};
export const actions: Actions = {
saveTrigger: async ({request,cookies}) => {
requireAdmin(await getSessionUser(cookies)); const form=await request.formData();
const id=String(form.get('id')??''), name=String(form.get('name')??'').trim(), description=String(form.get('description')??'').trim(), uptime=form.get('supportsUptime')==='on';
const min=uptime?Number(form.get('minUptime')):null,max=uptime?Number(form.get('maxUptime')):null;
if(name.length<2||!description)return fail(400,{triggerError:'A trigger name and description are required.'});
if(uptime&&(!Number.isFinite(min)||!Number.isFinite(max)||Number(min)<0||Number(max)>100||Number(min)>Number(max)))return fail(400,{triggerError:'Uptime must be a valid 0–100% range.'});
const db=await getDatabase(); if(await(await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name,id||'new'))return fail(409,{triggerError:'That trigger already exists.'});
const now=new Date().toISOString();
if(id)await(await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name,description,uptime?1:0,min,max,now,id);
else await(await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(randomUUID(),name,description,uptime?1:0,min,max,now,now);
return {triggerSaved:true};
},
saveTalent: async ({request,cookies}) => {
requireAdmin(await getSessionUser(cookies)); const form=await request.formData();
const id=String(form.get('id')??''),name=String(form.get('name')??'').trim(),category=String(form.get('category')??'');
const descriptions={I:String(form.get('tierIDescription')??'').trim(),II:String(form.get('tierIIDescription')??'').trim()};
const triggerIds={I:form.getAll('tierITriggerIds').map(String),II:form.getAll('tierIITriggerIds').map(String)};
let tierEffects:{I:Array<{statId:string;value:number}>;II:Array<{statId:string;value:number}>};
try{tierEffects={I:parseEffects(form.get('tierIEffects')),II:parseEffects(form.get('tierIIEffects'))};}catch{return fail(400,{talentError:'Effects could not be read.'});}
if(name.length<2||!['Weapon','Backpack','Vest'].includes(category))return fail(400,{talentError:'Provide a valid talent name and category.'});
for(const tier of ['I','II'] as const)if(!descriptions[tier]||!triggerIds[tier].length||!tierEffects[tier].length||tierEffects[tier].some((effect)=>!effect.statId||!Number.isFinite(effect.value)))return fail(400,{talentError:`Tier ${tier} requires a description, trigger, and valid attribute effects.`});
const db=await getDatabase(); if(await(await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name,category,id||'new'))return fail(409,{talentError:'That talent already exists in this category.'});
const talentId=id||randomUUID(),now=new Date().toISOString();
if(id)await(await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name,category,now,id);else await(await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId,name,category,now,now);
for(const tier of ['I','II'] as const){
await(await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(),talentId,tier,descriptions[tier],'','',now,now);
const tierRow=await(await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId,tier); const tierId=String(tierRow?.id);
await(await db.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId); await(await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for(const triggerId of triggerIds[tier])await(await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId,triggerId);
for(const effect of tierEffects[tier])await(await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(),tierId,effect.statId,effect.value,now);
}
return {talentSaved:true};
}
};
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let {data,form}=$props();
type Effect={statId:string;value:string};
let talent=$state<any>(null),trigger=$state<any>(null),uptime=$state(false);
const blankTalent=()=>({id:'',name:'',category:'Weapon',I:{description:'',triggerIds:[],effects:[{statId:'',value:''}]},II:{description:'',triggerIds:[],effects:[{statId:'',value:''}]}});
const editTalent=(row:any)=>{const tier=(name:string)=>row.tiers.find((item:any)=>item.tier===name)??{description:'',triggerIds:[],effects:[]};const one=tier('I'),two=tier('II');talent={...row,I:{description:one.description,triggerIds:[...one.triggerIds],effects:one.effects.map((e:any)=>({statId:String(e.stat_attribute_id),value:String(e.value)}))},II:{description:two.description,triggerIds:[...two.triggerIds],effects:two.effects.map((e:any)=>({statId:String(e.stat_attribute_id),value:String(e.value)}))}}};
const blankTrigger=()=>{uptime=false;trigger={id:'',name:'',description:'',min_uptime:'',max_uptime:''}};
const editTrigger=(row:any)=>{uptime=Number(row.supports_uptime)===1;trigger={...row}};
const toggle=(tier:'I'|'II',id:string,checked:boolean)=>talent[tier].triggerIds=checked?[...talent[tier].triggerIds,id]:talent[tier].triggerIds.filter((value:string)=>value!==id);
const addEffect=(tier:'I'|'II')=>talent[tier].effects=[...talent[tier].effects,{statId:'',value:''}];
const removeEffect=(tier:'I'|'II',index:number)=>talent[tier].effects=talent[tier].effects.filter((_:Effect,row:number)=>row!==index);
const configured=(tiers:Array<{tier:unknown}>,name:string)=>tiers.some((item)=>item.tier===name);
</script>
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Tier behavior, reusable activation conditions, uptime, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={()=>talent=blankTalent()}>ADD TALENT +</button></div></div>
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item)=>item.category==='Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item)=>item.category!=='Weapon').length}</strong><small>Vest and backpack</small></div></div>
{#if trigger}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id?'MODIFY TRIGGER':'NEW TRIGGER'}</p><h3>{trigger.id?trigger.name:'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={()=>trigger=null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>trigger=null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>{/if}
{#if talent}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{talent.id?'MODIFY TALENT':'NEW TALENT'}</p><h3>{talent.id?talent.name:'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={()=>talent=null}>×</button></div><PendingForm action="?/saveTalent"><input type="hidden" name="id" value={talent.id}/><input type="hidden" name="tierIEffects" value={JSON.stringify(talent.I.effects)}/><input type="hidden" name="tierIIEffects" value={JSON.stringify(talent.II.effects)}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Vest</option></select></label></div><div class="tier-grid">{#each ['I','II'] as tier}<fieldset class="tier-card"><legend>TIER {tier}</legend><label class="field">DISPLAY DESCRIPTION<textarea name={tier==='I'?'tierIDescription':'tierIIDescription'} bind:value={talent[tier].description} required></textarea></label><p class="label">ACTIVATION TRIGGERS</p><div class="trigger-list">{#each data.triggers.filter((item)=>Number(item.active)===1) as item}<div><label><input name={tier==='I'?'tierITriggerIds':'tierIITriggerIds'} type="checkbox" value={item.id} checked={talent[tier].triggerIds.includes(String(item.id))} onchange={(event)=>toggle(tier as 'I'|'II',String(item.id),event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={()=>editTrigger(item)}>EDIT</button></div>{/each}</div><div class="effects-head"><p class="label">ATTRIBUTE EFFECTS</p><button type="button" onclick={()=>addEffect(tier as 'I'|'II')}>ADD EFFECT +</button></div>{#each talent[tier].effects as effect,index}<div class="talent-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name}</option>{/each}</select><input type="number" step="any" bind:value={effect.value} placeholder="VALUE" required/><button type="button" onclick={()=>removeEffect(tier as 'I'|'II',index)}>×</button></div>{/each}</fieldset>{/each}</div>{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>talent=null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div></PendingForm></div>{/if}
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers,'I')}>{configured(item.tiers,'I')?'CONFIGURED':'MISSING'}</span><span class:configured={configured(item.tiers,'II')}>{configured(item.tiers,'II')?'CONFIGURED':'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={()=>editTalent(item)}>EDIT</button></div>{/each}</div>
+13
View File
@@ -0,0 +1,13 @@
import { getDatabase } from '$lib/server/database';
import { json } from '@sveltejs/kit';
export async function GET() {
const database = await getDatabase();
const statement = await database.prepare('SELECT 1 AS healthy');
const result = await statement.get();
return json({
status: result?.healthy === 1 ? 'ok' : 'error',
engine: '@tursodatabase/database'
});
}
+58
View File
@@ -0,0 +1,58 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { error, redirect } from '@sveltejs/kit';
import { createHash, randomBytes } from 'node:crypto';
import type { RequestHandler } from './$types';
const STATE_COOKIE = 'shd_discord_oauth_state';
export const GET: RequestHandler = async ({ cookies, url }) => {
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
error(503, 'Discord sign-in has not been configured yet.');
}
const state = randomBytes(32).toString('base64url');
const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
const mode = url.searchParams.get('mode') === 'link' ? 'link' : 'signin';
const user = mode === 'link' ? await getSessionUser(cookies) : null;
if (mode === 'link' && !user) {
redirect(303, '/sign-in?oauth_error=link_requires_login');
}
const database = await getDatabase();
const removeExpired = await database.prepare('DELETE FROM oauth_states WHERE expires_at <= ?');
await removeExpired.run(new Date().toISOString());
const insertState = await database.prepare(
'INSERT INTO oauth_states (state_hash, provider, mode, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
const now = new Date();
await insertState.run(
createHash('sha256').update(state).digest('hex'),
'discord',
mode,
user?.id ?? null,
new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
now.toISOString()
);
cookies.set(STATE_COOKIE, state, {
path: '/auth/discord',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: 60 * 10
});
const authorizeUrl = new URL('https://discord.com/oauth2/authorize');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', env.DISCORD_CLIENT_ID);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', 'identify');
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('prompt', 'consent');
redirect(302, authorizeUrl.toString());
};
+123
View File
@@ -0,0 +1,123 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { createSession, getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from './$types';
const STATE_COOKIE = 'shd_discord_oauth_state';
type DiscordToken = { access_token: string; token_type: string };
type DiscordUser = { id: string; username: string; global_name: string | null };
function stateMatches(expected: string | undefined, received: string | null): boolean {
if (!expected || !received) return false;
const expectedBytes = Buffer.from(expected);
const receivedBytes = Buffer.from(received);
return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
}
async function uniqueUsername(discordUser: DiscordUser): Promise<string> {
const database = await getDatabase();
const base =
(discordUser.global_name || discordUser.username)
.normalize('NFKC')
.replace(/[^\p{L}\p{N}_-]/gu, '')
.slice(0, 24) || 'Agent';
const find = await database.prepare('SELECT 1 FROM users WHERE username = ? COLLATE NOCASE');
if (!(await find.get(base))) return base;
const suffix = discordUser.id.slice(-6);
const candidate = `${base.slice(0, 17)}_${suffix}`;
if (!(await find.get(candidate))) return candidate;
return `Agent_${randomUUID().slice(0, 8)}`;
}
export const GET: RequestHandler = async ({ cookies, url, fetch }) => {
const expectedState = cookies.get(STATE_COOKIE);
cookies.delete(STATE_COOKIE, { path: '/auth/discord' });
if (url.searchParams.has('error')) {
redirect(303, '/sign-in?oauth_error=discord_denied');
}
const code = url.searchParams.get('code');
if (!code || !stateMatches(expectedState, url.searchParams.get('state'))) {
redirect(303, '/sign-in?oauth_error=invalid_state');
}
const database = await getDatabase();
const findState = await database.prepare(`
SELECT mode, user_id FROM oauth_states
WHERE state_hash = ? AND provider = 'discord' AND expires_at > ?
`);
const stateHash = createHash('sha256').update(String(url.searchParams.get('state'))).digest('hex');
const oauthState = await findState.get(stateHash, new Date().toISOString());
const deleteState = await database.prepare('DELETE FROM oauth_states WHERE state_hash = ?');
await deleteState.run(stateHash);
if (!oauthState) redirect(303, '/sign-in?oauth_error=invalid_state');
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
redirect(303, '/sign-in?oauth_error=not_configured');
}
const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
const tokenResponse = await fetch('https://discord.com/api/v10/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri
})
});
if (!tokenResponse.ok) redirect(303, '/sign-in?oauth_error=token_exchange');
const token = (await tokenResponse.json()) as DiscordToken;
const userResponse = await fetch('https://discord.com/api/v10/users/@me', {
headers: { authorization: `${token.token_type} ${token.access_token}` }
});
if (!userResponse.ok) redirect(303, '/sign-in?oauth_error=identity');
const discordUser = (await userResponse.json()) as DiscordUser;
if (oauthState.mode === 'link') {
const sessionUser = await getSessionUser(cookies);
if (!sessionUser || sessionUser.id !== String(oauthState.user_id)) {
redirect(303, '/account?oauth_error=link_session_changed');
}
const ownerStatement = await database.prepare('SELECT id FROM users WHERE discord_id = ?');
const owner = await ownerStatement.get(discordUser.id);
if (owner && String(owner.id) !== sessionUser.id) {
redirect(303, '/account?oauth_error=discord_in_use');
}
const link = await database.prepare('UPDATE users SET discord_id = ? WHERE id = ?');
await link.run(discordUser.id, sessionUser.id);
redirect(303, '/account?discord_linked=1');
}
const findUser = await database.prepare('SELECT id FROM users WHERE discord_id = ?');
let user = await findUser.get(discordUser.id);
let created = false;
if (!user) {
const username = await uniqueUsername(discordUser);
const id = randomUUID();
const insertUser = await database.prepare(
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
await insertUser.run(id, username, discordUser.id, 'user', new Date().toISOString());
user = { id };
created = true;
}
await createSession(cookies, String(user.id));
redirect(303, created ? '/account?choose_username=1' : '/account');
};
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import { onMount } from 'svelte';
type Slot = { name: string; item: string; brand: string; core: string };
let buildName = $state('Untitled loadout');
let saved = $state(false);
let message = $state('');
let slots = $state<Slot[]>([
{ name: 'Primary weapon', item: 'Police M4', brand: 'Assault rifle', core: '5,184 DMG' },
{ name: 'Secondary weapon', item: 'SVD', brand: 'Marksman rifle', core: '8,420 DMG' },
{ name: 'Mask', item: 'Striker Mask', brand: "Striker's Battlegear", core: '+42 Firearms' },
{ name: 'Vest', item: 'Reinforced Vest', brand: 'Fenris Group AB', core: '+38 Armor' },
{ name: 'Backpack', item: 'Field Pack', brand: "Striker's Battlegear", core: '+39 Firearms' },
{ name: 'Gloves', item: 'Tactical Gloves', brand: 'Sokolov Concern', core: '+6.5% CHC' }
]);
onMount(() => {
const draft = localStorage.getItem('shd-build-loadout-draft');
if (!draft) return;
try {
const parsed = JSON.parse(draft);
buildName = parsed.buildName ?? buildName;
slots = parsed.slots ?? slots;
saved = true;
} catch { localStorage.removeItem('shd-build-loadout-draft'); }
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft', JSON.stringify({ buildName, slots, updatedAt: new Date().toISOString() }));
saved = true; message = 'Draft saved on this device.';
setTimeout(() => (message = ''), 2200);
}
</script>
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
<main class="app-page">
<div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={buildName} aria-label="Build name" /></div><div class="actions"><span class:visible={saved}>SAVED LOCALLY</span><button class="primary" onclick={saveDraft}>SAVE DRAFT</button></div></div>
{#if message}<p class="success" role="status">{message}</p>{/if}
<div class="builder-layout">
<section class="slot-grid">{#each slots as slot}<button class="slot-card" onclick={() => (message = `${slot.name} editing will be added in the next builder increment.`)}><span class="slot-label">{slot.name}</span><span class="slot-icon">◇</span><strong>{slot.item}</strong><small>{slot.brand}</small><b>{slot.core}</b></button>{/each}</section>
<aside class="summary"><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2><div class="stat"><span>Firepower</span><strong>4,218</strong><i style="width:76%"></i></div><div class="stat"><span>Armor</span><strong>2,840</strong><i style="width:53%"></i></div><div class="stat"><span>Skill power</span><strong>1,265</strong><i style="width:30%"></i></div><hr/><div class="mini"><span>Critical chance</span><b>52.5%</b></div><div class="mini"><span>Critical damage</span><b>118%</b></div><p class="notice">Anonymous drafts stay on this device. Sign in to publish and sync builds.</p></aside>
</div>
</main>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
const builds = [
{ title: 'Striker Critical Rush', author: 'AgentNova', mode: 'PVE', rating: 4.9 },
{ title: 'Ironclad Vanguard', author: 'KelsoMain', mode: 'PVP', rating: 4.8 },
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7 }
];
</script>
<svelte:head><title>Discover builds — SHD.build</title></svelte:head>
<main class="page"><div class="page-head"><div><p class="eyebrow">FIELD-TESTED LOADOUTS</p><h1>Discover builds</h1><p class="muted">Browse, sort, rate, and copy community builds.</p></div><a class="primary" href="/builder">NEW BUILD +</a></div><section class="grid cards">{#each builds as build}<article class="card"><small>{build.mode} · ★ {build.rating}</small><h3>{build.title}</h3><p class="muted">BY {build.author.toUpperCase()}</p><button class="secondary">VIEW BUILD →</button></article>{/each}</section></main>
+2
View File
@@ -0,0 +1,2 @@
<svelte:head><title>Build guides — SHD.build</title></svelte:head>
<main class="page"><div class="page-head"><div><p class="eyebrow">FIELD NOTES</p><h1>Build guides</h1><p class="muted">Detailed community strategies and loadout walkthroughs will live here.</p></div></div><div class="panel"><h2>Guides are coming next</h2><p class="muted">The route is ready for guide listing, search, authorship, and rich build-linked articles.</p></div></main>
+24
View File
@@ -0,0 +1,24 @@
import { createSession, destroySession } from '$lib/server/auth/session';
import { verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
login: async ({ request, cookies }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');
if (!username || !password) return fail(400, { loginError: 'Enter your username and password.', username });
const database = await getDatabase();
const user = await (await database.prepare('SELECT id, password_hash FROM users WHERE username = ? COLLATE NOCASE')).get(username);
const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password);
if (!valid) return fail(400, { loginError: 'Invalid username or password.', username });
await createSession(cookies, String(user.id));
redirect(303, '/account');
},
logout: async ({ cookies }) => {
await destroySession(cookies);
redirect(303, '/');
}
};
+3
View File
@@ -0,0 +1,3 @@
<script lang="ts">let { data, form } = $props();</script>
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
<main class="auth-page"><section><p class="eyebrow"><span></span> AGENT ACCESS</p><h1>WELCOME BACK</h1><p>Sign in to publish builds, join discussions, and keep your loadouts synced.</p><a class="discord" href="/auth/discord">◉ CONTINUE WITH DISCORD</a><div class="divider"><span>OR USE YOUR ACCOUNT</span></div><form method="POST" action="?/login"><label>USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username" required /></label><label>PASSWORD<input name="password" type="password" autocomplete="current-password" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}<button class="primary full">SIGN IN →</button></form></section></main>
+2
View File
@@ -0,0 +1,2 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ url }) => ({ oauthError: url.searchParams.has('oauth_error') });