feat(isac): support deferred item icons
This commit is contained in:
@@ -1222,3 +1222,19 @@ Use explicit textual tier capacity wherever Gear tier is summarized: `1 MOD
|
|||||||
SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity band
|
SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity band
|
||||||
and preview controls stack, while repeated quality data may scroll rather than
|
and preview controls stack, while repeated quality data may scroll rather than
|
||||||
compressing numeric controls below usable sizes.
|
compressing numeric controls below usable sizes.
|
||||||
|
|
||||||
|
### Deferred game-data icons
|
||||||
|
|
||||||
|
Game-data icons are optional enrichment, never a blocker for creating or
|
||||||
|
proposing a canonical item. Upload fields must state that an icon may be
|
||||||
|
contributed later and show the shared `/missing-item-icon.svg` artwork when no
|
||||||
|
upload exists. Registries, relationship rails, and builder selection controls
|
||||||
|
use the same placeholder instead of initials, empty boxes, or ad hoc glyphs.
|
||||||
|
|
||||||
|
Treat the placeholder as an explicit incomplete-data state: keep its subdued
|
||||||
|
SHD styling and useful alternative text where the surrounding item name is not
|
||||||
|
already available. Do not persist placeholder metadata or copy the placeholder
|
||||||
|
into per-item storage. A later contributor replaces it through the normal icon
|
||||||
|
upload proposal, preserving the canonical record and audit history. The upload
|
||||||
|
itself retains all transparent-format, size, SVG-safety, focus, drag/drop, and
|
||||||
|
paste validation requirements.
|
||||||
|
|||||||
@@ -313,3 +313,11 @@ served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable
|
|||||||
cache headers and MIME sniffing disabled. ISAC save actions process files
|
cache headers and MIME sniffing disabled. ISAC save actions process files
|
||||||
before mutation, apply upload or removal after record validation, and include
|
before mutation, apply upload or removal after record validation, and include
|
||||||
only safe icon metadata—not image bytes—in their audit snapshots.
|
only safe icon metadata—not image bytes—in their audit snapshots.
|
||||||
|
|
||||||
|
Icon metadata is optional. Canonical records and contributor proposals may be
|
||||||
|
created without an upload, allowing values and artwork to be contributed in
|
||||||
|
separate dependency-safe changes. Missing artwork is represented at render
|
||||||
|
time by the static `/missing-item-icon.svg` asset through the shared
|
||||||
|
`ItemIcon` component; the placeholder is never inserted into
|
||||||
|
`game_data_icons`. A later edit or proposal can attach the real icon without
|
||||||
|
recreating or replacing the canonical item.
|
||||||
|
|||||||
@@ -196,4 +196,5 @@
|
|||||||
.weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:7px;letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:5px;letter-spacing:.35px}
|
.weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:7px;letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:5px;letter-spacing:.35px}
|
||||||
.weapon-mod-exotic-toggle>span{width:44px;min-height:42px;padding:0}.weapon-mod-exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.weapon-mod-exotic-toggle .game-icon{width:30px;height:30px}.weapon-mod-compatibility .segmented-choice>div{height:48px}.weapon-mod-compatibility .segmented-choice .game-icon{width:34px;height:34px}.weapon-mod-exotic-section{margin-top:10px;padding:9px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#0d1011}.weapon-mod-exotic-attributes{display:grid;gap:6px;margin-top:7px}.weapon-mod-exotic-attributes>div{display:grid;grid-template-columns:30px minmax(240px,1fr) 150px 150px 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-exotic-attributes>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--quality-high-end);font-size:8px}.weapon-mod-exotic-attributes label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-exotic-attributes input{height:32px;margin:0;padding:0 6px}.weapon-mod-exotic-attributes>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-exotic-attributes>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:700px){.weapon-mod-exotic-attributes>div{grid-template-columns:30px 1fr}.weapon-mod-exotic-attributes label,.weapon-mod-exotic-attributes>div>button{grid-column:2}.weapon-mod-compatibility .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.weapon-mod-compatibility .segmented-choice span{min-height:50px}}
|
.weapon-mod-exotic-toggle>span{width:44px;min-height:42px;padding:0}.weapon-mod-exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.weapon-mod-exotic-toggle .game-icon{width:30px;height:30px}.weapon-mod-compatibility .segmented-choice>div{height:48px}.weapon-mod-compatibility .segmented-choice .game-icon{width:34px;height:34px}.weapon-mod-exotic-section{margin-top:10px;padding:9px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#0d1011}.weapon-mod-exotic-attributes{display:grid;gap:6px;margin-top:7px}.weapon-mod-exotic-attributes>div{display:grid;grid-template-columns:30px minmax(240px,1fr) 150px 150px 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-exotic-attributes>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--quality-high-end);font-size:8px}.weapon-mod-exotic-attributes label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-exotic-attributes input{height:32px;margin:0;padding:0 6px}.weapon-mod-exotic-attributes>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-exotic-attributes>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:700px){.weapon-mod-exotic-attributes>div{grid-template-columns:30px 1fr}.weapon-mod-exotic-attributes label,.weapon-mod-exotic-attributes>div>button{grid-column:2}.weapon-mod-compatibility .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.weapon-mod-compatibility .segmented-choice span{min-height:50px}}
|
||||||
.gear-mod-identity{display:grid;grid-template-columns:minmax(320px,1fr) minmax(560px,1.5fr);align-items:end;gap:8px}.gear-mod-identity>.field{margin:0}.gear-mod-primary-types>div{flex-wrap:wrap}.gear-mod-row{min-width:1120px;grid-template-columns:1.2fr 1fr .9fr 1fr .55fr .55fr .3fr}.gear-mod-row>span{display:flex;align-items:center;gap:6px}.gear-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:900px){.gear-mod-identity{grid-template-columns:1fr}}@media(max-width:700px){.gear-mod-primary-types>div{display:grid;grid-template-columns:1fr 1fr}}
|
.gear-mod-identity{display:grid;grid-template-columns:minmax(320px,1fr) minmax(560px,1.5fr);align-items:end;gap:8px}.gear-mod-identity>.field{margin:0}.gear-mod-primary-types>div{flex-wrap:wrap}.gear-mod-row{min-width:1120px;grid-template-columns:1.2fr 1fr .9fr 1fr .55fr .55fr .3fr}.gear-mod-row>span{display:flex;align-items:center;gap:6px}.gear-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:900px){.gear-mod-identity{grid-template-columns:1fr}}@media(max-width:700px){.gear-mod-primary-types>div{display:grid;grid-template-columns:1fr 1fr}}
|
||||||
|
.missing-item-icon{opacity:.7;filter:saturate(.75)}.icon-upload-preview .missing-item-icon{width:66px;height:66px;opacity:.62}.registry-icon-identity>.missing-item-icon,.registry-icon-rail .missing-item-icon{padding:2px;border:1px dashed #424849;background:#101314}.builder-picker .missing-item-icon,.builder-radio-grid .missing-item-icon{opacity:.66}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import ItemIcon from './ItemIcon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
<div class="builder-picker">
|
<div class="builder-picker">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||||
{#if selected?.iconUrl}<img src={selected.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
|
<ItemIcon src={selected?.iconUrl} alt=""/>
|
||||||
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
|
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
|
||||||
{#each results as item}
|
{#each results as item}
|
||||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||||
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||||
</button>
|
</button>
|
||||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import ItemIcon from './ItemIcon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
label,
|
label,
|
||||||
name,
|
name,
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
<label title={item.name}>
|
<label title={item.name}>
|
||||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||||
<span>
|
<span>
|
||||||
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
<b>{item.name}</b>
|
<b>{item.name}</b>
|
||||||
{#if item.category}<small>{item.category}</small>{/if}
|
{#if item.category}<small>{item.category}</small>{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<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} {resetToken} emptyLabel="NO ICON" browseLabel={currentUrl ? 'REPLACE ICON' : 'CHOOSE ICON'}/>
|
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD 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>OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
accept,
|
accept,
|
||||||
required = false,
|
required = false,
|
||||||
currentUrl = '',
|
currentUrl = '',
|
||||||
|
placeholderUrl = '',
|
||||||
emptyLabel = 'NO IMAGE',
|
emptyLabel = 'NO IMAGE',
|
||||||
browseLabel = 'CHOOSE FILE',
|
browseLabel = 'CHOOSE FILE',
|
||||||
resetToken = 0
|
resetToken = 0
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
accept: string;
|
accept: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
currentUrl?: string | null;
|
currentUrl?: string | null;
|
||||||
|
placeholderUrl?: string | null;
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
browseLabel?: string;
|
browseLabel?: string;
|
||||||
resetToken?: string | number;
|
resetToken?: string | number;
|
||||||
@@ -120,8 +122,8 @@
|
|||||||
>
|
>
|
||||||
<input bind:this={input} class="image-file-input" {name} type="file" {accept} {required} onchange={choose}/>
|
<input bind:this={input} class="image-file-input" {name} type="file" {accept} {required} onchange={choose}/>
|
||||||
<div class="image-upload-preview">
|
<div class="image-upload-preview">
|
||||||
{#if previewUrl || currentUrl}
|
{#if previewUrl || currentUrl || placeholderUrl}
|
||||||
<img src={previewUrl || currentUrl || ''} alt="Selected upload preview"/>
|
<img class:missing-item-icon={!previewUrl && !currentUrl && Boolean(placeholderUrl)} src={previewUrl || currentUrl || placeholderUrl || ''} alt={previewUrl || currentUrl ? 'Selected upload preview' : emptyLabel}/>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{emptyLabel}</span>
|
<span>{emptyLabel}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
src = '',
|
||||||
|
alt = '',
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
src?: string | null;
|
||||||
|
alt?: string;
|
||||||
|
className?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img src={src || '/missing-item-icon.svg'} {alt} class:missing-item-icon={!src} class={className}/>
|
||||||
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
const [gear, weapons, chips, protocols, brands] = await Promise.all([
|
const [gear, weapons, chips, protocols, brands] = await Promise.all([
|
||||||
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
||||||
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
|
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
|
||||||
(await database.prepare('SELECT id,name,quality FROM skill_chip_sets')).all(),
|
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||||
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
|
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
|
||||||
(await database.prepare('SELECT id,name FROM brand_sets')).all()
|
(await database.prepare('SELECT id,name FROM brand_sets')).all()
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
||||||
{#each data.brands as brand}
|
{#each data.brands as brand}
|
||||||
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending}><div class="registry-icon-identity">{#if brand.iconUrl}<img src={brand.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button onclick={() => edit(brand)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending}><div class="registry-icon-identity"><ItemIcon src={brand.iconUrl} alt=""/><div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button onclick={() => edit(brand)}>EDIT</button>{/if}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<span>03</span>
|
<span>03</span>
|
||||||
<div><h3>APPROPRIATE IMAGES</h3><p>Upload only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, or SVG assets that remain legible at builder-icon sizes.</p></div>
|
<div><h3>APPROPRIATE IMAGES</h3><p>Icons are optional when contributing item data. If you do not have appropriate artwork, save the item without one; SHD.build will use a missing-icon placeholder and another contributor can add the icon later. When uploading, use only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, AVIF, or SVG assets that remain legible at builder-icon sizes.</p></div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<span>04</span>
|
<span>04</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
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 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[] };
|
||||||
@@ -135,6 +136,6 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><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}><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}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</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 onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><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 onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||||
{: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>
|
||||||
|
|||||||
@@ -6,6 +6,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 IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -166,6 +167,6 @@
|
|||||||
<div class="dashboard-table protocol-dashboard-table">
|
<div class="dashboard-table protocol-dashboard-table">
|
||||||
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
||||||
{#each data.protocols as protocol}
|
{#each data.protocols as protocol}
|
||||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending}><div class="registry-icon-identity">{#if protocol.iconUrl}<img src={protocol.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
@@ -106,6 +107,6 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
|
||||||
{#each data.sets as item}
|
{#each data.sets as item}
|
||||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -98,4 +99,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">Missing item icon</title>
|
||||||
|
<desc id="description">SHD placeholder used while an item icon awaits contribution.</desc>
|
||||||
|
<g fill="none" stroke="#596164">
|
||||||
|
<path stroke-width="5" stroke-dasharray="13 10" d="M22 22h212v212H22z"/>
|
||||||
|
<path stroke-width="4" d="m128 50 78 78-78 78-78-78z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#f6a821" stroke-linecap="square">
|
||||||
|
<path stroke-width="10" d="M88 88h80v80H88z"/>
|
||||||
|
<path stroke-width="12" d="m78 178 100-100"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#f6a821" d="M120 115h16v16h-16zm0 28h16v16h-16z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 667 B |
Reference in New Issue
Block a user