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
+2 -1
View File
@@ -69,7 +69,8 @@ CREATE TABLE IF NOT EXISTS notification_preferences (
);
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC);
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TEXT NOT NULL);
+16
View File
@@ -1155,3 +1155,19 @@ back to a short orange text mark rather than removing the relationship.
Use this pattern only for small bounded sets that remain scannable, such as the
two-to-four compatible Brand Sets on Gear. Long or unbounded relationships
continue to use counts, searchable inspection, or expanded detail views.
### Build persistence and sharing
The builder distinguishes device persistence from account persistence. `SAVE
LOCALLY` writes a working draft to the current browser. Signed-in agents also
receive one account-save action paired with an explicit visibility selector:
- `PRIVATE` is owner-only.
- `UNLISTED` is accessible by direct link but excluded from discovery.
- `PUBLIC` is accessible by direct link and included in the public archive.
Keep visibility adjacent to the account-save action and explain its selected
state in plain language. Show a share-page action only after a non-private
build has been saved. Public and unlisted build pages are read-only tactical
summaries; owner editing returns to the builder. On narrow layouts,
persistence controls and shared-loadout columns stack vertically.
+10
View File
@@ -255,6 +255,16 @@ for (const [name, definition] of [
}
}
const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all();
if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) {
console.log('Adding persistent builder loadout snapshots.');
await database.exec('ALTER TABLE builds ADD COLUMN loadout_json TEXT');
}
if (buildColumns.length) {
await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'");
}
await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)');
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) {
+1
View File
@@ -190,4 +190,5 @@
.profile-metrics>a{min-width:77px;height:59px;align-items:center;padding:7px;border:1px solid var(--shd-border);background:#111415}
.notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:5px;white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:5px;font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:6px;letter-spacing:1px}.notification-detail-sender b{font-size:10px}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:6px;letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:7px}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 Georgia,serif}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}}
.notification-command{padding:0 0 18px}.notification-command-actions{display:flex!important;align-items:stretch!important;gap:8px!important}.notification-unread-summary{min-width:86px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--shd-border);background:#0d1011}.notification-unread-summary strong{grid-row:auto!important;font-size:25px!important;line-height:1}.notification-unread-summary span{align-self:center;line-height:1.2}.notification-command-actions>button,.notification-toolbar button{min-height:40px;display:inline-flex;align-items:center;justify-content:center;margin:0!important;padding:0 18px;line-height:1;white-space:nowrap}.notification-command-actions>button>span{font-size:10px}.notification-toolbar{align-items:center;padding:10px 0}.notification-toolbar form{align-items:center;margin:0}.notification-toolbar select{min-width:125px;height:40px}.notification-toolbar form:first-child button{min-width:150px}.notification-toolbar>form:last-child{margin-left:auto}@media(max-width:700px){.notification-command-actions{width:100%;display:grid!important;grid-template-columns:auto 1fr}.notification-command-actions>button{width:100%}.notification-toolbar{align-items:stretch}.notification-toolbar>form:last-child{margin-left:0}}
.profile-row>a{color:var(--shd-orange);font-size:8px;font-weight:700}.builder-save-form{display:inline-block}.builder-save-form fieldset{display:flex}.builder-persistence{display:grid;grid-template-columns:minmax(300px,520px) 1fr auto;gap:14px;align-items:end;padding:11px 2px;border-top:1px solid var(--shd-border)}.builder-persistence p{margin:0 0 9px;color:var(--shd-text-muted);font-size:9px}.builder-share-link{align-self:center;color:var(--shd-orange);font-size:8px;font-weight:700;letter-spacing:1px}.catalogue-empty{grid-column:1/-1;display:grid;justify-items:center;gap:12px;padding:60px;border:1px solid var(--shd-border);background:var(--shd-panel);text-align:center}.catalogue-empty>span{color:var(--shd-orange);font-size:34px}.catalogue-empty p{margin:0;color:var(--shd-text-muted)}.build-open{display:block;text-align:center}.shared-build-page{padding-top:28px}.shared-build-command{display:flex;justify-content:space-between;gap:24px;align-items:end;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-command h1{margin:8px 0;font-size:clamp(30px,4vw,52px)}.shared-build-command p:last-child{margin:0;color:var(--shd-text-muted);font-size:10px}.shared-build-command>div:last-child{display:flex;gap:8px}.shared-build-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}.shared-build-grid>section{border:1px solid var(--shd-border);background:var(--shd-panel)}.shared-build-grid>section>header{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:8px;letter-spacing:1.2px}.shared-build-grid article{display:grid;gap:4px;min-height:70px;padding:12px;border-bottom:1px solid var(--shd-border)}.shared-build-grid article:last-child{border-bottom:0}.shared-build-grid article small{color:var(--shd-text-muted);font-size:7px;letter-spacing:1px}.shared-build-grid article strong{font-size:12px}.shared-build-grid article span{color:var(--shd-text-muted);font-size:8px}@media(max-width:760px){.builder-command .page-title,.shared-build-command{align-items:stretch;flex-direction:column}.builder-command .actions,.shared-build-command>div:last-child{flex-wrap:wrap}.builder-persistence{grid-template-columns:1fr}.shared-build-grid{grid-template-columns:1fr}}
+1 -1
View File
@@ -97,7 +97,7 @@
</div>
</div>
<div class="profile-module"><div class="module-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}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><button>OPEN →</button></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
{#if data.account.builds.length}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><a href={`/builder?build=${build.slug}`}>EDIT →</a></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
</div>
<div class="profile-columns"><div class="profile-module"><p class="eyebrow">SAVED INTELLIGENCE</p><h2>FAVORITES</h2><strong class="large-count">{data.account.favorites.length}</strong><span class="count-label">SAVED LOADOUTS</span></div><div class="profile-module"><p class="eyebrow">FIELD FEEDBACK</p><h2>RATINGS</h2><strong class="large-count">{data.account.ratings.length}</strong><span class="count-label">RATED LOADOUTS</span></div></div>
</section>
+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">
+20
View File
@@ -0,0 +1,20 @@
import { getDatabase } from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
const builds = await (await database.prepare(`
SELECT builds.id,builds.title,builds.slug,builds.updated_at,
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
COALESCE(AVG(ratings.value),0) AS rating,COUNT(ratings.value) AS votes
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN ratings ON ratings.build_id=builds.id
WHERE builds.visibility='public' AND builds.loadout_json IS NOT NULL
GROUP BY builds.id
ORDER BY builds.updated_at DESC
LIMIT 60
`)).all();
return { builds };
};
+19 -6
View File
@@ -1,9 +1,22 @@
<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' }
];
let { data } = $props();
</script>
<svelte:head><title>Discover builds — SHD.build</title></svelte:head>
<main class="page catalogue-page"><div class="catalogue-hero"><div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE</p><h1>DISCOVER<br/><em>BUILDS.</em></h1><p>Field-tested configurations submitted by agents across Manhattan.</p></div><div class="catalogue-count"><strong>2,847</strong><span>PUBLIC LOADOUTS</span><a class="primary" href="/builder">NEW BUILD +</a></div></div><div class="filter-bar"><label>SEARCH ARCHIVE<input placeholder="Build name, weapon, agent…" /></label><label>ACTIVITY<select><option>ALL ACTIVITIES</option><option>PVE</option><option>PVP</option></select></label><label>SORT BY<select><option>TOP RATED</option><option>MOST RECENT</option><option>MOST SAVED</option></select></label><button class="secondary">FILTERS +</button></div><section class="build-grid catalogue-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><button class="build-open">VIEW LOADOUT →</button></div></article>{/each}</section></main>
<main class="page catalogue-page">
<div class="catalogue-hero">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE</p><h1>DISCOVER<br/><em>BUILDS.</em></h1><p>Field-tested configurations submitted by registered agents.</p></div>
<div class="catalogue-count"><strong>{data.builds.length}</strong><span>PUBLIC LOADOUTS</span><a class="primary" href="/builder">NEW BUILD +</a></div>
</div>
<section class="build-grid catalogue-grid">
{#each data.builds as build}
<article style="--build-color:#f6a821">
<div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</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="author"><span class="avatar">{String(build.author).slice(0,1)}</span><span>BY <strong>{build.author}</strong></span><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
</article>
{:else}
<div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div>
{/each}
</section>
</main>
+34
View File
@@ -0,0 +1,34 @@
import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const build = await (await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE builds.slug=?
`)).get(params.slug);
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
let loadout;
try { loadout = JSON.parse(String(build.loadout_json)); } catch { error(404, 'Build data is unavailable'); }
const [gear, weapons, chips, protocols, brands] = await Promise.all([
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
(await database.prepare('SELECT id,name,quality FROM skill_chip_sets')).all(),
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all()
]);
return {
build: {
id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author),
owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at)
},
loadout, gear, weapons, chips, protocols, brands
};
};
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
</script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<main class="app-page shared-build-page">
<header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><p>Filed by <strong>{data.build.author}</strong> · Updated {new Date(data.build.updatedAt).toLocaleDateString()}</p></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
</section>
<section>
<header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>04 SLOTS</b></header>
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span></article>{/each}
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
</section>
</div>
</main>