Compare commits

..

10 commits

Author SHA1 Message Date
Erik
ae24cad458 build(frontend): deploy inventory search redesign
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:31:25 +02:00
Erik
66ee4bd82b feat(frontend)!: replace inventory.html with /?view=inventory (set analysis dropped)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:30:59 +02:00
Erik
559fd6596a feat(frontend): inventory item detail panel
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:26:55 +02:00
Erik
4735aa72e9 feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:26:38 +02:00
Erik
0533e2b9ee feat(frontend): inventory filter sidebar (characters, type, slots, cantrips, ratings, sets, state, reqs)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:22:10 +02:00
Erik
9a9b33a528 feat(frontend): /?view=inventory route + inventory page scaffold + dark CSS
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:16:12 +02:00
Erik
6fd87a3751 feat(frontend): useInventorySearch hook (debounced abortable search, URL state)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:09:47 +02:00
Erik
5ddcecaece feat(frontend): inventory search types + filter/column constants
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:09:22 +02:00
Erik
93982ac3dd docs(plan): inventory search redesign implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:06:41 +02:00
Erik
edf76f50db docs(spec): inventory search redesign — React full-page view + approved mockup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:57:10 +02:00
36 changed files with 2307 additions and 2167 deletions

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ frontend/node_modules/
# permission-neutral so devs can `claude` interactively here without
# inheriting production-agent restrictions.
.claude/
.superpowers/

View file

@ -0,0 +1,986 @@
# Inventory Search Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `static/inventory.html`/`inventory.js` with a dark-themed React full-page inventory search at `/?view=inventory` (facet sidebar, chips, instant search, detail panel), per the approved spec + mockup.
**Architecture:** New self-contained component tree under `frontend/src/components/inventory/` with a single `useInventorySearch` hook owning filter state, debounce, aborting fetch, and URL serialization. Zero backend changes — everything talks to the existing `GET /api/inv/search/items` (plus `/api/inv/characters/list`, `/api/inv/sets/list`, `/api/live`). Spec: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-design.md`; visual ground truth: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html` (styling/spacing/colors MUST follow it).
**Tech Stack:** React 19 + TypeScript + Vite (existing app, NO new npm deps). No test framework exists — per-task verification is `npm run build` (runs `tsc -b`) green; live verification is the final task. npm exists on this Windows machine; run builds via the Bash tool.
**Working directory for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**Build command (every task):**
```bash
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build
```
(If `node_modules` is missing, run `npm install` once first.)
**API cheat-sheet (verified live):**
- `GET /api/inv/search/items?...` → `{items:[...], total_count, page, limit, has_next, has_previous}`. Item fields used here: `name, character_name, slot_name, value, burden, wield_level, workmanship, spell_names (string[]), is_equipped, is_bonded, is_attuned, is_rare, item_set_name, armor_level, max_damage, condition_percent, object_class_name, last_updated`.
- Params: `text, character, characters, include_all_characters, armor_only, jewelry_only, weapon_only, weapon_type, clothing_only, shirt_only, pants_only, slot_names, legendary_cantrips (CSV, AND), spell_contains, equipment_status (equipped|unequipped), bonded, attuned, is_rare, item_set, min_* rating params, max_level, min_value, min_workmanship, max_burden, sort_by, sort_dir, page, limit`.
- `GET /api/inv/characters/list` → `{characters:[{character_name, item_count, last_updated}]}`
- `GET /api/inv/sets/list` → `{sets:[{id, item_count, name}]}` — display `id` (e.g. "Defender's Set"); the `name` field has a broken "Unknown Set " prefix, ignore it. Send `item_set=<id>`.
- `GET /api/live` → `{players:[{character_name, ...}]}` — online characters.
---
### Task 1: Types + constants module
**Files:**
- Create: `frontend/src/components/inventory/types.ts`
- Create: `frontend/src/components/inventory/constants.ts`
- [ ] **Step 1: Create `types.ts`:**
```ts
// Item shape returned by /api/inv/search/items (subset we render).
export interface InvItem {
name: string;
character_name: string;
slot_name: string | null;
value: number | null;
burden: number | null;
wield_level: number | null;
workmanship: number | null;
spell_names?: string[];
is_equipped: boolean;
is_bonded: boolean;
is_attuned: boolean;
is_rare: boolean;
item_set_name?: string;
armor_level: number | null;
max_damage: number | null;
condition_percent: number | null;
object_class_name?: string;
material_name?: string;
last_updated?: string;
}
export interface SearchResponse {
items: InvItem[];
total_count: number;
page: number;
limit: number;
has_next: boolean;
has_previous: boolean;
error?: string;
}
export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants';
export interface SearchFilters {
text: string;
/** 'all' → include_all_characters=true; otherwise explicit list. */
characters: 'all' | string[];
itemType: ItemType;
weaponType: string; // '' = all weapon types
slots: string[];
cantrips: string[]; // full "Legendary X" value strings
spellContains: string;
/** min-rating param name -> value ('' = unset). */
ratings: Record<string, number | ''>;
itemSet: string; // set id, '' = none
equippedOnly: boolean;
bonded: boolean;
attuned: boolean;
rare: boolean;
maxLevel: number | '';
minValue: number | '';
minWorkmanship: number | '';
maxBurden: number | '';
sortBy: string;
sortDir: 'asc' | 'desc';
page: number;
}
export const DEFAULT_FILTERS: SearchFilters = {
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
equippedOnly: false, bonded: false, attuned: false, rare: false,
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
sortBy: 'name', sortDir: 'asc', page: 1,
};
```
- [ ] **Step 2: Create `constants.ts`** — the 47 cantrip values are lifted verbatim from the old page's checkboxes (they are what the backend's `legendary_cantrips` matcher expects); labels drop the "Legendary " prefix:
```ts
export interface CantripDef { value: string; label: string; }
export interface CantripGroup { group: string; items: CantripDef[]; }
const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') });
export const CANTRIP_GROUPS: CantripGroup[] = [
{ group: 'Attributes', items: [
c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'),
c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'),
]},
{ group: 'Weapon skills', items: [
c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'),
c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'),
c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'),
c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'),
c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'),
c('Legendary Defender'), c('Legendary Blood Thirst'),
]},
{ group: 'Magic', items: [
c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'),
c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'),
c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'),
c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'),
c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'),
]},
{ group: 'Utility', items: [
c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'),
c('Legendary Leadership'), c('Legendary Deception Prowess'),
c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'),
]},
{ group: 'Defense', items: [
c('Legendary Invulnerability'), c('Legendary Impenetrability'),
c('Legendary Impregnability'), c('Legendary Armor'),
]},
{ group: 'Wards & Banes', items: [
c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'),
c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'),
c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'),
]},
];
export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak'];
export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms',
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
{ value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
{ value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
{ value: 'caster', label: 'Wand/Staff/Orb' },
];
export interface RatingDef { param: string; label: string; common?: boolean; }
export const RATING_DEFS: RatingDef[] = [
{ param: 'min_damage_rating', label: 'Damage rating', common: true },
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
{ param: 'min_vitality_rating', label: 'Vitality', common: true },
{ param: 'min_armor', label: 'Armor level' },
{ param: 'min_damage_resist_rating', label: 'Damage resist' },
{ param: 'min_crit_resist_rating', label: 'Crit resist' },
{ param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' },
{ param: 'min_healing_resist_rating', label: 'Healing resist' },
{ param: 'min_nether_resist_rating', label: 'Nether resist' },
{ param: 'min_healing_rating', label: 'Healing rating' },
{ param: 'min_dot_resist_rating', label: 'DoT resist' },
{ param: 'min_life_resist_rating', label: 'Life resist' },
{ param: 'min_sneak_attack_rating', label: 'Sneak attack' },
{ param: 'min_recklessness_rating', label: 'Recklessness' },
{ param: 'min_deception_rating', label: 'Deception' },
{ param: 'min_pk_damage_rating', label: 'PK damage' },
{ param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' },
{ param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' },
{ param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' },
{ param: 'min_tinks', label: 'Tinks' },
];
export interface ColumnDef {
key: string; label: string; sortKey?: string; defaultVisible: boolean;
}
// sortKey values must exist in the backend's sortMapping (search.go).
export const COLUMNS: ColumnDef[] = [
{ key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true },
{ key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true },
{ key: 'slot_name', label: 'Slot', defaultVisible: true },
{ key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true },
{ key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true },
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },
{ key: 'burden', label: 'Burden', defaultVisible: false },
{ key: 'condition_percent', label: 'Cond %', defaultVisible: false },
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
];
export const PAGE_SIZE = 200;
export const COLUMNS_LS_KEY = 'inv.visibleColumns';
```
- [ ] **Step 3: Build** — expected green (files compile standalone).
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/inventory/
git commit -m "feat(frontend): inventory search types + filter/column constants"
```
---
### Task 2: useInventorySearch hook
**Files:**
- Create: `frontend/src/components/inventory/useInventorySearch.ts`
- [ ] **Step 1: Create the hook** — owns filters, URL (de)serialization (single `q` JSON param alongside `view=inventory`), 400 ms debounce, AbortController fetch:
```ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types';
import { PAGE_SIZE } from './constants';
/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */
function filtersToUrl(f: SearchFilters): void {
const diff: Partial<SearchFilters> = {};
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k];
}
const url = new URL(window.location.href);
url.searchParams.set('view', 'inventory');
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
else url.searchParams.delete('q');
window.history.replaceState(null, '', url);
}
function filtersFromUrl(): SearchFilters {
try {
const q = new URLSearchParams(window.location.search).get('q');
if (!q) return DEFAULT_FILTERS;
return { ...DEFAULT_FILTERS, ...JSON.parse(q) };
} catch {
return DEFAULT_FILTERS;
}
}
export function buildParams(f: SearchFilters): URLSearchParams {
const p = new URLSearchParams();
if (f.characters === 'all') p.set('include_all_characters', 'true');
else if (f.characters.length === 1) p.set('character', f.characters[0]);
else p.set('characters', f.characters.join(','));
if (f.text) p.set('text', f.text);
switch (f.itemType) {
case 'armor': p.set('armor_only', 'true'); break;
case 'jewelry': p.set('jewelry_only', 'true'); break;
case 'clothing': p.set('clothing_only', 'true'); break;
case 'shirt': p.set('shirt_only', 'true'); break;
case 'pants': p.set('pants_only', 'true'); break;
case 'weapon':
p.set('weapon_only', 'true');
if (f.weaponType) p.set('weapon_type', f.weaponType);
break;
}
if (f.slots.length) p.set('slot_names', f.slots.join(','));
if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(','));
if (f.spellContains) p.set('spell_contains', f.spellContains);
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
if (f.itemSet) p.set('item_set', f.itemSet);
if (f.equippedOnly) p.set('equipment_status', 'equipped');
if (f.bonded) p.set('bonded', 'true');
if (f.attuned) p.set('attuned', 'true');
if (f.rare) p.set('is_rare', 'true');
if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel));
if (f.minValue !== '') p.set('min_value', String(f.minValue));
if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship));
if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden));
p.set('sort_by', f.sortBy);
p.set('sort_dir', f.sortDir);
p.set('page', String(f.page));
p.set('limit', String(PAGE_SIZE));
return p;
}
export interface InventorySearch {
filters: SearchFilters;
/** Patch filters; resets page to 1 unless the patch itself sets page. */
update: (patch: Partial<SearchFilters>) => void;
reset: () => void;
result: SearchResponse | null;
loading: boolean;
error: string | null;
queryMs: number | null;
}
export function useInventorySearch(): InventorySearch {
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
const [result, setResult] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queryMs, setQueryMs] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const update = useCallback((patch: Partial<SearchFilters>) => {
setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch }));
}, []);
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
useEffect(() => {
filtersToUrl(filters);
const timer = setTimeout(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
const t0 = performance.now();
try {
const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, {
credentials: 'include', signal: ctrl.signal,
});
if (!res.ok) throw new Error(`search: HTTP ${res.status}`);
const data: SearchResponse = await res.json();
if (data.error) throw new Error(data.error);
setResult(data);
setError(null);
setQueryMs(Math.round(performance.now() - t0));
} catch (e: any) {
if (e?.name !== 'AbortError') setError(String(e?.message ?? e));
} finally {
if (abortRef.current === ctrl) setLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [filters]);
return { filters, update, reset, result, loading, error, queryMs };
}
```
- [ ] **Step 2: Build** — green.
- [ ] **Step 3: Commit** — `feat(frontend): useInventorySearch hook (debounced abortable search, URL state)`
---
### Task 3: Routing, page scaffold, CSS
**Files:**
- Modify: `frontend/src/App.tsx`
- Create: `frontend/src/components/inventory/InventorySearchPage.tsx`
- Create: `frontend/src/styles/inventory.css`
- [ ] **Step 1: Route.** In `App.tsx`, add the import and branch (current file branches only on `'dashboard'`):
```tsx
import { InventorySearchPage } from './components/inventory/InventorySearchPage';
import './styles/inventory.css';
```
and change the ternary to:
```tsx
{view === 'dashboard' ? <PlayerDashboardFullPage />
: view === 'inventory' ? <InventorySearchPage />
: <DefaultApp />}
```
Also update the header comment to document `/?view=inventory`.
- [ ] **Step 2: Page scaffold** — `InventorySearchPage.tsx`. Full layout with top bar wired to the hook; sidebar/table/detail rendered from components built in Tasks 4-6 (create with placeholder stubs now so this task builds; the stubs are replaced by the real components in their tasks):
```tsx
import { useState } from 'react';
import { useInventorySearch } from './useInventorySearch';
import { FilterSidebar } from './FilterSidebar';
import { ActiveChips } from './ActiveChips';
import { ResultsTable } from './ResultsTable';
import { DetailPanel } from './DetailPanel';
import type { InvItem } from './types';
export function InventorySearchPage() {
const search = useInventorySearch();
const [selected, setSelected] = useState<InvItem | null>(null);
return (
<div className="inv-page">
<div className="inv-topbar">
<span className="inv-title">⚔ Inventory Search</span>
<input
className="inv-searchbox"
placeholder="Search name or material…"
value={search.filters.text}
onChange={e => search.update({ text: e.target.value })}
/>
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
<span className="inv-count">
{search.error ? <span className="inv-error">{search.error}</span>
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
{search.queryMs != null && <> · {search.queryMs} ms</>}
{search.loading && ' · …'}</>
: 'loading…'}
</span>
</div>
<ActiveChips search={search} />
<div className="inv-main">
<FilterSidebar search={search} />
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
</div>
</div>
);
}
```
For this task only, create minimal stub files so the build passes (each replaced by its real task):
```tsx
// FilterSidebar.tsx (stub — replaced in Task 4)
import type { InventorySearch } from './useInventorySearch';
export function FilterSidebar(_: { search: InventorySearch }) { return <div className="inv-sidebar" />; }
```
```tsx
// ActiveChips.tsx (stub — replaced in Task 5)
import type { InventorySearch } from './useInventorySearch';
export function ActiveChips(_: { search: InventorySearch }) { return null; }
```
```tsx
// ResultsTable.tsx (stub — replaced in Task 5)
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) {
return <div className="inv-results" />;
}
```
```tsx
// DetailPanel.tsx (stub — replaced in Task 6)
import type { InvItem } from './types';
export function DetailPanel(_: { item: InvItem; onClose: () => void }) { return null; }
```
- [ ] **Step 3: CSS** — `frontend/src/styles/inventory.css`. Port the approved mockup's `<style>` block (see `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html`) with class names prefixed `inv-` and BEM-ish nesting flattened. Required tokens: page bg `#111`, panels `#1a1a1a`, borders `#333`, accent `#88f`, legendary gold `#fc6` (chip bg `#2a2418`, border `#a80`), selected row bg `#20203a`, detail bg `#161620`, font `"Segoe UI", sans-serif`, sidebar width 210px, detail width 250px, sticky table header on `#191919`. Copy the mockup's rules for: `.topbar→.inv-topbar`, `.searchbox→.inv-searchbox`, `.btn→.inv-btn`, `.chipsrow/.chip→.inv-chipsrow/.inv-chip(.gold)`, `.sidebar/.grp/.grp-head/.grp-body/.badge/.minisearch/.fitem/.subhead/.linky/.range→.inv-*`, table rules (`.inv-results table/th/td`, `tr.inv-sel`, `.inv-leg`, `.inv-spells`, `.inv-equipped`, `.inv-pager/.inv-pg(.cur)`), `.detail→.inv-detail` with `.inv-kv/.inv-sp(.leg)/.inv-sphead/.inv-hint/.inv-closex`. Add `.inv-page{display:flex;flex-direction:column;height:100vh;background:#111;color:#eee;font-family:"Segoe UI",sans-serif}` and `.inv-main{display:flex;flex:1;overflow:hidden}`, `.inv-error{color:#c66}`.
- [ ] **Step 4: Build** — green. Manually sanity-check nothing else broke: `npm run build` output lists the new css/js chunks.
- [ ] **Step 5: Commit** — `feat(frontend): /?view=inventory route + inventory page scaffold + dark CSS`
---
### Task 4: FilterSidebar (all filter groups)
**Files:**
- Replace stub: `frontend/src/components/inventory/FilterSidebar.tsx`
- Create: `frontend/src/components/inventory/CharacterFilter.tsx`
- Create: `frontend/src/components/inventory/CantripFilter.tsx`
- [ ] **Step 1: Shared collapsible group.** At the top of `FilterSidebar.tsx`:
```tsx
import { useEffect, useState } from 'react';
import type { InventorySearch } from './useInventorySearch';
import { CharacterFilter } from './CharacterFilter';
import { CantripFilter } from './CantripFilter';
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
import type { ItemType } from './types';
import { apiFetch } from '../../api/client';
export function Group(props: {
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
}) {
const [open, setOpen] = useState(props.defaultOpen ?? false);
return (
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
<span className="inv-arrow">▶</span> {props.title}
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
</div>
{open && <div className="inv-grp-body">{props.children}</div>}
</div>
);
}
```
- [ ] **Step 2: `FilterSidebar` body** — groups in mockup order. Item type radios (All/Armor/Jewelry/Weapons+subtype select/Clothing/Shirts/Pants), Slots (jewelry slots always visible, armor slots behind a "show armor slots" toggle), Ratings (common four + "all ratings" toggle), Sets (fetched once from `/inv/sets/list`, searchable, single-select radio list sending `id`), Item state, Reqs & value:
```tsx
export function FilterSidebar({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [showArmorSlots, setShowArmorSlots] = useState(false);
const [allRatings, setAllRatings] = useState(false);
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
const [setQuery, setSetQuery] = useState('');
useEffect(() => {
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
.then(r => setSets(r.sets)).catch(() => {});
}, []);
const toggleSlot = (s: string) => update({
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
});
const setRating = (param: string, v: string) => update({
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
});
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
const stateCount = [f.equippedOnly, f.bonded, f.attuned, f.rare].filter(Boolean).length;
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
['shirt', 'Shirts'], ['pants', 'Pants']];
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
<input type="radio" name="inv-t" checked={f.itemType === v}
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
</label>
))}
{f.itemType === 'weapon' && (
<select className="inv-minisearch" value={f.weaponType}
onChange={e => update({ weaponType: e.target.value })}>
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
{JEWELRY_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
</span>
{showArmorSlots && ARMOR_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
</Group>
<CantripFilter search={search} />
<Group title="Ratings" badge={ratingCount || undefined}>
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
<div className="inv-range" key={r.param}>
<label>{r.label} ≥</label>
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
onChange={e => setRating(r.param, e.target.value)} />
</div>
))}
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
</span>
</Group>
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
onChange={e => setSetQuery(e.target.value)} />
<label className="inv-fitem">
<input type="radio" name="inv-set" checked={f.itemSet === ''}
onChange={() => update({ itemSet: '' })} /> Any set
</label>
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
<label className="inv-fitem" key={s.id}>
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
</label>
))}
</Group>
<Group title="Item state" badge={stateCount || undefined}>
<label className="inv-fitem"><input type="checkbox" checked={f.equippedOnly}
onChange={e => update({ equippedOnly: e.target.checked })} /> Equipped only</label>
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
</Group>
<Group title="Reqs & value" badge={reqCount || undefined}>
<div className="inv-range"><label>Wield lvl ≤</label>
<input type="number" value={f.maxLevel} placeholder="max"
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Value ≥</label>
<input type="number" value={f.minValue} placeholder="min"
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Workmanship ≥</label>
<input type="number" value={f.minWorkmanship} placeholder="min"
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Burden ≤</label>
<input type="number" value={f.maxBurden} placeholder="max"
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
</Group>
</div>
);
}
```
Add `.inv-dim{color:#666;font-size:10px}` to inventory.css.
- [ ] **Step 3: `CharacterFilter.tsx`** — full list from `/inv/characters/list`, online set from `/live` (one-shot fetches, no WebSocket), searchable, All/None/Online links:
```tsx
import { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../../api/client';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CharacterFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [names, setNames] = useState<string[]>([]);
const [online, setOnline] = useState<Set<string>>(new Set());
const [q, setQ] = useState('');
useEffect(() => {
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
.catch(() => {});
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
.catch(() => {});
}, []);
const checked = useMemo(() =>
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
const toggle = (n: string) => {
const next = new Set(checked);
if (next.has(n)) next.delete(n); else next.add(n);
update({ characters: next.size === names.length ? 'all' : [...next] });
};
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
return (
<Group title="Characters" defaultOpen
badge={f.characters === 'all' ? 'All' : f.characters.length}>
<input className="inv-minisearch" placeholder="filter characters…"
value={q} onChange={e => setQ(e.target.value)} />
<div className="inv-links">
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
</div>
{shown.map(n => (
<label className="inv-fitem" key={n}>
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
{n} {online.has(n) && <span className="inv-online" />}
</label>
))}
</Group>
);
}
```
Add to inventory.css: `.inv-online{width:6px;height:6px;border-radius:50%;background:#4c4;display:inline-block;margin-left:4px}` and `.inv-links{margin-bottom:4px;font-size:10px}`.
Note: `characters: []` (None) sends `characters=` empty → backend returns the "Empty characters list" error object; the hook surfaces it as an error string. Acceptable — the count area shows the message and choosing any character recovers. (Matches old-page behavior of requiring a character selection.)
- [ ] **Step 4: `CantripFilter.tsx`** — find-as-you-type, grouped, checked pinned to a Selected section, plus the any-tier `spell_contains` input:
```tsx
import { useState } from 'react';
import { CANTRIP_GROUPS } from './constants';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CantripFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [q, setQ] = useState('');
const toggle = (v: string) => update({
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
});
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
return (
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
value={q} onChange={e => setQ(e.target.value)} />
{f.cantrips.length > 0 && (<>
<div className="inv-subhead">Selected</div>
{f.cantrips.map(v => (
<label className="inv-fitem inv-gold" key={v}>
<input type="checkbox" checked onChange={() => toggle(v)} />
{v.replace(/^Legendary /, '')}
</label>
))}
</>)}
{CANTRIP_GROUPS.map(g => {
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
if (!items.length) return null;
return (
<div key={g.group}>
<div className="inv-subhead">{g.group}</div>
{items.map(i => (
<label className="inv-fitem" key={i.value}>
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
</label>
))}
</div>
);
})}
<div className="inv-subhead">Any-tier spell search</div>
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
</Group>
);
}
```
Add `.inv-gold{color:#fc6}` to inventory.css.
- [ ] **Step 5: Build** — green.
- [ ] **Step 6: Commit** — `feat(frontend): inventory filter sidebar (characters, type, slots, cantrips, ratings, sets, state, reqs)`
---
### Task 5: ActiveChips + ResultsTable
**Files:**
- Replace stubs: `frontend/src/components/inventory/ActiveChips.tsx`, `frontend/src/components/inventory/ResultsTable.tsx`
- [ ] **Step 1: `ActiveChips.tsx`** — derive chips from filters; × patches the filter away; legendary chips gold:
```tsx
import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS } from './constants';
import { DEFAULT_FILTERS } from './types';
interface Chip { label: string; gold?: boolean; remove: () => void; }
export function ActiveChips({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const chips: Chip[] = [];
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
if (f.characters !== 'all') chips.push({
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
remove: () => update({ characters: 'all' }),
});
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponType: '' }),
});
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
for (const c of f.cantrips) chips.push({
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
});
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
if (!chips.length) return null;
return (
<div className="inv-chipsrow">
<span className="inv-chips-lbl">ACTIVE</span>
{chips.map((c, i) => (
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
</span>
))}
</div>
);
}
```
(Unused import `DEFAULT_FILTERS` must not remain — do not include it; shown here as a reminder NOT to add it.) Remove that import line before committing.
- [ ] **Step 2: `ResultsTable.tsx`** — sticky sortable header, column picker (localStorage), spells with gold legendaries, equipped marker, pagination:
```tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
function loadVisible(): Set<string> {
try {
const raw = localStorage.getItem(COLUMNS_LS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch { /* fall through */ }
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
}
function cell(item: InvItem, key: string): React.ReactNode {
switch (key) {
case 'name':
return <>{item.name}{item.is_equipped && <span className="inv-equipped"> ⚔</span>}</>;
case 'spell_names':
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
))}</span>;
case 'value':
return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
default: {
const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v);
}
}
}
export function ResultsTable({ search, selected, onSelect }: {
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
}) {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
const items = result?.items ?? [];
useEffect(() => {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
if (e.key === 'Escape') { onSelect(null); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const idx = selected ? items.indexOf(selected) : -1;
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
if (items[next]) onSelect(items[next]);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect]);
const sortOn = (sortKey?: string) => {
if (!sortKey) return;
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
else update({ sortBy: sortKey, sortDir: 'asc' });
};
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
const goto = (p: number) => {
update({ page: Math.min(Math.max(1, p), totalPages) });
scrollRef.current?.scrollTo(0, 0);
};
return (
<div className="inv-results" ref={scrollRef}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
{pickerOpen && (
<div className="inv-colpicker">
{COLUMNS.map(c => (
<label className="inv-fitem" key={c.key}>
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
const next = new Set(visible);
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
setVisible(next);
}} /> {c.label}
</label>
))}
</div>
)}
</div>
<table>
<thead><tr>
{cols.map(c => (
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
))}
</tr></thead>
<tbody>
{items.map((it, i) => (
<tr key={i} className={it === selected ? 'inv-sel' : ''}
onClick={() => onSelect(it === selected ? null : it)}>
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
</tr>
))}
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
</tbody>
</table>
<div className="inv-pager">
<span className="inv-pg" onClick={() => goto(f.page - 1)}>◀</span>
<span>page {result?.page ?? 1} / {totalPages}</span>
<span className="inv-pg" onClick={() => goto(f.page + 1)}>▶</span>
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
</div>
</div>
);
}
```
Move the "⚙ Columns" button here (remove it from the top bar if Task 3 put one there — Task 3's top bar has no Columns button, correct). CSS additions: `.inv-colpicker-anchor{position:relative;align-self:flex-end;padding:4px 10px}`, `.inv-colpicker{position:absolute;right:10px;top:30px;background:#1a1a1a;border:1px solid #444;border-radius:4px;padding:8px;z-index:5;max-height:300px;overflow-y:auto}`, `.inv-sorted-asc::after{content:" ▲";font-size:9px}`, `.inv-sorted-desc::after{content:" ▼";font-size:9px}`, `.inv-pager-right{margin-left:auto}`.
- [ ] **Step 3: Build** — green.
- [ ] **Step 4: Commit** — `feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard)`
---
### Task 6: DetailPanel
**Files:**
- Replace stub: `frontend/src/components/inventory/DetailPanel.tsx`
- [ ] **Step 1: Implement:**
```tsx
import type { InvItem } from './types';
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
}
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
return (
<div className="inv-detail">
<span className="inv-closex" onClick={onClose}>×</span>
<h3>{item.name}</h3>
<div className="inv-detail-sub">
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
{item.is_rare && ' · ★ Rare'}
</div>
<Row k="Character" v={item.character_name} />
<Row k="Value" v={item.value?.toLocaleString()} />
<Row k="Burden" v={item.burden} />
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
<Row k="Workmanship" v={item.workmanship ?? '—'} />
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
<hr />
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
{(item.spell_names ?? []).map((s, i) => (
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
))}
<div className="inv-hint">↑/↓ next item · Esc close</div>
</div>
);
}
```
- [ ] **Step 2: Build** — green.
- [ ] **Step 3: Commit** — `feat(frontend): inventory item detail panel`
---
### Task 7: Cutover — link + delete old page
**Files:**
- Modify: `frontend/src/components/map/Sidebar.tsx:101`
- Delete: `static/inventory.html`, `static/inventory.js`
- [ ] **Step 1:** In `Sidebar.tsx` line 101 change
`<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
to
`<a href="/?view=inventory" target="_blank" className="ml-tool-link">🔍 Inv Search</a>`
- [ ] **Step 2:** `git rm static/inventory.html static/inventory.js`
- [ ] **Step 3:** Grep for stragglers: `grep -rn "inventory.html" frontend/src static/*.html static/classic docs/superpowers/specs/2026-07-15*.md` — only historical docs/specs may reference it (leave those; they're records). If `static/classic/` links to it, update that link the same way.
- [ ] **Step 4: Build** — green.
- [ ] **Step 5: Commit** — `feat(frontend)!: replace inventory.html with /?view=inventory (set analysis dropped)`
---
### Task 8: Deploy + live verification
- [ ] **Step 1:** `bash deploy-frontend.sh` from the repo root (runs the production build and copies `_build/` into `static/`).
- [ ] **Step 2:** `git add static/ && git commit -m "build(frontend): deploy inventory search redesign" && git push origin master`
- [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"` (bind mount serves it; no restart).
- [ ] **Step 4: Smoke checks** (unauthenticated, from local shell):
- `curl -s https://overlord.snakedesert.se/ | grep -o 'assets/index-[^"]*\.js'` — new asset hash present.
- `curl -s -o /dev/null -w '%{http_code}' https://overlord.snakedesert.se/inventory.html` — no longer the old page (SPA fallback 200 serving index.html is expected; verify body contains the React root div, not "Inventory Search - Dereth Tracker" title).
- [ ] **Step 5: User verification** (requires login — ask the user): open `/?view=inventory`, confirm: jewelry + Invuln + Summon → the known 2 items; Ring slot + Invuln → 56; chips remove correctly; sort by Value; column picker persists after reload; detail panel + arrow keys; a bookmarked URL with filters restores them.
---
## Self-review notes
- Spec coverage: every spec bullet maps to a task (routing→3, sidebar groups→4, chips→5, table/sort/columns/paging→5, detail+keyboard→5/6, URL sharing→2, cutover→7, deploy/verify→8). Set analysis intentionally absent.
- The `characters: []` edge intentionally surfaces the backend's error message (documented in Task 4 Step 3).
- Keyboard handling lives in ResultsTable (needs items+selected); spec said "page" — acceptable placement, noted.
- No new deps; no backend changes anywhere.

View file

@ -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.

View file

@ -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>

View file

@ -1,20 +1,25 @@
import { MapLayout } from './components/map/MapLayout';
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
import { InventorySearchPage } from './components/inventory/InventorySearchPage';
import { MidsummerProvider } from './hooks/useMidsummer';
import { useLiveData } from './hooks/useLiveData';
import './styles/map-layout.css';
import './styles/midsummer.css';
import './styles/inventory.css';
/**
* Single SPA entry. Branches on `?view=` query param:
* /?view=dashboard → fullscreen PlayerDashboardFullPage (new-tab target)
* /?view=inventory → fullscreen InventorySearchPage (new-tab target)
* / → default map + sidebar layout
*/
export default function App() {
const view = new URLSearchParams(window.location.search).get('view');
return (
<MidsummerProvider>
{view === 'dashboard' ? <PlayerDashboardFullPage /> : <DefaultApp />}
{view === 'dashboard' ? <PlayerDashboardFullPage />
: view === 'inventory' ? <InventorySearchPage />
: <DefaultApp />}
</MidsummerProvider>
);
}

View file

@ -0,0 +1,49 @@
import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS } from './constants';
interface Chip { label: string; gold?: boolean; remove: () => void; }
export function ActiveChips({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const chips: Chip[] = [];
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
if (f.characters !== 'all') chips.push({
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
remove: () => update({ characters: 'all' }),
});
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponType: '' }),
});
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
for (const c of f.cantrips) chips.push({
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
});
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
if (!chips.length) return null;
return (
<div className="inv-chipsrow">
<span className="inv-chips-lbl">ACTIVE</span>
{chips.map((c, i) => (
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
</span>
))}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { useState } from 'react';
import { CANTRIP_GROUPS } from './constants';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CantripFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [q, setQ] = useState('');
const toggle = (v: string) => update({
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
});
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
return (
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
value={q} onChange={e => setQ(e.target.value)} />
{f.cantrips.length > 0 && (<>
<div className="inv-subhead">Selected</div>
{f.cantrips.map(v => (
<label className="inv-fitem inv-gold" key={v}>
<input type="checkbox" checked onChange={() => toggle(v)} />
{v.replace(/^Legendary /, '')}
</label>
))}
</>)}
{CANTRIP_GROUPS.map(g => {
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
if (!items.length) return null;
return (
<div key={g.group}>
<div className="inv-subhead">{g.group}</div>
{items.map(i => (
<label className="inv-fitem" key={i.value}>
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
</label>
))}
</div>
);
})}
<div className="inv-subhead">Any-tier spell search</div>
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
</Group>
);
}

View file

@ -0,0 +1,48 @@
import { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../../api/client';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CharacterFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [names, setNames] = useState<string[]>([]);
const [online, setOnline] = useState<Set<string>>(new Set());
const [q, setQ] = useState('');
useEffect(() => {
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
.catch(() => {});
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
.catch(() => {});
}, []);
const checked = useMemo(() =>
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
const toggle = (n: string) => {
const next = new Set(checked);
if (next.has(n)) next.delete(n); else next.add(n);
update({ characters: next.size === names.length ? 'all' : [...next] });
};
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
return (
<Group title="Characters" defaultOpen
badge={f.characters === 'all' ? 'All' : f.characters.length}>
<input className="inv-minisearch" placeholder="filter characters…"
value={q} onChange={e => setQ(e.target.value)} />
<div className="inv-links">
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
</div>
{shown.map(n => (
<label className="inv-fitem" key={n}>
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
{n} {online.has(n) && <span className="inv-online" />}
</label>
))}
</Group>
);
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import type { InvItem } from './types';
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return <div className="inv-kv"><span>{k}</span><b>{v ?? '—'}</b></div>;
}
export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => void }) {
return (
<div className="inv-detail">
<span className="inv-closex" onClick={onClose}>×</span>
<h3>{item.name}</h3>
<div className="inv-detail-sub">
{item.slot_name ?? item.object_class_name ?? ''} · {item.is_equipped ? '⚔ Equipped' : '📦 Inventory'}
{item.is_rare && ' · ★ Rare'}
</div>
<Row k="Character" v={item.character_name} />
<Row k="Value" v={item.value?.toLocaleString()} />
<Row k="Burden" v={item.burden} />
<Row k="Wield req" v={item.wield_level ? `Level ${item.wield_level}` : '—'} />
<Row k="Workmanship" v={item.workmanship ?? '—'} />
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />}
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
{item.item_set_name && <Row k="Set" v={item.item_set_name} />}
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}
<hr />
<div className="inv-sphead">Spells ({item.spell_names?.length ?? 0})</div>
{(item.spell_names ?? []).map((s, i) => (
<div className={`inv-sp${/legendary/i.test(s) ? ' inv-leg' : ''}`} key={i}>{s}</div>
))}
<div className="inv-hint">↑/↓ next item · Esc close</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import type { InventorySearch } from './useInventorySearch';
import { CharacterFilter } from './CharacterFilter';
import { CantripFilter } from './CantripFilter';
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
import type { ItemType } from './types';
import { apiFetch } from '../../api/client';
export function Group(props: {
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
}) {
const [open, setOpen] = useState(props.defaultOpen ?? false);
return (
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
<span className="inv-arrow">▶</span> {props.title}
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
</div>
{open && <div className="inv-grp-body">{props.children}</div>}
</div>
);
}
export function FilterSidebar({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [showArmorSlots, setShowArmorSlots] = useState(false);
const [allRatings, setAllRatings] = useState(false);
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
const [setQuery, setSetQuery] = useState('');
useEffect(() => {
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
.then(r => setSets(r.sets)).catch(() => {});
}, []);
const toggleSlot = (s: string) => update({
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
});
const setRating = (param: string, v: string) => update({
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
});
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
const stateCount = [f.equippedOnly, f.bonded, f.attuned, f.rare].filter(Boolean).length;
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
['shirt', 'Shirts'], ['pants', 'Pants']];
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
<input type="radio" name="inv-t" checked={f.itemType === v}
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
</label>
))}
{f.itemType === 'weapon' && (
<select className="inv-minisearch" value={f.weaponType}
onChange={e => update({ weaponType: e.target.value })}>
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
{JEWELRY_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
</span>
{showArmorSlots && ARMOR_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
</Group>
<CantripFilter search={search} />
<Group title="Ratings" badge={ratingCount || undefined}>
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
<div className="inv-range" key={r.param}>
<label>{r.label} ≥</label>
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
onChange={e => setRating(r.param, e.target.value)} />
</div>
))}
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
</span>
</Group>
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
onChange={e => setSetQuery(e.target.value)} />
<label className="inv-fitem">
<input type="radio" name="inv-set" checked={f.itemSet === ''}
onChange={() => update({ itemSet: '' })} /> Any set
</label>
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
<label className="inv-fitem" key={s.id}>
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
</label>
))}
</Group>
<Group title="Item state" badge={stateCount || undefined}>
<label className="inv-fitem"><input type="checkbox" checked={f.equippedOnly}
onChange={e => update({ equippedOnly: e.target.checked })} /> Equipped only</label>
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
</Group>
<Group title="Reqs & value" badge={reqCount || undefined}>
<div className="inv-range"><label>Wield lvl ≤</label>
<input type="number" value={f.maxLevel} placeholder="max"
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Value ≥</label>
<input type="number" value={f.minValue} placeholder="min"
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Workmanship ≥</label>
<input type="number" value={f.minWorkmanship} placeholder="min"
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Burden ≤</label>
<input type="number" value={f.maxBurden} placeholder="max"
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
</Group>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { useState } from 'react';
import { useInventorySearch } from './useInventorySearch';
import { FilterSidebar } from './FilterSidebar';
import { ActiveChips } from './ActiveChips';
import { ResultsTable } from './ResultsTable';
import { DetailPanel } from './DetailPanel';
import type { InvItem } from './types';
export function InventorySearchPage() {
const search = useInventorySearch();
const [selected, setSelected] = useState<InvItem | null>(null);
return (
<div className="inv-page">
<div className="inv-topbar">
<span className="inv-title">⚔ Inventory Search</span>
<input
className="inv-searchbox"
placeholder="Search name or material…"
value={search.filters.text}
onChange={e => search.update({ text: e.target.value })}
/>
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
<span className="inv-count">
{search.error ? <span className="inv-error">{search.error}</span>
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
{search.queryMs != null && <> · {search.queryMs} ms</>}
{search.loading && ' · …'}</>
: 'loading…'}
</span>
</div>
<ActiveChips search={search} />
<div className="inv-main">
<FilterSidebar search={search} />
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types';
function loadVisible(): Set<string> {
try {
const raw = localStorage.getItem(COLUMNS_LS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch { /* fall through */ }
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
}
function cell(item: InvItem, key: string): React.ReactNode {
switch (key) {
case 'name':
return <>{item.name}{item.is_equipped && <span className="inv-equipped"> ⚔</span>}</>;
case 'spell_names':
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
))}</span>;
case 'value':
return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
default: {
const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v);
}
}
}
export function ResultsTable({ search, selected, onSelect }: {
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
}) {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
const items = result?.items ?? [];
useEffect(() => {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
if (e.key === 'Escape') { onSelect(null); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const idx = selected ? items.indexOf(selected) : -1;
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
if (items[next]) onSelect(items[next]);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect]);
const sortOn = (sortKey?: string) => {
if (!sortKey) return;
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
else update({ sortBy: sortKey, sortDir: 'asc' });
};
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
const goto = (p: number) => {
update({ page: Math.min(Math.max(1, p), totalPages) });
scrollRef.current?.scrollTo(0, 0);
};
return (
<div className="inv-results" ref={scrollRef}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
{pickerOpen && (
<div className="inv-colpicker">
{COLUMNS.map(c => (
<label className="inv-fitem" key={c.key}>
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
const next = new Set(visible);
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
setVisible(next);
}} /> {c.label}
</label>
))}
</div>
)}
</div>
<table>
<thead><tr>
{cols.map(c => (
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
))}
</tr></thead>
<tbody>
{items.map((it, i) => (
<tr key={i} className={it === selected ? 'inv-sel' : ''}
onClick={() => onSelect(it === selected ? null : it)}>
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
</tr>
))}
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
</tbody>
</table>
<div className="inv-pager">
<span className="inv-pg" onClick={() => goto(f.page - 1)}>◀</span>
<span>page {result?.page ?? 1} / {totalPages}</span>
<span className="inv-pg" onClick={() => goto(f.page + 1)}>▶</span>
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
</div>
</div>
);
}

View file

@ -0,0 +1,101 @@
export interface CantripDef { value: string; label: string; }
export interface CantripGroup { group: string; items: CantripDef[]; }
const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') });
export const CANTRIP_GROUPS: CantripGroup[] = [
{ group: 'Attributes', items: [
c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'),
c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'),
]},
{ group: 'Weapon skills', items: [
c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'),
c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'),
c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'),
c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'),
c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'),
c('Legendary Defender'), c('Legendary Blood Thirst'),
]},
{ group: 'Magic', items: [
c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'),
c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'),
c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'),
c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'),
c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'),
]},
{ group: 'Utility', items: [
c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'),
c('Legendary Leadership'), c('Legendary Deception Prowess'),
c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'),
]},
{ group: 'Defense', items: [
c('Legendary Invulnerability'), c('Legendary Impenetrability'),
c('Legendary Impregnability'), c('Legendary Armor'),
]},
{ group: 'Wards & Banes', items: [
c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'),
c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'),
c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'),
]},
];
export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak'];
export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms',
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
{ value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
{ value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
{ value: 'caster', label: 'Wand/Staff/Orb' },
];
export interface RatingDef { param: string; label: string; common?: boolean; }
export const RATING_DEFS: RatingDef[] = [
{ param: 'min_damage_rating', label: 'Damage rating', common: true },
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
{ param: 'min_vitality_rating', label: 'Vitality', common: true },
{ param: 'min_armor', label: 'Armor level' },
{ param: 'min_damage_resist_rating', label: 'Damage resist' },
{ param: 'min_crit_resist_rating', label: 'Crit resist' },
{ param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' },
{ param: 'min_healing_resist_rating', label: 'Healing resist' },
{ param: 'min_nether_resist_rating', label: 'Nether resist' },
{ param: 'min_healing_rating', label: 'Healing rating' },
{ param: 'min_dot_resist_rating', label: 'DoT resist' },
{ param: 'min_life_resist_rating', label: 'Life resist' },
{ param: 'min_sneak_attack_rating', label: 'Sneak attack' },
{ param: 'min_recklessness_rating', label: 'Recklessness' },
{ param: 'min_deception_rating', label: 'Deception' },
{ param: 'min_pk_damage_rating', label: 'PK damage' },
{ param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' },
{ param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' },
{ param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' },
{ param: 'min_tinks', label: 'Tinks' },
];
export interface ColumnDef {
key: string; label: string; sortKey?: string; defaultVisible: boolean;
}
// sortKey values must exist in the backend's sortMapping (search.go).
export const COLUMNS: ColumnDef[] = [
{ key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true },
{ key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true },
{ key: 'slot_name', label: 'Slot', defaultVisible: true },
{ key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true },
{ key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true },
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },
{ key: 'burden', label: 'Burden', defaultVisible: false },
{ key: 'condition_percent', label: 'Cond %', defaultVisible: false },
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
];
export const PAGE_SIZE = 200;
export const COLUMNS_LS_KEY = 'inv.visibleColumns';

View file

@ -0,0 +1,67 @@
// Item shape returned by /api/inv/search/items (subset we render).
export interface InvItem {
name: string;
character_name: string;
slot_name: string | null;
value: number | null;
burden: number | null;
wield_level: number | null;
workmanship: number | null;
spell_names?: string[];
is_equipped: boolean;
is_bonded: boolean;
is_attuned: boolean;
is_rare: boolean;
item_set_name?: string;
armor_level: number | null;
max_damage: number | null;
condition_percent: number | null;
object_class_name?: string;
material_name?: string;
last_updated?: string;
}
export interface SearchResponse {
items: InvItem[];
total_count: number;
page: number;
limit: number;
has_next: boolean;
has_previous: boolean;
error?: string;
}
export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants';
export interface SearchFilters {
text: string;
/** 'all' → include_all_characters=true; otherwise explicit list. */
characters: 'all' | string[];
itemType: ItemType;
weaponType: string; // '' = all weapon types
slots: string[];
cantrips: string[]; // full "Legendary X" value strings
spellContains: string;
/** min-rating param name -> value ('' = unset). */
ratings: Record<string, number | ''>;
itemSet: string; // set id, '' = none
equippedOnly: boolean;
bonded: boolean;
attuned: boolean;
rare: boolean;
maxLevel: number | '';
minValue: number | '';
minWorkmanship: number | '';
maxBurden: number | '';
sortBy: string;
sortDir: 'asc' | 'desc';
page: number;
}
export const DEFAULT_FILTERS: SearchFilters = {
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
equippedOnly: false, bonded: false, attuned: false, rare: false,
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
sortBy: 'name', sortDir: 'asc', page: 1,
};

View file

@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types';
import { PAGE_SIZE } from './constants';
/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */
function filtersToUrl(f: SearchFilters): void {
const diff: Partial<SearchFilters> = {};
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k];
}
const url = new URL(window.location.href);
url.searchParams.set('view', 'inventory');
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
else url.searchParams.delete('q');
window.history.replaceState(null, '', url);
}
function filtersFromUrl(): SearchFilters {
try {
const q = new URLSearchParams(window.location.search).get('q');
if (!q) return DEFAULT_FILTERS;
return { ...DEFAULT_FILTERS, ...JSON.parse(q) };
} catch {
return DEFAULT_FILTERS;
}
}
export function buildParams(f: SearchFilters): URLSearchParams {
const p = new URLSearchParams();
if (f.characters === 'all') p.set('include_all_characters', 'true');
else if (f.characters.length === 1) p.set('character', f.characters[0]);
else p.set('characters', f.characters.join(','));
if (f.text) p.set('text', f.text);
switch (f.itemType) {
case 'armor': p.set('armor_only', 'true'); break;
case 'jewelry': p.set('jewelry_only', 'true'); break;
case 'clothing': p.set('clothing_only', 'true'); break;
case 'shirt': p.set('shirt_only', 'true'); break;
case 'pants': p.set('pants_only', 'true'); break;
case 'weapon':
p.set('weapon_only', 'true');
if (f.weaponType) p.set('weapon_type', f.weaponType);
break;
}
if (f.slots.length) p.set('slot_names', f.slots.join(','));
if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(','));
if (f.spellContains) p.set('spell_contains', f.spellContains);
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
if (f.itemSet) p.set('item_set', f.itemSet);
if (f.equippedOnly) p.set('equipment_status', 'equipped');
if (f.bonded) p.set('bonded', 'true');
if (f.attuned) p.set('attuned', 'true');
if (f.rare) p.set('is_rare', 'true');
if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel));
if (f.minValue !== '') p.set('min_value', String(f.minValue));
if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship));
if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden));
p.set('sort_by', f.sortBy);
p.set('sort_dir', f.sortDir);
p.set('page', String(f.page));
p.set('limit', String(PAGE_SIZE));
return p;
}
export interface InventorySearch {
filters: SearchFilters;
/** Patch filters; resets page to 1 unless the patch itself sets page. */
update: (patch: Partial<SearchFilters>) => void;
reset: () => void;
result: SearchResponse | null;
loading: boolean;
error: string | null;
queryMs: number | null;
}
export function useInventorySearch(): InventorySearch {
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
const [result, setResult] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queryMs, setQueryMs] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const update = useCallback((patch: Partial<SearchFilters>) => {
setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch }));
}, []);
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
useEffect(() => {
filtersToUrl(filters);
const timer = setTimeout(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
const t0 = performance.now();
try {
const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, {
credentials: 'include', signal: ctrl.signal,
});
if (!res.ok) throw new Error(`search: HTTP ${res.status}`);
const data: SearchResponse = await res.json();
if (data.error) throw new Error(data.error);
setResult(data);
setError(null);
setQueryMs(Math.round(performance.now() - t0));
} catch (e: any) {
if (e?.name !== 'AbortError') setError(String(e?.message ?? e));
} finally {
if (abortRef.current === ctrl) setLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [filters]);
return { filters, update, reset, result, loading, error, queryMs };
}

View file

@ -98,7 +98,7 @@ export const Sidebar: React.FC<Props> = ({
{/* Tool links */}
<div className="ml-tool-links">
<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
<a href="/?view=inventory" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
<a href="/suitbuilder.html" target="_blank" className="ml-tool-link">🛡️ Suitbuilder</a>
<a href="/debug.html" target="_blank" className="ml-tool-link">🐛 Debug</a>
</div>

View file

@ -0,0 +1,101 @@
/* Inventory Search (/?view=inventory) — dark theme ported from
docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html
All rules are scoped under .inv-* classes (this SPA also renders the
map/dashboard views, so no bare element selectors at file scope). */
.inv-page, .inv-page * { box-sizing: border-box; margin: 0; padding: 0; }
.inv-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #111;
color: #eee;
font-family: "Segoe UI", sans-serif;
font-size: 13px;
}
.inv-main { display: flex; flex: 1; overflow: hidden; }
.inv-error { color: #c66; }
/* ── Top bar ── */
.inv-topbar { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #1a1a1a; border-bottom: 2px solid #333; }
.inv-title { color: #88f; font-weight: 600; font-size: 15px; white-space: nowrap; }
.inv-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; }
.inv-searchbox:focus { border-color: #88f; }
.inv-searchbox::placeholder { color: #666; }
.inv-btn { background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; padding: 6px 14px; font-size: 12px; cursor: pointer; }
.inv-btn:hover { background: #444; color: #fff; }
.inv-count { margin-left: auto; color: #888; font-size: 12px; white-space: nowrap; }
.inv-count b { color: #eee; }
/* ── Chips ── */
.inv-chipsrow { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 14px; background: #151515; border-bottom: 1px solid #2a2a2a; min-height: 37px; }
.inv-chips-lbl { color: #666; font-size: 10px; letter-spacing: 1px; }
.inv-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; }
.inv-chip-x { color: #c66; cursor: pointer; font-weight: bold; }
.inv-chip-x:hover { color: #f88; }
.inv-chip-gold { border-color: #a80; color: #fc6; background: #2a2418; }
/* ── Sidebar ── */
.inv-sidebar { width: 210px; min-width: 210px; background: #1a1a1a; border-right: 2px solid #333; overflow-y: auto; padding: 8px 10px; }
.inv-grp { border-bottom: 1px solid #262626; padding: 6px 0; }
.inv-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; }
.inv-grp-head:hover { color: #ccc; }
.inv-arrow { font-size: 9px; transition: transform .15s; }
.inv-grp.inv-open .inv-arrow { transform: rotate(90deg); }
.inv-badge { margin-left: auto; background: #3a3a6e; color: #cce; border-radius: 8px; padding: 0 7px; font-size: 9px; }
.inv-grp-body { padding: 5px 0 3px; }
.inv-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; }
.inv-minisearch::placeholder { color: #555; }
.inv-fitem { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: #bbb; cursor: pointer; }
.inv-fitem:hover { color: #fff; }
.inv-fitem input { accent-color: #88f; }
.inv-subhead { color: #666; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 2px; }
.inv-linky { color: #88f; font-size: 10px; cursor: pointer; }
.inv-linky:hover { text-decoration: underline; }
.inv-range { display: flex; gap: 4px; align-items: center; margin: 2px 0; }
.inv-range label { flex: 1; color: #999; font-size: 11px; }
.inv-range input { width: 52px; background: #222; border: 1px solid #3a3a3a; border-radius: 3px; color: #ccc; padding: 2px 5px; font-size: 11px; }
/* ── Results ── */
.inv-results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
.inv-results table { width: 100%; border-collapse: collapse; }
.inv-results thead { position: sticky; top: 0; background: #191919; z-index: 1; }
.inv-results 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; }
.inv-results th:hover { color: #aaf; }
.inv-results td { padding: 6px 10px; border-bottom: 1px solid #1e1e1e; font-size: 12px; color: #bbb; vertical-align: top; }
.inv-results tbody tr { cursor: pointer; }
.inv-results tbody tr:hover td { background: #191922; }
.inv-results tbody tr.inv-sel td { background: #20203a; }
.inv-results td.inv-num { text-align: right; font-variant-numeric: tabular-nums; }
.inv-leg { color: #fc6; }
.inv-spells { color: #889; font-size: 11px; }
.inv-equipped { color: #4c4; font-size: 10px; }
.inv-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; }
.inv-pg { padding: 2px 8px; border: 1px solid #333; border-radius: 3px; cursor: pointer; }
.inv-pg.inv-cur { background: #3a3a6e; border-color: #88f; color: #fff; }
/* ── Detail panel ── */
.inv-detail { width: 250px; min-width: 250px; background: #161620; border-left: 2px solid #333; overflow-y: auto; padding: 12px; }
.inv-detail h3 { color: #88f; font-size: 14px; margin-bottom: 2px; }
.inv-detail-sub { color: #777; font-size: 11px; margin-bottom: 10px; }
.inv-kv { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; color: #999; }
.inv-kv b { color: #ddd; font-weight: normal; text-align: right; }
.inv-detail hr { border: none; border-top: 1px solid #2a2a35; margin: 9px 0; }
.inv-sphead { color: #666; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.inv-sp { padding: 2px 0; font-size: 12px; color: #bbb; }
.inv-hint { color: #555; font-size: 10px; margin-top: 12px; }
.inv-closex { float: right; color: #666; cursor: pointer; font-size: 14px; }
.inv-closex:hover { color: #f88; }
/* ── FilterSidebar additions (Task 4) ── */
.inv-dim { color: #666; font-size: 10px; }
.inv-online { width: 6px; height: 6px; border-radius: 50%; background: #4c4; display: inline-block; margin-left: 4px; }
.inv-links { margin-bottom: 4px; font-size: 10px; }
.inv-gold { color: #fc6; }
/* ── ResultsTable additions (Task 5) ── */
.inv-colpicker-anchor { position: relative; align-self: flex-end; padding: 4px 10px; }
.inv-colpicker { position: absolute; right: 10px; top: 30px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; padding: 8px; z-index: 5; max-height: 300px; overflow-y: auto; }
.inv-sorted-asc::after { content: " ▲"; font-size: 9px; }
.inv-sorted-desc::after { content: " ▼"; font-size: 9px; }
.inv-pager-right { margin-left: auto; }

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/effects/deathnotification.tsx","./src/components/effects/rarenotification.tsx","./src/components/map/heatmapcanvas.tsx","./src/components/map/maplayout.tsx","./src/components/map/mapview.tsx","./src/components/map/playerdots.tsx","./src/components/map/portalmarkers.tsx","./src/components/map/sidebar.tsx","./src/components/map/trailssvg.tsx","./src/components/sidebar/playerlist.tsx","./src/components/sidebar/playerrow.tsx","./src/components/sidebar/sidebarwindowbuttons.tsx","./src/components/sidebar/sortbuttons.tsx","./src/components/windows/characterwindow.tsx","./src/components/windows/chatwindow.tsx","./src/components/windows/combatpickerwindow.tsx","./src/components/windows/combatstatswindow.tsx","./src/components/windows/draggablewindow.tsx","./src/components/windows/inventorywindow.tsx","./src/components/windows/issueswindow.tsx","./src/components/windows/playerdashboardwindow.tsx","./src/components/windows/queststatuswindow.tsx","./src/components/windows/radarwindow.tsx","./src/components/windows/statswindow.tsx","./src/components/windows/vitalsharingwindow.tsx","./src/components/windows/windowrenderer.tsx","./src/contexts/windowmanagercontext.tsx","./src/hooks/uselivedata.ts","./src/hooks/useplayercolors.ts","./src/hooks/usewebsocket.ts","./src/types/index.ts","./src/utils/coordinates.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/playerdashboardfullpage.tsx","./src/components/effects/deathnotification.tsx","./src/components/effects/rarenotification.tsx","./src/components/inventory/activechips.tsx","./src/components/inventory/cantripfilter.tsx","./src/components/inventory/characterfilter.tsx","./src/components/inventory/detailpanel.tsx","./src/components/inventory/filtersidebar.tsx","./src/components/inventory/inventorysearchpage.tsx","./src/components/inventory/resultstable.tsx","./src/components/inventory/constants.ts","./src/components/inventory/types.ts","./src/components/inventory/useinventorysearch.ts","./src/components/map/heatmapcanvas.tsx","./src/components/map/maplayout.tsx","./src/components/map/mapview.tsx","./src/components/map/playerdots.tsx","./src/components/map/portalmarkers.tsx","./src/components/map/sidebar.tsx","./src/components/map/trailssvg.tsx","./src/components/midsummer/frogtoggle.tsx","./src/components/midsummer/maypole.tsx","./src/components/midsummer/midsummerbanner.tsx","./src/components/midsummer/midsummerrain.tsx","./src/components/sidebar/playerlist.tsx","./src/components/sidebar/playerrow.tsx","./src/components/sidebar/sidebarwindowbuttons.tsx","./src/components/sidebar/sortbuttons.tsx","./src/components/windows/adminuserswindow.tsx","./src/components/windows/agentwindow.tsx","./src/components/windows/characterwindow.tsx","./src/components/windows/chatwindow.tsx","./src/components/windows/combatpickerwindow.tsx","./src/components/windows/combatstatswindow.tsx","./src/components/windows/draggablewindow.tsx","./src/components/windows/inventorywindow.tsx","./src/components/windows/issueswindow.tsx","./src/components/windows/playerdashboardwindow.tsx","./src/components/windows/queststatuswindow.tsx","./src/components/windows/radarwindow.tsx","./src/components/windows/statswindow.tsx","./src/components/windows/vitalsharingwindow.tsx","./src/components/windows/windowrenderer.tsx","./src/contexts/windowmanagercontext.tsx","./src/hooks/usecurrentuser.ts","./src/hooks/uselivedata.ts","./src/hooks/usemidsummer.tsx","./src/hooks/useplayercolors.ts","./src/hooks/usewebsocket.ts","./src/types/index.ts","./src/utils/coordinates.ts"],"version":"5.8.3"}

View file

@ -1 +1 @@
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-DtA97zvw.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};

View file

@ -1 +1 @@
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-DtA97zvw.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{u as c,j as r,D as d}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};
import{u as c,j as r,D as d}from"./index-DtA97zvw.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{r as c,j as t,D as u,a as f}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};
import{r as c,j as t,D as u,a as f}from"./index-DtA97zvw.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{r as o,j as t,D as d}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};
import{r as o,j as t,D as d}from"./index-DtA97zvw.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -1 +1 @@
import{r as n,j as t,D as x,a as m}from"./index-DlaI7zTK.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};
import{r as n,j as t,D as x,a as m}from"./index-DtA97zvw.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3554,7 +3554,7 @@ function createMilestoneFireworks() {
*/
function openInventorySearch() {
// Open the dedicated inventory search page in a new tab
window.open('/inventory.html', '_blank');
window.open('/?view=inventory', '_blank');
}
/**

View file

@ -8,9 +8,9 @@
<link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/assets/index-DlaI7zTK.js"></script>
<script type="module" crossorigin src="/assets/index-DtA97zvw.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
<link rel="stylesheet" crossorigin href="/assets/index-1RR6uoxX.css">
<link rel="stylesheet" crossorigin href="/assets/index-Dj1_Fy8n.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load diff

View file

@ -1,943 +0,0 @@
/**
* Inventory Search Application
* Dedicated JavaScript for the inventory search page
*/
// Configuration - use main app proxy for inventory service
const API_BASE = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:8766' // Local development - direct to inventory service
: `${window.location.origin}/inv`; // Production - through main app proxy
// DOM Elements - will be set after DOM loads
let searchForm, clearBtn, searchResults;
// Sorting state
let currentSort = {
field: 'name',
direction: 'asc'
};
// Store current search results for client-side sorting
let currentResultsData = null;
// Items limit - load all items for client-side sorting (backend max is 50000)
let currentPage = 1;
let itemsPerPage = 50000;
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
// Get DOM elements after DOM is loaded
searchForm = document.getElementById('inventorySearchForm');
clearBtn = document.getElementById('clearBtn');
searchResults = document.getElementById('searchResults');
initializeEventListeners();
loadCharacterOptions();
renderHiddenColumnsBar();
});
/**
* Initialize all event listeners
*/
function initializeEventListeners() {
// Form submission
searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
await performSearch();
});
// Clear button
clearBtn.addEventListener('click', clearAllFields);
// Slot filter change
document.getElementById('slotFilter').addEventListener('change', handleSlotFilterChange);
// Set analysis buttons
document.getElementById('setAnalysisBtn').addEventListener('click', showSetAnalysis);
document.getElementById('backToSearch').addEventListener('click', showSearchSection);
document.getElementById('runSetAnalysis').addEventListener('click', performSetAnalysis);
// Show/hide weapon type dropdown when equipment type changes
document.querySelectorAll('input[name="equipmentType"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const weaponGroup = document.getElementById('weaponTypeGroup');
weaponGroup.style.display = e.target.value === 'weapon' ? '' : 'none';
if (e.target.value !== 'weapon') {
document.getElementById('weaponTypeFilter').value = '';
}
});
});
// Checkbox visual feedback for cantrips and equipment sets
document.querySelectorAll('.checkbox-item input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', handleCheckboxChange);
});
}
/**
* Load available characters for the checkbox list
*/
async function loadCharacterOptions() {
try {
// Use inventory service proxy endpoint for character list
const response = await fetch(`${window.location.origin}/inventory-characters`);
const data = await response.json();
if (data.characters && data.characters.length > 0) {
const characterList = document.getElementById('characterList');
// Sort characters by name
data.characters.sort((a, b) => a.character_name.localeCompare(b.character_name));
// Add character checkboxes
data.characters.forEach(char => {
const div = document.createElement('div');
div.className = 'character-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `char_${char.character_name}`;
checkbox.value = char.character_name;
checkbox.className = 'character-checkbox';
checkbox.checked = true; // Check all by default
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = char.character_name;
div.appendChild(checkbox);
div.appendChild(label);
characterList.appendChild(div);
});
// Set up event listeners for character selection
setupCharacterCheckboxes();
}
} catch (error) {
console.warn('Could not load character list:', error);
}
}
/**
* Setup character checkbox functionality
*/
function setupCharacterCheckboxes() {
const allCheckbox = document.getElementById('char_all');
const characterCheckboxes = document.querySelectorAll('.character-checkbox');
// Handle "All Characters" checkbox
allCheckbox.addEventListener('change', function() {
characterCheckboxes.forEach(cb => {
cb.checked = this.checked;
});
});
// Handle individual character checkboxes
characterCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
// If any individual checkbox is unchecked, uncheck "All"
if (!this.checked) {
allCheckbox.checked = false;
} else {
// If all individual checkboxes are checked, check "All"
const allChecked = Array.from(characterCheckboxes).every(checkbox => checkbox.checked);
allCheckbox.checked = allChecked;
}
});
});
}
/**
* Handle checkbox change events for visual feedback
*/
function handleCheckboxChange(e) {
const item = e.target.closest('.checkbox-item');
if (e.target.checked) {
item.classList.add('checked');
} else {
item.classList.remove('checked');
}
}
/**
* Clear all form fields and checkboxes
*/
function clearAllFields() {
searchForm.reset();
// Reset character selection to "All"
document.getElementById('char_all').checked = true;
document.querySelectorAll('.character-checkbox').forEach(cb => {
cb.checked = true;
});
// Clear checkbox visual states
document.querySelectorAll('.checkbox-item').forEach(item => {
item.classList.remove('checked');
});
// Reset equipment type to armor
document.getElementById('armorOnly').checked = true;
// Reset slot filter and weapon type
document.getElementById('slotFilter').value = '';
document.getElementById('weaponTypeFilter').value = '';
document.getElementById('weaponTypeGroup').style.display = 'none';
// Clear item state checkboxes (not covered by form.reset for standalone checkboxes)
['searchBonded', 'searchAttuned', 'searchIsRare'].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = false;
});
// Reset page
currentPage = 1;
// Reset results and clear stored data
currentResultsData = null;
searchResults.innerHTML = '<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>';
}
/**
* Handle slot filter changes
*/
function handleSlotFilterChange() {
// If we have current results, reapply filtering and sorting
if (currentResultsData) {
// Reset items to original unfiltered data
const originalData = JSON.parse(JSON.stringify(currentResultsData));
// Apply slot filtering
applySlotFilter(originalData);
// Apply sorting
sortResults(originalData);
// Display results
displayResults(originalData);
}
}
/**
* Perform the search based on form inputs
*/
async function performSearch() {
currentPage = 1;
searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>';
try {
const params = buildSearchParameters();
const searchUrl = `${API_BASE}/search/items?${params.toString()}`;
console.log('Search URL:', searchUrl);
const response = await fetch(searchUrl);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Search failed');
}
// Store results for client-side re-sorting
currentResultsData = data;
// Apply client-side slot filtering
applySlotFilter(data);
// Display results (already sorted by server)
displayResults(data);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = `<div class="error">❌ Search failed: ${error.message}</div>`;
}
}
/**
* Build search parameters from form inputs
*/
function buildSearchParameters() {
const params = new URLSearchParams();
// Equipment type selection
const equipmentType = document.querySelector('input[name="equipmentType"]:checked').value;
if (equipmentType === 'armor') {
params.append('armor_only', 'true');
} else if (equipmentType === 'jewelry') {
params.append('jewelry_only', 'true');
} else if (equipmentType === 'shirt') {
params.append('shirt_only', 'true');
} else if (equipmentType === 'pants') {
params.append('pants_only', 'true');
} else if (equipmentType === 'weapon') {
params.append('weapon_only', 'true');
const weaponType = document.getElementById('weaponTypeFilter')?.value;
if (weaponType) {
params.append('weapon_type', weaponType);
}
} else if (equipmentType === 'clothing') {
params.append('clothing_only', 'true');
}
// If 'all' is selected, don't add any type filter
// Basic search parameters - handle character selection
const allCharactersChecked = document.getElementById('char_all').checked;
if (!allCharactersChecked) {
// Get selected characters
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
.map(cb => cb.value);
if (selectedCharacters.length === 1) {
// Single character selected
params.append('character', selectedCharacters[0]);
} else if (selectedCharacters.length > 1) {
// Multiple characters - use comma-separated list
params.append('characters', selectedCharacters.join(','));
} else {
// No characters selected - search nothing
return { items: [], total_count: 0, page: 1, total_pages: 0 };
}
} else {
// All characters selected
params.append('include_all_characters', 'true');
}
addParam(params, 'text', 'searchText');
addParam(params, 'material', 'searchMaterial');
const equipStatus = document.getElementById('searchEquipStatus').value;
if (equipStatus && equipStatus !== 'all') {
params.append('equipment_status', equipStatus);
}
// Armor statistics parameters
addParam(params, 'min_armor', 'searchMinArmor');
addParam(params, 'max_armor', 'searchMaxArmor');
addParam(params, 'min_crit_damage_rating', 'searchMinCritDamage');
addParam(params, 'max_crit_damage_rating', 'searchMaxCritDamage');
addParam(params, 'min_damage_rating', 'searchMinDamageRating');
addParam(params, 'max_damage_rating', 'searchMaxDamageRating');
addParam(params, 'min_heal_boost_rating', 'searchMinHealBoost');
addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost');
addParam(params, 'min_vitality_rating', 'searchMinVitalityRating');
addParam(params, 'min_damage_resist_rating', 'searchMinDamageResistRating');
addParam(params, 'min_crit_damage_resist_rating', 'searchMinCritDamageResistRating');
// Weapon / combat stats
addParam(params, 'min_damage', 'searchMinDamage');
addParam(params, 'max_damage', 'searchMaxDamage');
addParam(params, 'min_attack_bonus', 'searchMinAttackBonus');
addParam(params, 'min_crit_resist_rating', 'searchMinCritResistRating');
addParam(params, 'min_tinks', 'searchMinTinks');
// Requirements parameters
addParam(params, 'min_level', 'searchMinLevel');
addParam(params, 'max_level', 'searchMaxLevel');
addParam(params, 'min_workmanship', 'searchMinWorkmanship');
addParam(params, 'max_workmanship', 'searchMaxWorkmanship');
// Value parameters
addParam(params, 'min_value', 'searchMinValue');
addParam(params, 'max_value', 'searchMaxValue');
addParam(params, 'max_burden', 'searchMaxBurden');
// Spell text search
addParam(params, 'spell_contains', 'searchSpellContains');
// Item state filters (only send when checked)
if (document.getElementById('searchBonded')?.checked) {
params.append('bonded', 'true');
}
if (document.getElementById('searchAttuned')?.checked) {
params.append('attuned', 'true');
}
if (document.getElementById('searchIsRare')?.checked) {
params.append('is_rare', 'true');
}
// Equipment set filters
const selectedEquipmentSets = getSelectedEquipmentSets();
if (selectedEquipmentSets.length === 1) {
params.append('item_set', selectedEquipmentSets[0]);
} else if (selectedEquipmentSets.length > 1) {
params.append('item_sets', selectedEquipmentSets.join(','));
}
// Cantrip filters
const selectedCantrips = getSelectedCantrips();
const selectedProtections = getSelectedProtections();
const allSpells = [...selectedCantrips, ...selectedProtections];
if (allSpells.length > 0) {
params.append('legendary_cantrips', allSpells.join(','));
}
// Equipment slot filters
const selectedSlots = getSelectedSlots();
if (selectedSlots.length > 0) {
params.append('slot_names', selectedSlots.join(','));
}
// Pagination parameters
params.append('page', currentPage);
params.append('limit', itemsPerPage);
// Sorting parameters
params.append('sort_by', currentSort.field);
params.append('sort_dir', currentSort.direction);
return params;
}
/**
* Helper function to add parameter if value exists
*/
function addParam(params, paramName, elementId) {
const value = document.getElementById(elementId)?.value?.trim();
if (value) {
params.append(paramName, value);
}
}
/**
* Get selected equipment sets
*/
function getSelectedEquipmentSets() {
const selectedSets = [];
document.querySelectorAll('#equipmentSets input[type="checkbox"]:checked').forEach(cb => {
selectedSets.push(cb.value);
});
return selectedSets;
}
/**
* Get selected legendary cantrips
*/
function getSelectedCantrips() {
const selectedCantrips = [];
document.querySelectorAll('#cantrips input[type="checkbox"]:checked').forEach(cb => {
selectedCantrips.push(cb.value);
});
return selectedCantrips;
}
/**
* Get selected protection spells
*/
function getSelectedProtections() {
const selectedProtections = [];
document.querySelectorAll('#protections input[type="checkbox"]:checked').forEach(cb => {
selectedProtections.push(cb.value);
});
return selectedProtections;
}
/**
* Get selected equipment slots from checkboxes
*/
function getSelectedSlots() {
const selectedSlots = [];
// Get all selected slot checkboxes from the all-slots container
document.querySelectorAll('#all-slots input[type="checkbox"]:checked').forEach(cb => {
selectedSlots.push(cb.value);
});
return selectedSlots;
}
// Column definitions — single source of truth for headers, data, and visibility
const RESULT_COLUMNS = [
{ key: 'character_name', label: 'Character', sort: 'character_name', defaultVisible: true },
{ key: 'status', label: 'Status', sort: 'is_equipped', defaultVisible: true,
render: item => { const s = item.is_equipped; return `<td class="${s ? 'status-equipped' : 'status-inventory'}">${s ? '⚔️ Equipped' : '📦 Inventory'}</td>`; } },
{ key: 'name', label: 'Item Name', sort: 'name', defaultVisible: true, cls: 'item-name',
render: item => { let n = item.name; if (item.material_name && item.material_name !== '' && !n.toLowerCase().includes(item.material_name.toLowerCase())) n = `${item.material_name} ${n}`; return `<td class="item-name">${n}</td>`; } },
{ key: 'item_type_name', label: 'Type', sort: 'item_type_name', defaultVisible: true },
{ key: 'slot_name', label: 'Slot', sort: 'slot_name', defaultVisible: true, cls: 'text-right narrow-col',
render: item => `<td class="text-right narrow-col">${item.slot_name ? item.slot_name.replace(/,\s*/g, '<br>') : '-'}</td>` },
{ key: 'coverage', label: 'Coverage', sort: 'coverage', defaultVisible: false, cls: 'text-right narrow-col',
render: item => `<td class="text-right narrow-col">${item.coverage ? item.coverage.replace(/,\s*/g, '<br>') : '-'}</td>` },
{ key: 'armor_level', label: 'Armor', sort: 'armor', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.armor_level > 0 ? item.armor_level : '-'}</td>` },
{ key: 'base_armor_level', label: 'Base Armor', sort: 'base_armor_level', defaultVisible: false, cls: 'text-right',
render: item => `<td class="text-right">${item.base_armor_level > 0 ? item.base_armor_level : '-'}</td>` },
{ key: 'max_damage', label: 'Max Dmg', sort: 'max_damage', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.max_damage > 0 ? item.max_damage : '-'}</td>` },
{ key: 'base_max_damage', label: 'Base Dmg', sort: 'base_max_damage', defaultVisible: false, cls: 'text-right',
render: item => `<td class="text-right">${item.base_max_damage > 0 ? item.base_max_damage : '-'}</td>` },
{ key: 'weapon_time', label: 'Speed', sort: 'weapon_time', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.weapon_time > 0 && item.weapon_time < 100 ? item.weapon_time : '-'}</td>` },
{ key: 'attack_bonus', label: 'Attack Bonus', sort: 'attack_bonus', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.attack_bonus > 0 ? '+' + ((item.attack_bonus - 1) * 100).toFixed(0) + '%' : '-'}</td>` },
{ key: 'melee_defense_bonus', label: 'Melee Def', sort: 'melee_defense_bonus', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.melee_defense_bonus > 0 ? '+' + ((item.melee_defense_bonus - 1) * 100).toFixed(0) + '%' : '-'}</td>` },
{ key: 'material_name', label: 'Material', sort: 'material_name', defaultVisible: false },
{ key: 'workmanship', label: 'Wkm', sort: 'workmanship', defaultVisible: false, cls: 'text-right',
render: item => `<td class="text-right">${item.workmanship > 0 ? item.workmanship : '-'}</td>` },
{ key: 'item_set', label: 'Set', sort: 'item_set', defaultVisible: true, cls: 'set-col',
render: item => { let s = '-'; if (item.item_set_name) { s = item.item_set_name.replace(/\s+Set$/i, ''); } else if (item.item_set) { s = item.item_set.replace(/^Set\s+/i, '').replace(/\s+Set$/i, ''); } return `<td class="set-col" title="${s}">${s}</td>`; } },
{ key: 'spell_names', label: 'Spells/Cantrips', sort: 'spell_names', defaultVisible: true, cls: 'spells-cell medium-col',
render: item => { if (!item.spell_names || item.spell_names.length === 0) return '<td class="spells-cell medium-col">-</td>'; const f = item.spell_names.map(sp => sp.toLowerCase().includes('legendary') ? `<span class="legendary-cantrip">${sp}</span>` : `<span class="regular-spell">${sp}</span>`); return `<td class="spells-cell medium-col">${f.join('<br>')}</td>`; } },
{ key: 'crit_damage_rating', label: 'Crit Dmg', sort: 'crit_damage_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.crit_damage_rating > 0 ? item.crit_damage_rating : '-'}</td>` },
{ key: 'damage_rating', label: 'Dmg Rating', sort: 'damage_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.damage_rating > 0 ? item.damage_rating : '-'}</td>` },
{ key: 'heal_boost_rating', label: 'Heal Boost', sort: 'heal_boost_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.heal_boost_rating > 0 ? item.heal_boost_rating : '-'}</td>` },
{ key: 'vitality_rating', label: 'Vitality', sort: 'vitality_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.vitality_rating > 0 ? item.vitality_rating : '-'}</td>` },
{ key: 'damage_resist_rating', label: 'Dmg Resist', sort: 'damage_resist_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.damage_resist_rating > 0 ? item.damage_resist_rating : '-'}</td>` },
{ key: 'crit_damage_resist_rating', label: 'Crit Dmg Resist', sort: 'crit_damage_resist_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.crit_damage_resist_rating > 0 ? item.crit_damage_resist_rating : '-'}</td>` },
{ key: 'last_updated', label: 'Last Updated', sort: 'last_updated', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.last_updated ? new Date(item.last_updated).toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) : '-'}</td>` },
];
// Load column visibility from localStorage or use defaults
function getColumnVisibility() {
try {
const saved = localStorage.getItem('inventoryColumnVisibility');
if (saved) return JSON.parse(saved);
} catch (e) {}
const vis = {};
RESULT_COLUMNS.forEach(c => vis[c.key] = c.defaultVisible);
return vis;
}
let columnVisibility = getColumnVisibility();
function saveColumnVisibility() {
localStorage.setItem('inventoryColumnVisibility', JSON.stringify(columnVisibility));
}
function renderHiddenColumnsBar() {
const bar = document.getElementById('hiddenColumnsBar');
const list = document.getElementById('hiddenColumnsList');
if (!bar || !list) return;
const hidden = RESULT_COLUMNS.filter(c => columnVisibility[c.key] === false);
if (hidden.length === 0) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
list.innerHTML = '';
hidden.forEach(col => {
const btn = document.createElement('button');
btn.textContent = `+ ${col.label}`;
btn.style.cssText = 'font-size:9px;padding:1px 5px;border:1px solid #999;background:#fff;cursor:pointer;border-radius:3px;margin-right:3px;';
btn.addEventListener('click', () => {
columnVisibility[col.key] = true;
saveColumnVisibility();
if (currentResultsData) displayResults(currentResultsData);
renderHiddenColumnsBar();
});
list.appendChild(btn);
});
}
function hideColumn(key) {
columnVisibility[key] = false;
saveColumnVisibility();
if (currentResultsData) displayResults(currentResultsData);
renderHiddenColumnsBar();
}
/**
* Display search results in the UI
*/
function displayResults(data) {
if (!data.items || data.items.length === 0) {
searchResults.innerHTML = '<div class="no-results">No items found matching your search criteria.</div>';
return;
}
const visibleCols = RESULT_COLUMNS.filter(c => columnVisibility[c.key] !== false);
const getSortIcon = (field) => {
if (currentSort.field === field) {
return currentSort.direction === 'asc' ? ' ▲' : ' ▼';
}
return '';
};
let html = `
<div class="results-info">
Found <strong>${data.total_count}</strong> items - Showing all results
</div>
<table class="results-table">
<thead>
<tr>
${visibleCols.map(c => `<th class="${c.cls || ''} sortable" data-sort="${c.sort}"><span class="th-label">${c.label}${getSortIcon(c.sort)}</span><span class="th-hide" data-col="${c.key}" title="Hide column">\u00d7</span></th>`).join('\n ')}
</tr>
</thead>
<tbody>
`;
data.items.forEach((item) => {
html += '<tr>';
visibleCols.forEach(col => {
if (col.render) {
html += col.render(item);
} else {
const val = item[col.key];
html += `<td class="${col.cls || ''}">${val != null && val !== '' && val !== -1 ? val : '-'}</td>`;
}
});
html += '</tr>';
});
html += `
</tbody>
</table>
`;
searchResults.innerHTML = html;
// Add click event listeners to sortable headers (full th area sorts)
document.querySelectorAll('.sortable').forEach(header => {
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const sortField = header.getAttribute('data-sort');
handleSort(sortField);
});
});
// Add click event listeners to hide buttons
document.querySelectorAll('.th-hide').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
hideColumn(btn.getAttribute('data-col'));
});
});
// Update hidden columns bar
renderHiddenColumnsBar();
// Initialize resizable columns
initResizableColumns();
}
/**
* Initialize resizable columns for the results table
*/
function initResizableColumns() {
const table = document.querySelector('.results-table');
if (!table) return;
const headers = table.querySelectorAll('th');
headers.forEach((header, index) => {
// Skip last column (no need to resize)
if (index === headers.length - 1) return;
// Create resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'resize-handle';
header.appendChild(resizeHandle);
let startX, startWidth;
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation(); // Prevent sorting when resizing
startX = e.pageX;
startWidth = header.offsetWidth;
header.classList.add('resizing');
resizeHandle.classList.add('resizing');
const onMouseMove = (e) => {
const diff = e.pageX - startX;
const newWidth = Math.max(30, startWidth + diff); // Minimum 30px width
header.style.width = newWidth + 'px';
header.style.minWidth = newWidth + 'px';
};
const onMouseUp = () => {
header.classList.remove('resizing');
resizeHandle.classList.remove('resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Save column widths to localStorage
saveColumnWidths();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
// Restore saved column widths
restoreColumnWidths();
}
/**
* Save column widths to localStorage
*/
function saveColumnWidths() {
const headers = document.querySelectorAll('.results-table th');
const widths = [];
headers.forEach(header => {
widths.push(header.style.width || '');
});
localStorage.setItem('inventoryColumnWidths', JSON.stringify(widths));
}
/**
* Restore column widths from localStorage
*/
function restoreColumnWidths() {
const saved = localStorage.getItem('inventoryColumnWidths');
if (!saved) return;
try {
const widths = JSON.parse(saved);
const headers = document.querySelectorAll('.results-table th');
headers.forEach((header, index) => {
if (widths[index]) {
header.style.width = widths[index];
header.style.minWidth = widths[index];
}
});
} catch (e) {
console.error('Failed to restore column widths:', e);
}
}
/**
* Apply client-side slot filtering
*/
function applySlotFilter(data) {
const selectedSlot = document.getElementById('slotFilter').value;
if (!selectedSlot || !data.items) {
return; // No filter or no data
}
// Filter items that can be equipped in the selected slot
data.items = data.items.filter(item => {
const slotName = item.slot_name || '';
// Check if the item's slot_name contains the selected slot
// This handles multi-slot items like "Left Ring, Right Ring"
return slotName.includes(selectedSlot);
});
// Update total count
data.total_count = data.items.length;
}
/**
* Sort results client-side based on current sort settings
*/
function sortResults(data) {
if (!data.items || data.items.length === 0) return;
const field = currentSort.field;
const direction = currentSort.direction;
data.items.sort((a, b) => {
let aVal = a[field];
let bVal = b[field];
// Handle null/undefined values
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Special handling for spell_names array
if (field === 'spell_names') {
// Convert arrays to strings for sorting
aVal = Array.isArray(aVal) ? aVal.join(', ').toLowerCase() : '';
bVal = Array.isArray(bVal) ? bVal.join(', ').toLowerCase() : '';
const result = aVal.localeCompare(bVal);
return direction === 'asc' ? result : -result;
}
// Determine if we're sorting numbers or strings
const isNumeric = typeof aVal === 'number' || (!isNaN(aVal) && !isNaN(parseFloat(aVal)));
if (isNumeric) {
// Numeric sorting
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
const result = aVal - bVal;
return direction === 'asc' ? result : -result;
} else {
// String sorting
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
const result = aVal.localeCompare(bVal);
return direction === 'asc' ? result : -result;
}
});
}
/**
* Handle column sorting - client-side sorting for all columns
*/
function handleSort(field) {
// If clicking the same field, toggle direction
if (currentSort.field === field) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
// New field, default to ascending
currentSort.field = field;
currentSort.direction = 'asc';
}
// If we have cached results, sort them client-side and re-display
if (currentResultsData && currentResultsData.items) {
// Make a copy to preserve original order
const sortedData = JSON.parse(JSON.stringify(currentResultsData));
sortResults(sortedData);
displayResults(sortedData);
} else {
// No cached results, need to fetch first
performSearch();
}
}
/**
* Show set analysis section
*/
function showSetAnalysis() {
document.getElementById('setAnalysisSection').style.display = 'block';
document.getElementById('searchResults').style.display = 'none';
searchForm.style.display = 'none';
}
/**
* Show search section
*/
function showSearchSection() {
document.getElementById('setAnalysisSection').style.display = 'none';
document.getElementById('searchResults').style.display = 'block';
searchForm.style.display = 'block';
}
/**
* Perform set combination analysis
*/
async function performSetAnalysis() {
const primarySet = document.getElementById('primarySetSelect').value;
const secondarySet = document.getElementById('secondarySetSelect').value;
const setAnalysisResults = document.getElementById('setAnalysisResults');
if (primarySet === secondarySet) {
setAnalysisResults.innerHTML = '<div class="error">❌ Primary and secondary sets must be different.</div>';
return;
}
setAnalysisResults.innerHTML = '<div class="loading">🔍 Analyzing set combinations...</div>';
try {
const params = new URLSearchParams();
params.append('primary_set', primarySet);
params.append('secondary_set', secondarySet);
params.append('primary_count', '5');
params.append('secondary_count', '4');
// Use selected characters or all characters
const allCharactersChecked = document.getElementById('char_all').checked;
if (allCharactersChecked) {
params.append('include_all_characters', 'true');
} else {
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
.map(cb => cb.value);
if (selectedCharacters.length > 0) {
params.append('characters', selectedCharacters.join(','));
} else {
setAnalysisResults.innerHTML = '<div class="error">❌ Please select at least one character or check "All Characters".</div>';
return;
}
}
const analysisUrl = `${API_BASE}/analyze/sets?${params.toString()}`;
console.log('Set Analysis URL:', analysisUrl);
const response = await fetch(analysisUrl);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Set analysis failed');
}
displaySetAnalysisResults(data);
} catch (error) {
console.error('Set analysis error:', error);
setAnalysisResults.innerHTML = `<div class="error">❌ Set analysis failed: ${error.message}</div>`;
}
}
/**
* Display set analysis results
*/
function displaySetAnalysisResults(data) {
const setAnalysisResults = document.getElementById('setAnalysisResults');
if (!data.character_analysis || data.character_analysis.length === 0) {
setAnalysisResults.innerHTML = '<div class="no-results">No characters found with the selected sets.</div>';
return;
}
let html = `
<div class="results-info">
<strong>${data.primary_set.name}</strong> (${data.primary_set.pieces_needed} pieces) +
<strong>${data.secondary_set.name}</strong> (${data.secondary_set.pieces_needed} pieces)<br>
Found <strong>${data.characters_can_build}</strong> of <strong>${data.total_characters}</strong> characters who can build this combination
</div>
<table class="results-table">
<thead>
<tr>
<th>Character</th>
<th>Can Build?</th>
<th>${data.primary_set.name}</th>
<th>${data.secondary_set.name}</th>
<th>Primary Items</th>
<th>Secondary Items</th>
</tr>
</thead>
<tbody>
`;
data.character_analysis.forEach((char) => {
const canBuild = char.can_build_combination;
const canBuildText = canBuild ? '✅ Yes' : '❌ No';
const canBuildClass = canBuild ? 'status-equipped' : 'status-inventory';
const primaryStatus = `${char.primary_set_available}/${char.primary_set_needed}`;
const secondaryStatus = `${char.secondary_set_available}/${char.secondary_set_needed}`;
// Format item lists
const primaryItems = char.primary_items.map(item =>
`${item.name}${item.equipped ? ' ⚔️' : ''}`
).join('<br>') || '-';
const secondaryItems = char.secondary_items.map(item =>
`${item.name}${item.equipped ? ' ⚔️' : ''}`
).join('<br>') || '-';
html += `
<tr>
<td><strong>${char.character_name}</strong></td>
<td class="${canBuildClass}">${canBuildText}</td>
<td class="text-right">${primaryStatus}</td>
<td class="text-right">${secondaryStatus}</td>
<td class="spells-cell">${primaryItems}</td>
<td class="spells-cell">${secondaryItems}</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
setAnalysisResults.innerHTML = html;
}
// Pagination functions removed - using client-side sorting with all results loaded