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; 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 client-side reactive handling must consume each token only once to avoid
repeated state updates. 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. 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 column, and stack both controls at mobile widths. Do not enlarge an upload
drop-zone to fill spare identity-row space. 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: 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 46 pixels tall with larger, heavier text. This creates a consistent first
reading point for long registries while keeping secondary metadata compact. 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'; import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
let { let {
currentUrl = '', currentUrl = '',
label = 'ICON' label = 'ICON',
}: { currentUrl?: string | null; label?: string } = $props(); resetToken = 0
}: { currentUrl?: string | null; label?: string; resetToken?: string | number } = $props();
</script> </script>
<fieldset class="icon-upload-field"> <fieldset class="icon-upload-field">
<legend>{label}</legend> <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} {#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> <small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
</fieldset> </fieldset>
+27 -4
View File
@@ -7,7 +7,8 @@
required = false, required = false,
currentUrl = '', currentUrl = '',
emptyLabel = 'NO IMAGE', emptyLabel = 'NO IMAGE',
browseLabel = 'CHOOSE FILE' browseLabel = 'CHOOSE FILE',
resetToken = 0
}: { }: {
name: string; name: string;
accept: string; accept: string;
@@ -15,16 +16,38 @@
currentUrl?: string | null; currentUrl?: string | null;
emptyLabel?: string; emptyLabel?: string;
browseLabel?: string; browseLabel?: string;
resetToken?: string | number;
} = $props(); } = $props();
let input: HTMLInputElement; let input: HTMLInputElement | undefined;
let previewUrl = $state(''); let previewUrl = $state('');
let fileName = $state(''); let fileName = $state('');
let dragging = $state(false); let dragging = $state(false);
let error = $state(''); 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) { function setFile(file: File | undefined) {
if (!file) return; if (!file || !input) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
error = 'Clipboard or dropped content must be an image.'; error = 'Clipboard or dropped content must be an image.';
return; return;
@@ -108,7 +131,7 @@
<span>{fileName || 'OR USE AN UPLOAD METHOD'}</span> <span>{fileName || 'OR USE AN UPLOAD METHOD'}</span>
</div> </div>
<div class="image-upload-actions"> <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> <button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); pasteFromClipboard(); }}>PASTE IMAGE</button>
</div> </div>
</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 })) }); 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 editing = $state<Editor | null>(null);
let handledResultToken = $state(''); 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 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 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'}`; }; 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) { if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
if (form.brandMode === 'add') iconResetToken += 1;
editing = form.brandMode === 'add' ? blank() : null; editing = form.brandMode === 'add' ? blank() : null;
} }
}); });
@@ -36,7 +38,7 @@
<PendingForm action="?/save" enctype="multipart/form-data"> <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="brand-editor-body"> <div class="brand-editor-body">
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON"/> <IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
<div> <div>
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label> <label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor"> <div class="effect-editor">
+3 -1
View File
@@ -25,6 +25,7 @@
let talentUpgrade = $state(0); let talentUpgrade = $state(0);
let protocolLevel = $state(1); let protocolLevel = $state(1);
let handledResultToken = $state(''); let handledResultToken = $state('');
let iconResetToken = $state(0);
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`; const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName; const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) })); const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
@@ -80,6 +81,7 @@
} }
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) { if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
if (form.protocolMode === 'add') iconResetToken += 1;
editing = form.protocolMode === 'add' ? blank() : null; editing = form.protocolMode === 'add' ? blank() : null;
talentUpgrade = 0; talentUpgrade = 0;
protocolLevel = 1; protocolLevel = 1;
@@ -132,7 +134,7 @@
<input type="hidden" name="id" value={editing.id}/> <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 })))} /> <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="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"> <div class="protocol-classification-stack">
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/> <SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/> <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 editing = $state<any | null>(null);
let highEndCount = $state('0'); let highEndCount = $state('0');
let handledResultToken = $state(''); let handledResultToken = $state('');
let iconResetToken = $state(0);
const qualitySteps = [ const qualitySteps = [
{ value: '0', label: '0 HIGH-END' }, { value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' }, { value: '1', label: '1 HIGH-END' },
@@ -51,6 +52,7 @@
$effect(() => { $effect(() => {
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) { if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
if (form.skillChipMode === 'add') iconResetToken += 1;
editing = form.skillChipMode === 'add' ? blank() : null; editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0'; highEndCount = '0';
} }
@@ -73,7 +75,7 @@
<PendingForm action="?/save" enctype="multipart/form-data"> <PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/> <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 })))}/> <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"> <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> <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 }; type Effect = { statId: string; valueI: string; valueII: string };
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false); let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
let handledResultToken = $state(''); 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 blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const editTalent = (row: any) => { const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] }; const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
@@ -49,7 +50,10 @@
$effect(() => { $effect(() => {
if (!form?.resultToken || form.resultToken === handledResultToken) return; if (!form?.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken; 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.triggerSaved) {
if (form.triggerMode === 'add') blankTrigger(); if (form.triggerMode === 'add') blankTrigger();
else trigger = null; 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> <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"> <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)}/> <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> <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> <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> <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 }] }); 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 editing = $state<any | null>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false); let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => { const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => ({ ...blankTier(tier), ...(weapon.tiers.find((item: any) => Number(item.tier) === tier) ?? {}) })); 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) { if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null; editing = form.weaponMode === 'add' ? blank() : null;
} }
}); });
@@ -93,7 +95,7 @@
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/> <input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
<section class="weapon-exotic-talent"> <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="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="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 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> <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>