Show pending ISAC contributions immediately
This commit is contained in:
@@ -1024,6 +1024,16 @@ the live totals immediately. Trigger-dependent talents remain explicitly
|
|||||||
effects enter combined totals. This textual distinction must remain clear
|
effects enter combined totals. This textual distinction must remain clear
|
||||||
without relying on color.
|
without relying on color.
|
||||||
|
|
||||||
|
### Pending ISAC registry records
|
||||||
|
|
||||||
|
Pending additions appear directly in the same paginated ISAC registry as live
|
||||||
|
records and in every compatible relationship picker. They use a restrained
|
||||||
|
orange-tinted row, an explicit `PENDING` state, and proposer attribution.
|
||||||
|
Pending rows are read-only in module registries; changes remain the authority
|
||||||
|
of the Data Oversight review interface. Successful contributor submissions
|
||||||
|
refresh the registry immediately and follow the same editor lifecycle as a
|
||||||
|
direct save. Never rely on the tint alone to communicate moderation state.
|
||||||
|
|
||||||
Gear and weapon level are builder-instance state, never part of an ISAC item
|
Gear and weapon level are builder-instance state, never part of an ISAC item
|
||||||
definition. Each equipped item stores its own level from 1–50 in the local or
|
definition. Each equipped item stores its own level from 1–50 in the local or
|
||||||
persisted build. Range endpoints are calculated from the documented level
|
persisted build. Range endpoints are calculated from the documented level
|
||||||
|
|||||||
+1
-1
@@ -144,7 +144,7 @@
|
|||||||
.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{width:var(--isac-icon-editor-width);max-width:100%;min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.dashboard-editor input[name=name]{min-height:46px;padding:9px 11px;font-size:15px;font-weight:800;letter-spacing:.35px}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{width:100%;max-width:none}.icon-upload-preview{height:70px}}
|
.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{width:var(--isac-icon-editor-width);max-width:100%;min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.dashboard-editor input[name=name]{min-height:46px;padding:9px 11px;font-size:15px;font-weight:800;letter-spacing:.35px}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{width:100%;max-width:none}.icon-upload-preview{height:70px}}
|
||||||
.brand-editor-body{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{width:100%;display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}}
|
.brand-editor-body{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{width:100%;display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}}
|
||||||
.brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6}
|
.brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6}
|
||||||
.pending-reference{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap}
|
.pending-reference,.dashboard-row.pending-record{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label,.state-dot.pending{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap}.state-dot.pending:before{background:var(--shd-orange)}
|
||||||
.data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:6px;font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)}
|
.data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:6px;font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)}
|
||||||
.contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:9px}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:8px;font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:9px;letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:8px;line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:8px;letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:7px}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}}
|
.contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:9px}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:8px;font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:9px;letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:8px;line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:8px;letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:7px}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}}
|
||||||
.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f}@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}}
|
.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f}@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}}
|
||||||
|
|||||||
+29
-7
@@ -1,7 +1,8 @@
|
|||||||
import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
||||||
|
import { stringify } from 'devalue';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
|
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
|
||||||
@@ -22,14 +23,35 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
}
|
}
|
||||||
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
|
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
|
||||||
}
|
}
|
||||||
const proposalId = await queueDataProposal(user, module, action, await event.request.formData());
|
const submittedForm = await event.request.formData();
|
||||||
const target = new URL(event.url.pathname, event.url);
|
const proposal = await queueDataProposal(user, module, action, submittedForm);
|
||||||
target.searchParams.set('proposal', proposalId);
|
|
||||||
const location = `${target.pathname}${target.search}`;
|
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
if (event.request.headers.get('accept')?.includes('application/json')) {
|
||||||
return json({ type: 'redirect', status: 303, location });
|
const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id);
|
||||||
|
if (module === 'attributes' && action === 'save') {
|
||||||
|
Object.assign(result, {
|
||||||
|
savedItem: {
|
||||||
|
id: proposal.entityId ?? proposal.draftEntityId,
|
||||||
|
name: String(submittedForm.get('name') ?? ''),
|
||||||
|
type: String(submittedForm.get('type') ?? ''),
|
||||||
|
category: String(submittedForm.get('category') ?? ''),
|
||||||
|
calculation_type: String(submittedForm.get('calculationType') ?? ''),
|
||||||
|
unit: String(submittedForm.get('unit') ?? ''),
|
||||||
|
active: 1,
|
||||||
|
pending: true,
|
||||||
|
proposalId: proposal.id,
|
||||||
|
proposerName: user.displayName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json({
|
||||||
|
type: 'success',
|
||||||
|
status: 200,
|
||||||
|
data: stringify(result)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return new Response(null, { status: 303, headers: { location } });
|
const target = new URL(event.url.pathname, event.url);
|
||||||
|
target.searchParams.set('proposal', proposal.id);
|
||||||
|
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,32 @@ import { getContributorMode } from '$lib/server/contributor-mode';
|
|||||||
export const DATA_MODULES = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const;
|
export const DATA_MODULES = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const;
|
||||||
export type DataModule = typeof DATA_MODULES[number];
|
export type DataModule = typeof DATA_MODULES[number];
|
||||||
|
|
||||||
|
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||||
|
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
||||||
|
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
||||||
|
gear: {
|
||||||
|
save: { saved: 'gearSaved', mode: 'gearMode' },
|
||||||
|
saveAugmentProfile: { saved: 'augmentProfileSaved' }
|
||||||
|
},
|
||||||
|
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||||
|
talents: {
|
||||||
|
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
|
||||||
|
saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
|
||||||
|
},
|
||||||
|
protocols: {
|
||||||
|
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||||
|
saveProfile: { saved: 'profileSaved' }
|
||||||
|
},
|
||||||
|
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) {
|
||||||
|
const keys = proposalResultKeys[module]?.[action];
|
||||||
|
return keys
|
||||||
|
? { [keys.saved]: true, ...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}), proposalId, resultToken: randomUUID() }
|
||||||
|
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
|
||||||
|
}
|
||||||
|
|
||||||
type SerializedEntry =
|
type SerializedEntry =
|
||||||
| { name: string; kind: 'text'; value: string }
|
| { name: string; kind: 'text'; value: string }
|
||||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||||
@@ -84,6 +110,10 @@ export async function getPendingEntities(_userId: string, definitions: PendingDe
|
|||||||
const definition = definitions.find((item) => item.module === row.module && item.action === row.action)!;
|
const definition = definitions.find((item) => item.module === row.module && item.action === row.action)!;
|
||||||
const values = serializedTextValues(String(row.payload_json));
|
const values = serializedTextValues(String(row.payload_json));
|
||||||
const field = (name: string) => values.find((entry) => entry.name === name)?.value ?? '';
|
const field = (name: string) => values.find((entry) => entry.name === name)?.value ?? '';
|
||||||
|
const fieldValue = (name: string) => {
|
||||||
|
const matches = values.filter((entry) => entry.name === name).map((entry) => entry.value);
|
||||||
|
return matches.length > 1 ? matches : matches[0] ?? '';
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: String(row.draft_entity_id),
|
id: String(row.draft_entity_id),
|
||||||
proposalId: String(row.id),
|
proposalId: String(row.id),
|
||||||
@@ -92,7 +122,7 @@ export async function getPendingEntities(_userId: string, definitions: PendingDe
|
|||||||
kind: definition.kind,
|
kind: definition.kind,
|
||||||
name: field('name'),
|
name: field('name'),
|
||||||
proposerName: String(row.proposer_name),
|
proposerName: String(row.proposer_name),
|
||||||
...Object.fromEntries((definition.fields ?? []).map((name) => [name, field(name)]))
|
...Object.fromEntries((definition.fields ?? []).map((name) => [name, fieldValue(name)]))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,7 +154,7 @@ export async function queueDataProposal(user: AuthUser, module: DataModule, acti
|
|||||||
before: null,
|
before: null,
|
||||||
after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary }
|
after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary }
|
||||||
});
|
});
|
||||||
return id;
|
return { id, entityId, draftEntityId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shouldModerateDataMutation(user: AuthUser | null) {
|
export async function shouldModerateDataMutation(user: AuthUser | null) {
|
||||||
|
|||||||
@@ -6,20 +6,48 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requireDataView(await getSessionUser(cookies));
|
const user = await requireDataView(await getSessionUser(cookies));
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
|
const pending = (await getPendingEntities(user.id, [{
|
||||||
|
module: 'attributes',
|
||||||
|
action: 'save',
|
||||||
|
kind: 'attribute',
|
||||||
|
fields: ['type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
|
||||||
|
}])).map((stat) => ({
|
||||||
|
...stat,
|
||||||
|
calculation_type: stat.calculationType,
|
||||||
|
gear_min: stat.gearMin || null,
|
||||||
|
gear_max: stat.gearMax || null,
|
||||||
|
weapon_min: stat.weaponMin || null,
|
||||||
|
weapon_max: stat.weaponMax || null,
|
||||||
|
skill_chip_min: stat.chipMin || null,
|
||||||
|
skill_chip_max: stat.chipMax || null,
|
||||||
|
cap_value: stat.capValue || null
|
||||||
|
}));
|
||||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
|
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
|
||||||
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
|
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
|
||||||
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const where = filter === 'active' ? 'WHERE active=1' : filter === 'offensive' ? "WHERE type='Offensive'" : filter === 'defensive' ? "WHERE type='Defensive'" : '';
|
const where = filter === 'active' ? 'WHERE active=1' : filter === 'offensive' ? "WHERE type='Offensive'" : filter === 'defensive' ? "WHERE type='Defensive'" : '';
|
||||||
|
const visiblePending = pending.filter((stat) => filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true);
|
||||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get();
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get();
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||||
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset);
|
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||||
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })), summary, pagination, filter };
|
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||||
|
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||||
|
const stats = databaseLimit
|
||||||
|
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
|
||||||
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
|
||||||
|
pagination,
|
||||||
|
filter
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
@@ -41,7 +69,8 @@ export const actions: Actions = {
|
|||||||
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
|
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
|
||||||
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
|
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
|
||||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
|
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
|
||||||
return { saved: true, editorMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
const savedItem = await auditRecord('stat_attributes', statId);
|
||||||
|
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
archive: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let editing = $state<any>(null);
|
let editing = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
|
let immediateStats = $state<any[]>([]);
|
||||||
|
const visibleStats = $derived([...immediateStats, ...data.stats.filter((stat: any) => !immediateStats.some((saved) => saved.id === stat.id))]);
|
||||||
const typeOptions = ['Offensive', 'Defensive'].map((value) => ({ value, label: value }));
|
const typeOptions = ['Offensive', 'Defensive'].map((value) => ({ value, label: value }));
|
||||||
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
||||||
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
|
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
|
||||||
@@ -15,6 +17,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
|
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||||
handledResultToken = form.resultToken;
|
handledResultToken = form.resultToken;
|
||||||
|
if (form.savedItem) {
|
||||||
|
const savedItem = form.savedItem as any;
|
||||||
|
immediateStats = [savedItem, ...immediateStats.filter((stat) => stat.id !== savedItem.id)];
|
||||||
|
}
|
||||||
editing = form.editorMode === 'add' ? blank() : null;
|
editing = form.editorMode === 'add' ? blank() : null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -24,4 +30,4 @@
|
|||||||
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
|
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
|
||||||
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
|
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
|
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each visibleStats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending}><div><strong>{stat.name}</strong><small>{stat.pending ? `PROPOSED BY ${stat.proposerName}` : stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{/each}</div>
|
||||||
|
|||||||
@@ -7,12 +7,26 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
|
|
||||||
const thresholds = [2, 3, 4] as const;
|
const thresholds = [2, 3, 4] as const;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requireDataView(await getSessionUser(cookies));
|
const user = await requireDataView(await getSessionUser(cookies));
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
|
const [pendingBrands, pendingStats] = await Promise.all([
|
||||||
|
getPendingEntities(user.id, [{ module: 'brands', action: 'save', kind: 'brand', fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'] }]),
|
||||||
|
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }])
|
||||||
|
]);
|
||||||
|
const mappedPendingBrands = pendingBrands.map((brand) => ({
|
||||||
|
...brand,
|
||||||
|
iconUrl: '',
|
||||||
|
effects: thresholds.map((pieces) => ({
|
||||||
|
pieces_required: pieces,
|
||||||
|
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
|
||||||
|
value: Number(brand[`value${pieces}`] ?? 0)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||||
@@ -21,21 +35,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const where = filter === 'active' ? 'WHERE active=1' : filter === 'archived' ? 'WHERE active=0'
|
const where = filter === 'active' ? 'WHERE active=1' : filter === 'archived' ? 'WHERE active=0'
|
||||||
: filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : '';
|
: filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : '';
|
||||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get();
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get();
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const visiblePending = mappedPendingBrands.filter(() => filter !== 'archived');
|
||||||
|
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||||
|
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||||
|
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||||
|
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||||
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
||||||
(await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) : [],
|
||||||
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
|
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
|
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
|
||||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
brands: brands.map((brand) => {
|
brands: [...pendingOnPage, ...brands.map((brand) => {
|
||||||
const icon = icons.find((item) => item.entity_id === brand.id);
|
const icon = icons.find((item) => item.entity_id === brand.id);
|
||||||
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
|
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
|
||||||
}),
|
})],
|
||||||
stats,
|
stats: [...pendingStats, ...stats],
|
||||||
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) },
|
||||||
pagination,
|
pagination,
|
||||||
filter
|
filter
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
||||||
{#each data.brands as brand}
|
{#each data.brands as brand}
|
||||||
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div class="registry-icon-identity">{#if brand.iconUrl}<img src={brand.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
|
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending}><div class="registry-icon-identity">{#if brand.iconUrl}<img src={brand.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button onclick={() => edit(brand)}>EDIT</button>{/if}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -37,8 +37,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
|
: 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 filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, pending] = await Promise.all([
|
const pendingDefinitions = await getPendingEntities(user.id, [
|
||||||
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||||
|
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
|
||||||
|
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||||
|
{ module: 'gear', action: 'save', kind: 'gear', fields: ['slot', 'tier', 'healthMin', 'healthMax', 'armorMin', 'armorMax', 'brandIds', 'talentIds'] }
|
||||||
|
]);
|
||||||
|
const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) =>
|
||||||
|
filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true
|
||||||
|
);
|
||||||
|
const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
|
||||||
|
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
|
||||||
|
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
|
||||||
|
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
|
||||||
|
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
|
||||||
|
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) : [],
|
||||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
(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 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 FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
@@ -46,20 +59,26 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||||
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
|
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
|
||||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
|
||||||
getPendingEntities(user.id, [
|
|
||||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
|
||||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
|
|
||||||
])
|
|
||||||
]);
|
]);
|
||||||
const pendingBrands = pending.filter((item) => item.kind === 'brand');
|
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
|
||||||
const pendingTalents = pending.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
|
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
|
||||||
|
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
|
||||||
return {
|
return {
|
||||||
gear: gear.map((item) => ({
|
gear: [...pendingGearOnPage.map((item) => ({
|
||||||
|
...item,
|
||||||
|
tier: Number(item.tier),
|
||||||
|
base_health_min: item.healthMin,
|
||||||
|
base_health_max: item.healthMax,
|
||||||
|
base_armor_min: item.armorMin || null,
|
||||||
|
base_armor_max: item.armorMax || null,
|
||||||
|
brandIds: Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds].filter(Boolean),
|
||||||
|
talentIds: Array.isArray(item.talentIds) ? item.talentIds : [item.talentIds].filter(Boolean)
|
||||||
|
})), ...gear.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||||
})),
|
}))],
|
||||||
brands: [...pendingBrands, ...brands.map((brand) => {
|
brands: [...pendingBrands, ...brands.map((brand) => {
|
||||||
const icon = brandIcons.find((item) => item.entity_id === brand.id);
|
const icon = brandIcons.find((item) => item.entity_id === brand.id);
|
||||||
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
|
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
|
||||||
@@ -73,9 +92,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id)
|
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id)
|
||||||
}))
|
}))
|
||||||
})),
|
})),
|
||||||
stats,
|
stats: [...pendingStats, ...stats],
|
||||||
summary,
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
|
||||||
pagination,
|
pagination: adjustedPagination,
|
||||||
filter
|
filter
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,6 +135,6 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
||||||
{#each data.gear as item}
|
{#each data.gear as item}
|
||||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
|
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
|
|
||||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||||
@@ -48,7 +49,7 @@ async function profileSnapshot(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requireDataView(await getSessionUser(cookies));
|
const user = await requireDataView(await getSessionUser(cookies));
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||||
const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter)
|
const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter)
|
||||||
@@ -75,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
|
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
|
||||||
]);
|
]);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
|
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([
|
||||||
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
|
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
|
||||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(),
|
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(),
|
||||||
@@ -85,11 +86,33 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all()
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all(),
|
||||||
|
getPendingEntities(user.id, [
|
||||||
|
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||||
|
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
||||||
|
{ module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }
|
||||||
|
])
|
||||||
]);
|
]);
|
||||||
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
|
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
|
||||||
|
const pendingProtocols = pending.filter((item) => item.kind === 'protocol').filter((item) =>
|
||||||
|
filter === 'archived' ? false
|
||||||
|
: qualities.some((quality) => quality.toLowerCase() === filter) ? String(item.quality).toLowerCase() === filter
|
||||||
|
: attributeTypes.some((type) => type.toLowerCase() === filter) ? String(item.attributeType).toLowerCase() === filter : true
|
||||||
|
).map((protocol) => ({
|
||||||
|
...protocol,
|
||||||
|
iconUrl: '',
|
||||||
|
primary_attribute_type: protocol.attributeType,
|
||||||
|
primaryEffect: { stat_attribute_id: protocol.primaryStatId, base_value: Number(protocol.primaryBaseValue) },
|
||||||
|
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.attributeType) ?? null,
|
||||||
|
talent: {
|
||||||
|
name: protocol.talentName,
|
||||||
|
description_template: protocol.descriptionTemplate,
|
||||||
|
effects: (() => { try { return JSON.parse(String(protocol.talentEffects ?? '[]')); } catch { return []; } })(),
|
||||||
|
triggerIds: Array.isArray(protocol.triggerIds) ? protocol.triggerIds : [protocol.triggerIds].filter(Boolean)
|
||||||
|
}
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
protocols: protocols.map((protocol) => {
|
protocols: [...pendingProtocols, ...protocols.map((protocol) => {
|
||||||
const talent = talents.find((item) => item.protocol_id === protocol.id);
|
const talent = talents.find((item) => item.protocol_id === protocol.id);
|
||||||
return {
|
return {
|
||||||
...protocol,
|
...protocol,
|
||||||
@@ -102,11 +125,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
||||||
} : null
|
} : null
|
||||||
};
|
};
|
||||||
}),
|
})],
|
||||||
profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) =>
|
profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) =>
|
||||||
profile.quality === quality && profile.primary_attribute_type === attributeType
|
profile.quality === quality && profile.primary_attribute_type === attributeType
|
||||||
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
|
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
|
||||||
stats, triggers, summary, filter, pagination
|
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
|
||||||
|
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
|
||||||
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,6 @@
|
|||||||
<div class="dashboard-table protocol-dashboard-table">
|
<div class="dashboard-table protocol-dashboard-table">
|
||||||
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
||||||
{#each data.protocols as protocol}
|
{#each data.protocols as protocol}
|
||||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div class="registry-icon-identity">{#if protocol.iconUrl}<img src={protocol.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
|
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending}><div class="registry-icon-identity">{#if protocol.iconUrl}<img src={protocol.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
: filter === 'archived' ? 'WHERE sets.active=0'
|
: filter === 'archived' ? 'WHERE sets.active=0'
|
||||||
: filter === 'configured' ? `WHERE EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)
|
: filter === 'configured' ? `WHERE EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)
|
||||||
AND EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)` : '';
|
AND EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)` : '';
|
||||||
const [summary, filtered, pendingStats, pendingTriggers] = await Promise.all([
|
const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = await Promise.all([
|
||||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||||
@@ -58,7 +58,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
FROM skill_chip_sets`)).get(),
|
FROM skill_chip_sets`)).get(),
|
||||||
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(),
|
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(),
|
||||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
|
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
|
||||||
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }])
|
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]),
|
||||||
|
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }])
|
||||||
]);
|
]);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
|
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
|
||||||
@@ -79,8 +80,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
||||||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
|
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
|
||||||
})), ...stats];
|
})), ...stats];
|
||||||
|
const pendingSetRows = filter === 'archived' ? [] : pendingSets.map((set) => ({
|
||||||
|
...set,
|
||||||
|
iconUrl: '',
|
||||||
|
twoPiece: {
|
||||||
|
stat_attribute_id: set.twoPieceStatId,
|
||||||
|
min_value: Number(set.twoPieceMin),
|
||||||
|
max_value: Number(set.twoPieceMax)
|
||||||
|
},
|
||||||
|
primaryStatIds: Array.isArray(set.primaryStatIds) ? set.primaryStatIds : [set.primaryStatIds].filter(Boolean),
|
||||||
|
secondaryStatIds: Array.isArray(set.secondaryStatIds) ? set.secondaryStatIds : [set.secondaryStatIds].filter(Boolean),
|
||||||
|
talent: {
|
||||||
|
name: set.talentName,
|
||||||
|
description_template: set.descriptionTemplate,
|
||||||
|
effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(),
|
||||||
|
triggerIds: Array.isArray(set.triggerIds) ? set.triggerIds : [set.triggerIds].filter(Boolean)
|
||||||
|
}
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
sets: sets.map((set) => {
|
sets: [...pendingSetRows, ...sets.map((set) => {
|
||||||
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
|
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
|
||||||
const icon = icons.find((item) => item.entity_id === set.id);
|
const icon = icons.find((item) => item.entity_id === set.id);
|
||||||
return {
|
return {
|
||||||
@@ -95,11 +113,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
||||||
} : null
|
} : null
|
||||||
};
|
};
|
||||||
}),
|
})],
|
||||||
stats: availableStats,
|
stats: availableStats,
|
||||||
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
|
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
|
||||||
triggers: [...pendingTriggers, ...triggers],
|
triggers: [...pendingTriggers, ...triggers],
|
||||||
summary, filter, pagination
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,6 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
|
||||||
{#each data.sets as item}
|
{#each data.sets as item}
|
||||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>SUPERIOR · HIGH-END</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class="state-dot">{Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
|
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,13 +48,31 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
FROM talents`)).get(),
|
FROM talents`)).get(),
|
||||||
getPendingEntities(user.id, [
|
getPendingEntities(user.id, [
|
||||||
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
||||||
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }
|
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] },
|
||||||
|
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'cooldownSeconds', 'durationSeconds', 'maxStacks'] }
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
const pendingTriggers = pending.filter((item) => item.kind === 'trigger');
|
const pendingTriggers = pending.filter((item) => item.kind === 'trigger');
|
||||||
const pendingStats = pending.filter((item) => item.kind === 'stat');
|
const pendingStats = pending.filter((item) => item.kind === 'stat');
|
||||||
|
const pendingTalents = pending.filter((item) => item.kind === 'talent').filter((item) =>
|
||||||
|
filter === 'slot1' ? item.category === 'Weapon Talent 1'
|
||||||
|
: filter === 'slot2' ? item.category === 'Weapon Talent 2'
|
||||||
|
: filter === 'backpack' ? item.category === 'Backpack'
|
||||||
|
: filter === 'body-armor' ? item.category === 'Body Armor'
|
||||||
|
: filter === 'exotic' ? false : true
|
||||||
|
).map((item) => ({
|
||||||
|
...item,
|
||||||
|
iconUrl: '',
|
||||||
|
cooldown_seconds: item.cooldownSeconds || null,
|
||||||
|
duration_seconds: item.durationSeconds || null,
|
||||||
|
max_stacks: item.maxStacks || null,
|
||||||
|
descriptionTemplate: item.descriptionTemplate,
|
||||||
|
ownerWeaponId: null,
|
||||||
|
ownerWeaponName: null,
|
||||||
|
tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
talents: talents.map((talent) => ({
|
talents: [...pendingTalents, ...talents.map((talent) => ({
|
||||||
...talent,
|
...talent,
|
||||||
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
|
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
|
||||||
category: talent.category === 'Vest'
|
category: talent.category === 'Vest'
|
||||||
@@ -76,11 +94,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)),
|
triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)),
|
||||||
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
|
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||||
}))
|
}))
|
||||||
})),
|
}))],
|
||||||
triggers: [...pendingTriggers, ...triggers],
|
triggers: [...pendingTriggers, ...triggers],
|
||||||
stats: [...pendingStats, ...stats],
|
stats: [...pendingStats, ...stats],
|
||||||
summary: {
|
summary: {
|
||||||
total: Number(totalRow?.total ?? 0),
|
total: Number(totalRow?.total ?? 0) + pending.filter((item) => item.kind === 'talent').length,
|
||||||
triggers: Number(triggerSummary?.total ?? 0),
|
triggers: Number(triggerSummary?.total ?? 0),
|
||||||
slot1: Number(slotSummary?.slot1 ?? 0),
|
slot1: Number(slotSummary?.slot1 ?? 0),
|
||||||
slot2: Number(slotSummary?.slot2 ?? 0),
|
slot2: Number(slotSummary?.slot2 ?? 0),
|
||||||
|
|||||||
@@ -98,4 +98,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
|
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
@@ -26,7 +27,7 @@ const renderTalentDescription = (template: string, effects: ExoticEffect[], unit
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requireDataView(await getSessionUser(cookies));
|
const user = await requireDataView(await getSessionUser(cookies));
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
@@ -38,7 +39,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
|
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
|
||||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons] = await Promise.all([
|
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([
|
||||||
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
|
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
|
||||||
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
|
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
|
||||||
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
|
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
|
||||||
@@ -63,10 +64,34 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
|
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
|
||||||
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
|
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
|
||||||
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
|
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
|
||||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
|
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
|
||||||
|
getPendingEntities(user.id, [
|
||||||
|
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||||
|
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
||||||
|
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
|
||||||
|
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids'] }
|
||||||
|
])
|
||||||
]);
|
]);
|
||||||
|
const pendingTalents = pending.filter((item) => item.kind === 'talent');
|
||||||
|
const pendingWeapons = pending.filter((item) => item.kind === 'weapon').filter((item) =>
|
||||||
|
requestedWeaponId ? item.id === requestedWeaponId
|
||||||
|
: filter === 'standard' ? !item.isExotic
|
||||||
|
: filter === 'exotic' ? Boolean(item.isExotic) : true
|
||||||
|
).map((weapon) => ({
|
||||||
|
...weapon,
|
||||||
|
native_tier: Number(weapon.nativeTier),
|
||||||
|
damage_type: weapon.damageType,
|
||||||
|
is_exotic: weapon.isExotic ? 1 : 0,
|
||||||
|
reload_speed: weapon.reloadSpeed,
|
||||||
|
baseBonuses: [],
|
||||||
|
tiers: [],
|
||||||
|
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
|
||||||
|
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
|
||||||
|
augmentEffects: [],
|
||||||
|
exoticTalent: null
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
weapons: weapons.map((weapon) => {
|
weapons: [...pendingWeapons, ...weapons.map((weapon) => {
|
||||||
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
|
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
|
||||||
const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId);
|
const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId);
|
||||||
const tier = (name: string) => talentRows.find((item) => item.tier === name);
|
const tier = (name: string) => talentRows.find((item) => item.tier === name);
|
||||||
@@ -100,12 +125,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? ''
|
valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? ''
|
||||||
}))
|
}))
|
||||||
} : null
|
} : null
|
||||||
}}),
|
}})],
|
||||||
talent1Options,
|
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options],
|
||||||
talent2Options,
|
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options],
|
||||||
stats,
|
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
|
||||||
triggers,
|
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
|
||||||
summary,
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
|
||||||
pagination,
|
pagination,
|
||||||
filter,
|
filter,
|
||||||
requestedWeaponId
|
requestedWeaponId
|
||||||
|
|||||||
@@ -162,6 +162,6 @@
|
|||||||
<div class="dashboard-table weapon-dashboard-table">
|
<div class="dashboard-table weapon-dashboard-table">
|
||||||
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
|
||||||
{#each data.weapons as weapon}
|
{#each data.weapons as weapon}
|
||||||
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1}><div><strong>{weapon.name}</strong><small class="weapon-damage-label"><GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED</span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class="state-dot">{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(weapon)}>EDIT</button></div>
|
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED</span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)}>EDIT</button>{/if}</div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user