feat: add gear and weapon renders
Validate and deploy production / validate (push) Successful in 21s
Validate and deploy staging / validate (push) Successful in 25s
Validate and deploy production / deploy (push) Successful in 1m17s
Validate and deploy staging / deploy (push) Successful in 35s

This commit is contained in:
jb
2026-07-31 22:39:00 -04:00
parent 3acc08c81d
commit aaf9e0ce97
10 changed files with 171 additions and 25 deletions
+14
View File
@@ -1047,6 +1047,20 @@ full-width band. Keep destructive archive controls isolated on the left side of
the action bar and use the established `.moderation-action.danger` treatment; the action bar and use the established `.moderation-action.danger` treatment;
save and cancel remain grouped on the right. save and cancel remain grouped on the right.
### Weapon and gear renders
Weapon and gear editors offer an optional item-render upload separate from the
icon system. Operators may save a definition without artwork, then add, replace,
or remove the render in a later edit. The upload accepts JPEG, PNG, WebP, and
AVIF up to 5 MiB. The server center-crops each upload to 1000 × 1000 and stores
it as WebP. It does not call remove.bg, trim the scene, or add transparency.
Show the current square render in the upload control and as a compact square
thumbnail before the item name in ISAC registry rows. Keep the image decorative
when the adjacent text identifies the item. The control states the crop and
format contract so contributors do not confuse a render with a transparent
game-data icon.
Archived records that support restoration replace their destructive archive Archived records that support restoration replace their destructive archive
control with a green outlined `.moderation-action.restore` control in the same control with a green outlined `.moderation-action.restore` control in the same
left-side position. The label must explicitly say “Unarchive” or “Restore”; left-side position. The label must explicitly say “Unarchive” or “Restore”;
+4 -1
View File
@@ -22,10 +22,13 @@ for (const image of bundle.images) {
|| typeof image.contentBase64 !== 'string') { || typeof image.contentBase64 !== 'string') {
throw new Error('The bundle contains an invalid managed image.'); throw new Error('The bundle contains an invalid managed image.');
} }
const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type);
if (image.row.source_entity_type && ( if (image.row.source_entity_type && (
typeof image.row.source_entity_id !== 'string' typeof image.row.source_entity_id !== 'string'
|| !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) || !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id)
|| !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`) || (isItemRender
? !image.row.storage_key.startsWith('library-')
: !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`))
)) throw new Error('The bundle contains an invalid linked ISAC image.'); )) throw new Error('The bundle contains an invalid linked ISAC image.');
} }
+2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
<script lang="ts">
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
let {
currentUrl = '',
label = 'ITEM RENDER',
resetToken = 0
}: { currentUrl?: string | null; label?: string; resetToken?: string | number } = $props();
</script>
<fieldset class="game-data-render-upload">
<legend>{label}</legend>
<ImageUploadInput name="render" accept="image/png,image/webp,image/avif,image/jpeg" {currentUrl} {resetToken} emptyLabel="NO RENDER ASSIGNED" browseLabel={currentUrl ? 'REPLACE RENDER' : 'ADD RENDER'}/>
{#if currentUrl}<label class="render-remove"><input name="removeRender" type="checkbox" value="1"/><span>REMOVE CURRENT RENDER</span></label>{/if}
<small>OPTIONAL · PNG, WEBP, AVIF, OR JPEG · CENTER-CROPPED TO 1000 × 1000 WEBP · NO BACKGROUND REMOVAL</small>
</fieldset>
+16 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import sharp from 'sharp';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })); const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
@@ -8,7 +9,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
})); }));
import { GAME_ICON_TYPES } from './game-data-icons'; import { GAME_ICON_TYPES } from './game-data-icons';
import { ManagedImageDeletionError, deleteManagedImage } from './managed-images'; import { ManagedImageDeletionError, deleteManagedImage, processManagedImage } from './managed-images';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
@@ -137,3 +138,17 @@ describe('deleteManagedImage', () => {
expect(rmMock).not.toHaveBeenCalled(); expect(rmMock).not.toHaveBeenCalled();
}); });
}); });
describe('processManagedImage', () => {
it('center-crops game-data renders to 1000 pixel WebP squares without removing the background', async () => {
const input = await sharp({ create: { width: 1296, height: 1000, channels: 3, background: '#263238' } })
.png()
.toBuffer();
const result = await processManagedImage(new File([input], 'mask.png', { type: 'image/png' }), false, false, true);
const metadata = await sharp(result.output).metadata();
expect(metadata.format).toBe('webp');
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
});
});
+70 -7
View File
@@ -1,4 +1,5 @@
import type { Database } from '@tursodatabase/database'; import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
@@ -10,6 +11,8 @@ export const MANAGED_IMAGE_CATEGORIES = [
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System' 'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System'
] as const; ] as const;
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024; export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
export const GAME_DATA_RENDER_TYPES = ['weapon', 'gear'] as const;
export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
const MAX_PIXELS = 24_000_000; const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i; const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i; const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
@@ -43,7 +46,7 @@ function validateSvg(input: Buffer) {
} }
} }
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) { export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean, square = false) {
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) { if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.'); throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.');
} }
@@ -67,10 +70,12 @@ export async function processManagedImage(file: File, icon: boolean, forceWhite:
if (metadata.hasAlpha) { if (metadata.hasAlpha) {
pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 }); pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
} }
pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, { pipeline = square
fit: 'inside', ? pipeline.resize(1000, 1000, { fit: 'cover', position: 'centre' })
withoutEnlargement: !icon : pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, {
}); fit: 'inside',
withoutEnlargement: !icon
});
if (icon && forceWhite) { if (icon && forceWhite) {
const normalized = await pipeline const normalized = await pipeline
.resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
@@ -104,6 +109,63 @@ export async function saveManagedImageFile(id: string, output: Buffer) {
return key; return key;
} }
export async function readGameDataRenderMutation(form: FormData): Promise<{
file: File | null;
remove: boolean;
processed?: { output: Buffer; width: number; height: number };
}> {
const file = form.get('render');
return {
file: file instanceof File && file.size > 0 ? file : null,
remove: form.get('removeRender') === '1'
};
}
export async function applyGameDataRender(
database: Database,
type: GameDataRenderType,
entityId: string,
entityName: string,
mutation: { file: File | null; remove: boolean; processed?: { output: Buffer; width: number; height: number } },
actorId: string
) {
const sourceType = `${type}_render`;
const existing = await (await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?'
)).get(sourceType, entityId);
if (!mutation.file && !mutation.remove) return existing ?? null;
if (mutation.remove) {
if (!existing) return null;
await (await database.prepare('DELETE FROM managed_images WHERE id=?')).run(existing.id);
if (typeof existing.storage_key === 'string') await rm(storagePath(existing.storage_key), { force: true });
return null;
}
const processed = mutation.processed ?? await processManagedImage(mutation.file!, false, false, true);
const now = new Date().toISOString();
const category = type === 'weapon' ? 'Weapons' : 'Gear';
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
if (existing) {
await writeFile(storagePath(String(existing.storage_key)), processed.output, { mode: 0o600 });
await (await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)).run(
category, tags, mutation.file!.name, processed.output.length, processed.width, processed.height,
actorId, now, existing.id
);
return { ...existing, category, tags_json: tags, byte_size: processed.output.length, width: processed.width, height: processed.height, updated_at: now };
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
await (await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id, name, category, tags, 0, 0, storageKey, mutation.file!.name, processed.output.length,
processed.width, processed.height, sourceType, entityId, actorId, now, now
);
return { id, name, category, tags_json: tags, is_icon: 0, storage_key: storageKey, byte_size: processed.output.length, width: processed.width, height: processed.height, source_entity_type: sourceType, source_entity_id: entityId, updated_at: now };
}
export async function deleteManagedImage(database: Database, id: string) { export async function deleteManagedImage(database: Database, id: string) {
const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id); const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
if (!image) return null; if (!image) return null;
@@ -114,11 +176,12 @@ export async function deleteManagedImage(database: Database, id: string) {
if (Boolean(sourceType) !== Boolean(sourceId)) { if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.'); throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.');
} }
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) { if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
&& !GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)) {
throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.'); throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.');
} }
const sourceIcon = sourceType && sourceId const sourceIcon = sourceType && sourceId && GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null ? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null
: null; : null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) { if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
+19 -5
View File
@@ -8,6 +8,7 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getPendingEntities } from '$lib/server/data-moderation'; import { getPendingEntities } from '$lib/server/data-moderation';
import { gameDataIconUrl } from '$lib/server/game-data-icons'; import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
@@ -76,7 +77,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : []; const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length); const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length); const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons] = await Promise.all([ const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons, renders] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [], databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, 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(),
@@ -86,7 +87,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(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(),
(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(),
(await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'")).all()
]); ]);
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand'); const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
const pendingTalents = pendingDefinitions.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)));
@@ -116,7 +118,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})), ...gear.map((item) => ({ })), ...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)),
renderUrl: (() => {
const render = renders.find((image) => image.source_entity_id === item.id);
return render ? managedImageUrl(String(render.id), render.updated_at) : '';
})()
}))], }))],
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);
@@ -209,6 +215,13 @@ export const actions: Actions = {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData(); const form = await request.formData();
const id = String(form.get('id') ?? ''); const id = String(form.get('id') ?? '');
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true);
} catch (error) {
if (error instanceof ManagedImageValidationError) return fail(400, { gearError: error.message });
throw error;
}
const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const slot = String(form.get('slot') ?? ''); const slot = String(form.get('slot') ?? '');
const tier = Number(form.get('tier')); const tier = Number(form.get('tier'));
@@ -241,7 +254,7 @@ export const actions: Actions = {
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` }); if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
} }
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null; const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id), render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?")).get(id) ?? null } : null;
if (id) { if (id) {
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`)) await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
.run(name, slot, tier, ...health, ...armor, now, id); .run(name, slot, tier, ...health, ...armor, now, id);
@@ -253,7 +266,8 @@ export const actions: Actions = {
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId); for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId); await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId);
for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId); for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds } }); const render = await applyGameDataRender(database, 'gear', gearId, name, renderMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds, render } });
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() }; return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}, },
archive: async ({ request, cookies }) => { archive: async ({ request, cookies }) => {
+6 -4
View File
@@ -8,15 +8,16 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import GameIcon from '$lib/components/GameIcon.svelte'; import GameIcon from '$lib/components/GameIcon.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte'; import ItemIcon from '$lib/components/ItemIcon.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
let { data, form } = $props(); let { data, form } = $props();
type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] }; type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[]; renderUrl: string };
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const slotIcon = (slot: string): GameIconName => slot.toLocaleLowerCase().replaceAll(' ', '_') as GameIconName; const slotIcon = (slot: string): GameIconName => slot.toLocaleLowerCase().replaceAll(' ', '_') as GameIconName;
const slotOptions = slots.map((slot) => ({ value: slot, label: slot, icon: slotIcon(slot), iconOnly: true })); const slotOptions = slots.map((slot) => ({ value: slot, label: slot, icon: slotIcon(slot), iconOnly: true }));
const tierOptions = [0, 1, 2].map((tier) => ({ value: String(tier), label: `T${tier}` })); const tierOptions = [0, 1, 2].map((tier) => ({ value: String(tier), label: `T${tier}` }));
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: '0', base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: '0', base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [], renderUrl: '' });
let editing = $state<Editor | null>(null); let editing = $state<Editor | null>(null);
let augmentEditing = $state<any | null>(null); let augmentEditing = $state<any | null>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
@@ -71,13 +72,14 @@
{#if editing} {#if editing}
<div class="dashboard-editor"> <div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div> <div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save"> <PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id} /> <input type="hidden" name="id" value={editing.id} />
<div class="gear-identity"> <div class="gear-identity">
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label> <label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/> <SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} required/> <SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} required/>
</div> </div>
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="GEAR RENDER"/>
<div class="gear-ranges"> <div class="gear-ranges">
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div> <div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div> <div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
@@ -142,7 +144,7 @@
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row gear-row table-heading"><SortableColumnHeader label="GEAR" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="TIER" column="tier" sort={data.sort}/><SortableColumnHeader label="BASE RANGES" column="ranges" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="BRAND SETS" column="brands" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div> <div class="dashboard-row gear-row table-heading"><SortableColumnHeader label="GEAR" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="TIER" column="tier" sort={data.sort}/><SortableColumnHeader label="BASE RANGES" column="ranges" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="BRAND SETS" column="brands" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><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} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><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}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></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}><ItemIcon src={brand.iconUrl} alt={brand.name}/></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 aria-expanded={editing?.id === item.id} aria-controls={`gear-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div> <div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<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></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></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}><ItemIcon src={brand.iconUrl} alt={brand.name}/></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 aria-expanded={editing?.id === item.id} aria-controls={`gear-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`gear-editor-${item.id}`} label={`Edit ${item.name}`}>{@render gearEditor()}</RegistryInlineEditor>{/if} {#if editing?.id === item.id}<RegistryInlineEditor id={`gear-editor-${item.id}`} label={`Edit ${item.name}`}>{@render gearEditor()}</RegistryInlineEditor>{/if}
{: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>
+20 -5
View File
@@ -7,6 +7,7 @@ import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { gameDataIconUrl } from '$lib/server/game-data-icons'; import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation'; import { getPendingEntities } from '$lib/server/data-moderation';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses'; import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots'; import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
@@ -60,7 +61,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])]; const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
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, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, pending] = await Promise.all([ const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, renders, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} 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 * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(), (await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
@@ -89,6 +90,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
ORDER BY talents.name COLLATE NOCASE`)).all(), ORDER BY talents.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,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='talent'")).all(), (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
(await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='weapon_render'")).all(),
getPendingEntities(user.id, [ getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
@@ -146,7 +148,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category) baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category),
renderUrl: (() => {
const render = renders.find((item) => item.source_entity_id === weapon.id);
return render ? managedImageUrl(String(render.id), render.updated_at) : '';
})()
})); }));
return { return {
weapons: [...pendingWeapons, ...configuredWeapons], weapons: [...pendingWeapons, ...configuredWeapons],
@@ -233,6 +239,13 @@ export const actions: Actions = {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData(); const form = await request.formData();
const id = String(form.get('id') ?? ''); const id = String(form.get('id') ?? '');
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true);
} catch (error) {
if (error instanceof ManagedImageValidationError) return fail(400, { weaponError: error.message });
throw error;
}
const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? ''); const category = String(form.get('category') ?? '');
const damageType = String(form.get('damageType') ?? ''); const damageType = String(form.get('damageType') ?? '');
@@ -338,14 +351,15 @@ export const actions: Actions = {
} }
} }
const weaponId = id || randomUUID(); const weaponId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
const before = id ? { const before = id ? {
record: await auditRecord('weapons', id), record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id), tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id), talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id), augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id) modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='weapon_render' AND source_entity_id=?")).get(id) ?? null
} : null; } : null;
await database.exec('BEGIN'); await database.exec('BEGIN');
try { try {
@@ -377,12 +391,13 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
} }
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } });
await database.exec('COMMIT'); await database.exec('COMMIT');
} catch (error) { } catch (error) {
await database.exec('ROLLBACK'); await database.exec('ROLLBACK');
throw error; throw error;
} }
const render = await applyGameDataRender(database, 'weapon', weaponId, name, renderMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, render, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } });
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}, },
archive: async ({ request, cookies }) => { archive: async ({ request, cookies }) => {
+4 -2
View File
@@ -8,6 +8,7 @@
import GameIcon from '$lib/components/GameIcon.svelte'; import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses'; import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { import {
excludedTierZeroWeaponModSlot, excludedTierZeroWeaponModSlot,
@@ -23,7 +24,7 @@
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true })); const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 }); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', active: 1, renderUrl: '', tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null); let editing = $state<any | null>(null);
let bonusEditing = $state<any | null>(null); let bonusEditing = $state<any | null>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
@@ -146,6 +147,7 @@
<SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/> <SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/>
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label> <label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
</div> </div>
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="WEAPON RENDER"/>
{#if Number(editing.is_exotic)} {#if Number(editing.is_exotic)}
<section class="weapon-exotic-talent"> <section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>SELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY</small></div><b>{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}</b></div> <div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>SELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY</small></div><b>{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}</b></div>
@@ -217,7 +219,7 @@
<div class="dashboard-table weapon-dashboard-table"> <div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div> <div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><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} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><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<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></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:config-required={weaponState(weapon) === 'CONFIG REQUIRED'} class="state-dot">{weaponState(weapon)}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div> <div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div class="registry-render-identity">{#if weapon.renderUrl}<img class="registry-render" src={weapon.renderUrl} alt=""/>{/if}<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></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<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></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:config-required={weaponState(weapon) === 'CONFIG REQUIRED'} class="state-dot">{weaponState(weapon)}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === weapon.id} {#if editing?.id === weapon.id}
<RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}> <RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}>
{@render weaponEditor()} {@render weaponEditor()}