Add shared gear tactical augment profiles
This commit is contained in:
@@ -382,6 +382,22 @@ CREATE TABLE IF NOT EXISTS gear_talents (
|
||||
talent_id TEXT NOT NULL REFERENCES talents(id),
|
||||
PRIMARY KEY(gear_id, talent_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_tactical_augments (
|
||||
id TEXT PRIMARY KEY,
|
||||
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
|
||||
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
value REAL NOT NULL,
|
||||
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(gear_slot, augment_slot),
|
||||
CHECK(
|
||||
(gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4)
|
||||
OR
|
||||
(gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapons (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
|
||||
@@ -798,3 +798,19 @@ augmentation rows: its selected record and search field remain inline while
|
||||
the filtered result registry opens as an anchored overlay. The compact overlay
|
||||
must layer above adjacent rows, retain pending-record labels, and collapse to
|
||||
one column on narrow screens.
|
||||
|
||||
### Shared equipment profiles
|
||||
|
||||
Configuration inherited by every item of a finite equipment type belongs in a
|
||||
collapsible shared-profile registry above the item table. Each profile card
|
||||
states its type, required slot count, configured state, and a compact summary;
|
||||
selecting it opens a separate dense editor and closes the individual-record
|
||||
editor. Successful saves close the profile editor and return to the registry.
|
||||
|
||||
Gear Tactical Augment profiles use this pattern for all six gear types.
|
||||
Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee
|
||||
Pads, and Masks expose three. Every slot contains exactly one searchable
|
||||
Attribute relationship, one numeric effect value, and one non-negative
|
||||
whole-number unlock cost. At narrow widths profile cards and augment editor
|
||||
cards reduce from multi-column grids to a single column while retaining
|
||||
44-pixel interactive targets.
|
||||
|
||||
@@ -139,6 +139,9 @@
|
||||
.command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:7px;font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:6px}.proposal-correction-head>b{color:var(--shd-orange);font-size:7px}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:5px}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:6px}.immutable-proposal-file b{color:var(--shd-text);font-size:8px}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}}
|
||||
.mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:5px;letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:6px;letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:6px;letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:10px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:6px;letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:11px}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}.site-header .brand-name small{display:none}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}}
|
||||
@media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}}
|
||||
|
||||
/* Gear shared Tactical Augment profiles */
|
||||
.gear-augment-registry{margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.gear-augment-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.gear-augment-registry>summary::-webkit-details-marker{display:none}.gear-augment-registry>summary:hover,.gear-augment-registry>summary:focus-visible{background:#171b1c}.gear-augment-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.gear-augment-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.gear-augment-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.gear-augment-registry[open]>summary i{transform:rotate(45deg)}.gear-augment-profile-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.gear-augment-profile-grid>button{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.gear-augment-profile-grid>button:hover,.gear-augment-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.gear-augment-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.gear-augment-profile-grid header{display:flex;justify-content:space-between;align-items:center;padding-bottom:6px;border-bottom:1px solid #292e2f}.gear-augment-profile-grid header div{display:grid;gap:2px}.gear-augment-profile-grid header strong{color:#d7d9d5;font-size:9px}.gear-augment-profile-grid header small,.gear-augment-profile-grid header>span{color:#626a6c;font-size:6px;letter-spacing:.6px}.gear-augment-profile-grid>button.configured header>span{color:var(--shd-success)}.gear-augment-profile-grid>button>div{display:grid;margin-top:5px}.gear-augment-profile-grid>button>div span{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:6px;padding:5px 2px;border-top:1px solid #232829}.gear-augment-profile-grid>button>div span:first-child{border-top:0}.gear-augment-profile-grid>button>div small{color:var(--shd-orange);font-size:6px}.gear-augment-profile-grid>button>div b{overflow:hidden;color:#aeb4b5;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.gear-augment-profile-grid>button>div em{color:#626a6c;font-size:6px;font-style:normal}.gear-augment-editor{border-top-color:var(--shd-success)}.gear-augment-slots{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.gear-augment-slots section{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#101314}.gear-augment-slots section>header{display:flex;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid #2a2f30}.gear-augment-slots section>header strong{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.gear-augment-slots section>header span{color:#596164;font-size:6px}.gear-augment-slots .field{margin:7px 0 0}.gear-augment-slots .field>span,.gear-augment-slots label{font-size:7px}@media(max-width:1100px){.gear-augment-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-augment-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.gear-augment-profile-grid,.gear-augment-slots{grid-template-columns:1fr}.gear-augment-profile-grid>button{min-height:44px}}
|
||||
.mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}}
|
||||
.avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}}
|
||||
.assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.55px}
|
||||
|
||||
@@ -10,6 +10,12 @@ import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
|
||||
|
||||
async function augmentProfileSnapshot(slot: string) {
|
||||
const database = await getDatabase();
|
||||
return (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
@@ -23,12 +29,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [gear, brandLinks, talentLinks, brands, talents, pending] = await Promise.all([
|
||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, stats, pending] = await Promise.all([
|
||||
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
|
||||
@@ -44,6 +52,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})),
|
||||
brands: [...pendingBrands, ...brands],
|
||||
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
|
||||
augmentProfiles: slots.map((slot) => ({
|
||||
slot,
|
||||
requiredSlots: augmentSlotCount(slot),
|
||||
augments: augmentRows.filter((augment) => augment.gear_slot === slot)
|
||||
})),
|
||||
stats,
|
||||
summary,
|
||||
pagination,
|
||||
filter
|
||||
@@ -58,6 +72,55 @@ const readRange = (form: FormData, prefix: string) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveAugmentProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
|
||||
const requiredSlots = augmentSlotCount(slot);
|
||||
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
statId: String(form.get(`statId${index + 1}`) ?? ''),
|
||||
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
|
||||
rawUnlockCost: String(form.get(`unlockCost${index + 1}`) ?? '').trim(),
|
||||
value: Number(form.get(`value${index + 1}`)),
|
||||
unlockCost: Number(form.get(`unlockCost${index + 1}`))
|
||||
}));
|
||||
if (augments.some((augment) => !augment.statId || !augment.rawValue || !Number.isFinite(augment.value))) {
|
||||
return fail(400, { augmentProfileError: 'Every tactical augment slot requires exactly one attribute and value.' });
|
||||
}
|
||||
if (augments.some((augment) => !augment.rawUnlockCost || !Number.isInteger(augment.unlockCost) || augment.unlockCost < 0)) {
|
||||
return fail(400, { augmentProfileError: 'Every tactical augment slot requires a non-negative whole-number unlock cost.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = [...new Set(augments.map((augment) => augment.statId))];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' });
|
||||
const before = await augmentProfileSnapshot(slot);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
|
||||
for (const augment of augments) {
|
||||
await (await database.prepare(`INSERT INTO gear_tactical_augments
|
||||
(id,gear_slot,augment_slot,stat_attribute_id,value,unlock_cost,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`)).run(randomUUID(), slot, augment.slot, augment.statId, augment.value, augment.unlockCost, now, now);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created',
|
||||
entityId: slot,
|
||||
before,
|
||||
after: await augmentProfileSnapshot(slot)
|
||||
});
|
||||
return { augmentProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
|
||||
@@ -9,12 +9,35 @@
|
||||
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
|
||||
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] });
|
||||
let editing = $state<Editor | null>(null);
|
||||
let augmentEditing = $state<any | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
const stat = (id: string) => data.stats.find((item) => item.id === id);
|
||||
const format = (value: number | string, statId: string) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '—';
|
||||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = stat(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', ');
|
||||
const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; };
|
||||
const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot);
|
||||
const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; };
|
||||
const editAugments = (profile: any) => {
|
||||
augmentEditing = {
|
||||
...profile,
|
||||
augments: Array.from({ length: profile.requiredSlots }, (_, index) => {
|
||||
const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1);
|
||||
return augment ? { statId: augment.stat_attribute_id, value: augment.value, unlockCost: augment.unlock_cost } : { statId: '', value: '', unlockCost: '' };
|
||||
})
|
||||
};
|
||||
editing = null;
|
||||
};
|
||||
$effect(() => {
|
||||
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
augmentEditing = null;
|
||||
}
|
||||
if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = form.gearMode === 'add' ? blank() : null;
|
||||
@@ -23,7 +46,40 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Gear — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure shared tactical augments, slot progression, survivability, brands, and talents.</p></div><button class="primary" onclick={() => { editing = blank(); augmentEditing = null; }}>ADD GEAR <span>+</span></button></div>
|
||||
<details class="gear-augment-registry">
|
||||
<summary class="selection-head"><div><span>SHARED TACTICAL AUGMENT PROFILES</span><small>ONE PROFILE PER GEAR TYPE · REUSED BY EVERY MATCHING ITEM</small></div><div><b>{data.augmentProfiles.filter((profile) => profile.augments.length === profile.requiredSlots).length} / 6 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
|
||||
<div class="gear-augment-profile-grid">
|
||||
{#each data.augmentProfiles as profile}
|
||||
<button type="button" class:configured={profile.augments.length === profile.requiredSlots} onclick={() => editAugments(profile)}>
|
||||
<header><div><strong>{profile.slot}</strong><small>{profile.requiredSlots} AUGMENT SLOTS</small></div><span>{profile.augments.length === profile.requiredSlots ? 'CONFIGURED' : 'REQUIRED'}</span></header>
|
||||
<div>{#each Array(profile.requiredSlots) as _, index}{@const augment = profile.augments.find((item) => Number(item.augment_slot) === index + 1)}<span><small>0{index + 1}</small><b>{augment ? `${stat(augment.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(augment.value, augment.stat_attribute_id)}` : 'NOT SET'}</b><em>{augment ? `${augment.unlock_cost} COST` : '—'}</em></span>{/each}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{#if augmentEditing}
|
||||
<div class="dashboard-editor gear-augment-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED TACTICAL AUGMENTS</p><h3>{augmentEditing.slot.toUpperCase()} PROFILE</h3></div><button aria-label="Close tactical augment editor" onclick={() => augmentEditing = null}>×</button></div>
|
||||
<PendingForm action="?/saveAugmentProfile">
|
||||
<input type="hidden" name="slot" value={augmentEditing.slot}/>
|
||||
<input type="hidden" name="name" value={`${augmentEditing.slot} tactical augments`}/>
|
||||
<p class="console-note">These {augmentEditing.requiredSlots} augment slots apply to every {augmentEditing.slot} item. Each slot grants exactly one attribute and unlocks at its configured tactical-augment cost.</p>
|
||||
<div class="gear-augment-slots">
|
||||
{#each augmentEditing.augments as augment: any, index}
|
||||
<section>
|
||||
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>SHARED SLOT</span></header>
|
||||
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`statId${index + 1}`} mode="single" compact subjectLabel={`AUGMENT ${index + 1} ATTRIBUTE`} searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
|
||||
<label class="field">VALUE<input name={`value${index + 1}`} type="number" step="any" bind:value={augment.value} required/></label>
|
||||
<label class="field">UNLOCK COST<input name={`unlockCost${index + 1}`} type="number" min="0" step="1" bind:value={augment.unlockCost} required/></label>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.augmentProfileError}<p class="error">{form.augmentProfileError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => augmentEditing = null}>CANCEL</button><button class="primary">SAVE SHARED PROFILE →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL GEAR',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'armored',label:'ARMOR-BEARING',count:data.summary.armored,description:'Gloves, knees, and body armor'},{value:'talented',label:'TALENT-BEARING',count:data.summary.talented,description:'Backpacks and body armor'}]}/>
|
||||
{#if editing}
|
||||
<div class="dashboard-editor">
|
||||
|
||||
Reference in New Issue
Block a user