docs(spec): inventory search redesign — React full-page view + approved mockup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7096f30566
commit
edf76f50db
3 changed files with 410 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,3 +15,4 @@ frontend/node_modules/
|
|||
# permission-neutral so devs can `claude` interactively here without
|
||||
# inheriting production-agent restrictions.
|
||||
.claude/
|
||||
.superpowers/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
# Inventory Search redesign — React full-page view
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved (layout + behaviors validated interactively in visual-companion mockup)
|
||||
**Mockup:** `2026-07-15-inventory-search-redesign-mockup.html` (same directory — the approved clickable reference)
|
||||
|
||||
## Problem
|
||||
|
||||
The inventory search UI (`static/inventory.html` + `inventory.js`, ~2100 lines of
|
||||
2010s-era dense gray vanilla HTML) is functionally complete (all filters work
|
||||
against the Go `inventory-go` service, including the July 2026 spell filters)
|
||||
but visually dated and inconsistent with the React dashboard. Full redesign
|
||||
requested.
|
||||
|
||||
## Decisions (user-validated)
|
||||
|
||||
1. **Platform:** new full-page view in the existing React app (`frontend/`),
|
||||
reached via `/?view=inventory` — same routing pattern as `?view=dashboard`
|
||||
in `App.tsx`.
|
||||
2. **Layout:** facet sidebar (direction A of three mocked options) + active-
|
||||
filter chips row borrowed from direction B.
|
||||
3. **Search trigger:** instant auto-search, debounced ~400 ms, no Search
|
||||
button; in-flight requests aborted via `AbortController`.
|
||||
4. **Item details:** right-side detail panel opened by row click; ↑/↓ moves
|
||||
selection, Esc closes.
|
||||
5. **Set analysis:** dropped entirely. The new page fully replaces
|
||||
`inventory.html`/`inventory.js` — both deleted at cutover; the "Analyze
|
||||
Sets" feature ceases to exist.
|
||||
6. **Shareable URLs:** filter state serialized to the query string.
|
||||
|
||||
## Layout (per approved mockup)
|
||||
|
||||
- **Top bar:** title, name/material search box (`text` param), Reset (clears
|
||||
all filters), column picker (⚙), result count + query time.
|
||||
- **Chips row:** one removable chip per active filter; legendary-cantrip chips
|
||||
styled gold (#fc6 on #2a2418).
|
||||
- **Facet sidebar** (~210 px, collapsible groups with active-count badges):
|
||||
- **Characters** — searchable checkbox list, All/None/Online links, green
|
||||
online dots (from the `/live` data the app already polls).
|
||||
- **Item type** — radios: All / Armor / Jewelry / Weapons (+subtype) /
|
||||
Clothing (maps to `armor_only`/`jewelry_only`/`weapon_only`+`weapon_type`/
|
||||
`clothing_only`; shirts/pants reachable as clothing subtypes).
|
||||
- **Slots** — jewelry slots (Ring/Bracelet/Neck/Trinket/Cloak) up front,
|
||||
armor slots behind a "show armor slots" expander (`slot_names` CSV).
|
||||
- **Cantrips** — find-as-you-type filter over the legendary cantrip list,
|
||||
grouped (Attributes / Skills / Defense / Banes / Other); checked items pin
|
||||
to a "Selected" section on top; gold styling; plus an "any-tier spell
|
||||
search" text input (`spell_contains`). Checkbox values = the same display
|
||||
names the old page sent (`legendary_cantrips` CSV, AND semantics).
|
||||
- **Ratings** (collapsed) — min inputs for the 4 common ratings, "all 20
|
||||
ratings" expander for the rest (`min_*` params).
|
||||
- **Equipment sets** (collapsed) — searchable set list (`item_set`/`item_sets`).
|
||||
- **Item state** (collapsed) — equipped-only, bonded, attuned, rare
|
||||
(`equipment_status`, `bonded`, `attuned`, `is_rare`).
|
||||
- **Reqs & value** (collapsed) — wield level ≤, value ≥, workmanship ≥,
|
||||
burden ≤ (`max_level`, `min_value`, `min_workmanship`, `max_burden`).
|
||||
- **Results table:** server-side sort via `sort_by`/`sort_dir` (click headers);
|
||||
sticky header; equipped marker (green ⚔); Spells column with legendary names
|
||||
gold; pagination footer (200/page default, `page`/`limit`). Column set
|
||||
togglable via the ⚙ picker, persisted to `localStorage`.
|
||||
- **Detail panel** (~250 px): item name, slot, equipped state, character,
|
||||
value/burden/wield/workmanship/mana, full ordered spell list (legendary
|
||||
gold). Keyboard: ↑/↓/Esc.
|
||||
|
||||
## Implementation shape
|
||||
|
||||
- **No backend changes.** The existing `GET /api/inv/search/items` supports
|
||||
every filter above (validated; the spell filters shipped 2026-07-14).
|
||||
- New directory `frontend/src/components/inventory/`:
|
||||
- `InventorySearchPage.tsx` — page layout, keyboard handling.
|
||||
- `FilterSidebar.tsx` + one small component per filter group.
|
||||
- `ActiveChips.tsx`, `ResultsTable.tsx`, `DetailPanel.tsx`.
|
||||
- `useInventorySearch.ts` — single-reducer filter state, debounce,
|
||||
AbortController fetch, URL (de)serialization.
|
||||
- Filter state ↔ URL: serialize non-default filters into query params on
|
||||
change (replaceState), parse on mount — bookmarkable searches.
|
||||
- Styling: `frontend/src/styles/inventory.css`, `.inv-*` class prefix,
|
||||
dashboard dark tokens (#111 bg, #1a1a1a panels, #333 borders, #88f accent,
|
||||
#fc6 legendary gold, Segoe UI) — mirrors `map-layout.css` conventions.
|
||||
- **No new npm dependencies.**
|
||||
- Cantrip list + grouping: lift the value strings from the old
|
||||
`inventory.html` checkbox grid (they are the strings the backend matches);
|
||||
store as a typed constant module.
|
||||
- Character list: reuse the app's existing live-players data source for names
|
||||
+ online status; fall back to `/api/inv/characters/list` for characters that
|
||||
are offline/never online.
|
||||
|
||||
## Cutover
|
||||
|
||||
Same commit series: add the new view → repoint the dashboard tool-link that
|
||||
currently opens `/inventory.html` to `/?view=inventory` → delete
|
||||
`static/inventory.html` and `static/inventory.js`. `/inventory.html` then 404s
|
||||
to the SPA fallback; acceptable (single-user tool, no external links).
|
||||
|
||||
## Testing & verification
|
||||
|
||||
The frontend has no test framework (build = `tsc -b && vite build`).
|
||||
Verification per task: TypeScript build green + live checks in the Vite dev
|
||||
server against the production API, then post-deploy spot checks of the same
|
||||
scenarios used for the spell-filter validation (Invuln+Summon → 2 items;
|
||||
Ring+Invuln → 56; filterless jewelry → ~4.8k). Adding a test harness is out
|
||||
of scope.
|
||||
|
||||
## Deploy
|
||||
|
||||
`bash deploy-frontend.sh` (builds + copies into `static/`), commit `static/`
|
||||
+ `frontend/` + deletions, push; server picks it up via bind mount, no
|
||||
restart.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Set analysis (dropped per decision 5).
|
||||
- Suitbuilder page (untouched).
|
||||
- React-window (`InventoryWindow.tsx`) per-character inventory — unrelated.
|
||||
- Mobile layout (desktop tool); no test framework introduction.
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Inventory Search — mockup</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body { background:#111; color:#eee; font-family:"Segoe UI",sans-serif; font-size:13px; display:flex; flex-direction:column; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.topbar { display:flex; align-items:center; gap:10px; padding:10px 14px; background:#1a1a1a; border-bottom:2px solid #333; }
|
||||
.topbar .title { color:#88f; font-weight:600; font-size:15px; white-space:nowrap; }
|
||||
.searchbox { flex:1; max-width:520px; background:#222; border:1px solid #444; border-radius:4px; padding:7px 12px; color:#eee; font-size:13px; outline:none; }
|
||||
.searchbox:focus { border-color:#88f; }
|
||||
.searchbox::placeholder { color:#666; }
|
||||
.btn { background:#333; border:1px solid #555; color:#ccc; border-radius:4px; padding:6px 14px; font-size:12px; cursor:pointer; }
|
||||
.btn:hover { background:#444; color:#fff; }
|
||||
.count { margin-left:auto; color:#888; font-size:12px; white-space:nowrap; }
|
||||
.count b { color:#eee; }
|
||||
|
||||
/* ── Chips ── */
|
||||
.chipsrow { display:flex; align-items:center; flex-wrap:wrap; gap:6px; padding:8px 14px; background:#151515; border-bottom:1px solid #2a2a2a; min-height:37px; }
|
||||
.chipsrow .lbl { color:#666; font-size:10px; letter-spacing:1px; }
|
||||
.chip { display:inline-flex; align-items:center; gap:6px; background:#23233a; border:1px solid #55f; border-radius:12px; padding:2px 10px; font-size:11px; color:#bbd; cursor:default; }
|
||||
.chip .x { color:#c66; cursor:pointer; font-weight:bold; }
|
||||
.chip .x:hover { color:#f88; }
|
||||
.chip.gold { border-color:#a80; color:#fc6; background:#2a2418; }
|
||||
|
||||
/* ── Main ── */
|
||||
.main { display:flex; flex:1; overflow:hidden; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar { width:210px; min-width:210px; background:#1a1a1a; border-right:2px solid #333; overflow-y:auto; padding:8px 10px; }
|
||||
.grp { border-bottom:1px solid #262626; padding:6px 0; }
|
||||
.grp-head { display:flex; align-items:center; gap:6px; cursor:pointer; color:#999; text-transform:uppercase; font-size:10px; letter-spacing:1px; padding:3px 0; user-select:none; }
|
||||
.grp-head:hover { color:#ccc; }
|
||||
.grp-head .arrow { font-size:9px; transition:transform .15s; }
|
||||
.grp.open .arrow { transform:rotate(90deg); }
|
||||
.grp-head .badge { margin-left:auto; background:#3a3a6e; color:#cce; border-radius:8px; padding:0 7px; font-size:9px; }
|
||||
.grp-body { display:none; padding:5px 0 3px; }
|
||||
.grp.open .grp-body { display:block; }
|
||||
.minisearch { width:100%; background:#222; border:1px solid #3a3a3a; border-radius:3px; padding:4px 8px; color:#ccc; font-size:11px; outline:none; margin-bottom:5px; }
|
||||
.minisearch::placeholder { color:#555; }
|
||||
.fitem { display:flex; align-items:center; gap:6px; padding:2px 0; font-size:12px; color:#bbb; cursor:pointer; }
|
||||
.fitem:hover { color:#fff; }
|
||||
.fitem input { accent-color:#88f; }
|
||||
.fitem .online { width:6px; height:6px; border-radius:50%; background:#4c4; }
|
||||
.fitem.gold { color:#fc6; }
|
||||
.subhead { color:#666; font-size:9px; text-transform:uppercase; letter-spacing:1px; margin:6px 0 2px; }
|
||||
.linky { color:#88f; font-size:10px; cursor:pointer; }
|
||||
.linky:hover { text-decoration:underline; }
|
||||
.range { display:flex; gap:4px; align-items:center; margin:2px 0; }
|
||||
.range label { flex:1; color:#999; font-size:11px; }
|
||||
.range input { width:52px; background:#222; border:1px solid #3a3a3a; border-radius:3px; color:#ccc; padding:2px 5px; font-size:11px; }
|
||||
|
||||
/* ── Results ── */
|
||||
.results { flex:1; overflow-y:auto; display:flex; flex-direction:column; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
thead { position:sticky; top:0; background:#191919; z-index:1; }
|
||||
th { text-align:left; color:#88f; font-size:11px; font-weight:600; padding:8px 10px; border-bottom:2px solid #333; cursor:pointer; white-space:nowrap; user-select:none; }
|
||||
th:hover { color:#aaf; }
|
||||
th.sorted::after { content:" ▼"; font-size:9px; }
|
||||
td { padding:6px 10px; border-bottom:1px solid #1e1e1e; font-size:12px; color:#bbb; vertical-align:top; }
|
||||
tbody tr { cursor:pointer; }
|
||||
tbody tr:hover td { background:#191922; }
|
||||
tbody tr.sel td { background:#20203a; }
|
||||
td .leg { color:#fc6; }
|
||||
td .spells { color:#889; font-size:11px; }
|
||||
td.num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.equipped { color:#4c4; font-size:10px; }
|
||||
.pager { display:flex; align-items:center; gap:8px; padding:8px 14px; color:#888; font-size:12px; border-top:1px solid #262626; background:#151515; margin-top:auto; }
|
||||
.pager .pg { padding:2px 8px; border:1px solid #333; border-radius:3px; cursor:pointer; }
|
||||
.pager .pg.cur { background:#3a3a6e; border-color:#88f; color:#fff; }
|
||||
|
||||
/* ── Detail panel ── */
|
||||
.detail { width:250px; min-width:250px; background:#161620; border-left:2px solid #333; overflow-y:auto; padding:12px; }
|
||||
.detail h3 { color:#88f; font-size:14px; margin-bottom:2px; }
|
||||
.detail .sub { color:#777; font-size:11px; margin-bottom:10px; }
|
||||
.kv { display:flex; justify-content:space-between; padding:2px 0; font-size:12px; color:#999; }
|
||||
.kv b { color:#ddd; font-weight:normal; text-align:right; }
|
||||
.detail hr { border:none; border-top:1px solid #2a2a35; margin:9px 0; }
|
||||
.detail .sphead { color:#666; font-size:10px; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px; }
|
||||
.sp { padding:2px 0; font-size:12px; color:#bbb; }
|
||||
.sp.leg { color:#fc6; }
|
||||
.detail .hint { color:#555; font-size:10px; margin-top:12px; }
|
||||
.closex { float:right; color:#666; cursor:pointer; font-size:14px; }
|
||||
.closex:hover { color:#f88; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<span class="title">⚔ Inventory Search</span>
|
||||
<input class="searchbox" placeholder="Search name or material… (e.g. 'sapphire ring')">
|
||||
<button class="btn" onclick="document.querySelectorAll('.chip').forEach(c=>c.remove())">Reset</button>
|
||||
<button class="btn">⚙ Columns</button>
|
||||
<span class="count"><b>56</b> items · 24 ms</span>
|
||||
</div>
|
||||
|
||||
<div class="chipsrow">
|
||||
<span class="lbl">ACTIVE</span>
|
||||
<span class="chip">Jewelry <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
<span class="chip">Slot: Ring <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
<span class="chip gold">Legendary Invulnerability <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
<span class="chip gold">Legendary Summoning <span class="x" onclick="this.parentNode.remove()">×</span></span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Characters <span class="badge">All</span></div>
|
||||
<div class="grp-body">
|
||||
<input class="minisearch" placeholder="filter characters…">
|
||||
<div style="margin-bottom:4px"><span class="linky">All</span> · <span class="linky">None</span> · <span class="linky">Online</span></div>
|
||||
<div class="fitem"><input type="checkbox" checked> Bank of Sawato</div>
|
||||
<div class="fitem"><input type="checkbox" checked> Bowgod <span class="online"></span></div>
|
||||
<div class="fitem"><input type="checkbox" checked> Larsson <span class="online"></span></div>
|
||||
<div class="fitem"><input type="checkbox" checked> Mrbow</div>
|
||||
<div class="fitem"><input type="checkbox" checked> Kosmo Kramer <span class="online"></span></div>
|
||||
<div class="fitem" style="color:#555">… 33 more</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Item type <span class="badge">1</span></div>
|
||||
<div class="grp-body">
|
||||
<div class="fitem"><input type="radio" name="t"> All items</div>
|
||||
<div class="fitem"><input type="radio" name="t"> Armor</div>
|
||||
<div class="fitem"><input type="radio" name="t" checked> Jewelry</div>
|
||||
<div class="fitem"><input type="radio" name="t"> Weapons</div>
|
||||
<div class="fitem"><input type="radio" name="t"> Clothing</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Slots <span class="badge">1</span></div>
|
||||
<div class="grp-body">
|
||||
<div class="fitem"><input type="checkbox" checked> Ring</div>
|
||||
<div class="fitem"><input type="checkbox"> Bracelet</div>
|
||||
<div class="fitem"><input type="checkbox"> Neck</div>
|
||||
<div class="fitem"><input type="checkbox"> Trinket</div>
|
||||
<div class="fitem"><input type="checkbox"> Cloak</div>
|
||||
<span class="linky">show armor slots ▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp open">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Cantrips <span class="badge">2</span></div>
|
||||
<div class="grp-body">
|
||||
<input class="minisearch" id="cantripSearch" placeholder="find cantrip… e.g. invuln" oninput="filterCantrips(this.value)">
|
||||
<div class="subhead">Selected</div>
|
||||
<div class="fitem gold"><input type="checkbox" checked> Invulnerability</div>
|
||||
<div class="fitem gold"><input type="checkbox" checked> Summoning</div>
|
||||
<div class="subhead">Attributes</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Strength</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Endurance</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Quickness</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Focus</div>
|
||||
<div class="subhead">Skills</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Heavy Weapon</div>
|
||||
<div class="fitem cf"><input type="checkbox"> War Magic</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Defender</div>
|
||||
<div class="subhead">Defense</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Impenetrability</div>
|
||||
<div class="fitem cf"><input type="checkbox"> Magic Resistance</div>
|
||||
<div class="fitem" style="color:#555">… all 45 in real page</div>
|
||||
<div class="subhead">Any-tier spell search</div>
|
||||
<input class="minisearch" placeholder="spell name contains… e.g. Epic Invuln">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Ratings</div>
|
||||
<div class="grp-body">
|
||||
<div class="range"><label>Dmg rating ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Crit dmg ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Heal boost ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Vitality ≥</label><input placeholder="min"></div>
|
||||
<span class="linky">all 20 ratings ▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Equipment sets</div>
|
||||
<div class="grp-body"><input class="minisearch" placeholder="find set…"></div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Item state</div>
|
||||
<div class="grp-body">
|
||||
<div class="fitem"><input type="checkbox"> Equipped only</div>
|
||||
<div class="fitem"><input type="checkbox"> Bonded</div>
|
||||
<div class="fitem"><input type="checkbox"> Rare</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grp">
|
||||
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow">▶</span> Reqs & value</div>
|
||||
<div class="grp-body">
|
||||
<div class="range"><label>Wield lvl ≤</label><input placeholder="max"></div>
|
||||
<div class="range"><label>Value ≥</label><input placeholder="min"></div>
|
||||
<div class="range"><label>Workmanship ≥</label><input placeholder="min"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Character</th><th>Slot</th><th class="num sorted">Value</th><th>Wield</th><th>Spells / Cantrips</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<span class="pg">◀</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">▶</span>
|
||||
<span style="margin-left:auto">200 / page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail" id="detail"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const items = [
|
||||
{ name:"Gold Ring", char:"Bowgod", slot:"Ring", value:24510, wield:"180", equipped:false, burden:50, work:"—", mana:"142/200",
|
||||
spells:[["Legendary Invulnerability",1],["Legendary Summoning Prowess",1],["Major Heavy Weapon Aptitude",0],["Blessing of the Arrow Turner",0]] },
|
||||
{ name:"Sapphire Ring", char:"Larsson", slot:"Ring", value:18200, wield:"150", equipped:true, burden:50, work:"—", mana:"98/180",
|
||||
spells:[["Legendary Invulnerability",1],["Epic Focus",0],["Minor Impenetrability",0]] },
|
||||
{ name:"Bronze Ring", char:"Mrbow", slot:"Ring", value:9340, wield:"125", equipped:false, burden:50, work:"—", mana:"77/120",
|
||||
spells:[["Legendary Invulnerability",1],["Epic Willpower",0]] },
|
||||
{ name:"Diamond Ring", char:"Kosmo Kramer", slot:"Ring", value:31000, wield:"200", equipped:false, burden:50, work:"—", mana:"200/200",
|
||||
spells:[["Legendary Invulnerability",1],["Legendary Willpower",1],["Epic Mana Conversion Prowess",0]] },
|
||||
{ name:"Ivory Ring", char:"Bank of Sawato", slot:"Ring", value:12750, wield:"150", equipped:false, burden:50, work:"—", mana:"64/150",
|
||||
spells:[["Legendary Invulnerability",1],["Major Void Magic Aptitude",0]] },
|
||||
{ name:"Black Opal Ring", char:"Bowgod", slot:"Ring", value:27890, wield:"180", equipped:true, burden:50, work:"—", mana:"180/220",
|
||||
spells:[["Legendary Invulnerability",1],["Legendary Critical Damage",1]] },
|
||||
];
|
||||
|
||||
function render() {
|
||||
const tb = document.getElementById('rows');
|
||||
tb.innerHTML = items.map((it,i) => `
|
||||
<tr onclick="sel(${i})" id="r${i}">
|
||||
<td>${it.name}${it.equipped ? ' <span class="equipped">⚔</span>' : ''}</td>
|
||||
<td>${it.char}</td><td>${it.slot}</td>
|
||||
<td class="num">${it.value.toLocaleString()}</td><td>${it.wield}</td>
|
||||
<td class="spells">${it.spells.map(([s,l]) => l ? `<span class="leg">${s}</span>` : s).join(', ')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function sel(i) {
|
||||
document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel'));
|
||||
document.getElementById('r'+i).classList.add('sel');
|
||||
const it = items[i];
|
||||
document.getElementById('detail').innerHTML = `
|
||||
<span class="closex" onclick="document.getElementById('detail').innerHTML=hint">×</span>
|
||||
<h3>${it.name}</h3>
|
||||
<div class="sub">${it.slot} · ${it.equipped ? '⚔ Equipped' : '📦 Inventory'}</div>
|
||||
<div class="kv"><span>Character</span><b>${it.char}</b></div>
|
||||
<div class="kv"><span>Value</span><b>${it.value.toLocaleString()}</b></div>
|
||||
<div class="kv"><span>Burden</span><b>${it.burden}</b></div>
|
||||
<div class="kv"><span>Wield req</span><b>Level ${it.wield}</b></div>
|
||||
<div class="kv"><span>Workmanship</span><b>${it.work}</b></div>
|
||||
<div class="kv"><span>Mana</span><b>${it.mana}</b></div>
|
||||
<hr>
|
||||
<div class="sphead">Spells (${it.spells.length})</div>
|
||||
${it.spells.map(([s,l]) => `<div class="sp${l ? ' leg' : ''}">${s}</div>`).join('')}
|
||||
<div class="hint">↑/↓ next item · Esc close</div>`;
|
||||
}
|
||||
|
||||
function filterCantrips(q) {
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('.cf').forEach(el => {
|
||||
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
const hint = '<div class="hint">Click a row to inspect an item.</div>';
|
||||
document.getElementById('detail').innerHTML = hint;
|
||||
render();
|
||||
sel(0);
|
||||
document.addEventListener('keydown', e => {
|
||||
const cur = document.querySelector('tbody tr.sel');
|
||||
if (!cur) return;
|
||||
const i = +cur.id.slice(1);
|
||||
if (e.key === 'ArrowDown' && i < items.length-1) { sel(i+1); e.preventDefault(); }
|
||||
if (e.key === 'ArrowUp' && i > 0) { sel(i-1); e.preventDefault(); }
|
||||
if (e.key === 'Escape') { document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel')); document.getElementById('detail').innerHTML = hint; }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue