docs(plan): inventory search redesign implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
edf76f50db
commit
93982ac3dd
1 changed files with 986 additions and 0 deletions
986
docs/superpowers/plans/2026-07-15-inventory-search-redesign.md
Normal file
986
docs/superpowers/plans/2026-07-15-inventory-search-redesign.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue