feat(isac): add searchable gear assignments

This commit is contained in:
JB
2026-07-25 14:31:42 -04:00
parent e30d4e4598
commit ff7f3a6d34
4 changed files with 29 additions and 14 deletions
+10 -1
View File
@@ -603,12 +603,21 @@ active state.
Pending records may be used as dependencies in later proposals by any
contributor. Render these choices in the ordinary assignment control with a
restrained orange border/background and the explicit text `PENDING` plus the
proposer's display name; never present them as live data. Approval is dependency
proposer’s display name; never present them as live data. Approval is dependency
ordered: a dependent proposal stays pending until every referenced proposal is
approved, while rejected or failed dependencies block it. This state must be
conveyed by text as well as color and must remain legible in compact and
responsive editor layouts.
Large many-to-many selections use the shared searchable assignment control,
including weapon talent pools, gear talent compatibility, and compatible brand
sets. The control keeps assigned records visible as removable chips, filters
available records by name, limits the rendered result set, and submits canonical
hidden inputs. Configure its subject labels and empty-state copy for the entity
being assigned rather than embedding talent-specific language in the shared
component. Pending contributor records retain their orange treatment and named
proposer in both search results and dependency workflows.
Audit History may use a five-column KPI filter rail when all durable operational
domains need first-class visibility. It retains the standard compact KPI height
and collapses through the shared dashboard breakpoints; do not increase card
+1
View File
@@ -139,4 +139,5 @@
@media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}}
.mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}}
.avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}}
.assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.55px}
+15 -7
View File
@@ -1,17 +1,25 @@
<script lang="ts">
type Item = { id: string; name: string };
type Item = { id: string; name: string; pending?: boolean; proposerName?: string };
let {
items,
selected = $bindable<string[]>([]),
excludedIds = [],
fieldName,
emptyLabel = 'NO MATCHING RECORDS'
emptyLabel = 'NO MATCHING RECORDS',
subjectLabel = 'TALENTS',
searchLabel = `FILTER AVAILABLE ${subjectLabel}`,
searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`,
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`
}: {
items: Item[];
selected?: string[];
excludedIds?: string[];
fieldName: string;
emptyLabel?: string;
subjectLabel?: string;
searchLabel?: string;
searchPlaceholder?: string;
emptySelectionLabel?: string;
} = $props();
let query = $state('');
const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase());
@@ -32,18 +40,18 @@
<div class="assignment-summary"><span>ASSIGNED</span><b>{selected.length}</b></div>
<div class="assignment-chips">
{#each selectedItems as item}
<button type="button" onclick={() => toggle(item.id)} aria-label={`Remove ${item.name}`}><span>{item.name}</span><b>×</b></button>
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${item.name}`}><span>{item.name}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b></button>
{:else}
<small>NO TALENTS ASSIGNED</small>
<small>{emptySelectionLabel}</small>
{/each}
</div>
</div>
<label class="assignment-search"><span>FILTER AVAILABLE TALENTS</span><input type="search" bind:value={query} placeholder="Search by talent name…" autocomplete="off"/></label>
<label class="assignment-search"><span>{searchLabel}</span><input type="search" bind:value={query} placeholder={searchPlaceholder} autocomplete="off"/></label>
<div class="assignment-results-head"><span>{available.length} MATCHES</span>{#if available.length > visible.length}<b>SHOWING FIRST {visible.length}</b>{/if}</div>
<div class="assignment-results" role="list">
{#each visible as item}
<button type="button" class:selected={selected.includes(item.id)} aria-pressed={selected.includes(item.id)} onclick={() => toggle(item.id)}>
<span>{item.name}</span><b>{selected.includes(item.id) ? 'ASSIGNED' : 'ADD +'}</b>
<button type="button" class:selected={selected.includes(item.id)} class:pending-reference={item.pending} aria-pressed={selected.includes(item.id)} onclick={() => toggle(item.id)}>
<span>{item.name}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selected.includes(item.id) ? 'ASSIGNED' : 'ADD +'}</b>
</button>
{:else}
<p>{emptyLabel}</p>
+3 -6
View File
@@ -2,6 +2,7 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props();
type Editor = { id: string; name: string; slot: string; tier: number; level: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
@@ -40,14 +41,10 @@
<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>
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
<div class="selection-grid">
{#each data.brands as brand}<label class:pending-reference={brand.pending}><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{brand.pending ? `PENDING · ${brand.proposerName}` : editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
</div>
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
<div class="selection-grid talent-selection">
{#each gearTalents(editing.slot) as talent}<label class:pending-reference={talent.pending}><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{talent.pending ? `PENDING · ${talent.proposerName}` : editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
</div>
<SearchableAssignment items={gearTalents(editing.slot)} fieldName="talentIds" subjectLabel={`${editing.slot.toUpperCase()} TALENTS`} searchPlaceholder={`Search ${editing.slot.toLocaleLowerCase()} talents…`} emptySelectionLabel="NO TALENTS ASSIGNED" bind:selected={editing.talentIds}/>
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
{/if}
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}