Reset ISAC icon uploads after additions

This commit is contained in:
JB
2026-07-25 16:29:49 -04:00
parent 0a547af9fe
commit 10ab65e0b5
9 changed files with 59 additions and 13 deletions
+4
View File
@@ -62,6 +62,10 @@ All ISAC data-management editors must follow the same successful-save behavior:
4. Server actions must return the add/edit mode and a unique result token;
client-side reactive handling must consume each token only once to avoid
repeated state updates.
5. When a successful add keeps an icon-enabled editor open, increment the
shared upload reset token so the native file input, generated preview,
filename, drag/paste state, validation message, and removal state are fresh
for the next record.
Apply this convention whenever an ISAC editor is added or materially changed.
+6
View File
@@ -772,6 +772,12 @@ fixed column to `IconUploadField`, give the record name the flexible dominant
column, and stack both controls at mobile widths. Do not enlarge an upload
drop-zone to fill spare identity-row space.
Icon-enabled consecutive-entry editors pass a successful-add result token into
the shared upload control. Advancing that token clears the native file input,
temporary preview URL, filename, drag/paste feedback, and validation state.
This reset occurs only after a validated add succeeds; failed saves preserve
the selected image, and successful edits close the editor normally.
The primary `name` field in every ISAC data editor is a header-level input:
46 pixels tall with larger, heavier text. This creates a consistent first
reading point for long registries while keeping secondary metadata compact.
+4 -3
View File
@@ -2,13 +2,14 @@
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
let {
currentUrl = '',
label = 'ICON'
}: { currentUrl?: string | null; label?: string } = $props();
label = 'ICON',
resetToken = 0
}: { currentUrl?: string | null; label?: string; resetToken?: string | number } = $props();
</script>
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} emptyLabel="NO ICON" browseLabel={currentUrl ? 'REPLACE ICON' : 'CHOOSE ICON'}/>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} {resetToken} emptyLabel="NO ICON" browseLabel={currentUrl ? 'REPLACE ICON' : 'CHOOSE ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
</fieldset>
+27 -4
View File
@@ -7,7 +7,8 @@
required = false,
currentUrl = '',
emptyLabel = 'NO IMAGE',
browseLabel = 'CHOOSE FILE'
browseLabel = 'CHOOSE FILE',
resetToken = 0
}: {
name: string;
accept: string;
@@ -15,16 +16,38 @@
currentUrl?: string | null;
emptyLabel?: string;
browseLabel?: string;
resetToken?: string | number;
} = $props();
let input: HTMLInputElement;
let input: HTMLInputElement | undefined;
let previewUrl = $state('');
let fileName = $state('');
let dragging = $state(false);
let error = $state('');
let handledResetToken: string | number | null = null;
function resetUpload() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = '';
fileName = '';
dragging = false;
error = '';
if (input) input.value = '';
}
$effect(() => {
if (handledResetToken === null) {
handledResetToken = resetToken;
return;
}
if (resetToken !== handledResetToken) {
handledResetToken = resetToken;
resetUpload();
}
});
function setFile(file: File | undefined) {
if (!file) return;
if (!file || !input) return;
if (!file.type.startsWith('image/')) {
error = 'Clipboard or dropped content must be an image.';
return;
@@ -108,7 +131,7 @@
<span>{fileName || 'OR USE AN UPLOAD METHOD'}</span>
</div>
<div class="image-upload-actions">
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); input.click(); }}>{browseLabel}</button>
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); input?.click(); }}>{browseLabel}</button>
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); pasteFromClipboard(); }}>PASTE IMAGE</button>
</div>
</div>
+3 -1
View File
@@ -10,6 +10,7 @@
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
let editing = $state<Editor | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
@@ -22,6 +23,7 @@
}
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.brandMode === 'add') iconResetToken += 1;
editing = form.brandMode === 'add' ? blank() : null;
}
});
@@ -36,7 +38,7 @@
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id} />
<div class="brand-editor-body">
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON"/>
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
<div>
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
+3 -1
View File
@@ -25,6 +25,7 @@
let talentUpgrade = $state(0);
let protocolLevel = $state(1);
let handledResultToken = $state('');
let iconResetToken = $state(0);
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
@@ -80,6 +81,7 @@
}
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.protocolMode === 'add') iconResetToken += 1;
editing = form.protocolMode === 'add' ? blank() : null;
talentUpgrade = 0;
protocolLevel = 1;
@@ -132,7 +134,7 @@
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON"/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
+3 -1
View File
@@ -13,6 +13,7 @@
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
let iconResetToken = $state(0);
const qualitySteps = [
{ value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' },
@@ -51,6 +52,7 @@
$effect(() => {
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.skillChipMode === 'add') iconResetToken += 1;
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
@@ -73,7 +75,7 @@
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON"/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<section class="skill-chip-bonus">
<div class="selection-head"><div><span>2-PIECE BONUS</span><small>MINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END</small></div><b>{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}</b></div>
+6 -2
View File
@@ -8,6 +8,7 @@
type Effect = { statId: string; valueI: string; valueII: string };
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
let handledResultToken = $state('');
let iconResetToken = $state(0);
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
@@ -49,7 +50,10 @@
$effect(() => {
if (!form?.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
if (form.talentSaved) talent = form.talentMode === 'add' ? blankTalent() : null;
if (form.talentSaved) {
if (form.talentMode === 'add') iconResetToken += 1;
talent = form.talentMode === 'add' ? blankTalent() : null;
}
if (form.triggerSaved) {
if (form.triggerMode === 'add') blankTrigger();
else trigger = null;
@@ -70,7 +74,7 @@
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON"/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div></div>
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div></div>
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<p class="label">SHARED ACTIVATION TRIGGERS</p>
+3 -1
View File
@@ -16,6 +16,7 @@
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: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => ({ ...blankTier(tier), ...(weapon.tiers.find((item: any) => Number(item.tier) === tier) ?? {}) }));
@@ -67,6 +68,7 @@
}
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
}
});
@@ -93,7 +95,7 @@
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
<section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON"/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div></div>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
<div class="weapon-exotic-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>