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