Add persisted and shareable builds

This commit is contained in:
JB
2026-07-25 22:28:40 -04:00
parent 1c32b1dfdc
commit 7bb4b92965
11 changed files with 263 additions and 14 deletions
+74 -2
View File
@@ -1,9 +1,39 @@
import { getDatabase } from '$lib/server/database';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import type { PageServerLoad } from './$types';
import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
.replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').slice(0, 60) || 'loadout';
function parseLoadout(raw: FormDataEntryValue | null) {
if (typeof raw !== 'string' || raw.length > 250_000) return null;
try {
const value = JSON.parse(raw);
if (value?.version !== 1 || typeof value.buildName !== 'string' || !value.gear ||
!Array.isArray(value.weapons) || value.weapons.length !== 2 ||
!Array.isArray(value.chips) || value.chips.length !== 3 || !value.protocol) return null;
return value;
} catch {
return null;
}
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const requestedSlug = url.searchParams.get('build');
let initialBuild = null;
if (requestedSlug && viewer) {
const row = await (await database.prepare(
'SELECT id,title,slug,visibility,loadout_json FROM builds WHERE slug=? AND owner_id=?'
)).get(requestedSlug, viewer.id);
const loadout = typeof row?.loadout_json === 'string' ? parseLoadout(row.loadout_json) : null;
if (row && loadout) initialBuild = { id: String(row.id), slug: String(row.slug), visibility: String(row.visibility), loadout };
}
const [
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
@@ -57,6 +87,8 @@ export const load: PageServerLoad = async () => {
}))
}));
return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
initialBuild,
attributes,
coreRanges,
gear: gear.map((item) => ({
@@ -105,3 +137,43 @@ export const load: PageServerLoad = async () => {
})
};
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { buildError: 'Sign in to save and share this loadout.' });
const formData = await request.formData();
const loadout = parseLoadout(formData.get('loadout'));
const visibility = String(formData.get('visibility') ?? 'private');
const id = String(formData.get('id') ?? '').trim();
if (!loadout) return fail(400, { buildError: 'The loadout data is invalid. Refresh the builder and try again.' });
const title = loadout.buildName.trim();
if (title.length < 2 || title.length > 100) return fail(400, { buildError: 'Build names must be between 2 and 100 characters.' });
if (!validVisibility.has(visibility)) return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const now = new Date().toISOString();
let buildId = id;
let slug = '';
if (buildId) {
const existing = await (await database.prepare('SELECT id,slug FROM builds WHERE id=? AND owner_id=?')).get(buildId, user.id);
if (!existing) return fail(404, { buildError: 'This saved build could not be found.' });
slug = String(existing.slug);
await (await database.prepare(
'UPDATE builds SET title=?,visibility=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?'
)).run(title, visibility, JSON.stringify(loadout), now, buildId, user.id);
} else {
buildId = randomUUID();
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
await (await database.prepare(
`INSERT INTO builds(id,owner_id,title,slug,visibility,loadout_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`
)).run(buildId, user.id, title, slug, visibility, JSON.stringify(loadout), now, now);
}
return {
buildSaved: true,
buildToken: randomUUID(),
savedBuild: { id: buildId, slug, visibility, title }
};
}
};
+54 -4
View File
@@ -1,21 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import BuilderPicker from '$lib/components/BuilderPicker.svelte';
import BuilderRadioGrid from '$lib/components/BuilderRadioGrid.svelte';
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
} from '$lib/builder/loadout';
let { data } = $props();
let draft = $state<LoadoutDraft>(createDraft());
let { data, form } = $props();
let draft = $state<LoadoutDraft>(untrack(() => data.initialBuild?.loadout ?? createDraft()));
let active = $state('gear:Holster');
let saved = $state(false);
let message = $state('');
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
let handledBuildToken = '';
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
const coreKeys = ['firepower','toughness','engineering'] as const;
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
@@ -285,6 +290,7 @@
});
onMount(() => {
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v1');
if (!stored) return;
try {
@@ -306,6 +312,18 @@
}
} catch { localStorage.removeItem('shd-build-loadout-draft-v1'); }
});
$effect(() => {
if (!form?.buildSaved || !form?.buildToken || form.buildToken === handledBuildToken) return;
handledBuildToken = form.buildToken;
persistedBuildId = form.savedBuild.id;
persistedSlug = form.savedBuild.slug;
persistedVisibility = form.savedBuild.visibility;
message = form.savedBuild.visibility === 'private'
? 'Private build saved to your account.'
: form.savedBuild.visibility === 'unlisted'
? 'Unlisted build saved. Anyone with the link can view it.'
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
saved = true; message = 'Draft saved on this device.';
@@ -315,8 +333,40 @@
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
<main class="app-page builder-page">
<div class="builder-command"><div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={draft.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><div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>12 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div></div>
<div class="builder-command">
<div class="page-title">
<div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={draft.buildName} aria-label="Build name"/></div>
<div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
{#if data.viewer}
<PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)}
<input type="hidden" name="id" value={persistedBuildId}/>
<input type="hidden" name="loadout" value={JSON.stringify(draft)}/>
<button class="primary" type="submit" name="visibility" value={persistedVisibility} aria-label="Save build to account">{pending ? 'SAVING…' : 'SAVE TO ACCOUNT'}</button>
{/snippet}
</PendingForm>
{:else}
<a class="primary" href="/sign-in?returnTo=%2Fbuilder">SIGN IN TO SHARE</a>
{/if}
</div>
</div>
{#if data.viewer}
<div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} showIndicator={false} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>12 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
<div class="loadout-workspace">
<section class="loadout-slots">