feat(isac): add searchable gear assignments
This commit is contained in:
+10
-1
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user