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_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 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 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 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); 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 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 two-to-four compatible Brand Sets on Gear. Long or unbounded relationships
continue to use counts, searchable inspection, or expanded detail views. 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}`); console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) { 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} .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{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}} .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> </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> <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>
<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> <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> </section>
+74 -2
View File
@@ -1,9 +1,39 @@
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { gameDataIconUrl } from '$lib/server/game-data-icons'; 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 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 [ const [
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects, attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses, weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
@@ -57,6 +87,8 @@ export const load: PageServerLoad = async () => {
})) }))
})); }));
return { return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
initialBuild,
attributes, attributes,
coreRanges, coreRanges,
gear: gear.map((item) => ({ 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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, untrack } from 'svelte';
import GameIcon from '$lib/components/GameIcon.svelte'; import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
import BuilderPicker from '$lib/components/BuilderPicker.svelte'; import BuilderPicker from '$lib/components/BuilderPicker.svelte';
import BuilderRadioGrid from '$lib/components/BuilderRadioGrid.svelte'; import BuilderRadioGrid from '$lib/components/BuilderRadioGrid.svelte';
import RangeValueControl from '$lib/components/RangeValueControl.svelte'; import RangeValueControl from '$lib/components/RangeValueControl.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange, blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll type LoadoutDraft, type Roll
} from '$lib/builder/loadout'; } from '$lib/builder/loadout';
let { data } = $props(); let { data, form } = $props();
let draft = $state<LoadoutDraft>(createDraft()); let draft = $state<LoadoutDraft>(untrack(() => data.initialBuild?.loadout ?? createDraft()));
let active = $state('gear:Holster'); let active = $state('gear:Holster');
let saved = $state(false); let saved = $state(false);
let message = $state(''); 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 weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
const coreKeys = ['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'}` }))); 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(() => { onMount(() => {
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v1'); const stored = localStorage.getItem('shd-build-loadout-draft-v1');
if (!stored) return; if (!stored) return;
try { try {
@@ -306,6 +312,18 @@
} }
} catch { localStorage.removeItem('shd-build-loadout-draft-v1'); } } 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() { function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft)); localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
saved = true; message = 'Draft saved on this device.'; saved = true; message = 'Draft saved on this device.';
@@ -315,8 +333,40 @@
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head> <svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
<main class="app-page builder-page"> <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 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} {#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"> <div class="loadout-workspace">
<section class="loadout-slots"> <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"> <script lang="ts">
const builds = [ let { data } = $props();
{ 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> </script>
<svelte:head><title>Discover builds — SHD.build</title></svelte:head> <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>