diff --git a/.cursor/rules/project-rules.mdc b/.cursor/rules/project-rules.mdc index ce100e71..272ffc2e 100644 --- a/.cursor/rules/project-rules.mdc +++ b/.cursor/rules/project-rules.mdc @@ -36,8 +36,8 @@ Cross-cutting conventions live in sibling rules: `code-style.mdc`, `naming.mdc`, - **Data controls** — toolbar **Data** / **Данные** opens `DataControlsPanel` (2×2 unit blocks: reset all toggles, delete all characters, delete all dungeons or add-from-template when empty, delete all custom BiS lists); confirms via `DeleteConfirmDialog`; domain `handleDeleteAllCharacters` / `handleDeleteAllDungeons` / `handleResetAllToggles` + BiS `clearAllLocalPresets` (`clearAllLocalBisPresets` in `utils/bis-lists.ts`) - **Delete dungeon** — per-row edit and delete icons in the pinned actions column; delete removes that dungeon and its toggles - **Reset per character** — icon in the character column header clears toggles for that character only -- **Add forms** — only one of character or dungeon form open at a time; wrapped in `TrackerToolbarPanel` with title and close ✕ (no Cancel button); submit closes the form; opening a form closes export, soft pick, BiS, and Data panels; duplicate character (name + class) is rejected; new character names must be Unicode letters only (`isCharacterNameLettersOnly` / `\p{L}+` — no digits/symbols; legacy names are not re-checked on load); create/display capitalize first letter via `formatCharacterDisplayName` (`qwe` → `Qwe`); character form accepts optional main/off spec and gear score (WotLK specs for selected class; main and off must differ when both set); dungeon form name field suggests locale-aware WotLK raid names (autocomplete, free-text still allowed); optional short name defaults from `RaidNames` when a known raid is selected (updates when switching suggestions) -- **Toolbar panels** — shared shell in `tracker-toolbar-panel/` (`TrackerToolbarPanel`: outlined `Paper`, title, optional description + tooltip, close icon); metadata via `getToolbarPanelMeta` in `toolbar-panel-meta.ts`; active panel resolved by `resolveToolbarPanelId` (character → dungeon → export → gear → bis → data); character, dungeon, export, soft pick, BiS, and Data panels all render inside one `TrackerToolbarPanel` in `RaidTrackerMain` (export/soft pick use `tableState.sortedDungeons` for filtered rows); narrow panels (character, dungeon) use `TRACKER_NARROW_PANEL_MAX_WIDTH` (480); BiS uses `TRACKER_WIDE_PANEL_MAX_WIDTH` (1280); Character pick / Soft pick use `TRACKER_UNIT_GRID_PANEL_MAX_WIDTH` (1920) so the unit-grid side-by-side layouts keep a 2-unit results/softs column; Data uses `TRACKER_DATA_CONTROLS_PANEL_MAX_WIDTH` (680) for a 2×2 unit grid; `useScrollToTopOnPanelOpen` scrolls to top on open/switch +- **Add forms** — only one of character or dungeon form open at a time; wrapped in `TrackerToolbarPanel` with title and close ✕ (no Cancel button); submit closes the form; opening a form closes character pick, soft pick, BiS, and Data panels; duplicate character (name + class) is rejected; new character names must be Unicode letters only (`isCharacterNameLettersOnly` / `\p{L}+` — no digits/symbols; legacy names are not re-checked on load); create/display capitalize first letter via `formatCharacterDisplayName` (`qwe` → `Qwe`); character form accepts optional main/off spec and gear score (WotLK specs for selected class; main and off must differ when both set); dungeon form name field suggests locale-aware WotLK raid names (autocomplete, free-text still allowed); optional short name defaults from `RaidNames` when a known raid is selected (updates when switching suggestions) +- **Toolbar panels** — shared shell in `tracker-toolbar-panel/` (`TrackerToolbarPanel`: outlined `Paper`, title, optional description + tooltip, close icon); metadata via `getToolbarPanelMeta` in `toolbar-panel-meta.ts`; active panel resolved by `resolveToolbarPanelId` (character → dungeon → characterPick → gear → bis → data); character, dungeon, character pick, soft pick, BiS, and Data panels all render inside one `TrackerToolbarPanel` in `RaidTrackerMain` (character pick/soft pick use `tableState.sortedDungeons` for filtered rows); narrow panels (character, dungeon) use `TRACKER_NARROW_PANEL_MAX_WIDTH` (480); BiS uses `TRACKER_WIDE_PANEL_MAX_WIDTH` (1280); Character pick / Soft pick use `TRACKER_UNIT_GRID_PANEL_MAX_WIDTH` (1920) so the unit-grid side-by-side layouts keep a 2-unit results/softs column; Data uses `TRACKER_DATA_CONTROLS_PANEL_MAX_WIDTH` (680) for a 2×2 unit grid; `useScrollToTopOnPanelOpen` scrolls to top on open/switch - **Edit character** — edit icon in character column header opens `CharacterEditDialog` (`maxWidth` lg) to update main/off spec and gear score (name and class unchanged); optional **table column order** control in the dialog title (`CharacterTableOrderControl`: compact `Position: < n >` with ←/→; `moveCharacter` swaps array order immediately; persists via existing save debounce; no schema field); class row uses `ClassOptionLabel`; `CharacterSpecGearColumn` per side (main | off): `CharacterSingleSpecGearFields` (vertical layout), stored gear list, WowSims import via `CharacterSpecGearImportSection` (**Import gear**, **Clear gear**); changing a spec clears that column’s gear draft until re-imported; save ties gear to spec name via `character-edit-spec-gear.ts`; `StoredGearItemLine` shows slot/ilvl muted with `WowItemLink` item names styled via `item-link-styles.ts`; shared **Also owned gear** / **Также есть** (`CharacterAlsoOwnedGearSection`) lists item ids available outside the WowSims export — counts for BiS/ilvl/tier-token hints and Soft pick on both specs - **BiS lists** — toolbar **BiS builds** (EN) / **BIS сборки** (RU) opens `BisListsPanel` inside `TrackerToolbarPanel` (session state via `useBisListsSessionState` in `RaidTrackerMain` — class/spec survive switching away and back; preset selection already in BiS localStorage); class/spec selectors use `ClassOptionLabel` / `SpecOptionLabel`; built-in presets read-only (all 27 WotLK specs; Kingdom chip first); local presets editable (item IDs per slot, confirm per slot, save/delete); slot row UI in `bis-slot-row/` with `GearSlotIcon` (WoW paper-doll placeholders from `assets/gear-slot-icons/`; Back reuses Chest; shirt/tabard cosmetic placeholders via `BisCosmeticSlotRow`); view mode: primary `itemIds[0]` + drop sources, then stacked **Alternatives** / **Варианты** for `itemIds[1…]` (gear-hint tooltips keep compact `WowItemAlternatives` `A / B`); **Copy** on Items header copies pasteable `Slot: Item` lines via `formatBisListCopyText`; items laid out as in-game paper doll via `data/bis-paper-doll-slots.ts` (left|right armor pairs in a table for row alignment, bottom main/off/ranged unchanged); editor draft/validation in `utils/bis-list-editor.ts` + `use-bis-lists-editor-state.ts` (session sync on class/spec/preset change); `isCustomListCreation` fallback when a class/spec has no built-in preset; slot validation uses `canEquipItemForCharacter` for the panel’s class/spec; `BisListsDomain.getBisSlotMapForSpec` drives upgrade hints; slot view shows `BisItemDropSources` for the primary item (export-style raid + boss caption when bundled data exists; no caption when no raid drop); save failures surface `storageError` as an alert; close hides external WoW tooltips - **Gear upgrade hints** — `CharacterToggleCell` (memoized per cell; `locale` + stable `getBisSlotMapForSpec` callback in equality check) cell tints via `evaluateCharacterGearHintTints` / `getGearHintCellDisplay` with MUI cell sx from `gear-hint-display.ts`; tooltips enrich via `evaluateCharacterGearHintsFromTints` (boss groups lazily): **amber** (`theme.palette.warning`, 3 intensities) for missing BiS exact or normal-variant targets when a list is selected; **blue** (`theme.palette.info` in `create-app-theme.ts`, 3 intensities) for stat-filtered ilvl upgrades — not tied to dungeon ilvl-tier rainbow; hints hidden when the character–dungeon toggle is on (marked complete); controlled hint tooltip closes on switch toggle; dungeon **name** and **ilvl** columns use plain typography (ilvl-tier colors remain on item links only); `bis-item-variants.ts` links same-name N/H ids per slot plus explicit faction-variant groups (e.g. ICC Solace Alliance/Horde); BiS satisfaction uses `isItemIdOrSameIlvlFactionVariantAtSlot` (exact) / name variants + same-ilvl faction twins (variant), including character `alsoOwnedItemIds`; ilvl exclusion uses name variants of equipped + also-owned (`filterLootIdsNotNameVariantOfPossessedAtSlot`) so faction twins stay choosable; BiS→ilvl scrub uses `expandItemIdsWithNameVariantsAtSlot` only; split cell when character has main and off specs (`gearUpgradeHintDualCellSx`: left = main, right = off; untinted half when that spec has no cell hint; hint tints on a `::after` overlay so row hover shows through evenly); dismissible **GearHintLegend** above the table (`raidwise-gear-hint-legend-dismissed`) when characters and dungeons exist — BiS vs Upgrades colors; tooltip via `GearHintTooltipContent` (boss-loot section headers with amber/blue dot + **BiS** / **Upgrades** label; short ilvl summary via `formatGearUpgradeHintTooltip` when no ilvl boss list; scrollable boss-grouped lists for missing exact + variant + ilvl loot; tier set tokens class-filtered via `canClassUseTierSetToken`, item links only with `×N` when count > 1); ilvl track uses `item-stat-fit.ts` + `spec-stat-priorities.ts` + `wotlk-item-stats.json` + `item-equip-restrictions.ts` (tank specs: `requireTankAvoidanceStats` — ilvl loot must have +defense, +dodge, or +parry; BiS tracks skip stat filter); role-exclusive proc trinket allowlists in `item-stat-fit.ts` (`TankOnlyProcTrinketItemIds`, `HealerOnlyProcTrinketItemIds`, `MeleeOnlyProcTrinketItemIds` — bundled stats often omit proc effects); boss grouping via `groupBisItemIdsByBossForDungeonWithFallback` (flat list when boss metadata missing; ICC/ToC sorted by encounter order in `raid-boss-encounter-order.ts`); BiS tracks skip stat filter; `WowItemLink` is `display: inline` @@ -47,24 +47,24 @@ Cross-cutting conventions live in sibling rules: `code-style.mdc`, `naming.mdc`, - **Character column** — shows class icon, name as class-color chip (`characterNameDisplaySx` / `classColorChipSx`: darkened WoW hex fill + accessible black/white ink), optional spec icons with compact gear score (`6.6k`, rounded down); display name capitalized via `formatCharacterDisplayName`; tooltip has full spec names; header stack order name → specs/GS (always dual slots with `/`; missing main/off as `-`) → edit/reset/delete on one row (character-toggle sort UI removed for now; plumbing marked TODO) - **Compact table** — below `md`, pinned columns are actions + name only (type, item level, completions hidden); name cell shows localized short raid name + size (+ skull for Heroic), e.g. `ICC 25 ☠️` (full name in tooltip) - **Dungeon table search** — header filter parses mixed queries: raid name/short name (EN/RU via `getDungeonSearchNameCandidates`), optional trailing size (`5`/`10`/`20`/`25`/`40`), optional mode suffix (normal `n`/`об`, heroic `h`/`хм`/`х`); e.g. `ICC`, `Uld10`, `ICC25N`, `ЦЛК25об`, `ToC25H`; size without mode matches both difficulties; `parse-dungeon-search-query.ts` + `filter-dungeons-by-name.ts`; placeholder/tooltip examples in `table.dungeonSearchPlaceholder` / `table.filterByDungeonName` -- **Export** — filter dungeons with table search, then **Character pick** / **Подбор персонажа** in toolbar (`toolbar.export`; toolbar order: template → **Character pick** → **Soft pick** → **BiS builds** → add character/dungeon → **Data**); `ExportPanel` inside `TrackerToolbarPanel` (session state via `useExportSessionState` in `RaidTrackerMain` — GS/role/specs/raid exclusions + result format toggles `includeExportSpecs` / `includeExportGearScore` survive switching away and back; not localStorage; title, instructions, close in shell; **Reset all filters** via `ExportPanelHeaderActions` in `headerActions`); wide panel (`TRACKER_UNIT_GRID_PANEL_MAX_WIDTH`; grid `width: 100%` on xs; results column min 2 units on side-by-side); one line per visible raid lists characters without CD. Filter UI is a fixed **3-column × 2-row grid** (`export-panel/constants.ts`: equal **300px** unit columns for GS / role / specs, **224px** fixed 1× row height, specs spans 2×; `getFilterUnitColumnTemplate`, `getExportFilterGridHeight`; `ExportFilterSection` + `ExportFilterBlock` (step chrome: soft brand numeric badge + brand title + 3px left edge — not `N.` text prefix); layout in `constants.test.ts`). Row 1: **min GS** (`export-min-gear-score-filter.tsx` — checkbox, slider `4`–`7`, large bold value under track), **role** (`export-role-filter.tsx` — equal-width 2×2 WoW LFG role icons from `assets/role-icons/`, hover highlight), **character specs** (`export-character-spec-filter.tsx` — shared compact list via `getCharacterSpecListGridSx` / `CharacterSpecListName`; name + main/off checkboxes with spec icons + GS; syncs with visible raids via `hasCharacterWithoutCdInVisibleDungeons` + `clearUnavailableExportSpecSelections`; inactive CD = italic/muted/grayscale, filter-blocked spec = strikethrough + hover tooltips; `CharacterSpecGearLabel` with `showDetailTooltip={false}`; **Select all** / **Clear all** via `export-character-spec-filter-actions.tsx`; scrolls after ~8 rows). Row 2: **raids** (`export-dungeon-filter.tsx` — table search summary + `selected of matched` count; total dungeon count as section `titleMark` `(N total)` / `(N всего)`; clickable chips with `assets/raid-icons/` for template raids; active chips default / passive (click) stay visible and are excluded via `filter-dungeons-excluding-ids.ts`; header reset (`ExportDungeonFilterActions`) clears search + exclusions; **Reset all filters** also clears search/exclusions; scrolls when many raids) spans two columns; specs column continues. **Wide layout:** at viewport ≥**1680px** (`EXPORT_PANEL_SIDE_BY_SIDE_MIN_PX`, `use-export-panel-side-by-side.ts`, static `@media` key `EXPORT_PANEL_SIDE_BY_SIDE_MQ_KEY`), filter grid left + result lines right (`export-result-lines.tsx` fills grid height, scroll inside). Bulk spec helpers in `format-character-export.ts` (`buildSelectAllExportSpecSelection`, `buildClearAllExportSpecSelection`, `getCharacterExportInactiveReason`). Optional **role filter** (tank / healer / melee DPS / ranged DPS via `export-spec-role.ts`): unchecked roles disable matching spec checkboxes and omit those specs from lines (`resolveEffectiveExportSpecSelection`). Optional **min gear score** uses the same effective-selection pattern. Per character: icon checkboxes for main/off spec (when set), or a single checkbox when no specs; result section (`export-result-lines.tsx`) header toggles **Specs** / **GS** (`ExportResultFormatActions`); row order **Copy** → raid badge (with icon) → characters text (copy omits raid prefix) — format `Name Spec gs / Spec gs` with `, ` between characters (e.g. `Elst Udk 6.6 / Blood 6, Beta SP 5.8`; gear scores omit `k`; see `format-character-export.ts`, `build-export-status.ts`, `format-dungeon-label.ts`, `parse-export-min-gear-score.ts`) +- **Character pick** — filter dungeons with table search, then **Character pick** / **Подбор персонажа** in toolbar (`toolbar.export`; toolbar order: template → **Character pick** → **Soft pick** → **BiS builds** → add character/dungeon → **Data**); `CharacterPickPanel` inside `TrackerToolbarPanel` (session state via `useCharacterPickSessionState` in `RaidTrackerMain` — GS/role/specs/raid exclusions + result format toggles `includeExportSpecs` / `includeExportGearScore` survive switching away and back; not localStorage; title, instructions, close in shell; **Reset parameters** via `ResetParametersActions` in `headerActions`); wide panel (`TRACKER_UNIT_GRID_PANEL_MAX_WIDTH`; grid `width: 100%` on xs; results column min 2 units on side-by-side); one line per visible raid lists characters without CD. Filter UI is a fixed **3-column × 2-row grid** (`character-pick-panel/constants.ts + filter-unit/constants.ts`: equal **300px** unit columns for GS / role / specs, **224px** fixed 1× row height, specs spans 2×; `getFilterUnitColumnTemplate`, `getCharacterPickFilterGridHeight`; `FilterSection` + `CharacterPickFilterBlock` (step chrome: soft brand numeric badge + brand title + 3px left edge — not `N.` text prefix); layout in `constants.test.ts`). Row 1: **min GS** (`min-gear-score-filter.tsx` — checkbox, slider `4`–`7`, large bold value under track), **role** (`role-filter.tsx` — equal-width 2×2 WoW LFG role icons from `assets/role-icons/`, hover highlight), **character specs** (`character-spec-filter.tsx` — shared compact list via `getCharacterSpecListGridSx` / `CharacterSpecListName`; name + main/off checkboxes with spec icons + GS; syncs with visible raids via `hasCharacterWithoutCdInVisibleDungeons` + `clearUnavailableExportSpecSelections`; inactive CD = italic/muted/grayscale, filter-blocked spec = strikethrough + hover tooltips; `CharacterSpecGearLabel` with `showDetailTooltip={false}`; **Select all** / **Clear all** via `character-spec-filter-actions.tsx`; scrolls after ~8 rows). Row 2: **raids** (`filter-unit/dungeon-filter.tsx` — table search summary + `selected of matched` count; total dungeon count as section `titleMark` `(N total)` / `(N всего)`; clickable chips with `assets/raid-icons/` for template raids; active chips default / passive (click) stay visible and are excluded via `filter-dungeons-excluding-ids.ts`; header reset (`DungeonFilterActions`) clears search + exclusions; **Reset parameters** also clears search/exclusions; scrolls when many raids) spans two columns; specs column continues. **Wide layout:** at viewport ≥**1680px** (`CHARACTER_PICK_SIDE_BY_SIDE_MIN_PX`, `use-character-pick-panel-side-by-side.ts`, static `@media` key `CHARACTER_PICK_SIDE_BY_SIDE_MQ_KEY`), filter grid left + result lines right (`result-lines.tsx` fills grid height, scroll inside). Bulk spec helpers in `format-character-export.ts` (`buildSelectAllExportSpecSelection`, `buildClearAllExportSpecSelection`, `getCharacterExportInactiveReason`). Optional **role filter** (tank / healer / melee DPS / ranged DPS via `export-spec-role.ts`): unchecked roles disable matching spec checkboxes and omit those specs from lines (`resolveEffectiveExportSpecSelection`). Optional **min gear score** uses the same effective-selection pattern. Per character: icon checkboxes for main/off spec (when set), or a single checkbox when no specs; result section (`result-lines.tsx`) header toggles **Specs** / **GS** (`CharacterPickResultFormatActions`); row order **Copy** → raid badge (with icon) → characters text (copy omits raid prefix) — format `Name Spec gs / Spec gs` with `, ` between characters (e.g. `Elst Udk 6.6 / Blood 6, Beta SP 5.8`; gear scores omit `k`; see `format-character-export.ts`, `build-export-status.ts`, `format-dungeon-label.ts`, `parse-export-min-gear-score.ts`) - **Soft pick** — toolbar **Soft pick** / **Подбор софтов** (`toolbar.gearPick`); `GearPickPanel` in `gear-pick-panel/` (session state via `useGearPickSessionState` in `RaidTrackerMain` — character/spec, rules, softs, raid exclusions, copy format toggles survive switching away and back; not localStorage). Soft rules: max softs 1–4 + system `reroll` (default) / `plus100` (`gear-pick-soft-roll.ts`). Single character + main/off spec (`gear-pick-character-select.tsx`; shared character-spec list layout with Character pick; characters on CD for every visible raid are italic/muted/disabled via `hasCharacterWithoutCdInVisibleDungeons`). Soft targets = BiS exact + variant upgrades from active (non-excluded) filtered raids via `buildGearPickItems` / `evaluateCharacterGearHints` (ICC/ToC bosses ordered by encounter progression; same clickable Selected raid chips / `filter-dungeons-excluding-ids.ts` as Character pick; excluding a raid prunes softs for item ids that drop off the active list via `pruneSoftAssignmentsToItemIds`); other-spec imported gear + `alsoOwnedItemIds` count via `getEffectiveAlsoOwnedItemIds`. Per item: my soft stepper (budget-capped) + others histogram `×1..×max` (zero buckets dimmed) + demand-colored competition caption + win-odds advice (`softCompetitionDemandTone` / `buildRerollOddsOptions` / `buildPlus100OddsAdvice`); reset one row or all Soft targets (`GearPickItemsActions`). Copy block (`GearPickCopyBlock` / `formatGearPickCopyText`): optional name + Details toggles; lines `- Item (Boss) xN` or compact `- Item xN`; fixed **1×2** unit span. Layout uses fixed **300px** filter columns + fixed **2-unit** softs column; container queries wrap softs then copy onto their own rows when the panel is narrower than ~1236px / ~1860px (`getGearPickMdLayoutMinWidth` / `getGearPickWideLayoutMinWidth`). Close hides external WoW tooltips -- **Data controls** — toolbar **Data** / **Данные** (`toolbar.dataControls`); `DataControlsPanel` in `data-controls-panel/` — 2×2 `EXPORT_FILTER_UNIT_*` blocks (`ExportFilterSection` + `DataControlsBlock`); reset toggles / delete all characters / delete all dungeons (or **Add from template** when empty) / delete all custom BiS lists; confirms with `DeleteConfirmDialog` +- **Data controls** — toolbar **Data** / **Данные** (`toolbar.dataControls`); `DataControlsPanel` in `data-controls-panel/` — 2×2 `FILTER_UNIT_*` blocks (`FilterSection` + `DataControlsBlock`); reset toggles / delete all characters / delete all dungeons (or **Add from template** when empty) / delete all custom BiS lists; confirms with `DeleteConfirmDialog` - **Empty table** — no dungeons: body hint to add or use template; search with no matches: “No dungeons match your search” row (`raid-tracker-table-empty-state`) ## Component Structure -- Domain state lives in `useTrackerDomain` (persistence, toggles, deletes, template, `updateCharacter`, `updateDungeon`, bulk delete-all helpers); `RaidTrackerProvider` + `useRaidTrackerContext()` expose domain to the table and main content. `BisListsProvider` + `useBisListsContext()` expose `useBisListsDomain` (presets, selection, `getBisSlotMapForSpec`, local save/delete, `clearAllLocalPresets`). `TrackerLayout` composes toolbar (`TrackerControls`) and `useOverlayPanels` (mutually exclusive character/dungeon/export/gear/BiS/data panels + form field state via `pickTrackerFormsState`); `closeAllOverlayPanels` for template/toolbar switches; add-form field state is not in raid-tracker context (avoids re-rendering toolbar/table on keystrokes). `RaidTrackerMain` owns `useRaidTrackerTableState` and renders all toolbar panels + passes `tableState` to `RaidTrackerTable`. Validation in `utils/validate-character.ts` and `utils/validate-dungeon.ts`. `CharacterForm` / `DungeonForm` / `CharacterEditDialog` / `DungeonEditDialog` / `BisListsPanel` / `GearPickPanel` / `DataControlsPanel` stay presentational (panel chrome in `TrackerToolbarPanel`). `App` mounts providers + `TrackerLayout` only. Edit-character and edit-dungeon dialog state lives in `RaidTrackerTable`. Character name display / capitalize in `utils/character-display.ts`; completion chip fills in `utils/completion-chip-color.ts`; spec/gear display in `utils/format-character-details.ts` and `components/spec-option-label/`; gear import in `utils/parse-wowsims-exporter.ts`. +- Domain state lives in `useTrackerDomain` (persistence, toggles, deletes, template, `updateCharacter`, `updateDungeon`, bulk delete-all helpers); `RaidTrackerProvider` + `useRaidTrackerContext()` expose domain to the table and main content. `BisListsProvider` + `useBisListsContext()` expose `useBisListsDomain` (presets, selection, `getBisSlotMapForSpec`, local save/delete, `clearAllLocalPresets`). `TrackerLayout` composes toolbar (`TrackerControls`) and `useOverlayPanels` (mutually exclusive character/dungeon/characterPick/gear/BiS/data panels + form field state via `pickTrackerFormsState`); `closeAllOverlayPanels` for template/toolbar switches; add-form field state is not in raid-tracker context (avoids re-rendering toolbar/table on keystrokes). `RaidTrackerMain` owns `useRaidTrackerTableState` and renders all toolbar panels + passes `tableState` to `RaidTrackerTable`. Validation in `utils/validate-character.ts` and `utils/validate-dungeon.ts`. `CharacterForm` / `DungeonForm` / `CharacterEditDialog` / `DungeonEditDialog` / `BisListsPanel` / `GearPickPanel` / `DataControlsPanel` stay presentational (panel chrome in `TrackerToolbarPanel`). `App` mounts providers + `TrackerLayout` only. Edit-character and edit-dungeon dialog state lives in `RaidTrackerTable`. Character name display / capitalize in `utils/character-display.ts`; completion chip fills in `utils/completion-chip-color.ts`; spec/gear display in `utils/format-character-details.ts` and `components/spec-option-label/`; gear import in `utils/parse-wowsims-exporter.ts`. ## Project Structure ``` src/ -├── components/ # app-header, tracker-layout, raid-tracker-main, tracker-toolbar-panel (+ toolbar-panel-meta.ts, resolve-toolbar-panel-id.ts, constants.ts, headerActions), character-form, class-option-label, character-edit-dialog (+ character-spec-gear-column, character-spec-gear-import-section, character-also-owned-gear-section, character-edit-spec-gear), character-spec-gear-fields (+ CharacterSingleSpecGearFields), character-spec-list (+ CharacterSpecListName, InactiveSpecTooltip, SpecCell), spec-option-label, dungeon-form, dungeon-edit-dialog, dungeon-customization-fields, raid-tracker-table, tracker-controls (+ actions.ts), export-panel (+ constants.ts, use-export-panel-side-by-side.ts, export-filter-block.tsx, export-filter-section.tsx, export-character-spec-filter.tsx, export-character-spec-filter-actions.tsx, export-dungeon-filter.tsx, export-min-gear-score-filter.tsx, export-role-filter.tsx, export-raid-icon.tsx, export-result-lines.tsx, export-panel-header-actions.tsx, constants.test.ts), gear-pick-panel (+ constants.ts, gear-pick-rules.tsx, gear-pick-character-select.tsx, gear-pick-item-row.tsx, gear-pick-copy-block.tsx, use-gear-pick-panel-side-by-side.ts), data-controls-panel (+ constants.ts, data-controls-block.tsx), bis-lists-panel (+ index.test.tsx), bis-slot-row, gear-slot-icon, bis-item-drop-sources, gear-hint-legend, gear-hint-tooltip, wow-item-link (+ item-link-styles.ts), stored-gear-item-line, item-tooltip-locale-toggle, … +├── components/ # app-header, tracker-layout, raid-tracker-main, tracker-toolbar-panel (+ toolbar-panel-meta.ts, resolve-toolbar-panel-id.ts, constants.ts, headerActions), character-form, class-option-label, character-edit-dialog (+ character-spec-gear-column, character-spec-gear-import-section, character-also-owned-gear-section, character-edit-spec-gear), character-spec-gear-fields (+ CharacterSingleSpecGearFields), character-spec-list (+ CharacterSpecListName, InactiveSpecTooltip, SpecCell), spec-option-label, dungeon-form, dungeon-edit-dialog, dungeon-customization-fields, raid-tracker-table, tracker-controls (+ actions.ts), filter-unit (+ constants.ts, filter-section.tsx, dungeon-filter.tsx, dungeon-filter-actions.tsx, raid-icon.tsx, reset-parameters-actions.tsx), character-pick-panel (+ constants.ts, filter-block.tsx, character-spec-filter.tsx, character-spec-filter-actions.tsx, min-gear-score-filter.tsx, role-filter.tsx, result-lines.tsx, result-format-actions.tsx, use-character-pick-panel-side-by-side.ts, constants.test.ts), gear-pick-panel (+ constants.ts, gear-pick-rules.tsx, gear-pick-character-select.tsx, gear-pick-item-row.tsx, gear-pick-copy-block.tsx, use-gear-pick-panel-side-by-side.ts), data-controls-panel (+ constants.ts, data-controls-block.tsx), bis-lists-panel (+ index.test.tsx), bis-slot-row, gear-slot-icon, bis-item-drop-sources, gear-hint-legend, gear-hint-tooltip, wow-item-link (+ item-link-styles.ts), stored-gear-item-line, item-tooltip-locale-toggle, … ├── i18n/ # messages/en.ts, messages/ru.ts, translate.ts, localized-domain.ts, use-translation.ts, types.ts ├── constants/ # character.ts, dungeon-form-defaults.ts, item-tooltips.ts ├── contexts/ # raid-tracker-context.ts, raid-tracker-provider.tsx, color-mode-provider.tsx, bis-lists-provider.tsx, item-tooltip-locale-provider.tsx -├── hooks/ # use-tracker-domain.ts, use-overlay-panels.ts (+ overlay-panel-id.ts), use-compact-layout.ts, use-raid-tracker-context.ts, use-bis-lists-domain.ts, use-bis-lists-editor-state.ts, use-gear-hint-legend-dismissed.ts, use-*-form-state.ts, use-scroll-to-top-on-panel-open.ts, color-mode.ts, item-tooltip-locale.ts, … +├── hooks/ # use-tracker-domain.ts, use-overlay-panels.ts (+ overlay-panel-id.ts), use-character-pick-session-state.ts, use-gear-pick-session-state.ts, use-compact-layout.ts, use-raid-tracker-context.ts, use-bis-lists-domain.ts, use-bis-lists-editor-state.ts, use-gear-hint-legend-dismissed.ts, use-*-form-state.ts, use-scroll-to-top-on-panel-open.ts, color-mode.ts, item-tooltip-locale.ts, … ├── theme/ # create-app-theme.ts, tooltip-surface.ts (+ tooltip-surface.test.ts) ├── types/ # characters, dungeons, character-gear, bis-lists, tier-sets ├── data/ # raid-names.ts, raid-boss-names.ts, class-specs.ts, spec-stat-priorities.ts, bis-item-variants.ts (+ faction-variant groups, getEquivalentItemIdsAtSlot), build-item-id-map.ts, wowsims-item-stat.ts, dungeon-list.ts, create-template-dungeon.ts, dungeons.ts (barrel), bis-presets/, item-drop-sources.ts, gear-slot-names.ts, raid-loot.ts, tier-sets.ts, tier-set-tokens.ts, wotlk-item-* (+ bundled JSON incl. wotlk-item-equip-props.json, wotlk-item-stats.json, wotlk-item-drop-sources.json, tier-sets-by-item-id.json) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..4d5d62d5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +## Summary + + + +## Checklist + +Author fills every applicable box before merge. Mark N/A items as checked with a short note in Summary if needed. + +### Quality + +- [ ] `npm i` - found 0 vulnerabilities +- [ ] `npm run ci` passes locally (lint + `test:run` + build) +- [ ] No secrets, credentials, or local-only paths committed + + + + + + + +### Release (required for merge to `main`) + +CI **Release files** fails without a full bump — see `/bump` / `scripts/release/check-release-files.mjs`. + +- [ ] Version bumped in `package.json` **and** `package-lock.json` (root + `packages[""]`) +- [ ] `CHANGELOG.md` has `## [X.Y.Z] - YYYY-MM-DD` with real user-facing bullets (not “version bump”) +- [ ] `App_version` badges in `README.md` and `README.ru.md` match the new version + +## Changelog draft + + diff --git a/CHANGELOG.md b/CHANGELOG.md index c469e8d0..dbfd98c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.8.0] - 2026-08-17 + +### Added + +- **With upgrades filter:** Character pick and Soft pick can dim and disable characters with no BiS upgrade targets in the selected raids (toggle under Needed specs / character list). +- **Soft pick reset:** Soft pick header **Reset parameters** clears character/spec, soft rules, softs, raid exclusions, and copy format toggles (same control as Character pick). +- **Unit-grid forms:** Add character (identity + main/off), add dungeon, and BiS side columns align to the shared 300px filter unit grid used by Character pick / Soft pick / Settings. +- **Page & panel atmosphere:** Subtle dot-grid background on the app page and toolbar panels; step cards stay solid. + +### Changed + +- **Character pick reset label:** Header control is **Reset parameters** / **Сбросить параметры** (was “Reset all filters”). +- **Character pick result lines:** Raid badge and copy controls use a table layout for clearer column alignment. +- **Docs:** README EN+RU cover With upgrades, Soft pick reset, and unit-grid / background polish; design gallery for panel backgrounds. + ## [2.7.0] - 2026-08-16 ### Changed diff --git a/README.md b/README.md index 425733e7..60b4fb59 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,65 @@ -# Raidwise - **English** | [Русский](README.ru.md) + + Web app to track raid cooldowns per character and dungeon, with BiS gear hints and raid signup helpers (WotLK-focused). Data persists locally in `localStorage`. Live site: [sergimax.ru/raidwise](https://sergimax.ru/raidwise) -![App version](https://img.shields.io/badge/App_version-2.7.0-purple) +![App version](https://img.shields.io/badge/App_version-2.8.0-purple) ![Game version](https://img.shields.io/badge/WoW-3.3.5a-brown) - - ## Features Toolbar panels are mutually exclusive (only one open at a time). ### Characters & dungeons -Add manually (**Add character** / **Add raid**, stepped forms) or load the WotLK raid template when the list is empty. -New character names are letters only; the UI capitalizes the first letter. -Edit specs, WowSims gear, also-owned items (bags / other spec / vendors for hints), and raid metadata later. -Reorder table columns from the edit dialog (`Position: < n >`). -Header **info** toggles a short usage-help intro (three ways to use the app: CDs, gear planning, or BiS-only). +Add characters and raids manually, or load the WotLK template when the list is empty; edit specs, WowSims gear, also-owned items, raid metadata, and column order later. +Header **info** opens a short intro (CDs, gear planning, or BiS-only). ### Cooldown toggles Mark which character has CD on which raid. -Reset one character from the table header, or everyone from the **Data** panel. +Reset one character from the table header, or everyone from **Settings**. ### Table -Sort and search raids (name, size, mode — EN/RU, e.g. `ICC25H` / `ЦЛК25хм`). -Character headers show class-colored names, main/off GS (or `- / -`), and edit / reset / delete. -Complete column uses a progress bar (count on hover). -Compact layout on small screens. -Wide character columns scroll horizontally inside the table (page stays put). +Sort and search raids (EN/RU, e.g. `ICC25H` / `ЦЛК25хм`); **Type** shows size (+ skull for Heroic), with per-row edit/delete. +Character columns use class-colored names and main/off GS; Complete is a progress bar; compact on small screens, with horizontal scroll inside the table when many characters are present. -### Data controls +### Settings -Bulk reset CDs, or delete all characters / dungeons / local BiS lists (with confirm). -When there are no dungeons — **Add raids from template**. +Bulk reset CDs, or delete all characters / dungeons / local BiS lists (with confirm); when empty, **Add raids from template**. ### Character pick for a raid -Copyable signup line of characters still missing CD on filtered raids. -Filters: min GS, role, specs (kept for the browser session when you close or switch panels). -Result line format toggles (**Specs** / **GS**); characters look like `Name Spec gs / Spec gs`, separated by commas. -Click Selected raid chips to exclude false matches from the results (chips stay visible). -Header reset clears the raid search and chip exclusions. +Copyable signup lines of characters still missing CD on filtered raids, with min GS / role / spec filters, optional **With upgrades**, and optional **Specs** / **GS** in the line format. +Session-kept filters; **Reset parameters** clears them; click Selected raid chips to exclude false matches. ### Soft pick for a raid -Soft reserves for one character + spec on BiS upgrades from filtered raids (active list from **BiS builds**). -Session-only (character, rules, softs, and raid exclusions survive closing the panel). -Competition colors and win-odds hints help pick softs; reset one row or the whole Soft targets list. -Pasteable call list can include/hide the character name and boss details. -Gear on the other spec counts as already owned for Soft targets and table hints. -Same clickable raid chips to exclude rows (softs for items that drop off the list are cleared); header reset for search/exclusions. -Layout wraps Soft targets / the copy block onto their own rows when the panel is too narrow. +Plan soft reserves for one character + spec on BiS upgrades from filtered raids (**re-roll** or **+100**, max softs 1–4), with competition colors, win-odds hints, and a pasteable call list. +Optional **With upgrades** dims characters with no BiS targets; **Reset parameters** clears the panel filters. +Uses the active **BiS builds** list; other-spec gear counts as owned; session state survives closing the panel. ### BiS builds -Built-in presets per spec (Kingdom with slot variants first, then Titans/community); local copies are editable and used for gear-choice hints. -Class/spec choice stays for the browser session when you leave the panel; preset selection is already saved in BiS storage. -Slot layout matches the in-game paper doll; alternative items stack under the primary choice. -Custom lists support clear-slot / clear-all; Items shows the active list name and scrolls with Lists within fixed height budgets. +Built-in presets per spec (Kingdom first, then Titans/community); local copies are editable for gear-choice hints. +Paper-doll slot layout with alternatives; **Copy** exports `Slot: Item` lines. ### Gear hints -CD cells: -- amber — missing BiS -- blue — ilvl upgrade - -Tooltips group loot by boss. -Also-owned items (edit character) count as available for BiS/ilvl hints without being on the WowSims export. +CD cells tint amber for missing BiS and blue for stat-filtered ilvl upgrades (hidden when that CD is marked; main/off split when both specs are set). +Boss-grouped tooltips include tier tokens; also-owned items count without being on the WowSims export. ### EN / RU -Full UI and item tooltips. -First visit defaults to Russian. +Full UI and item tooltips; first visit defaults to Russian. ### Theme -Light/dark mode, saved locally (palette, typography, and link styles). -Header links to GitHub and [sergimax.ru](https://sergimax.ru). -Design tokens / recipes: [docs/design/design-system.md](docs/design/design-system.md). +Light/dark mode saved locally; quiet MUJI chrome with a subtle page/panel dot grid; design tokens in [docs/design/design-system.md](docs/design/design-system.md). ## Development @@ -132,6 +107,7 @@ Open [http://localhost:5173](http://localhost:5173). | `npm run generate:bis-presets` | Regenerate built-in BiS presets from `scripts/bis/bis-list-sources.md` + `scripts/bis/bis-list-mix.md` | | `npm run comment:bis-presets` | Add slot comments to BiS preset files | | `npm run download:gear-slot-icons` | Regenerate WoW paper-doll slot placeholder PNGs in `src/assets/gear-slot-icons/` | +| `npm run download:raid-icons` | Regenerate template-raid achievement icons in `src/assets/raid-icons/` | | `npm run download:fonts` | Regenerate self-hosted Onest / Noto Sans / JetBrains Mono woff2 + `src/fonts.css` | | `npm run compress:class-icons` | Resize class/spec icons to 32×32 WebP in `src/assets/class-icons/` | | `npm run lighthouse` | Desktop Lighthouse vs preview/production URL → `docs/lighthouse/` (run `preview` first for local) | diff --git a/README.ru.md b/README.ru.md index f6cd2801..1f03f0a5 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,90 +1,65 @@ -# Raidwise - [English](README.md) | **Русский** + + Веб-приложение для учёта рейдовых КД по персонажам и подземельям, с BiS-подсказками по гиру и помощниками для набора в рейд (фокус на WotLK). Данные хранятся локально в `localStorage`. Активная ссылка: [sergimax.ru/raidwise](https://sergimax.ru/raidwise) -![Версия приложения](https://img.shields.io/badge/App_version-2.7.0-purple) +![Версия приложения](https://img.shields.io/badge/App_version-2.8.0-purple) ![Версия игры](https://img.shields.io/badge/WoW-3.3.5a-brown) - - ## Возможности Панели тулбара взаимоисключающие (открыта только одна). ### Персонажи и подземелья -Добавляйте вручную (**Добавить персонажа** / **Добавить рейд**, пошаговые формы) или загрузите шаблон рейдов WotLK, если список пуст. -Имена новых персонажей — только буквы; в UI первая буква с заглавной. -Позже можно править спеки, гир из WowSims, список «Также есть» (сумки / другой спек / торговцы для подсказок) и данные рейда. -Порядок колонок в таблице — из диалога правки (`Позиция: < n >`). -Кнопка **info** в хедере показывает краткую справку (три способа: КД, план гира или только BIS). +Добавляйте персонажей и рейды вручную или загрузите шаблон WotLK, если список пуст; позже правьте спеки, гир WowSims, «Также есть», данные рейда и порядок колонок. +Кнопка **info** в хедере открывает краткую справку (КД, план гира или только BIS). ### Переключатели КД Отмечайте, у кого висит КД на каком рейде. -Сброс одного персонажа — из заголовка таблицы, всех — из панели **Данные**. +Сброс одного персонажа — из заголовка таблицы, всех — из **Настроек**. ### Таблица -Сортировка и поиск рейдов (имя, размер, режим — EN/RU, напр. `ICC25H` / `ЦЛК25хм`). -В заголовках персонажей — имя цветом класса, GS мейна/оффа (или `- / -`), правка / сброс / удаление. -Колонка Complete — прогресс-бар (счётчик при наведении). -На узких экранах — компактный вид. -Широкие колонки персонажей прокручиваются горизонтально внутри таблицы (страница не ездит). +Сортировка и поиск рейдов (EN/RU, напр. `ICC25H` / `ЦЛК25хм`); колонка **Тип** — размер (+ череп для героика), у строк — правка/удаление. +В колонках персонажей — имя цветом класса и GS мейна/оффа; Complete — прогресс-бар; на узких экранах компактный вид, при многих персонажах — горизонтальный скролл внутри таблицы. -### Управление данными +### Настройки -Массовый сброс КД или удаление всех персонажей / подземелий / локальных BIS (с подтверждением). -Если подземелий нет — **Добавить рейды из шаблона**. +Массовый сброс КД или удаление всех персонажей / подземелий / локальных BIS (с подтверждением); если список пуст — **Добавить рейды из шаблона**. ### Подбор персонажа под рейд -Строка набора персонажей без КД по отфильтрованным рейдам — с копированием. -Фильтры: минимальный GS, роль, спеки (сохраняются в сессии браузера при закрытии или смене панели). -Переключатели формата строки (**Спеки** / **GS**); персонажи вида `Имя Спек gs / Спек gs`, через запятую. -Клик по чипу выбранного рейда исключает его из результатов (чип остаётся видимым). -Сброс в заголовке очищает поиск и исключения чипов. +Копируемые строки набора персонажей без КД по отфильтрованным рейдам: фильтры мин. GS / роль / спеки, опционально **С апгрейдами**, и **Спеки** / **GS** в формате строки. +Фильтры хранятся в сессии; **Сбросить параметры** очищает их; клик по чипу выбранного рейда исключает ложные совпадения. ### Подбор софтов под рейд -Софт-резервы для одного персонажа + спека по BiS-апгрейдам из отфильтрованных рейдов (активный список из **BIS сборок**). -Только на сессию (персонаж, правила, софты и исключения рейдов переживают закрытие панели). -Цвет спроса и расчёт шансов помогают выбрать софты; сброс одной строки или всего списка предметов. -В списке для вставки можно скрыть имя персонажа и подробности (боссов). -Шмот со второго спека того же персонажа считается уже имеющимся для софтов и подсказок в таблице. -Те же кликабельные чипы рейдов для исключений (софты по предметам, исчезнувшим из списка, сбрасываются); сброс в заголовке для поиска/исключений. -При узкой панели блоки предметов и списка софтов переносятся на отдельные строки. +Софт-резервы для одного персонажа + спека по BiS-апгрейдам из отфильтрованных рейдов (**переролл** или **+100**, максимум 1–4), с цветом спроса, шансами и списком для вставки. +Опция **С апгрейдами** приглушает персонажей без BiS-целей; **Сбросить параметры** очищает фильтры панели. +Используется активный список из **BIS сборок**; гир второго спека считается имеющимся; состояние сессии переживает закрытие панели. ### BIS-сборки -Встроенные пресеты по спекам (сначала Kingdom с вариантами слотов, затем Titans/community); локальные копии редактируются и используются в подсказках для выбора гира. -Выбор класса/спека сохраняется в сессии при уходе с панели; выбранный пресет уже пишется в BiS-хранилище. -Раскладка слотов как в интерфейсе игры; альтернативы показываются стеком под основным предметом. -У своих списков — очистка слота / всех слотов; в Items показано имя активного списка; Items и Lists с ограниченной высотой и прокруткой. +Встроенные пресеты по спекам (сначала Kingdom, затем Titans/community); локальные копии редактируются и идут в подсказки по гиру. +Раскладка слотов как в игре с альтернативами; **Копировать** выгружает строки `Слот: Предмет`. ### Подсказки гира -Ячейки КД: -- янтарный — недостающий BiS -- синий — апгрейд по ilvl. - -В тултипе лут распределен по боссам. -Предметы из «Также есть» (редактирование персонажа) учитываются в BiS/ilvl-подсказках, даже если их нет в экспорте WowSims. +Ячейки КД подсвечиваются янтарным за недостающий BiS и синим за апгрейд по ilvl с учётом статов (скрываются при отмеченном КД; при мейне и оффе — половинки ячейки). +В тултипе лут по боссам и токены тира; «Также есть» учитывается без экспорта WowSims. ### EN / RU -Полный UI и тултипы предметов. -При первом визите по умолчанию русский. +Полный UI и тултипы предметов; при первом визите по умолчанию русский. ### Тема -Светлая/тёмная тема, сохраняется локально (палитра, типографика и стили ссылок). -В хедере ссылки на GitHub и [sergimax.ru](https://sergimax.ru). -Дизайн-токены / рецепты: [docs/design/design-system.md](docs/design/design-system.md). +Светлая/тёмная тема сохраняется локально; спокойный MUJI-хром с лёгкой точечной сеткой на странице и панелях; дизайн-токены в [docs/design/design-system.md](docs/design/design-system.md). ## Разработка @@ -133,6 +108,7 @@ npm run dev | `npm run generate:bis-presets` | Пересборка встроенных BiS из `scripts/bis/bis-list-sources.md` + `scripts/bis/bis-list-mix.md` | | `npm run comment:bis-presets` | Комментарии слотов в файлах BiS-пресетов | | `npm run download:gear-slot-icons` | PNG-заглушки слотов paper-doll в `src/assets/gear-slot-icons/` | +| `npm run download:raid-icons` | Иконки шаблонных рейдов (achievement art) в `src/assets/raid-icons/` | | `npm run download:fonts` | Локальные Onest / Noto Sans / JetBrains Mono woff2 + `src/fonts.css` | | `npm run compress:class-icons` | Иконки классов/спеков 32×32 WebP в `src/assets/class-icons/` | | `npm run lighthouse` | Lighthouse desktop для preview/production → `docs/lighthouse/` (для локали сначала `preview`) | diff --git a/docs/design/design-system.md b/docs/design/design-system.md index 882f57ab..7063436c 100644 --- a/docs/design/design-system.md +++ b/docs/design/design-system.md @@ -35,6 +35,10 @@ When tokens change in code, update this doc and `design-tokens.json` in the same **Avoid:** Facebook/SaaS blue as brand; pink/magenta identity; brand≈danger or brand≈ok pairs; Latin-only display fonts (Syne, Space Grotesk) as sole UI fonts. +**Logo concepts (Raidwise rename):** [logo-variants/](./logo-variants/) — SVG templates + PNG previews; not production `public/` assets yet. + +**Toolbar panel backgrounds:** [panel-backgrounds/](./panel-backgrounds/) — hatch / grid / wash variants for `TrackerToolbarPanel` (step blocks stay solid). + --- ## Color mode @@ -125,7 +129,7 @@ Warm brand-soft radials (not SaaS blue/teal): | `--radius` / `shape.borderRadius` | `10px` | Global MUI shape; tables, menus, alerts | | `--control-radius` / control | `8px` | Buttons, icon buttons, tooltips | | Dialog | `12px` | Dialog paper | -| Filter / step card | `8px` (`borderRadius: 1`) | `ExportFilterSection` | +| Filter / step card | `8px` (`borderRadius: 1`) | `FilterSection` | | Inline mention chip | `8px` | Intro feature chips | | Game / slot icon thumb | `4px` | Class, spec, raid, gear-slot thumbs | | Scrollbar thumb | `999` (pill) | WebKit thumb only | @@ -146,7 +150,7 @@ border: 1px solid | Surface | Border | Notes | | --- | --- | --- | | Outlined `Paper` | `divider` | Toolbar panels (`TrackerToolbarPanel`) | -| Step / filter card | `border: 1`, `borderColor: "divider"`, `borderRadius: 1` | `ExportFilterSection` | +| Step / filter card | `border: 1`, `borderColor: "divider"`, `borderRadius: 1` | `FilterSection` | | Table container | `1px solid divider` | + paper fill + soft shadow | | Dialog paper | `1px solid divider` | radius 12 | | Menu paper | `1px solid divider` | radius 10 | @@ -207,7 +211,7 @@ Implemented in `src/theme/links.css`. Hover may intensify toward brand; do not t ### Recipes 1. **Toolbar shell** — `Paper variant="outlined"`, padding `{ xs: 1.5, sm: 2 }`, header row = title + close (`TrackerToolbarPanel`). Prefer **no** panel-level layout blurb; put help on steps. -2. **Numbered step** — Always use `ExportFilterSection` (`step`, `title`, optional `titleMark` / `description` / `subtitle`). Do not add a second step-chrome component. Header chrome: **brand** title (Onest), soft brand **step badge** (number only — not a `N.` text prefix; DOM text is `N` + title), **3px brand left edge** on the block. +2. **Numbered step** — Always use `FilterSection` (`step`, `title`, optional `titleMark` / `description` / `subtitle`). Do not add a second step-chrome component. Header chrome: **brand** title (Onest), soft brand **step badge** (number only — not a `N.` text prefix; DOM text is `N` + title), **3px brand left edge** on the block. 3. **Field density** — Prefer `size="small"` on TextField / Select / FormControl in add forms. 4. **Stack rhythm** — Outer form / panel stacks use `spacing={1.5}`; fields inside a step often `1`–`1.25`. 5. **Errors** — Field-level `helperText` + `error` when possible; otherwise `FormErrorMessage` under actions. @@ -330,7 +334,7 @@ UI: MUI determinate `LinearProgress` only (`CompletionCountChip` in `dungeon-cel 4. Load Noto Sans + Onest + JetBrains Mono (Cyrillic + Latin) 5. Implement quiet vs `.link-spot` links (`links.css`) 6. Keep border `#8a8a8a`, radii 8/10/12 -7. Prefer stepped `ExportFilterSection`-style cards for multi-field workflows (brand title, step badge, 3px brand left edge) +7. Prefer stepped `FilterSection`-style cards for multi-field workflows (brand title, step badge, 3px brand left edge) 8. Contrast-check text / muted / brand / ok / danger / link / link-spot / border 9. Port class-color chips (`character-display.ts`) and completion chip fills (`completion-chip-color.ts`) when the product needs them diff --git a/docs/design/design-tokens.json b/docs/design/design-tokens.json index 685bb8cc..38ebbac0 100644 --- a/docs/design/design-tokens.json +++ b/docs/design/design-tokens.json @@ -12,7 +12,7 @@ "src/App.css", "src/utils/completion-chip-color.ts", "src/utils/character-display.ts", - "src/components/export-panel/export-filter-section.tsx" + "src/components/filter-unit/filter-section.tsx" ], "muiSpacingPx": 8, "bilingualFonts": { @@ -233,7 +233,7 @@ "shadowToken": "card" }, "filterSection": { - "source": "src/components/export-panel/export-filter-section.tsx", + "source": "src/components/filter-unit/filter-section.tsx", "border": 1, "borderColor": "divider", "borderLeft": "3px solid var(--brand)", diff --git a/docs/design/logo-variants/README.md b/docs/design/logo-variants/README.md new file mode 100644 index 00000000..49e4cb0e --- /dev/null +++ b/docs/design/logo-variants/README.md @@ -0,0 +1,44 @@ +# Raidwise logo concepts + +Exploration set for renaming the old **MRCD** medallion marks to **Raidwise**. +Not production assets — explore here, then ship the chosen mark as `public/logo.svg` (header + favicon share one file). + +**In use now:** letterforms from [`g12.svg`](./g12.svg) → `public/logo.svg`. Full-seal experiments such as [`g9-1 opt.svg`](./g9-1%20opt.svg) stay here for reference. + +**Browse locally:** open [`index.html`](./index.html) in a browser (PNG concepts at original / favicon / header sizes). + +## Palette (from current logos) + +| Token | Hex | +|-------|-----| +| Gold rim | `#eda71b` | +| Navy disk | `#0c1728` | +| Cream glyphs | `#f0eadf` | + +## Variants + +| ID | Concept | Preview | +|----|---------|---------| +| **A** | Full **Raidwise** in the seal | [PNG](./raidwise-logo-variant-a-wordmark.png) | +| **B** | **RW** monogram (icon) | [PNG](./raidwise-logo-variant-b-rw-monogram.png) | +| **C** | Stacked **RAID / WISE** | [PNG](./raidwise-logo-variant-c-stacked.png) | +| **D** | Seal **R** + horizontal wordmark | [PNG](./raidwise-logo-variant-d-lockup.png) | +| **E** | Dominant **R** + arched **AIDWISE** | [PNG](./raidwise-logo-variant-e-r-dominant.png) | +| **F** | **RW** + cooldown arc | [PNG](./raidwise-logo-variant-f-rw-symbol.png) | +| **G** | Arc type **RAID** / **WISE** | [PNG](./raidwise-logo-variant-g-arc-type.png) | +| **H** | **Raid·wise** middle-dot | [PNG](./raidwise-logo-variant-h-dot-split.png) | +| **I** | Stencil / cream disk **R** | [PNG](./raidwise-logo-variant-i-stencil-r.png) | +| **J** | Wordmark banner (no seal) | [PNG](./raidwise-logo-variant-j-wordmark-banner.png) | + +## Suggested production pair + +| File | Notes | +|------|-------| +| `public/logo.svg` | Single mark for header + favicon (current: g12 letterforms) | +| Wide / seal experiments | Optional companions only (e.g. g9-1 opt) — not required at small sizes | + +## Notes + +- PNG previews are AI concept renders (fantasy letterforms closer to the old hand-drawn mark). +- Convert the chosen concept to SVG manually (outlined paths) so fonts do not change across machines. +- Prefer a simpler mark for toolbar/favicon size; dense seals tend to stair-step at 28px. diff --git a/docs/design/logo-variants/g12.svg b/docs/design/logo-variants/g12.svg new file mode 100644 index 00000000..b60d840c --- /dev/null +++ b/docs/design/logo-variants/g12.svg @@ -0,0 +1,81 @@ + + + + diff --git a/docs/design/logo-variants/g9-1 opt.svg b/docs/design/logo-variants/g9-1 opt.svg new file mode 100644 index 00000000..b42ede53 --- /dev/null +++ b/docs/design/logo-variants/g9-1 opt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/design/logo-variants/g9-1.svg b/docs/design/logo-variants/g9-1.svg new file mode 100644 index 00000000..1f19796c --- /dev/null +++ b/docs/design/logo-variants/g9-1.svg @@ -0,0 +1,67 @@ + + + + diff --git a/docs/design/logo-variants/index.html b/docs/design/logo-variants/index.html new file mode 100644 index 00000000..d22898b8 --- /dev/null +++ b/docs/design/logo-variants/index.html @@ -0,0 +1,431 @@ + + + + + + Raidwise logo variants (temporary) + + + +
+

Raidwise logo variants

+

+ Temporary scale gallery (PNG concepts only). Each card: original, + browser-icon sizes (16 / 32), and header logo size (28px, matching + AppHeader). +

+
+ +
+ + + + + + diff --git a/docs/design/logo-variants/path8.svg b/docs/design/logo-variants/path8.svg new file mode 100644 index 00000000..7f0f375f --- /dev/null +++ b/docs/design/logo-variants/path8.svg @@ -0,0 +1,63 @@ + + + + diff --git a/docs/design/logo-variants/raidwise-logo-variant-a-wordmark.png b/docs/design/logo-variants/raidwise-logo-variant-a-wordmark.png new file mode 100644 index 00000000..c7625d84 Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-a-wordmark.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-b-rw-monogram.png b/docs/design/logo-variants/raidwise-logo-variant-b-rw-monogram.png new file mode 100644 index 00000000..43db8a04 Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-b-rw-monogram.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-c-stacked.png b/docs/design/logo-variants/raidwise-logo-variant-c-stacked.png new file mode 100644 index 00000000..ef6d5d1e Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-c-stacked.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-d-lockup.png b/docs/design/logo-variants/raidwise-logo-variant-d-lockup.png new file mode 100644 index 00000000..4ef6d39b Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-d-lockup.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-e-r-dominant.png b/docs/design/logo-variants/raidwise-logo-variant-e-r-dominant.png new file mode 100644 index 00000000..d5596084 Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-e-r-dominant.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-f-rw-symbol.png b/docs/design/logo-variants/raidwise-logo-variant-f-rw-symbol.png new file mode 100644 index 00000000..5f21f53f Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-f-rw-symbol.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-g-arc-type.png b/docs/design/logo-variants/raidwise-logo-variant-g-arc-type.png new file mode 100644 index 00000000..d2307876 Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-g-arc-type.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-h-dot-split.png b/docs/design/logo-variants/raidwise-logo-variant-h-dot-split.png new file mode 100644 index 00000000..c4d2e49c Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-h-dot-split.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-i-stencil-r.png b/docs/design/logo-variants/raidwise-logo-variant-i-stencil-r.png new file mode 100644 index 00000000..d3307d6c Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-i-stencil-r.png differ diff --git a/docs/design/logo-variants/raidwise-logo-variant-j-wordmark-banner.png b/docs/design/logo-variants/raidwise-logo-variant-j-wordmark-banner.png new file mode 100644 index 00000000..a57ef7a1 Binary files /dev/null and b/docs/design/logo-variants/raidwise-logo-variant-j-wordmark-banner.png differ diff --git a/docs/design/panel-backgrounds/README.md b/docs/design/panel-backgrounds/README.md new file mode 100644 index 00000000..fc8d7771 --- /dev/null +++ b/docs/design/panel-backgrounds/README.md @@ -0,0 +1,39 @@ +# Toolbar panel background variants + +Exploration set for the patterned chrome inside `TrackerToolbarPanel` +(Character pick, Soft pick, BiS, forms, Data). Step cards +(`FilterSection`) stay **solid plane fill**; only the panel shell shows +the pattern. + +**Browse locally:** open [`index.html`](./index.html) in a browser. Use the +light/dark toggle to check both modes. + +## In app today + +| Piece | Role | +|-------|------| +| `--dot-grid` / `--dot-grid-size` | `src/index.css` (page + panels) | +| Dot grid on page (`body::before`, rotated 45°) | `src/index.css` + CssBaseline in `create-app-theme.ts` | +| Dot grid on `TrackerToolbarPanel` (upright) | `src/components/tracker-toolbar-panel/index.tsx` | +| Solid `bgcolor` on step blocks | `src/components/filter-unit/filter-section.tsx` | + +Current shipping choice: **F — Dot grid** (`12px` pin dots) on page chrome and toolbar panels. + +## Variants in the gallery + +| ID | Idea | +|----|------| +| **A** `none` | Control — no pattern | +| **B** `stage-14` | Diagonal hatch 14px | +| **C** `stage-10` | Finer hatch | +| **D** `stage-20` | Coarser hatch | +| **E** `stage-strong` | Same hatch, stronger contrast | +| **F** `dots` | Dot grid (current) | +| **G** `grid` | Square graph lines | +| **H** `lines` | Soft diagonal stripes | +| **I** `brand-wash` | Brand radials only | +| **J** `stage-plus-wash` | Hatch + brand wash | +| **K** `checker` | Offset checker weave | + +When you pick one, update the CSS vars / panel shell to match that variant’s +rules from `index.html`. diff --git a/docs/design/panel-backgrounds/index.html b/docs/design/panel-backgrounds/index.html new file mode 100644 index 00000000..d53c3090 --- /dev/null +++ b/docs/design/panel-backgrounds/index.html @@ -0,0 +1,423 @@ + + + + + + Raidwise — toolbar panel background variants + + + +
+
+

Toolbar panel backgrounds

+

+ Mock of TrackerToolbarPanel: pattern on the outer view, + solid plane fill on step blocks (FilterSection). + Pick a variant, then wire CSS vars in + src/index.css + panel shell. +

+
+ +
+ +
+ + + + diff --git a/index.html b/index.html index c7a2d4c8..a854e502 100644 --- a/index.html +++ b/index.html @@ -54,7 +54,7 @@ type="font/woff2" crossorigin /> - + Raidwise – Raid Cooldown Tracker diff --git a/package-lock.json b/package-lock.json index 003ed40c..d279c088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "raidwise", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raidwise", - "version": "2.7.0", + "version": "2.8.0", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/package.json b/package.json index 00111fec..259eb598 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "raidwise", "private": true, - "version": "2.7.0", + "version": "2.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/public/logo-as-icon.svg b/public/logo-as-icon.svg deleted file mode 100644 index fdee7fc2..00000000 --- a/public/logo-as-icon.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - diff --git a/public/logo.svg b/public/logo.svg index 10602e5b..b13297f8 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/src/App.css b/src/App.css index f2080a02..7d689ead 100644 --- a/src/App.css +++ b/src/App.css @@ -12,6 +12,7 @@ /* Sticky AppBar: keep scrolled content from sitting under the toolbar. */ html { scroll-padding-top: var(--app-toolbar-min-height); + background-color: var(--page-bg); } .app-shell { diff --git a/src/components/app-intro/index.tsx b/src/components/app-intro/index.tsx index d917c51f..3c296ba3 100644 --- a/src/components/app-intro/index.tsx +++ b/src/components/app-intro/index.tsx @@ -2,7 +2,7 @@ import CloseIcon from "@mui/icons-material/Close"; import { Box, IconButton, Paper, Stack, Typography } from "@mui/material"; import type { MessageKey } from "../../i18n/translate.ts"; import { useTranslation } from "../../i18n/use-translation.ts"; -import { ExportFilterSection } from "../export-panel/export-filter-section.tsx"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; import type { AppIntroProps } from "./types.ts"; import { renderIntroRichText } from "./render-intro-rich-text.tsx"; @@ -116,7 +116,7 @@ export function AppIntro({ visible = true, onDismiss }: AppIntroProps) { }} > {INTRO_SCENARIOS.map((scenario) => ( - - + ))} diff --git a/src/components/bis-lists-panel/constants.ts b/src/components/bis-lists-panel/constants.ts index 1096d565..053d9e58 100644 --- a/src/components/bis-lists-panel/constants.ts +++ b/src/components/bis-lists-panel/constants.ts @@ -1,3 +1,8 @@ +import { + FILTER_UNIT_GRID_GAP_SPACING, + getFilterUnitColumnTemplate, +} from "../filter-unit/constants.ts"; + /** * BiS panel scroll budgets for the Items paper-doll and Lists chips. * @@ -37,8 +42,8 @@ export const BIS_LISTS_CHIP_GAP_PX = 6; export const BIS_LISTS_VISIBLE_CHIP_COUNT = 6; /** - * ExportFilterSection chrome shared by BiS steps (padding + title + description). - * Items with titleActions is close enough for column alignment math. + * FilterSection chrome shared by BiS steps (padding + title + description). + * Items with titleActions / descriptionActions is close enough for column alignment math. */ export const BIS_FILTER_SECTION_CHROME_HEIGHT_PX = 88; @@ -48,6 +53,17 @@ export const BIS_CLASS_SPEC_CONTENT_HEIGHT_PX = 112; /** Left-column gap between Class & spec and Lists (`spacing={1.5}` → 12px). */ export const BIS_LEFT_COLUMN_SECTION_GAP_PX = 12; +/** + * Side columns (class/lists | save) use the shared 300px unit; Items fills the + * middle. Matches Character pick / Soft pick / form unit widths. + */ +export function getBisListsGridTemplateColumns(): string { + const unitColumn = getFilterUnitColumnTemplate(); + return `${unitColumn} minmax(0, 1fr) ${unitColumn}`; +} + +export { FILTER_UNIT_GRID_GAP_SPACING }; + /** Max height of the Items paper-doll scroll viewport (md+). */ export function getBisItemsContentMaxHeight(): number { return ( diff --git a/src/components/bis-lists-panel/index.tsx b/src/components/bis-lists-panel/index.tsx index 929e4840..998e001b 100644 --- a/src/components/bis-lists-panel/index.tsx +++ b/src/components/bis-lists-panel/index.tsx @@ -49,14 +49,16 @@ import type { CharacterEquipContext } from "../../utils/item-equip-restrictions. import { hideExternalWowTooltips } from "../../utils/hide-external-wow-tooltips.ts"; import { BisSlotRow } from "../bis-slot-row/index.tsx"; import { ClassOptionLabel } from "../class-option-label/index.tsx"; -import { ExportFilterSection } from "../export-panel/export-filter-section.tsx"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; import { FormErrorMessage } from "../form-error-message/index.tsx"; import { SpecOptionLabel } from "../spec-option-label/index.tsx"; import { BisCosmeticSlotRow } from "./bis-cosmetic-slot-row.tsx"; import { BIS_ITEMS_CONTENT_MAX_HEIGHT_XS_PX, + FILTER_UNIT_GRID_GAP_SPACING, getBisItemsContentMaxHeight, getBisListsContentMaxHeight, + getBisListsGridTemplateColumns, } from "./constants.ts"; function localizeBisStorageMessage(message: string, t: TranslateFn): string { @@ -437,16 +439,17 @@ export function BisListsPanel({ session }: BisListsPanelProps) { sx={{ display: "grid", gridTemplateColumns: { - xs: "1fr", - md: "14rem minmax(0, 1fr) 13rem", - lg: "16rem minmax(0, 1fr) 14.5rem", + xs: "minmax(0, 1fr)", + md: getBisListsGridTemplateColumns(), }, - gap: { xs: 1.5, md: 2 }, + gap: FILTER_UNIT_GRID_GAP_SPACING, alignItems: "start", + width: "100%", + maxWidth: "100%", }} > - - + - )} - + - {slotEditor} - + - {saveListForm} - + {error && !saveNameError ? : null} diff --git a/src/components/character-form/constants.ts b/src/components/character-form/constants.ts new file mode 100644 index 00000000..0013a2e7 --- /dev/null +++ b/src/components/character-form/constants.ts @@ -0,0 +1,35 @@ +import { + FILTER_UNIT_GRID_GAP_SPACING, + FILTER_UNIT_WIDTH, + getFilterUnitColumnTemplate, +} from "../filter-unit/constants.ts"; + +/** Identity stays 1 unit; main / off use 2× unit width for gear import lists. */ +export const CHARACTER_FORM_IDENTITY_WIDTH_UNITS = 1; +export const CHARACTER_FORM_SPEC_WIDTH_UNITS = 2; +export const CHARACTER_FORM_GRID_COLUMN_COUNT = 3; + +export function getCharacterFormSpecColumnTemplate(): string { + return `minmax(0, ${CHARACTER_FORM_SPEC_WIDTH_UNITS * FILTER_UNIT_WIDTH}px)`; +} + +export function getCharacterFormGridTemplateColumns(): string { + const identityColumn = getFilterUnitColumnTemplate(); + const specColumn = getCharacterFormSpecColumnTemplate(); + return `${identityColumn} ${specColumn} ${specColumn}`; +} + +/** Content width of the unit grid (columns + inter-column gaps). */ +export function getCharacterFormGridMaxWidth( + gridColumnGapPx = FILTER_UNIT_GRID_GAP_SPACING * 8, +): number { + const totalWidthUnits = + CHARACTER_FORM_IDENTITY_WIDTH_UNITS + + 2 * CHARACTER_FORM_SPEC_WIDTH_UNITS; + return ( + totalWidthUnits * FILTER_UNIT_WIDTH + + (CHARACTER_FORM_GRID_COLUMN_COUNT - 1) * gridColumnGapPx + ); +} + +export { FILTER_UNIT_GRID_GAP_SPACING }; diff --git a/src/components/character-form/index.tsx b/src/components/character-form/index.tsx index cea4860f..84479286 100644 --- a/src/components/character-form/index.tsx +++ b/src/components/character-form/index.tsx @@ -1,4 +1,5 @@ import { + Box, FormControl, InputLabel, MenuItem, @@ -14,9 +15,13 @@ import { Classes } from "../../types/characters.ts"; import { CharacterSpecGearImportSection } from "../character-edit-dialog/character-spec-gear-import-section.tsx"; import { CharacterSingleSpecGearFields } from "../character-spec-gear-fields/index.tsx"; import { ClassOptionLabel } from "../class-option-label/index.tsx"; -import { ExportFilterSection } from "../export-panel/export-filter-section.tsx"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; import { FormActionsRow } from "../form-actions-row/index.tsx"; import { FormErrorMessage } from "../form-error-message/index.tsx"; +import { + FILTER_UNIT_GRID_GAP_SPACING, + getCharacterFormGridTemplateColumns, +} from "./constants.ts"; import type { CharacterFormProps } from "./types.ts"; const characterOverflowVisibleContentSx = { overflow: "visible" } as const; @@ -63,166 +68,190 @@ export function CharacterForm({ return (
- - - { - onNameChange(event.target.value); - }} - required - autoComplete="off" - size="small" - slotProps={{ - htmlInput: { maxLength: MAX_CHARACTER_NAME_LENGTH }, - }} - helperText={t("characterForm.nameLettersOnlyHint", { - count: name.length, - max: MAX_CHARACTER_NAME_LENGTH, - })} - /> - - {t("common.class")} - - - - + required + autoComplete="off" + size="small" + slotProps={{ + htmlInput: { maxLength: MAX_CHARACTER_NAME_LENGTH }, + }} + helperText={t("characterForm.nameLettersOnlyHint", { + count: name.length, + max: MAX_CHARACTER_NAME_LENGTH, + })} + /> + + + {t("common.class")} + + + + + - - - - - - {t("characterForm.importGear")}{" "} - - - + + + + + {t("characterForm.importGear")}{" "} + + + + - - + - - - - - - {t("characterForm.importGear")}{" "} - - - + + + + + {t("characterForm.importGear")}{" "} + + + + - - + + {error ? : null} diff --git a/src/components/export-panel/export-character-spec-filter-actions.tsx b/src/components/character-pick-panel/character-spec-filter-actions.tsx similarity index 71% rename from src/components/export-panel/export-character-spec-filter-actions.tsx rename to src/components/character-pick-panel/character-spec-filter-actions.tsx index b8cfc5aa..6a0f0754 100644 --- a/src/components/export-panel/export-character-spec-filter-actions.tsx +++ b/src/components/character-pick-panel/character-spec-filter-actions.tsx @@ -3,41 +3,41 @@ import SelectAllIcon from "@mui/icons-material/SelectAll"; import { IconButton, Tooltip } from "@mui/material"; import { useTranslation } from "../../i18n/use-translation.ts"; -type ExportCharacterSpecFilterActionsProps = { +type CharacterPickSpecFilterActionsProps = { disabled?: boolean; onSelectAll: () => void; onClearAll: () => void; }; -export function ExportCharacterSpecFilterActions({ +export function CharacterPickSpecFilterActions({ disabled = false, onSelectAll, onClearAll, -}: ExportCharacterSpecFilterActionsProps) { +}: CharacterPickSpecFilterActionsProps) { const { t } = useTranslation(); return ( <> - + - + diff --git a/src/components/export-panel/export-character-spec-filter.tsx b/src/components/character-pick-panel/character-spec-filter.tsx similarity index 67% rename from src/components/export-panel/export-character-spec-filter.tsx rename to src/components/character-pick-panel/character-spec-filter.tsx index 7667840d..8647f14f 100644 --- a/src/components/export-panel/export-character-spec-filter.tsx +++ b/src/components/character-pick-panel/character-spec-filter.tsx @@ -23,11 +23,13 @@ import { CharacterSpecGearLabel } from "../spec-option-label/index.tsx"; import { CHARACTER_SPEC_LIST_ICON_SIZE, getCharacterSpecListGridSx, -} from "./constants.ts"; +} from "../filter-unit/constants.ts"; -type ExportCharacterSpecFilterProps = { +type CharacterPickSpecFilterProps = { characters: CharacterRecord[]; includedCharacterIds: ReadonlySet; + charactersWithUpgradesIds: ReadonlySet; + onlyWithUpgrades: boolean; exportSpecSelectionByCharacterId: ExportSpecSelectionByCharacterId; roleFilter: ExportRoleFilter; minGearScore: number | undefined; @@ -38,13 +40,13 @@ type ExportCharacterSpecFilterProps = { ) => void; }; -type ExportSpecCheckboxProps = { +type CharacterPickSpecCheckboxProps = { character: CharacterRecord; specGear: CharacterSpecGear; slot: keyof CharacterExportSpecSelection; checked: boolean; disabled?: boolean; - cooldownInactive?: boolean; + rowInactiveReason: CharacterExportInactiveReason | null; roleAllowed: boolean; gearScoreAllowed: boolean; onCheckedChange: ( @@ -55,16 +57,23 @@ type ExportSpecCheckboxProps = { }; type SpecFilterTooltipKey = - | "exportPanel.specInactiveRoleFilter" - | "exportPanel.specInactiveGearScoreFilter" - | "exportPanel.specInactiveRoleAndGearScoreFilter"; + | "characterPickPanel.specInactiveRoleFilter" + | "characterPickPanel.specInactiveGearScoreFilter" + | "characterPickPanel.specInactiveRoleAndGearScoreFilter"; function characterInactiveTooltipKey( inactiveReason: CharacterExportInactiveReason, -): "exportPanel.characterInactiveCooldownHint" | "exportPanel.characterInactiveFiltersHint" { - return inactiveReason === "cooldown" - ? "exportPanel.characterInactiveCooldownHint" - : "exportPanel.characterInactiveFiltersHint"; +): + | "characterPickPanel.characterInactiveCooldownHint" + | "characterPickPanel.characterInactiveFiltersHint" + | "gearPickPanel.characterInactiveNoUpgradesHint" { + if (inactiveReason === "cooldown") { + return "characterPickPanel.characterInactiveCooldownHint"; + } + if (inactiveReason === "noUpgrades") { + return "gearPickPanel.characterInactiveNoUpgradesHint"; + } + return "characterPickPanel.characterInactiveFiltersHint"; } function specFilterTooltipKey( @@ -75,26 +84,32 @@ function specFilterTooltipKey( return null; } if (!roleAllowed && !gearScoreAllowed) { - return "exportPanel.specInactiveRoleAndGearScoreFilter"; + return "characterPickPanel.specInactiveRoleAndGearScoreFilter"; } if (!roleAllowed) { - return "exportPanel.specInactiveRoleFilter"; + return "characterPickPanel.specInactiveRoleFilter"; } - return "exportPanel.specInactiveGearScoreFilter"; + return "characterPickPanel.specInactiveGearScoreFilter"; +} + +function isRowLevelInactive( + reason: CharacterExportInactiveReason | null, +): reason is "cooldown" | "noUpgrades" { + return reason === "cooldown" || reason === "noUpgrades"; } -function ExportSpecCheckbox({ +function CharacterPickSpecCheckbox({ character, specGear, slot, checked, disabled = false, - cooldownInactive = false, + rowInactiveReason, roleAllowed, gearScoreAllowed, onCheckedChange, t, -}: ExportSpecCheckboxProps) { +}: CharacterPickSpecCheckboxProps) { const { locale } = useTranslation(); if (!character.class) { @@ -106,10 +121,13 @@ function ExportSpecCheckbox({ specGear.spec, locale, ); - const specFilteredOut = !cooldownInactive && (!roleAllowed || !gearScoreAllowed); + const cooldownInactive = rowInactiveReason === "cooldown"; + const upgradesInactive = rowInactiveReason === "noUpgrades"; + const rowInactive = isRowLevelInactive(rowInactiveReason); + const specFilteredOut = !rowInactive && (!roleAllowed || !gearScoreAllowed); const filterTooltipKey = specFilterTooltipKey(roleAllowed, gearScoreAllowed); - const tooltipTitle = cooldownInactive - ? t("exportPanel.characterInactiveCooldownHint") + const tooltipTitle = rowInactive + ? t(characterInactiveTooltipKey(rowInactiveReason)) : filterTooltipKey ? t(filterTooltipKey) : null; @@ -126,7 +144,7 @@ function ExportSpecCheckbox({ }} slotProps={{ input: { - "aria-label": t("exportPanel.includeSpecAria", { + "aria-label": t("characterPickPanel.includeSpecAria", { spec: specLabel, name: character.name, }), @@ -143,7 +161,11 @@ function ExportSpecCheckbox({ variant="caption" showSpecName={false} showDetailTooltip={false} - color={cooldownInactive || specFilteredOut ? "text.secondary" : "inherit"} + color={ + cooldownInactive || upgradesInactive || specFilteredOut + ? "text.secondary" + : "inherit" + } /> } sx={{ @@ -152,7 +174,7 @@ function ExportSpecCheckbox({ minWidth: 0, "& .MuiCheckbox-root": { p: 0.25, - ...(cooldownInactive + ...(cooldownInactive || upgradesInactive ? { opacity: 0.45 } : specFilteredOut ? { opacity: 0.55 } @@ -161,7 +183,9 @@ function ExportSpecCheckbox({ "& .MuiFormControlLabel-label": { ml: 0, minWidth: 0 }, ...(cooldownInactive ? { "& img": { opacity: 0.45, filter: "grayscale(1)" } } - : null), + : upgradesInactive + ? { "& img": { opacity: 0.55 } } + : null), ...(specFilteredOut ? { "& .MuiTypography-root": { textDecoration: "line-through" } } : null), @@ -172,20 +196,22 @@ function ExportSpecCheckbox({ return {control}; } -export function ExportCharacterSpecFilter({ +export function CharacterPickSpecFilter({ characters, includedCharacterIds, + charactersWithUpgradesIds, + onlyWithUpgrades, exportSpecSelectionByCharacterId, roleFilter, minGearScore, onSpecIncluded, -}: ExportCharacterSpecFilterProps) { +}: CharacterPickSpecFilterProps) { const { t } = useTranslation(); if (characters.length === 0) { return ( - {t("exportPanel.noCharacters")} + {t("characterPickPanel.noCharacters")} ); } @@ -198,6 +224,10 @@ export function ExportCharacterSpecFilter({ includedCharacterIds, roleFilter, minGearScore, + { + onlyWithUpgrades, + charactersWithUpgradesIds, + }, ); const selection = resolveEffectiveExportSpecSelection( character, @@ -226,13 +256,15 @@ export function ExportCharacterSpecFilter({ const offGearScoreAllowed = !character.offSpec || specPassesExportMinGearScore(character.offSpec, minGearScore); - const cooldownInactive = inactiveReason === "cooldown"; + const rowInactive = isRowLevelInactive(inactiveReason); const characterName = ( ); @@ -240,7 +272,9 @@ export function ExportCharacterSpecFilter({ return ( {inactiveReason ? ( - + {characterName} ) : ( @@ -248,17 +282,15 @@ export function ExportCharacterSpecFilter({ )} {character.mainSpec ? ( - { @@ -269,47 +301,45 @@ export function ExportCharacterSpecFilter({ ) : !hasSpecs ? ( { - onSpecIncluded( - character, - "includeWithoutSpec", - event.target.checked, - ); - }} - slotProps={{ - input: { - "aria-label": t("exportPanel.includeCharacterAria", { - name: character.name, - }), - }, - }} - /> + onSpecIncluded( + character, + "includeWithoutSpec", + event.target.checked, + ); + }} + slotProps={{ + input: { + "aria-label": t("characterPickPanel.includeCharacterAria", { + name: character.name, + }), + }, + }} + /> ) : null} {character.offSpec ? ( - { diff --git a/src/components/character-pick-panel/constants.test.ts b/src/components/character-pick-panel/constants.test.ts new file mode 100644 index 00000000..3de2b29f --- /dev/null +++ b/src/components/character-pick-panel/constants.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + FILTER_UNIT_HEIGHT, + FILTER_UNIT_SPECS_VISIBLE_ROW_COUNT, + FILTER_UNIT_WIDTH, + getCharacterSpecListMaxHeight, + getFilterUnitBlockHeight, + getFilterUnitColumnTemplate, +} from "../filter-unit/constants.ts"; +import { + CHARACTER_PICK_FILTER_BLOCK_SPANS, + CHARACTER_PICK_FILTER_GRID_COLUMN_COUNT, + CHARACTER_PICK_SIDE_BY_SIDE_MIN_PX, + CHARACTER_PICK_SIDE_BY_SIDE_MQ, + CHARACTER_PICK_SIDE_BY_SIDE_MQ_KEY, + getCharacterPickFilterGridHeight, + getCharacterPickFilterGridMaxWidth, + getCharacterPickFilterGridTemplateAreas, + getCharacterPickFilterGridTemplateColumns, + getCharacterPickFilterGridTemplateRows, + getCharacterPickResultColumnMinWidth, +} from "./constants.ts"; + +describe("getCharacterPickFilterGridTemplateAreas", () => { + it("places raid on row one; GS, role, and specs continue below / right", () => { + const areas = getCharacterPickFilterGridTemplateAreas(true); + + expect(areas).toContain("dungeon dungeon characterSpecs"); + expect(areas).toContain("gearScore role characterSpecs"); + expect(areas.indexOf("dungeon")).toBeLessThan(areas.indexOf("gearScore")); + }); + + it("omits dungeon row when there are no dungeons", () => { + const areas = getCharacterPickFilterGridTemplateAreas(false); + + expect(areas).not.toContain("dungeon"); + expect(areas).toContain("gearScore role characterSpecs"); + expect(areas).toContain(". . characterSpecs"); + }); +}); + +describe("CHARACTER_PICK_FILTER_BLOCK_SPANS", () => { + it("uses equal unit-width columns with specs as a 2×1 block", () => { + expect(CHARACTER_PICK_FILTER_GRID_COLUMN_COUNT).toBe(3); + expect(CHARACTER_PICK_FILTER_BLOCK_SPANS.dungeon).toEqual({ + heightUnits: 1, + widthUnits: 2, + }); + expect(CHARACTER_PICK_FILTER_BLOCK_SPANS.gearScore).toEqual({ + heightUnits: 1, + widthUnits: 1, + }); + expect(CHARACTER_PICK_FILTER_BLOCK_SPANS.role).toEqual({ + heightUnits: 1, + widthUnits: 1, + }); + expect(CHARACTER_PICK_FILTER_BLOCK_SPANS.characterSpecs).toEqual({ + heightUnits: 2, + widthUnits: 1, + }); + const unitColumn = getFilterUnitColumnTemplate(); + expect(getCharacterPickFilterGridTemplateColumns()).toBe( + `${unitColumn} ${unitColumn} ${unitColumn}`, + ); + expect(getCharacterPickFilterGridMaxWidth()).toBe(FILTER_UNIT_WIDTH * 3); + }); +}); + +describe("character pick filter fixed heights", () => { + it("uses fixed grid row heights (no auto growth)", () => { + expect(getCharacterPickFilterGridTemplateRows()).toBe( + `repeat(2, ${FILTER_UNIT_HEIGHT}px)`, + ); + }); + + it("sizes 1× and 2× blocks from the unit height", () => { + expect(getFilterUnitBlockHeight(1)).toBe(FILTER_UNIT_HEIGHT); + expect(getFilterUnitBlockHeight(2)).toBe(FILTER_UNIT_HEIGHT * 2); + }); + + it("fits eight character rows in the specs scroll viewport", () => { + expect(getCharacterSpecListMaxHeight()).toBe(346); + expect(FILTER_UNIT_SPECS_VISIBLE_ROW_COUNT).toBe(8); + }); + + it("includes inter-row gap in total filter grid height", () => { + expect(getCharacterPickFilterGridHeight()).toBe( + FILTER_UNIT_HEIGHT * 2 + 12, + ); + }); + + it("uses 1680px as the side-by-side layout threshold", () => { + expect(CHARACTER_PICK_SIDE_BY_SIDE_MIN_PX).toBe(1680); + expect(CHARACTER_PICK_SIDE_BY_SIDE_MQ).toBe("(min-width:1680px)"); + expect(CHARACTER_PICK_SIDE_BY_SIDE_MQ_KEY).toBe( + "@media (min-width:1680px)", + ); + }); + + it("sizes the results column as a 2-unit span including gap", () => { + expect(getCharacterPickResultColumnMinWidth()).toBe( + FILTER_UNIT_WIDTH * 2 + 12, + ); + }); +}); diff --git a/src/components/character-pick-panel/constants.ts b/src/components/character-pick-panel/constants.ts new file mode 100644 index 00000000..334f0c02 --- /dev/null +++ b/src/components/character-pick-panel/constants.ts @@ -0,0 +1,95 @@ +import { + FILTER_UNIT_GRID_GAP_SPACING, + FILTER_UNIT_HEIGHT, + FILTER_UNIT_WIDTH, + getFilterUnitColumnTemplate, +} from "../filter-unit/constants.ts"; + +/** Min viewport width for filter grid left + result lines right. */ +export const CHARACTER_PICK_SIDE_BY_SIDE_MIN_PX = 1680; + +/** `matchMedia` / `useMediaQuery` query (no `@media` prefix). */ +export const CHARACTER_PICK_SIDE_BY_SIDE_MQ = `(min-width:${CHARACTER_PICK_SIDE_BY_SIDE_MIN_PX}px)`; + +/** MUI `sx` object key for the side-by-side breakpoint. */ +export const CHARACTER_PICK_SIDE_BY_SIDE_MQ_KEY = `@media ${CHARACTER_PICK_SIDE_BY_SIDE_MQ}`; + +/** Max height of the result list before it scrolls (stacked layout). */ +export const CHARACTER_PICK_RESULT_MAX_HEIGHT = 320; + +/** Column units across the Character pick filter grid (GS + role + specs). */ +export const CHARACTER_PICK_FILTER_GRID_COLUMN_COUNT = 3; + +export type CharacterPickFilterBlockSpan = { + heightUnits: number; + widthUnits: number; +}; + +/** Default H×W spans per Character pick filter block. */ +export const CHARACTER_PICK_FILTER_BLOCK_SPANS = { + dungeon: { heightUnits: 1, widthUnits: 2 }, + gearScore: { heightUnits: 1, widthUnits: 1 }, + role: { heightUnits: 1, widthUnits: 1 }, + characterSpecs: { heightUnits: 2, widthUnits: 1 }, +} as const satisfies Record; + +export type CharacterPickFilterGridAreaId = + keyof typeof CHARACTER_PICK_FILTER_BLOCK_SPANS; + +/** Total pixel height of the 2-row filter grid (rows + inter-row gaps). */ +export function getCharacterPickFilterGridHeight( + gridRowGapPx = FILTER_UNIT_GRID_GAP_SPACING * 8, +): number { + const rowCount = Math.max( + ...Object.values(CHARACTER_PICK_FILTER_BLOCK_SPANS).map( + (span) => span.heightUnits, + ), + ); + return rowCount * FILTER_UNIT_HEIGHT + (rowCount - 1) * gridRowGapPx; +} + +export function getCharacterPickFilterGridTemplateColumns(): string { + const unitColumn = getFilterUnitColumnTemplate(); + return `${unitColumn} ${unitColumn} ${unitColumn}`; +} + +export function getCharacterPickFilterGridTemplateRows(): string { + const maxHeightUnits = Math.max( + ...Object.values(CHARACTER_PICK_FILTER_BLOCK_SPANS).map( + (span) => span.heightUnits, + ), + ); + return `repeat(${maxHeightUnits}, ${FILTER_UNIT_HEIGHT}px)`; +} + +export function getCharacterPickFilterGridTemplateAreas( + hasDungeon: boolean, +): string { + if (hasDungeon) { + return [ + '"dungeon dungeon characterSpecs"', + '"gearScore role characterSpecs"', + ].join(" "); + } + + return ['"gearScore role characterSpecs"', '". . characterSpecs"'].join(" "); +} + +export function getCharacterPickFilterGridMaxWidth(): number { + return FILTER_UNIT_WIDTH * CHARACTER_PICK_FILTER_GRID_COLUMN_COUNT; +} + +/** + * Results column on wide layouts — 2 unit columns including the inter-column gap + * (same span math as Soft pick copy). + */ +export function getCharacterPickResultColumnMinWidth( + gridColumnGapPx = FILTER_UNIT_GRID_GAP_SPACING * 8, +): number { + return 2 * FILTER_UNIT_WIDTH + gridColumnGapPx; +} + +export { + FILTER_UNIT_GRID_GAP_SPACING, + dungeonFilterContentSx, +} from "../filter-unit/constants.ts"; diff --git a/src/components/export-panel/export-filter-block.tsx b/src/components/character-pick-panel/filter-block.tsx similarity index 68% rename from src/components/export-panel/export-filter-block.tsx rename to src/components/character-pick-panel/filter-block.tsx index 53914987..1459ab6d 100644 --- a/src/components/export-panel/export-filter-block.tsx +++ b/src/components/character-pick-panel/filter-block.tsx @@ -1,13 +1,13 @@ import { Box } from "@mui/material"; import type { ReactNode } from "react"; -import type { ExportFilterGridAreaId } from "./constants.ts"; +import type { CharacterPickFilterGridAreaId } from "./constants.ts"; -type ExportFilterBlockProps = { - gridArea: ExportFilterGridAreaId; +type CharacterPickFilterBlockProps = { + gridArea: CharacterPickFilterGridAreaId; children: ReactNode; }; -export function ExportFilterBlock({ gridArea, children }: ExportFilterBlockProps) { +export function CharacterPickFilterBlock({ gridArea, children }: CharacterPickFilterBlockProps) { return ( 0; @@ -79,7 +85,7 @@ export function ExportPanel({ [minGearScoreCompact, minGearScoreFilterEnabled], ); - const includedCharacterIds = useMemo( + const availableCharacterIds = useMemo( () => new Set( characters @@ -95,14 +101,54 @@ export function ExportPanel({ [activeDungeons, characters, dungeonToggles], ); + const charactersWithUpgradesIds = useMemo(() => { + if (!onlyCharactersWithUpgrades) { + return new Set(); + } + return new Set( + characters + .filter((character) => + characterHasGearPickUpgrades({ + character, + dungeons: activeDungeons, + getBisSlotMapForSpec, + locale, + }), + ) + .map((character) => character.id), + ); + }, [ + activeDungeons, + characters, + getBisSlotMapForSpec, + locale, + onlyCharactersWithUpgrades, + ]); + + /** Available for Select all / export lines: not on CD, and upgrades when toggled. */ + const selectableCharacterIds = useMemo(() => { + if (!onlyCharactersWithUpgrades) { + return availableCharacterIds; + } + return new Set( + [...availableCharacterIds].filter((characterId) => + charactersWithUpgradesIds.has(characterId), + ), + ); + }, [ + availableCharacterIds, + charactersWithUpgradesIds, + onlyCharactersWithUpgrades, + ]); + const exportSpecSelectionForPanel = useMemo( () => clearUnavailableExportSpecSelections( characters, exportSpecSelectionByCharacterId, - includedCharacterIds, + selectableCharacterIds, ), - [characters, exportSpecSelectionByCharacterId, includedCharacterIds], + [characters, exportSpecSelectionByCharacterId, selectableCharacterIds], ); const resetDungeonFilter = () => { @@ -164,12 +210,12 @@ export function ExportPanel({ sx={{ display: "flex", flexDirection: "column", - gap: EXPORT_FILTER_GRID_GAP_SPACING, + gap: FILTER_UNIT_GRID_GAP_SPACING, alignItems: "stretch", width: "100%", minWidth: 0, maxWidth: "100%", - [EXPORT_PANEL_SIDE_BY_SIDE_MQ_KEY]: { + [CHARACTER_PICK_SIDE_BY_SIDE_MQ_KEY]: { flexDirection: "row", }, }} @@ -179,17 +225,17 @@ export function ExportPanel({ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", - md: getExportFilterGridTemplateColumns(), + md: getCharacterPickFilterGridTemplateColumns(), }, gridTemplateRows: { xs: "auto", - md: getExportFilterGridTemplateRows(), + md: getCharacterPickFilterGridTemplateRows(), }, gridTemplateAreas: { xs: "none", - md: getExportFilterGridTemplateAreas(hasDungeonFilter), + md: getCharacterPickFilterGridTemplateAreas(hasDungeonFilter), }, - gap: EXPORT_FILTER_GRID_GAP_SPACING, + gap: FILTER_UNIT_GRID_GAP_SPACING, alignItems: "stretch", width: { xs: "100%", md: "fit-content" }, maxWidth: "100%", @@ -199,23 +245,23 @@ export function ExportPanel({ }} > {hasDungeonFilter ? ( - - + } - contentSx={exportDungeonFilterContentSx} + contentSx={dungeonFilterContentSx} > - - - + + ) : null} - - + - - - + + - - + - - - + + - - + - selectAllCharacterSpecs(characters, includedCharacterIds) + selectAllCharacterSpecs(characters, selectableCharacterIds) } onClearAll={() => clearAllCharacterSpecs(characters)} /> } + descriptionActions={ + + } > - - - + + - void; onCompactValueChange: (compactValue: number) => void; }; -export function ExportMinGearScoreFilter({ +export function CharacterPickMinGearScoreFilter({ enabled, compactValue, onEnabledChange, onCompactValueChange, -}: ExportMinGearScoreFilterProps) { +}: CharacterPickMinGearScoreFilterProps) { const { t } = useTranslation(); const compactLabel = formatCompactExportMinGearScore(compactValue); @@ -42,14 +42,14 @@ export function ExportMinGearScoreFilter({ }} slotProps={{ input: { - "aria-label": t("exportPanel.minGearScoreEnableAria"), + "aria-label": t("characterPickPanel.minGearScoreEnableAria"), }, }} /> } label={ - {t("exportPanel.minGearScoreEnable")} + {t("characterPickPanel.minGearScoreEnable")} } sx={{ @@ -82,13 +82,13 @@ export function ExportMinGearScoreFilter({ }} valueLabelDisplay="off" getAriaValueText={(value) => - t("exportPanel.minGearScoreSliderValue", { + t("characterPickPanel.minGearScoreSliderValue", { value: formatCompactExportMinGearScore(value), }) } slotProps={{ input: { - "aria-label": t("exportPanel.minGearScoreAria"), + "aria-label": t("characterPickPanel.minGearScoreAria"), }, }} sx={{ @@ -115,7 +115,7 @@ export function ExportMinGearScoreFilter({ }} > {enabled - ? t("exportPanel.minGearScoreSliderValue", { value: compactLabel }) + ? t("characterPickPanel.minGearScoreSliderValue", { value: compactLabel }) : "—"} diff --git a/src/components/export-panel/export-result-format-actions.tsx b/src/components/character-pick-panel/result-format-actions.tsx similarity index 81% rename from src/components/export-panel/export-result-format-actions.tsx rename to src/components/character-pick-panel/result-format-actions.tsx index 468fc60c..dffa464f 100644 --- a/src/components/export-panel/export-result-format-actions.tsx +++ b/src/components/character-pick-panel/result-format-actions.tsx @@ -1,26 +1,26 @@ import { FormControlLabel, Stack, Switch, Typography } from "@mui/material"; import { useTranslation } from "../../i18n/use-translation.ts"; -type ExportResultFormatActionsProps = { +type CharacterPickResultFormatActionsProps = { includeSpecs: boolean; includeGearScore: boolean; onIncludeSpecsChange: (includeSpecs: boolean) => void; onIncludeGearScoreChange: (includeGearScore: boolean) => void; }; -export function ExportResultFormatActions({ +export function CharacterPickResultFormatActions({ includeSpecs, includeGearScore, onIncludeSpecsChange, onIncludeGearScoreChange, -}: ExportResultFormatActionsProps) { +}: CharacterPickResultFormatActionsProps) { const { t } = useTranslation(); return ( } label={ - {t("exportPanel.includeSpecs")} + {t("characterPickPanel.includeSpecs")} } sx={{ mr: 0.5, ml: 0 }} @@ -55,7 +55,7 @@ export function ExportResultFormatActions({ }} slotProps={{ input: { - "aria-label": t("exportPanel.includeGearScoreAria"), + "aria-label": t("characterPickPanel.includeGearScoreAria"), }, }} /> @@ -69,7 +69,7 @@ export function ExportResultFormatActions({ color: includeSpecs ? "inherit" : "text.disabled", }} > - {t("exportPanel.includeGearScore")} + {t("characterPickPanel.includeGearScore")} } sx={{ mr: 0, ml: 0 }} diff --git a/src/components/character-pick-panel/result-lines.tsx b/src/components/character-pick-panel/result-lines.tsx new file mode 100644 index 00000000..0329b33f --- /dev/null +++ b/src/components/character-pick-panel/result-lines.tsx @@ -0,0 +1,228 @@ +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { + Box, + Button, + Table, + TableBody, + TableCell, + TableRow, + Typography, +} from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "../../i18n/use-translation.ts"; +import { + formatExportLineCopyText, + type BuildExportStatusResult, + type ExportStatusLine, +} from "../../utils/build-export-status.ts"; +import { CHARACTER_PICK_RESULT_MAX_HEIGHT } from "./constants.ts"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; +import { RaidIcon } from "../filter-unit/raid-icon.tsx"; +import { CharacterPickResultFormatActions } from "./result-format-actions.tsx"; +import { useCharacterPickPanelSideBySide } from "./use-character-pick-panel-side-by-side.ts"; + +type CharacterPickResultLinesProps = { + result: BuildExportStatusResult; + includeSpecs: boolean; + includeGearScore: boolean; + onIncludeSpecsChange: (includeSpecs: boolean) => void; + onIncludeGearScoreChange: (includeGearScore: boolean) => void; +}; + +async function copyTextToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +const resultTableCellSx = { + borderBottom: 1, + borderColor: "divider", + verticalAlign: "middle", + py: 0.75, + px: 0.5, +} as const; + +type CharacterPickResultLineRowProps = { + line: ExportStatusLine; + emphasizeCopy: boolean; + isLast: boolean; +}; + +function CharacterPickResultLineRow({ + line, + emphasizeCopy, + isLast, +}: CharacterPickResultLineRowProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + const didCopy = await copyTextToClipboard(formatExportLineCopyText(line)); + if (!didCopy) { + return; + } + setCopied(true); + window.setTimeout(() => { + setCopied(false); + }, 1500); + }, [line]); + + const cellSx = { + ...resultTableCellSx, + ...(isLast ? { borderBottom: 0 } : null), + }; + + return ( + + + + + + {line.raidLabel} + + + + + + + + + {line.charactersLabel} + + + + ); +} + +export function CharacterPickResultLines({ + result, + includeSpecs, + includeGearScore, + onIncludeSpecsChange, + onIncludeGearScoreChange, +}: CharacterPickResultLinesProps) { + const { t } = useTranslation(); + const sideBySide = useCharacterPickPanelSideBySide(); + + const sectionLayoutSx = sideBySide + ? { width: "100%", height: "100%", minHeight: 0, flex: 1 } + : { width: "100%", height: "auto" }; + + const sectionContentSx = sideBySide + ? { flex: 1, minHeight: 0, maxHeight: "none" } + : { maxHeight: CHARACTER_PICK_RESULT_MAX_HEIGHT }; + + const descriptionActions = ( + + ); + + if (result.kind === "message") { + return ( + + + {result.message} + + + ); + } + + const singleLine = result.lines.length === 1; + + return ( + + + + {result.lines.map((line, index) => ( + + ))} + +
+
+ ); +} diff --git a/src/components/export-panel/export-role-filter.tsx b/src/components/character-pick-panel/role-filter.tsx similarity index 94% rename from src/components/export-panel/export-role-filter.tsx rename to src/components/character-pick-panel/role-filter.tsx index 0e1cda7d..7e191b98 100644 --- a/src/components/export-panel/export-role-filter.tsx +++ b/src/components/character-pick-panel/role-filter.tsx @@ -92,7 +92,7 @@ function RoleFilterOption({ }} slotProps={{ input: { - "aria-label": t(`exportPanel.${ROLE_FILTER_ARIA_KEYS[roleId]}`), + "aria-label": t(`characterPickPanel.${ROLE_FILTER_ARIA_KEYS[roleId]}`), }, }} sx={{ alignSelf: "flex-start", mt: 0.25, p: 0.5, flexShrink: 0 }} @@ -111,14 +111,14 @@ function RoleFilterOption({ component="span" sx={{ lineHeight: 1.2, textAlign: "center", width: "100%" }} > - {t(`exportPanel.${ROLE_FILTER_MESSAGE_KEYS[roleId]}`)} + {t(`characterPickPanel.${ROLE_FILTER_MESSAGE_KEYS[roleId]}`)}
); } -export function ExportRoleFilterPanel({ +export function CharacterPickRoleFilter({ roleFilter, onRoleFilterChange, }: ExportRoleFilterProps) { diff --git a/src/components/export-panel/types.ts b/src/components/character-pick-panel/types.ts similarity index 67% rename from src/components/export-panel/types.ts rename to src/components/character-pick-panel/types.ts index 9f86afd3..4079941b 100644 --- a/src/components/export-panel/types.ts +++ b/src/components/character-pick-panel/types.ts @@ -1,13 +1,13 @@ import type { CharacterRecord } from "../../types/characters.ts"; import type { DungeonRecord, DungeonToggles } from "../../types/dungeons.ts"; -import type { ExportSessionState } from "../../hooks/use-export-session-state.ts"; +import type { CharacterPickSessionState } from "../../hooks/use-character-pick-session-state.ts"; -export type ExportPanelProps = { +export type CharacterPickPanelProps = { characters: CharacterRecord[]; visibleDungeons: DungeonRecord[]; dungeonToggles: DungeonToggles; dungeonNameSearch: string; onDungeonNameSearchChange: (query: string) => void; totalDungeonCount: number; - session: ExportSessionState; + session: CharacterPickSessionState; }; diff --git a/src/components/character-pick-panel/use-character-pick-panel-side-by-side.ts b/src/components/character-pick-panel/use-character-pick-panel-side-by-side.ts new file mode 100644 index 00000000..e794c6d7 --- /dev/null +++ b/src/components/character-pick-panel/use-character-pick-panel-side-by-side.ts @@ -0,0 +1,6 @@ +import { useMediaQuery } from "@mui/material"; +import { CHARACTER_PICK_SIDE_BY_SIDE_MQ } from "./constants.ts"; + +export function useCharacterPickPanelSideBySide(): boolean { + return useMediaQuery(CHARACTER_PICK_SIDE_BY_SIDE_MQ); +} diff --git a/src/components/data-controls-panel/constants.test.ts b/src/components/data-controls-panel/constants.test.ts index 9aa5b09e..fda48f4d 100644 --- a/src/components/data-controls-panel/constants.test.ts +++ b/src/components/data-controls-panel/constants.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { - EXPORT_FILTER_UNIT_HEIGHT, - EXPORT_FILTER_UNIT_WIDTH, + FILTER_UNIT_HEIGHT, + FILTER_UNIT_WIDTH, getFilterUnitColumnTemplate, -} from "../export-panel/constants.ts"; +} from "../filter-unit/constants.ts"; import { DATA_CONTROLS_GRID_COLUMN_COUNT, getDataControlsGridMaxWidth, @@ -20,7 +20,7 @@ describe("data controls grid", () => { `${unitColumn} ${unitColumn}`, ); expect(getDataControlsGridTemplateRows()).toBe( - `repeat(2, ${EXPORT_FILTER_UNIT_HEIGHT}px)`, + `repeat(2, ${FILTER_UNIT_HEIGHT}px)`, ); expect(getDataControlsGridTemplateAreas()).toContain( "resetToggles deleteCharacters", @@ -29,7 +29,7 @@ describe("data controls grid", () => { "deleteDungeons deleteBisLists", ); expect(getDataControlsGridMaxWidth()).toBe( - EXPORT_FILTER_UNIT_WIDTH * 2 + 12, + FILTER_UNIT_WIDTH * 2 + 12, ); }); }); diff --git a/src/components/data-controls-panel/constants.ts b/src/components/data-controls-panel/constants.ts index 5ef3a370..fa4c5543 100644 --- a/src/components/data-controls-panel/constants.ts +++ b/src/components/data-controls-panel/constants.ts @@ -1,9 +1,9 @@ import { - EXPORT_FILTER_GRID_GAP_SPACING, - EXPORT_FILTER_UNIT_HEIGHT, - EXPORT_FILTER_UNIT_WIDTH, + FILTER_UNIT_GRID_GAP_SPACING, + FILTER_UNIT_HEIGHT, + FILTER_UNIT_WIDTH, getFilterUnitColumnTemplate, -} from "../export-panel/constants.ts"; +} from "../filter-unit/constants.ts"; /** 2×2 unit grid: toggles | characters / dungeons | BiS. */ export const DATA_CONTROLS_GRID_COLUMN_COUNT = 2; @@ -21,7 +21,7 @@ export function getDataControlsGridTemplateColumns(): string { } export function getDataControlsGridTemplateRows(): string { - return `repeat(${DATA_CONTROLS_GRID_ROW_COUNT}, ${EXPORT_FILTER_UNIT_HEIGHT}px)`; + return `repeat(${DATA_CONTROLS_GRID_ROW_COUNT}, ${FILTER_UNIT_HEIGHT}px)`; } export function getDataControlsGridTemplateAreas(): string { @@ -33,12 +33,12 @@ export function getDataControlsGridTemplateAreas(): string { /** Content width of the 2-column unit grid (columns + inter-column gap). */ export function getDataControlsGridMaxWidth( - gridColumnGapPx = EXPORT_FILTER_GRID_GAP_SPACING * 8, + gridColumnGapPx = FILTER_UNIT_GRID_GAP_SPACING * 8, ): number { return ( - DATA_CONTROLS_GRID_COLUMN_COUNT * EXPORT_FILTER_UNIT_WIDTH + + DATA_CONTROLS_GRID_COLUMN_COUNT * FILTER_UNIT_WIDTH + (DATA_CONTROLS_GRID_COLUMN_COUNT - 1) * gridColumnGapPx ); } -export { EXPORT_FILTER_GRID_GAP_SPACING, EXPORT_FILTER_UNIT_WIDTH }; +export { FILTER_UNIT_GRID_GAP_SPACING, FILTER_UNIT_WIDTH }; diff --git a/src/components/data-controls-panel/index.tsx b/src/components/data-controls-panel/index.tsx index a2bfa6e4..7115e5d4 100644 --- a/src/components/data-controls-panel/index.tsx +++ b/src/components/data-controls-panel/index.tsx @@ -4,9 +4,9 @@ import { useBisListsContext } from "../../hooks/use-bis-lists-context.ts"; import { useRaidTrackerContext } from "../../hooks/use-raid-tracker-context.ts"; import { useTranslation } from "../../i18n/use-translation.ts"; import { DeleteConfirmDialog } from "../raid-tracker-table/delete-confirm-dialog.tsx"; -import { ExportFilterSection } from "../export-panel/export-filter-section.tsx"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; import { - EXPORT_FILTER_GRID_GAP_SPACING, + FILTER_UNIT_GRID_GAP_SPACING, getDataControlsGridTemplateAreas, getDataControlsGridTemplateColumns, getDataControlsGridTemplateRows, @@ -119,14 +119,14 @@ export function DataControlsPanel({ xs: "none", sm: getDataControlsGridTemplateAreas(), }, - gap: EXPORT_FILTER_GRID_GAP_SPACING, + gap: FILTER_UNIT_GRID_GAP_SPACING, alignItems: "stretch", width: { xs: "100%", sm: "fit-content" }, maxWidth: "100%", }} > - @@ -139,11 +139,11 @@ export function DataControlsPanel({ > {t("toolbar.resetAllToggles")} - + - @@ -156,11 +156,11 @@ export function DataControlsPanel({ > {t("dataControlsPanel.deleteCharactersAction")} - + - )} - + - @@ -204,7 +204,7 @@ export function DataControlsPanel({ > {t("dataControlsPanel.deleteBisAction")} - +
diff --git a/src/components/dungeon-form/constants.ts b/src/components/dungeon-form/constants.ts new file mode 100644 index 00000000..a6a1db72 --- /dev/null +++ b/src/components/dungeon-form/constants.ts @@ -0,0 +1,32 @@ +import { + FILTER_UNIT_GRID_GAP_SPACING, + FILTER_UNIT_WIDTH, + getFilterUnitColumnTemplate, +} from "../filter-unit/constants.ts"; + +/** Two columns: name/short | type/ilvl. */ +export const DUNGEON_FORM_GRID_COLUMN_COUNT = 2; + +export function getDungeonFormGridTemplateColumns(): string { + const unitColumn = getFilterUnitColumnTemplate(); + return `${unitColumn} ${unitColumn}`; +} + +export function getDungeonFormGridTemplateAreas(): string { + return [ + '"name type"', + '"shortName itemLevels"', + ].join(" "); +} + +/** Content width of the 2-column unit grid (columns + inter-column gap). */ +export function getDungeonFormGridMaxWidth( + gridColumnGapPx = FILTER_UNIT_GRID_GAP_SPACING * 8, +): number { + return ( + DUNGEON_FORM_GRID_COLUMN_COUNT * FILTER_UNIT_WIDTH + + (DUNGEON_FORM_GRID_COLUMN_COUNT - 1) * gridColumnGapPx + ); +} + +export { FILTER_UNIT_GRID_GAP_SPACING }; diff --git a/src/components/dungeon-form/index.tsx b/src/components/dungeon-form/index.tsx index 0063eec1..7cb00f42 100644 --- a/src/components/dungeon-form/index.tsx +++ b/src/components/dungeon-form/index.tsx @@ -1,5 +1,6 @@ import { Autocomplete, + Box, FormControl, InputLabel, MenuItem, @@ -22,9 +23,14 @@ import { type DungeonDifficulty as DungeonDifficultyValue, type DungeonSize, } from "../../types/dungeons.ts"; -import { ExportFilterSection } from "../export-panel/export-filter-section.tsx"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; import { FormActionsRow } from "../form-actions-row/index.tsx"; import { FormErrorMessage } from "../form-error-message/index.tsx"; +import { + FILTER_UNIT_GRID_GAP_SPACING, + getDungeonFormGridTemplateAreas, + getDungeonFormGridTemplateColumns, +} from "./constants.ts"; import type { DungeonFormProps } from "./types.ts"; const dungeonOverflowVisibleContentSx = { overflow: "visible" } as const; @@ -73,122 +79,157 @@ export function DungeonForm({ return ( - - ( + + + ( + + )} + /> + + + + + { + onShortNameChange(event.target.value); + }} size="small" + slotProps={{ + htmlInput: { maxLength: MAX_DUNGEON_SHORT_NAME_LENGTH }, + }} + autoComplete="off" + fullWidth /> - )} - /> - + + - - { - onShortNameChange(event.target.value); - }} - size="small" - slotProps={{ htmlInput: { maxLength: MAX_DUNGEON_SHORT_NAME_LENGTH } }} - autoComplete="off" - fullWidth - /> - + + + + + + {t("common.size")} + + + + + + {t("dungeonForm.difficulty")} + + + + + + - - - - {t("common.size")} - - - - - {t("dungeonForm.difficulty")} - - - - - - - - { - onItemLevelTextChange(event.target.value); - }} - required - autoComplete="off" - size="small" - fullWidth - /> - + required + autoComplete="off" + size="small" + fullWidth + /> + + + {error ? : null} diff --git a/src/components/export-panel/constants.test.ts b/src/components/export-panel/constants.test.ts deleted file mode 100644 index 6bce4125..00000000 --- a/src/components/export-panel/constants.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - EXPORT_FILTER_BLOCK_SPANS, - EXPORT_FILTER_GRID_COLUMN_COUNT, - EXPORT_PANEL_SIDE_BY_SIDE_MIN_PX, - EXPORT_PANEL_SIDE_BY_SIDE_MQ, - EXPORT_PANEL_SIDE_BY_SIDE_MQ_KEY, - EXPORT_FILTER_SPECS_VISIBLE_ROW_COUNT, - EXPORT_FILTER_UNIT_HEIGHT, - EXPORT_FILTER_UNIT_WIDTH, - getExportFilterBlockHeight, - getExportFilterGridHeight, - getExportFilterGridMaxWidth, - getExportFilterGridTemplateAreas, - getExportFilterGridTemplateColumns, - getExportFilterGridTemplateRows, - getExportFilterSpecsListMaxHeight, - getExportResultColumnMinWidth, - getFilterUnitColumnTemplate, -} from "./constants.ts"; - -describe("getExportFilterGridTemplateAreas", () => { - it("places raid on row one; GS, role, and specs continue below / right", () => { - const areas = getExportFilterGridTemplateAreas(true); - - expect(areas).toContain("dungeon dungeon characterSpecs"); - expect(areas).toContain("gearScore role characterSpecs"); - expect(areas.indexOf("dungeon")).toBeLessThan(areas.indexOf("gearScore")); - }); - - it("omits dungeon row when there are no dungeons", () => { - const areas = getExportFilterGridTemplateAreas(false); - - expect(areas).not.toContain("dungeon"); - expect(areas).toContain("gearScore role characterSpecs"); - expect(areas).toContain(". . characterSpecs"); - }); -}); - -describe("EXPORT_FILTER_BLOCK_SPANS", () => { - it("uses equal unit-width columns with specs as a 2×1 block", () => { - expect(EXPORT_FILTER_GRID_COLUMN_COUNT).toBe(3); - expect(EXPORT_FILTER_BLOCK_SPANS.dungeon).toEqual({ - heightUnits: 1, - widthUnits: 2, - }); - expect(EXPORT_FILTER_BLOCK_SPANS.gearScore).toEqual({ - heightUnits: 1, - widthUnits: 1, - }); - expect(EXPORT_FILTER_BLOCK_SPANS.role).toEqual({ - heightUnits: 1, - widthUnits: 1, - }); - expect(EXPORT_FILTER_BLOCK_SPANS.characterSpecs).toEqual({ - heightUnits: 2, - widthUnits: 1, - }); - const unitColumn = getFilterUnitColumnTemplate(); - expect(getExportFilterGridTemplateColumns()).toBe( - `${unitColumn} ${unitColumn} ${unitColumn}`, - ); - expect(getExportFilterGridMaxWidth()).toBe(EXPORT_FILTER_UNIT_WIDTH * 3); - }); -}); - -describe("export filter fixed heights", () => { - it("uses fixed grid row heights (no auto growth)", () => { - expect(getExportFilterGridTemplateRows()).toBe( - `repeat(2, ${EXPORT_FILTER_UNIT_HEIGHT}px)`, - ); - }); - - it("sizes 1× and 2× blocks from the unit height", () => { - expect(getExportFilterBlockHeight(1)).toBe(EXPORT_FILTER_UNIT_HEIGHT); - expect(getExportFilterBlockHeight(2)).toBe(EXPORT_FILTER_UNIT_HEIGHT * 2); - }); - - it("fits eight character rows in the specs scroll viewport", () => { - expect(getExportFilterSpecsListMaxHeight()).toBe(346); - expect(EXPORT_FILTER_SPECS_VISIBLE_ROW_COUNT).toBe(8); - }); - - it("includes inter-row gap in total filter grid height", () => { - expect(getExportFilterGridHeight()).toBe(EXPORT_FILTER_UNIT_HEIGHT * 2 + 12); - }); - - it("uses 1680px as the side-by-side layout threshold", () => { - expect(EXPORT_PANEL_SIDE_BY_SIDE_MIN_PX).toBe(1680); - expect(EXPORT_PANEL_SIDE_BY_SIDE_MQ).toBe("(min-width:1680px)"); - expect(EXPORT_PANEL_SIDE_BY_SIDE_MQ_KEY).toBe("@media (min-width:1680px)"); - }); - - it("sizes the results column as a 2-unit span including gap", () => { - expect(getExportResultColumnMinWidth()).toBe( - EXPORT_FILTER_UNIT_WIDTH * 2 + 12, - ); - }); -}); diff --git a/src/components/export-panel/constants.ts b/src/components/export-panel/constants.ts deleted file mode 100644 index e21998f5..00000000 --- a/src/components/export-panel/constants.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** Min viewport width for filter grid left + result lines right. */ -export const EXPORT_PANEL_SIDE_BY_SIDE_MIN_PX = 1680; - -/** `matchMedia` / `useMediaQuery` query (no `@media` prefix). */ -export const EXPORT_PANEL_SIDE_BY_SIDE_MQ = `(min-width:${EXPORT_PANEL_SIDE_BY_SIDE_MIN_PX}px)`; - -/** MUI `sx` object key for the side-by-side breakpoint. */ -export const EXPORT_PANEL_SIDE_BY_SIDE_MQ_KEY = `@media ${EXPORT_PANEL_SIDE_BY_SIDE_MQ}`; - -/** Theme spacing multiplier used for export filter grid gap (`gap: 1.5`). */ -export const EXPORT_FILTER_GRID_GAP_SPACING = 1.5; - -/** Max height of the export result list before it scrolls (stacked layout). */ -export const EXPORT_RESULT_MAX_HEIGHT = 320; - -/** Width of one grid column unit (export / Soft pick filter blocks share this). */ -export const EXPORT_FILTER_UNIT_WIDTH = 300; - -/** - * Fixed height of one grid row unit (1× block). - * Fits raid chips, GS slider, and role 2×2 icons (chips scroll inside the raid block). - */ -export const EXPORT_FILTER_UNIT_HEIGHT = 224; - -/** Keeps search + match count pinned; only raid chips scroll inside the section. */ -export const exportDungeonFilterContentSx = { - overflow: "hidden", - display: "flex", - flexDirection: "column", -} as const; - -/** - * Character-spec list (Character pick + Soft pick) — fits a 2×1 unit column. - * Shared so both panels stay equal in size. - */ -export const CHARACTER_SPEC_LIST_GRID_TEMPLATE_COLUMNS = - "minmax(0, 1fr) auto auto"; -export const CHARACTER_SPEC_LIST_COLUMN_GAP = 0.5; -export const CHARACTER_SPEC_LIST_ROW_GAP = 0.75; -export const CHARACTER_SPEC_LIST_ICON_SIZE = 16; - -/** Character spec rows visible before the list scrolls (2× block content). */ -export const EXPORT_FILTER_SPECS_VISIBLE_ROW_COUNT = 8; - -/** Spec row height — small FormControlLabel with spec icon + gear score. */ -export const EXPORT_FILTER_SPECS_ROW_HEIGHT_PX = 38; - -/** Matches `CHARACTER_SPEC_LIST_ROW_GAP` (0.75 → 6px at default spacing). */ -export const EXPORT_FILTER_SPECS_ROW_GAP_PX = 6; - -/** Max scroll viewport height for the character specs list (8 rows). */ -export function getExportFilterSpecsListMaxHeight(): number { - const rowCount = EXPORT_FILTER_SPECS_VISIBLE_ROW_COUNT; - return ( - rowCount * EXPORT_FILTER_SPECS_ROW_HEIGHT_PX + - (rowCount - 1) * EXPORT_FILTER_SPECS_ROW_GAP_PX - ); -} - -/** Total height of a block spanning `heightUnits` grid rows (excludes inter-row gap). */ -export function getExportFilterBlockHeight(heightUnits: number): number { - return heightUnits * EXPORT_FILTER_UNIT_HEIGHT; -} - -/** Column units across the filter grid (GS + role + specs). */ -export const EXPORT_FILTER_GRID_COLUMN_COUNT = 3; - -export type ExportFilterBlockSpan = { - /** Row span — height in unit rows (H in H×W). */ - heightUnits: number; - /** Column span — width in unit columns (W in H×W). */ - widthUnits: number; -}; - -/** Default H×W spans per filter block. */ -export const EXPORT_FILTER_BLOCK_SPANS = { - dungeon: { heightUnits: 1, widthUnits: 2 }, - gearScore: { heightUnits: 1, widthUnits: 1 }, - role: { heightUnits: 1, widthUnits: 1 }, - characterSpecs: { heightUnits: 2, widthUnits: 1 }, -} as const satisfies Record; - -/** Total pixel height of the 2-row filter grid (rows + inter-row gaps). */ -export function getExportFilterGridHeight( - gridRowGapPx = EXPORT_FILTER_GRID_GAP_SPACING * 8, -): number { - const rowCount = Math.max( - ...Object.values(EXPORT_FILTER_BLOCK_SPANS).map((span) => span.heightUnits), - ); - return rowCount * EXPORT_FILTER_UNIT_HEIGHT + (rowCount - 1) * gridRowGapPx; -} - -export type ExportFilterGridAreaId = keyof typeof EXPORT_FILTER_BLOCK_SPANS; - -export function getFilterUnitColumnTemplate(): string { - return `minmax(0, ${EXPORT_FILTER_UNIT_WIDTH}px)`; -} - -export function getExportFilterGridTemplateColumns(): string { - const unitColumn = getFilterUnitColumnTemplate(); - return `${unitColumn} ${unitColumn} ${unitColumn}`; -} - -/** Shared grid sx for Character pick / Soft pick character-spec lists. */ -export function getCharacterSpecListGridSx(options?: { - maxHeight?: number; -}): Record { - return { - display: "grid", - gridTemplateColumns: CHARACTER_SPEC_LIST_GRID_TEMPLATE_COLUMNS, - columnGap: CHARACTER_SPEC_LIST_COLUMN_GAP, - rowGap: CHARACTER_SPEC_LIST_ROW_GAP, - alignItems: "center", - minWidth: 0, - ...(options?.maxHeight != null - ? { - maxHeight: options.maxHeight, - overflowY: "auto", - pr: 0.25, - } - : null), - }; -} - -export function getExportFilterGridTemplateRows(): string { - const maxHeightUnits = Math.max( - ...Object.values(EXPORT_FILTER_BLOCK_SPANS).map((span) => span.heightUnits), - ); - return `repeat(${maxHeightUnits}, ${EXPORT_FILTER_UNIT_HEIGHT}px)`; -} - -export function getExportFilterGridTemplateAreas(hasDungeon: boolean): string { - if (hasDungeon) { - // Step order: raid (1) on top; GS (2), role (3), specs (4) below / right. - return [ - '"dungeon dungeon characterSpecs"', - '"gearScore role characterSpecs"', - ].join(" "); - } - - return [ - '"gearScore role characterSpecs"', - '". . characterSpecs"', - ].join(" "); -} - -export function getExportFilterGridMaxWidth(): number { - return EXPORT_FILTER_UNIT_WIDTH * EXPORT_FILTER_GRID_COLUMN_COUNT; -} - -/** - * Character pick results column on wide layouts — 2 unit columns including the - * inter-column gap (same span math as Soft pick copy). - */ -export function getExportResultColumnMinWidth( - gridColumnGapPx = EXPORT_FILTER_GRID_GAP_SPACING * 8, -): number { - return 2 * EXPORT_FILTER_UNIT_WIDTH + gridColumnGapPx; -} diff --git a/src/components/export-panel/export-result-lines.tsx b/src/components/export-panel/export-result-lines.tsx deleted file mode 100644 index 43247d93..00000000 --- a/src/components/export-panel/export-result-lines.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import { - Box, - Button, - Stack, - Typography, -} from "@mui/material"; -import { useCallback, useState } from "react"; -import { useTranslation } from "../../i18n/use-translation.ts"; -import { - formatExportLineCopyText, - type BuildExportStatusResult, - type ExportStatusLine, -} from "../../utils/build-export-status.ts"; -import { EXPORT_RESULT_MAX_HEIGHT } from "./constants.ts"; -import { ExportFilterSection } from "./export-filter-section.tsx"; -import { ExportRaidIcon } from "./export-raid-icon.tsx"; -import { ExportResultFormatActions } from "./export-result-format-actions.tsx"; -import { useExportPanelSideBySide } from "./use-export-panel-side-by-side.ts"; - -type ExportResultLinesProps = { - result: BuildExportStatusResult; - includeSpecs: boolean; - includeGearScore: boolean; - onIncludeSpecsChange: (includeSpecs: boolean) => void; - onIncludeGearScoreChange: (includeGearScore: boolean) => void; -}; - -async function copyTextToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} - -type ExportResultLineRowProps = { - line: ExportStatusLine; - emphasizeCopy: boolean; -}; - -function ExportResultLineRow({ line, emphasizeCopy }: ExportResultLineRowProps) { - const { t } = useTranslation(); - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - const didCopy = await copyTextToClipboard(formatExportLineCopyText(line)); - if (!didCopy) { - return; - } - setCopied(true); - window.setTimeout(() => { - setCopied(false); - }, 1500); - }, [line]); - - return ( - - - - - - {line.raidLabel} - - - - {line.charactersLabel} - - - ); -} - -export function ExportResultLines({ - result, - includeSpecs, - includeGearScore, - onIncludeSpecsChange, - onIncludeGearScoreChange, -}: ExportResultLinesProps) { - const { t } = useTranslation(); - const sideBySide = useExportPanelSideBySide(); - - const sectionLayoutSx = sideBySide - ? { width: "100%", height: "100%", minHeight: 0, flex: 1 } - : { width: "100%", height: "auto" }; - - const sectionContentSx = sideBySide - ? { flex: 1, minHeight: 0, maxHeight: "none" } - : { maxHeight: EXPORT_RESULT_MAX_HEIGHT }; - - const titleActions = ( - - ); - - if (result.kind === "message") { - return ( - - - {result.message} - - - ); - } - - const singleLine = result.lines.length === 1; - - return ( - - - {result.lines.map((line) => ( - - ))} - - - ); -} diff --git a/src/components/export-panel/use-export-panel-side-by-side.ts b/src/components/export-panel/use-export-panel-side-by-side.ts deleted file mode 100644 index 4706be86..00000000 --- a/src/components/export-panel/use-export-panel-side-by-side.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useMediaQuery } from "@mui/material"; -import { EXPORT_PANEL_SIDE_BY_SIDE_MQ } from "./constants.ts"; - -export function useExportPanelSideBySide(): boolean { - return useMediaQuery(EXPORT_PANEL_SIDE_BY_SIDE_MQ); -} diff --git a/src/components/filter-unit/constants.ts b/src/components/filter-unit/constants.ts new file mode 100644 index 00000000..548ed1f0 --- /dev/null +++ b/src/components/filter-unit/constants.ts @@ -0,0 +1,95 @@ +/** + * Shared unit-grid tokens for toolbar filter chrome (Character pick, Soft pick, + * forms, BiS, Data). Character pick–only layout lives in `character-pick-panel/`. + */ + +/** Theme spacing multiplier used for filter grid gap (`gap: 1.5`). */ +export const FILTER_UNIT_GRID_GAP_SPACING = 1.5; + +/** Width of one grid column unit. */ +export const FILTER_UNIT_WIDTH = 300; + +/** + * Fixed height of one grid row unit (1× block). + * Fits raid chips, GS slider, and role 2×2 icons (chips scroll inside the raid block). + */ +export const FILTER_UNIT_HEIGHT = 224; + +/** Keeps search + match count pinned; only raid chips scroll inside the section. */ +export const dungeonFilterContentSx = { + overflow: "hidden", + display: "flex", + flexDirection: "column", +} as const; + +/** + * Character-spec list (Character pick + Soft pick) — fits a 2×1 unit column. + * Shared so both panels stay equal in size. + */ +export const CHARACTER_SPEC_LIST_GRID_TEMPLATE_COLUMNS = + "minmax(0, 1fr) auto auto"; +export const CHARACTER_SPEC_LIST_COLUMN_GAP = 0.5; +export const CHARACTER_SPEC_LIST_ROW_GAP = 0.75; +export const CHARACTER_SPEC_LIST_ICON_SIZE = 16; + +/** Character spec rows visible before the list scrolls (2× block content). */ +export const FILTER_UNIT_SPECS_VISIBLE_ROW_COUNT = 8; + +/** Spec row height — small FormControlLabel with spec icon + gear score. */ +export const FILTER_UNIT_SPECS_ROW_HEIGHT_PX = 38; + +/** Matches `CHARACTER_SPEC_LIST_ROW_GAP` (0.75 → 6px at default theme spacing). */ +export const FILTER_UNIT_SPECS_ROW_GAP_PX = 6; + +/** Max scroll viewport height for the character specs list (8 rows). */ +export function getCharacterSpecListMaxHeight(): number { + const rowCount = FILTER_UNIT_SPECS_VISIBLE_ROW_COUNT; + return ( + rowCount * FILTER_UNIT_SPECS_ROW_HEIGHT_PX + + (rowCount - 1) * FILTER_UNIT_SPECS_ROW_GAP_PX + ); +} + +/** Total height of a block spanning `heightUnits` grid rows (excludes inter-row gap). */ +export function getFilterUnitBlockHeight(heightUnits: number): number { + return heightUnits * FILTER_UNIT_HEIGHT; +} + +export function getFilterUnitColumnTemplate(): string { + return `minmax(0, ${FILTER_UNIT_WIDTH}px)`; +} + +/** Shared grid sx for Character pick / Soft pick character-spec lists. */ +export function getCharacterSpecListGridSx(options?: { + maxHeight?: number; +}): Record { + return { + display: "grid", + gridTemplateColumns: CHARACTER_SPEC_LIST_GRID_TEMPLATE_COLUMNS, + columnGap: CHARACTER_SPEC_LIST_COLUMN_GAP, + rowGap: CHARACTER_SPEC_LIST_ROW_GAP, + alignItems: "center", + minWidth: 0, + ...(options?.maxHeight != null + ? { + maxHeight: options.maxHeight, + overflowY: "auto", + pr: 0.25, + } + : null), + }; +} + +/** + * Default 2-row filter grid height (Character pick / Soft pick filter rows). + * Soft pick reuses this for its filter band. + */ +export function getFilterUnitTwoRowGridHeight( + gridRowGapPx = FILTER_UNIT_GRID_GAP_SPACING * 8, +): number { + return 2 * FILTER_UNIT_HEIGHT + gridRowGapPx; +} + +export function getFilterUnitTwoRowTemplateRows(): string { + return `repeat(2, ${FILTER_UNIT_HEIGHT}px)`; +} diff --git a/src/components/export-panel/export-dungeon-filter-actions.tsx b/src/components/filter-unit/dungeon-filter-actions.tsx similarity index 69% rename from src/components/export-panel/export-dungeon-filter-actions.tsx rename to src/components/filter-unit/dungeon-filter-actions.tsx index 002ceb43..6979021c 100644 --- a/src/components/export-panel/export-dungeon-filter-actions.tsx +++ b/src/components/filter-unit/dungeon-filter-actions.tsx @@ -2,25 +2,25 @@ import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { IconButton, Tooltip } from "@mui/material"; import { useTranslation } from "../../i18n/use-translation.ts"; -type ExportDungeonFilterActionsProps = { +type DungeonFilterActionsProps = { disabled?: boolean; onReset: () => void; }; -export function ExportDungeonFilterActions({ +export function DungeonFilterActions({ disabled = false, onReset, -}: ExportDungeonFilterActionsProps) { +}: DungeonFilterActionsProps) { const { t } = useTranslation(); return ( - + diff --git a/src/components/export-panel/export-dungeon-filter.test.tsx b/src/components/filter-unit/dungeon-filter.test.tsx similarity index 94% rename from src/components/export-panel/export-dungeon-filter.test.tsx rename to src/components/filter-unit/dungeon-filter.test.tsx index c2b530ec..5ecb034e 100644 --- a/src/components/export-panel/export-dungeon-filter.test.tsx +++ b/src/components/filter-unit/dungeon-filter.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createTestDungeon } from "../../test/fixtures.ts"; import { testTranslator } from "../../test/i18n.ts"; import { renderWithTheme } from "../../test/render-with-theme.tsx"; -import { ExportDungeonFilter } from "./export-dungeon-filter.tsx"; +import { DungeonFilter } from "./dungeon-filter.tsx"; function FilterHarness({ initialExcludedIds = [] as string[], @@ -30,7 +30,7 @@ function FilterHarness({ }); return ( - { +describe("DungeonFilter", () => { it("keeps the raid search field editable and in sync with local state", async () => { const user = userEvent.setup(); renderWithTheme(); diff --git a/src/components/export-panel/export-dungeon-filter.tsx b/src/components/filter-unit/dungeon-filter.tsx similarity index 89% rename from src/components/export-panel/export-dungeon-filter.tsx rename to src/components/filter-unit/dungeon-filter.tsx index 5119300c..16f64f99 100644 --- a/src/components/export-panel/export-dungeon-filter.tsx +++ b/src/components/filter-unit/dungeon-filter.tsx @@ -6,9 +6,9 @@ import { getRaidIcon } from "../../assets/raid-icons/raid-icons.ts"; import { resolveDungeonRaidKey } from "../../utils/resolve-dungeon-raid-key.ts"; import { formatDungeonExportLabel } from "../../utils/format-dungeon-label.ts"; import { DungeonNameSearchField } from "../dungeon-name-search-field/index.tsx"; -import { ExportRaidIcon } from "./export-raid-icon.tsx"; +import { RaidIcon } from "./raid-icon.tsx"; -type ExportDungeonFilterProps = { +type DungeonFilterProps = { dungeonNameSearch: string; onDungeonNameSearchChange: (query: string) => void; visibleDungeons: readonly DungeonRecord[]; @@ -18,7 +18,7 @@ type ExportDungeonFilterProps = { t: TranslateFn; }; -export function ExportDungeonFilter({ +export function DungeonFilter({ dungeonNameSearch, onDungeonNameSearchChange, visibleDungeons, @@ -26,7 +26,7 @@ export function ExportDungeonFilter({ onToggleDungeonExcluded, locale, t, -}: ExportDungeonFilterProps) { +}: DungeonFilterProps) { const selectedCount = visibleDungeons.reduce( (count, dungeon) => excludedDungeonIds.has(dungeon.id) ? count : count + 1, @@ -60,7 +60,7 @@ export function ExportDungeonFilter({ variant="body2" sx={{ flexShrink: 0, lineHeight: 1.35, whiteSpace: "nowrap" }} > - {t("exportPanel.dungeonFilterMatchCount", { + {t("characterPickPanel.dungeonFilterMatchCount", { selected: selectedCount, matched: visibleDungeons.length, })} @@ -78,7 +78,7 @@ export function ExportDungeonFilter({ > {visibleDungeons.length === 0 ? ( - {t("exportPanel.dungeonFilterNoMatches")} + {t("characterPickPanel.dungeonFilterNoMatches")} ) : ( onToggleDungeonExcluded(dungeon.id)} sx={{ @@ -125,7 +125,7 @@ export function ExportDungeonFilter({ label={ <> {raidIcon ? ( - + ) : null} {label} diff --git a/src/components/export-panel/export-filter-section.tsx b/src/components/filter-unit/filter-section.tsx similarity index 80% rename from src/components/export-panel/export-filter-section.tsx rename to src/components/filter-unit/filter-section.tsx index 2c318b4a..77489f97 100644 --- a/src/components/export-panel/export-filter-section.tsx +++ b/src/components/filter-unit/filter-section.tsx @@ -2,16 +2,22 @@ import { Box, Stack, Typography } from "@mui/material"; import type { SxProps, Theme } from "@mui/material/styles"; import type { ReactNode } from "react"; -type ExportFilterSectionProps = { +type FilterSectionProps = { /** Optional workflow step number shown before the title (Character pick). */ step?: number; title: string; /** When set, shown muted after the title as `(…)` — e.g. optional filters. */ titleMark?: string; + /** Icon / button actions in the title row (reset, select all, copy, …). */ titleActions?: ReactNode; /** Optional line under the title row (e.g. active BiS list name). */ subtitle?: string; description?: string; + /** + * Format / filter toggles under the description (e.g. With upgrades, + * Include specs / GS, Name / Details). Prefer this over titleActions for switches. + */ + descriptionActions?: ReactNode; children: ReactNode; sx?: SxProps; contentSx?: SxProps; @@ -37,17 +43,20 @@ const stepBadgeSx = { verticalAlign: "middle", } as const; -export function ExportFilterSection({ +export function FilterSection({ step, title, titleMark, titleActions, subtitle, description, + descriptionActions, children, sx, contentSx, -}: ExportFilterSectionProps) { +}: FilterSectionProps) { + const hasDescriptionBlock = Boolean(description || descriptionActions); + return ( @@ -144,16 +154,33 @@ export function ExportFilterSection({ sx={{ display: "block", mt: subtitle ? 0.15 : 0.25, - mb: 0.75, + mb: descriptionActions ? 0.35 : 0.75, lineHeight: 1.35, flexShrink: 0, }} > {description} - ) : ( + ) : null} + {descriptionActions ? ( + + {descriptionActions} + + ) : null} + {!hasDescriptionBlock ? ( - )} + ) : null} void; +type ResetParametersActionsProps = { + onResetParameters: () => void; }; -export function ExportPanelHeaderActions({ - onResetAllFilters, -}: ExportPanelHeaderActionsProps) { +export function ResetParametersActions({ + onResetParameters, +}: ResetParametersActionsProps) { const { t } = useTranslation(); return ( ); } diff --git a/src/components/gear-pick-panel/constants.test.ts b/src/components/gear-pick-panel/constants.test.ts index 5d394640..f2d9b4aa 100644 --- a/src/components/gear-pick-panel/constants.test.ts +++ b/src/components/gear-pick-panel/constants.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { - EXPORT_FILTER_UNIT_HEIGHT, - EXPORT_FILTER_UNIT_WIDTH, -} from "../export-panel/constants.ts"; + FILTER_UNIT_HEIGHT, + FILTER_UNIT_WIDTH, +} from "../filter-unit/constants.ts"; import { GEAR_PICK_COPY_BLOCK_SPAN, getGearPickCopyBlockMaxHeight, @@ -49,39 +49,40 @@ describe("GEAR_PICK_COPY_BLOCK_SPAN", () => { widthUnits: 2, }); expect(getGearPickCopyBlockMaxWidth()).toBe( - EXPORT_FILTER_UNIT_WIDTH * 2 + 12, + FILTER_UNIT_WIDTH * 2 + 12, ); - expect(getGearPickCopyBlockMaxHeight()).toBe(EXPORT_FILTER_UNIT_HEIGHT); + expect(getGearPickCopyBlockMaxHeight()).toBe(FILTER_UNIT_HEIGHT); expect(getGearPickSoftsBlockMaxWidth()).toBe( - EXPORT_FILTER_UNIT_WIDTH * 2 + 12, + FILTER_UNIT_WIDTH * 2 + 12, ); expect(getGearPickSoftsBlockMaxHeight()).toBe( - EXPORT_FILTER_UNIT_HEIGHT * 2 + 12, + FILTER_UNIT_HEIGHT * 2 + 12, ); }); }); describe("getGearPickGridTemplateColumns", () => { - it("uses two fixed filter columns when softs wraps below", () => { - const unitColumn = `${EXPORT_FILTER_UNIT_WIDTH}px`; + it("uses two shrinkable filter columns when softs wraps below", () => { + const unitColumn = `minmax(0, ${FILTER_UNIT_WIDTH}px)`; expect(getGearPickGridTemplateColumns("filters")).toBe( `${unitColumn} ${unitColumn}`, ); }); - it("adds a fixed 2-unit softs column when softs fits beside filters", () => { - const unitColumn = `${EXPORT_FILTER_UNIT_WIDTH}px`; - const softsColumn = `${getGearPickSoftsBlockMaxWidth()}px`; + it("adds a shrinkable 2-unit softs column when softs fits beside filters", () => { + const unitColumn = `minmax(0, ${FILTER_UNIT_WIDTH}px)`; + const softsColumn = `minmax(0, ${getGearPickSoftsBlockMaxWidth()}px)`; expect(getGearPickGridTemplateColumns("md")).toBe( `${unitColumn} ${unitColumn} ${softsColumn}`, ); }); it("adds softs and copy columns when both fit beside filters", () => { - const unitColumn = `${EXPORT_FILTER_UNIT_WIDTH}px`; - const softsColumn = `${getGearPickSoftsBlockMaxWidth()}px`; + const unitColumn = `minmax(0, ${FILTER_UNIT_WIDTH}px)`; + const softsColumn = `minmax(0, ${getGearPickSoftsBlockMaxWidth()}px)`; + const copyColumn = `minmax(0, ${getGearPickCopyBlockMaxWidth()}px)`; expect(getGearPickGridTemplateColumns("wide")).toBe( - `${unitColumn} ${unitColumn} ${softsColumn} ${getGearPickCopyBlockMaxWidth()}px`, + `${unitColumn} ${unitColumn} ${softsColumn} ${copyColumn}`, ); }); }); @@ -89,19 +90,19 @@ describe("getGearPickGridTemplateColumns", () => { describe("getGearPickGridTemplateRows", () => { it("adds softs and copy rows under filters when they wrap", () => { expect(getGearPickGridTemplateRows("filters")).toBe( - `repeat(2, ${EXPORT_FILTER_UNIT_HEIGHT}px) ${getGearPickSoftsBlockMaxHeight()}px ${getGearPickCopyBlockMaxHeight()}px`, + `repeat(2, ${FILTER_UNIT_HEIGHT}px) ${getGearPickSoftsBlockMaxHeight()}px ${getGearPickCopyBlockMaxHeight()}px`, ); }); it("keeps two filter rows plus a copy row when softs is beside", () => { expect(getGearPickGridTemplateRows("md")).toBe( - `repeat(2, ${EXPORT_FILTER_UNIT_HEIGHT}px) ${EXPORT_FILTER_UNIT_HEIGHT}px`, + `repeat(2, ${FILTER_UNIT_HEIGHT}px) ${FILTER_UNIT_HEIGHT}px`, ); }); it("uses only the two fixed rows on wide", () => { expect(getGearPickGridTemplateRows("wide")).toBe( - `repeat(2, ${EXPORT_FILTER_UNIT_HEIGHT}px)`, + `repeat(2, ${FILTER_UNIT_HEIGHT}px)`, ); }); }); diff --git a/src/components/gear-pick-panel/constants.ts b/src/components/gear-pick-panel/constants.ts index 201a4d36..8089e6b6 100644 --- a/src/components/gear-pick-panel/constants.ts +++ b/src/components/gear-pick-panel/constants.ts @@ -1,12 +1,13 @@ import { - EXPORT_FILTER_GRID_GAP_SPACING, - EXPORT_FILTER_UNIT_HEIGHT, - EXPORT_FILTER_UNIT_WIDTH, - getExportFilterGridTemplateRows, -} from "../export-panel/constants.ts"; + FILTER_UNIT_GRID_GAP_SPACING, + FILTER_UNIT_HEIGHT, + FILTER_UNIT_WIDTH, + getFilterUnitColumnTemplate, + getFilterUnitTwoRowTemplateRows, +} from "../filter-unit/constants.ts"; /** Pixel gap between Soft pick grid tracks (`gap: 1.5` → 12px at default spacing). */ -const GEAR_PICK_GRID_GAP_PX = EXPORT_FILTER_GRID_GAP_SPACING * 8; +const GEAR_PICK_GRID_GAP_PX = FILTER_UNIT_GRID_GAP_SPACING * 8; /** * Soft pick panel grid areas. @@ -46,7 +47,7 @@ export function getGearPickCopyBlockMaxWidth( ): number { const { widthUnits } = GEAR_PICK_COPY_BLOCK_SPAN; return ( - widthUnits * EXPORT_FILTER_UNIT_WIDTH + (widthUnits - 1) * gridColumnGapPx + widthUnits * FILTER_UNIT_WIDTH + (widthUnits - 1) * gridColumnGapPx ); } @@ -55,7 +56,7 @@ export function getGearPickSoftsBlockMaxWidth( gridColumnGapPx = GEAR_PICK_GRID_GAP_PX, ): number { return ( - GEAR_PICK_SOFTS_BLOCK_WIDTH_UNITS * EXPORT_FILTER_UNIT_WIDTH + + GEAR_PICK_SOFTS_BLOCK_WIDTH_UNITS * FILTER_UNIT_WIDTH + (GEAR_PICK_SOFTS_BLOCK_WIDTH_UNITS - 1) * gridColumnGapPx ); } @@ -65,7 +66,7 @@ export function getGearPickCopyBlockMaxHeight( ): number { const { heightUnits } = GEAR_PICK_COPY_BLOCK_SPAN; return ( - heightUnits * EXPORT_FILTER_UNIT_HEIGHT + (heightUnits - 1) * gridRowGapPx + heightUnits * FILTER_UNIT_HEIGHT + (heightUnits - 1) * gridRowGapPx ); } @@ -74,7 +75,7 @@ export function getGearPickSoftsBlockMaxHeight( gridRowGapPx = GEAR_PICK_GRID_GAP_PX, ): number { return ( - GEAR_PICK_SOFTS_BLOCK_HEIGHT_UNITS * EXPORT_FILTER_UNIT_HEIGHT + + GEAR_PICK_SOFTS_BLOCK_HEIGHT_UNITS * FILTER_UNIT_HEIGHT + (GEAR_PICK_SOFTS_BLOCK_HEIGHT_UNITS - 1) * gridRowGapPx ); } @@ -83,7 +84,7 @@ export function getGearPickSoftsBlockMaxHeight( export function getGearPickFiltersRowWidth( gridColumnGapPx = GEAR_PICK_GRID_GAP_PX, ): number { - return 2 * EXPORT_FILTER_UNIT_WIDTH + gridColumnGapPx; + return 2 * FILTER_UNIT_WIDTH + gridColumnGapPx; } /** Min container width for softs beside filters (copy still below). */ @@ -153,15 +154,13 @@ export function getGearPickGridTemplateAreas(layout: GearPickGridLayout): string export function getGearPickGridTemplateColumns( layout: GearPickGridLayout, ): string { - /** - * Fixed unit columns — raids / character / rules must not shrink below - * the shared filter unit (unlike Character pick's minmax(0, unit)). - */ - const unitColumn = `${EXPORT_FILTER_UNIT_WIDTH}px`; - const softsColumn = `${getGearPickSoftsBlockMaxWidth()}px`; + // Same shrinkable unit columns as Character pick (`filter-unit`). + const unitColumn = getFilterUnitColumnTemplate(); + const softsColumn = `minmax(0, ${getGearPickSoftsBlockMaxWidth()}px)`; + const copyColumn = `minmax(0, ${getGearPickCopyBlockMaxWidth()}px)`; if (layout === "wide") { - return `${unitColumn} ${unitColumn} ${softsColumn} ${getGearPickCopyBlockMaxWidth()}px`; + return `${unitColumn} ${unitColumn} ${softsColumn} ${copyColumn}`; } if (layout === "md") { @@ -172,7 +171,7 @@ export function getGearPickGridTemplateColumns( } export function getGearPickGridTemplateRows(layout: GearPickGridLayout): string { - const filterRows = getExportFilterGridTemplateRows(); + const filterRows = getFilterUnitTwoRowTemplateRows(); if (layout === "wide") { return filterRows; } diff --git a/src/components/gear-pick-panel/gear-pick-character-filter-actions.tsx b/src/components/gear-pick-panel/gear-pick-character-filter-actions.tsx new file mode 100644 index 00000000..0a4e9b97 --- /dev/null +++ b/src/components/gear-pick-panel/gear-pick-character-filter-actions.tsx @@ -0,0 +1,39 @@ +import { FormControlLabel, Switch, Typography } from "@mui/material"; +import { useTranslation } from "../../i18n/use-translation.ts"; + +type GearPickCharacterFilterActionsProps = { + onlyWithUpgrades: boolean; + onOnlyWithUpgradesChange: (onlyWithUpgrades: boolean) => void; +}; + +export function GearPickCharacterFilterActions({ + onlyWithUpgrades, + onOnlyWithUpgradesChange, +}: GearPickCharacterFilterActionsProps) { + const { t } = useTranslation(); + + return ( + { + onOnlyWithUpgradesChange(event.target.checked); + }} + slotProps={{ + input: { + "aria-label": t("gearPickPanel.onlyCharactersWithUpgradesAria"), + }, + }} + /> + } + label={ + + {t("gearPickPanel.onlyCharactersWithUpgrades")} + + } + sx={{ mr: 0, ml: 0 }} + /> + ); +} diff --git a/src/components/gear-pick-panel/gear-pick-character-select.tsx b/src/components/gear-pick-panel/gear-pick-character-select.tsx index 78cac640..36f0aa42 100644 --- a/src/components/gear-pick-panel/gear-pick-character-select.tsx +++ b/src/components/gear-pick-panel/gear-pick-character-select.tsx @@ -12,8 +12,8 @@ import { import { CHARACTER_SPEC_LIST_ICON_SIZE, getCharacterSpecListGridSx, - getExportFilterSpecsListMaxHeight, -} from "../export-panel/constants.ts"; + getCharacterSpecListMaxHeight, +} from "../filter-unit/constants.ts"; import { CharacterSpecGearLabel } from "../spec-option-label/index.tsx"; export type GearPickCharacterSelection = { @@ -21,9 +21,16 @@ export type GearPickCharacterSelection = { side: GearPickSpecSide; }; +export type GearPickCharacterInactiveReason = "cooldown" | "noUpgrades"; + type GearPickCharacterSelectProps = { characters: readonly CharacterRecord[]; - includedCharacterIds: ReadonlySet; + /** Characters not on CD for every active raid. */ + availableCharacterIds: ReadonlySet; + /** Characters with Soft pick BiS targets in active raids. */ + charactersWithUpgradesIds: ReadonlySet; + /** When true, characters without upgrades are dimmed and non-selectable. */ + onlyWithUpgrades: boolean; selection: GearPickCharacterSelection | null; onSelectionChange: (selection: GearPickCharacterSelection) => void; t: TranslateFn; @@ -41,17 +48,42 @@ function parseSelectionValue(value: string): GearPickCharacterSelection | null { return { characterId, side }; } +function getInactiveReason( + characterId: string, + availableCharacterIds: ReadonlySet, + charactersWithUpgradesIds: ReadonlySet, + onlyWithUpgrades: boolean, +): GearPickCharacterInactiveReason | null { + if (!availableCharacterIds.has(characterId)) { + return "cooldown"; + } + if (onlyWithUpgrades && !charactersWithUpgradesIds.has(characterId)) { + return "noUpgrades"; + } + return null; +} + +function inactiveTooltipKey( + reason: GearPickCharacterInactiveReason, +): + | "characterPickPanel.characterInactiveCooldownHint" + | "gearPickPanel.characterInactiveNoUpgradesHint" { + return reason === "cooldown" + ? "characterPickPanel.characterInactiveCooldownHint" + : "gearPickPanel.characterInactiveNoUpgradesHint"; +} + function GearPickSpecRadio({ character, specGear, side, - cooldownInactive, + inactiveReason, t, }: { character: CharacterRecord; specGear: CharacterSpecGear; side: GearPickSpecSide; - cooldownInactive: boolean; + inactiveReason: GearPickCharacterInactiveReason | null; t: TranslateFn; }) { const { locale } = useTranslation(); @@ -60,10 +92,14 @@ function GearPickSpecRadio({ return null; } + const cooldownInactive = inactiveReason === "cooldown"; + const upgradesInactive = inactiveReason === "noUpgrades"; + const inactive = inactiveReason !== null; + const control = ( } + control={} aria-label={t("gearPickPanel.selectSpecAria", { name: character.name, spec: getLocalizedSpecName(character.class.name, specGear.spec, locale), @@ -77,7 +113,7 @@ function GearPickSpecRadio({ variant="caption" showSpecName={false} showDetailTooltip={false} - color={cooldownInactive ? "text.secondary" : "inherit"} + color={inactive ? "text.secondary" : "inherit"} /> } sx={{ @@ -86,21 +122,21 @@ function GearPickSpecRadio({ minWidth: 0, "& .MuiRadio-root": { p: 0.25, - ...(cooldownInactive ? { opacity: 0.45 } : null), + ...(inactive ? { opacity: 0.45 } : null), }, "& .MuiFormControlLabel-label": { ml: 0, minWidth: 0 }, ...(cooldownInactive ? { "& img": { opacity: 0.45, filter: "grayscale(1)" } } - : null), + : upgradesInactive + ? { "& img": { opacity: 0.55 } } + : null), }} /> ); return ( {control} @@ -109,12 +145,14 @@ function GearPickSpecRadio({ export function GearPickCharacterSelect({ characters, - includedCharacterIds, + availableCharacterIds, + charactersWithUpgradesIds, + onlyWithUpgrades, selection, onSelectionChange, t, }: GearPickCharacterSelectProps) { - const listMaxHeight = getExportFilterSpecsListMaxHeight(); + const listMaxHeight = getCharacterSpecListMaxHeight(); if (characters.length === 0) { return ( @@ -129,7 +167,15 @@ export function GearPickCharacterSelect({ value={selection ? selectionValue(selection) : ""} onChange={(event) => { const next = parseSelectionValue(event.target.value); - if (next && includedCharacterIds.has(next.characterId)) { + if ( + next && + getInactiveReason( + next.characterId, + availableCharacterIds, + charactersWithUpgradesIds, + onlyWithUpgrades, + ) === null + ) { onSelectionChange(next); } }} @@ -143,20 +189,26 @@ export function GearPickCharacterSelect({ const hasMain = Boolean(character.mainSpec); const hasOff = Boolean(character.offSpec); const hasNoSpecs = !hasMain && !hasOff; - const cooldownInactive = !includedCharacterIds.has(character.id); + const inactiveReason = getInactiveReason( + character.id, + availableCharacterIds, + charactersWithUpgradesIds, + onlyWithUpgrades, + ); return ( @@ -165,7 +217,7 @@ export function GearPickCharacterSelect({ character={character} specGear={character.mainSpec} side="main" - cooldownInactive={cooldownInactive} + inactiveReason={inactiveReason} t={t} /> ) : hasNoSpecs ? ( @@ -180,7 +232,7 @@ export function GearPickCharacterSelect({ character={character} specGear={character.offSpec} side="off" - cooldownInactive={cooldownInactive} + inactiveReason={inactiveReason} t={t} /> ) : null} diff --git a/src/components/gear-pick-panel/gear-pick-copy-block.tsx b/src/components/gear-pick-panel/gear-pick-copy-block.tsx index ffd027f0..71b3b26f 100644 --- a/src/components/gear-pick-panel/gear-pick-copy-block.tsx +++ b/src/components/gear-pick-panel/gear-pick-copy-block.tsx @@ -1,7 +1,8 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import { Box, Button, Stack, Typography } from "@mui/material"; +import { Box, Button, Typography } from "@mui/material"; import { useCallback, useState } from "react"; import type { TranslateFn } from "../../i18n/translate.ts"; +import { FilterSection } from "../filter-unit/filter-section.tsx"; import { GearPickCopyFormatActions } from "./gear-pick-copy-format-actions.tsx"; type GearPickCopyBlockProps = { @@ -49,32 +50,11 @@ export function GearPickCopyBlock({ }, [copyText, hasSoftCalls]); return ( - - - - - 5. - - {t("gearPickPanel.copyTitle")} - + {copied ? t("gearPickPanel.copied") : t("gearPickPanel.copyLine")} - - - - {t("gearPickPanel.copyHelper")} - + } + descriptionActions={ + + } + > {hasSoftCalls ? ( @@ -121,6 +101,6 @@ export function GearPickCopyBlock({ {t("gearPickPanel.copyEmpty")} )} - + ); } diff --git a/src/components/gear-pick-panel/gear-pick-copy-format-actions.tsx b/src/components/gear-pick-panel/gear-pick-copy-format-actions.tsx index 436d5c49..9c25010e 100644 --- a/src/components/gear-pick-panel/gear-pick-copy-format-actions.tsx +++ b/src/components/gear-pick-panel/gear-pick-copy-format-actions.tsx @@ -20,7 +20,7 @@ export function GearPickCopyFormatActions({ new Set( characters @@ -102,9 +108,36 @@ export function GearPickPanel({ [activeDungeons, characters, dungeonToggles], ); - /** Ignore a stored pick when that character is on CD for every visible raid. */ + const charactersWithUpgradesIds = useMemo(() => { + if (!onlyCharactersWithUpgrades) { + return new Set(); + } + return new Set( + characters + .filter((character) => + characterHasGearPickUpgrades({ + character, + dungeons: activeDungeons, + getBisSlotMapForSpec, + locale, + }), + ) + .map((character) => character.id), + ); + }, [ + activeDungeons, + characters, + getBisSlotMapForSpec, + locale, + onlyCharactersWithUpgrades, + ]); + + /** Ignore a stored pick when that character is on CD or dimmed by the upgrades filter. */ const activeSelection = - selection !== null && includedCharacterIds.has(selection.characterId) + selection !== null && + availableCharacterIds.has(selection.characterId) && + (!onlyCharactersWithUpgrades || + charactersWithUpgradesIds.has(selection.characterId)) ? selection : null; @@ -252,7 +285,7 @@ export function GearPickPanel({ xs: "none", md: getGearPickGridTemplateAreas("filters"), }, - gap: EXPORT_FILTER_GRID_GAP_SPACING, + gap: FILTER_UNIT_GRID_GAP_SPACING, alignItems: "stretch", width: "100%", minWidth: 0, @@ -269,22 +302,22 @@ export function GearPickPanel({ }} > - } - contentSx={exportDungeonFilterContentSx} + contentSx={dungeonFilterContentSx} > - - + - + } > - + - - + - )} - + diff --git a/src/components/raid-tracker-main/index.tsx b/src/components/raid-tracker-main/index.tsx index 62e22094..20308d10 100644 --- a/src/components/raid-tracker-main/index.tsx +++ b/src/components/raid-tracker-main/index.tsx @@ -1,7 +1,7 @@ import { Alert, Stack } from "@mui/material"; import { useCallback, useMemo } from "react"; import { useBisListsSessionState } from "../../hooks/use-bis-lists-session-state.ts"; -import { useExportSessionState } from "../../hooks/use-export-session-state.ts"; +import { useCharacterPickSessionState } from "../../hooks/use-character-pick-session-state.ts"; import { useGearHintLegendDismissed } from "../../hooks/use-gear-hint-legend-dismissed.ts"; import { useGearPickSessionState } from "../../hooks/use-gear-pick-session-state.ts"; import { useRaidTrackerContext } from "../../hooks/use-raid-tracker-context.ts"; @@ -15,8 +15,8 @@ import { BisListsPanel } from "../bis-lists-panel/index.tsx"; import { CharacterForm } from "../character-form/index.tsx"; import { DataControlsPanel } from "../data-controls-panel/index.tsx"; import { DungeonForm } from "../dungeon-form/index.tsx"; -import { ExportPanel } from "../export-panel/index.tsx"; -import { ExportPanelHeaderActions } from "../export-panel/export-panel-header-actions.tsx"; +import { CharacterPickPanel } from "../character-pick-panel/index.tsx"; +import { ResetParametersActions } from "../filter-unit/reset-parameters-actions.tsx"; import { GearHintLegend } from "../gear-hint-legend/index.tsx"; import { GearPickPanel } from "../gear-pick-panel/index.tsx"; import { RaidTrackerTable } from "../raid-tracker-table/index.tsx"; @@ -30,8 +30,8 @@ type RaidTrackerMainProps = { onAddFromTemplate: () => void; introVisible: boolean; onDismissIntro: () => void; - showExportPanel: boolean; - closeExportPanel: () => void; + showCharacterPickPanel: boolean; + closeCharacterPickPanel: () => void; showGearPickPanel: boolean; closeGearPickPanel: () => void; showBisListsPanel: boolean; @@ -61,8 +61,8 @@ export function RaidTrackerMain({ onAddFromTemplate, introVisible, onDismissIntro, - showExportPanel, - closeExportPanel, + showCharacterPickPanel, + closeCharacterPickPanel, showGearPickPanel, closeGearPickPanel, showBisListsPanel, @@ -88,16 +88,17 @@ export function RaidTrackerMain({ }); const gearPickSession = useGearPickSessionState(); - const exportSession = useExportSessionState(); + const exportSession = useCharacterPickSessionState(); const bisListsSession = useBisListsSessionState(); const { resetAllFilters: resetExportSessionFilters } = exportSession; + const { resetAllFilters: resetGearPickSessionFilters } = gearPickSession; const { setDungeonNameSearch } = tableState; const toolbarPanelId = resolveToolbarPanelId({ showCharacterForm: forms.showCharacterForm, showDungeonForm: forms.showDungeonForm, showBisListsPanel, - showExportPanel, + showCharacterPickPanel, showGearPickPanel, showDataControlsPanel, }); @@ -117,6 +118,11 @@ export function RaidTrackerMain({ setDungeonNameSearch(""); }, [resetExportSessionFilters, setDungeonNameSearch]); + const resetGearPickAllFilters = useCallback(() => { + resetGearPickSessionFilters(); + setDungeonNameSearch(""); + }, [resetGearPickSessionFilters, setDungeonNameSearch]); + const toolbarPanelMeta = useMemo(() => { if (!toolbarPanelId) { return null; @@ -127,15 +133,24 @@ export function RaidTrackerMain({ closeDungeonForm: forms.closeDungeonForm, closeBisListsPanel: closeBisListsPanelWithTooltips, closeDataControlsPanel, - closeExportPanel, + closeCharacterPickPanel, closeGearPickPanel: closeGearPickPanelWithTooltips, }); - if (toolbarPanelId === "export") { + if (toolbarPanelId === "characterPick") { + return { + ...meta, + headerActions: ( + + ), + }; + } + + if (toolbarPanelId === "gear") { return { ...meta, headerActions: ( - + ), }; } @@ -144,11 +159,12 @@ export function RaidTrackerMain({ }, [ closeBisListsPanelWithTooltips, closeDataControlsPanel, - closeExportPanel, + closeCharacterPickPanel, closeGearPickPanelWithTooltips, forms.closeCharacterForm, forms.closeDungeonForm, resetExportAllFilters, + resetGearPickAllFilters, t, toolbarPanelId, ]); @@ -207,8 +223,8 @@ export function RaidTrackerMain({ /> ) : null} - {showExportPanel ? ( - void; showCharacterForm: boolean; showDungeonForm: boolean; - showExportPanel: boolean; + showCharacterPickPanel: boolean; showGearPickPanel: boolean; showBisListsPanel: boolean; showDataControlsPanel: boolean; toggleCharacterForm: () => void; toggleDungeonForm: () => void; - toggleExportPanel: () => void; + toggleCharacterPickPanel: () => void; toggleGearPickPanel: () => void; toggleBisListsPanel: () => void; toggleDataControlsPanel: () => void; diff --git a/src/components/tracker-layout/index.tsx b/src/components/tracker-layout/index.tsx index 31b3c2a3..6285bc62 100644 --- a/src/components/tracker-layout/index.tsx +++ b/src/components/tracker-layout/index.tsx @@ -50,13 +50,13 @@ export function TrackerLayout() { handleAddFromTemplate: requestAddFromTemplate, showCharacterForm: overlayPanels.showCharacterForm, showDungeonForm: overlayPanels.showDungeonForm, - showExportPanel: overlayPanels.showExportPanel, + showCharacterPickPanel: overlayPanels.showCharacterPickPanel, showGearPickPanel: overlayPanels.showGearPickPanel, showBisListsPanel: overlayPanels.showBisListsPanel, showDataControlsPanel: overlayPanels.showDataControlsPanel, toggleCharacterForm: overlayPanels.toggleCharacterForm, toggleDungeonForm: overlayPanels.toggleDungeonForm, - toggleExportPanel: overlayPanels.toggleExportPanel, + toggleCharacterPickPanel: overlayPanels.toggleCharacterPickPanel, toggleGearPickPanel: overlayPanels.toggleGearPickPanel, toggleBisListsPanel: overlayPanels.toggleBisListsPanel, toggleDataControlsPanel: overlayPanels.toggleDataControlsPanel, @@ -69,13 +69,13 @@ export function TrackerLayout() { overlayPanels.showCharacterForm, overlayPanels.showDataControlsPanel, overlayPanels.showDungeonForm, - overlayPanels.showExportPanel, + overlayPanels.showCharacterPickPanel, overlayPanels.showGearPickPanel, overlayPanels.toggleBisListsPanel, overlayPanels.toggleCharacterForm, overlayPanels.toggleDataControlsPanel, overlayPanels.toggleDungeonForm, - overlayPanels.toggleExportPanel, + overlayPanels.toggleCharacterPickPanel, overlayPanels.toggleGearPickPanel, ], ); @@ -98,8 +98,8 @@ export function TrackerLayout() { onAddFromTemplate={requestAddFromTemplate} introVisible={appIntro.visible} onDismissIntro={appIntro.dismiss} - showExportPanel={overlayPanels.showExportPanel} - closeExportPanel={overlayPanels.closeExportPanel} + showCharacterPickPanel={overlayPanels.showCharacterPickPanel} + closeCharacterPickPanel={overlayPanels.closeCharacterPickPanel} showGearPickPanel={overlayPanels.showGearPickPanel} closeGearPickPanel={overlayPanels.closeGearPickPanel} showBisListsPanel={overlayPanels.showBisListsPanel} diff --git a/src/components/tracker-toolbar-panel/constants.ts b/src/components/tracker-toolbar-panel/constants.ts index 86e6aba8..7c7536fd 100644 --- a/src/components/tracker-toolbar-panel/constants.ts +++ b/src/components/tracker-toolbar-panel/constants.ts @@ -1,9 +1,18 @@ -/** Matches add-character / add-dungeon form width. */ -export const TRACKER_NARROW_PANEL_MAX_WIDTH = 480; +/** + * Cap for New raid (2× 300px unit columns + gap + panel padding). + * Content grid is 612px; padding keeps the outlined card from clipping. + */ +export const TRACKER_DUNGEON_FORM_PANEL_MAX_WIDTH = 680; + +/** + * Cap for New character (1 + 2 + 2 unit columns + gaps + panel padding). + * Content grid is 1524px; padding keeps the outlined card from clipping. + */ +export const TRACKER_CHARACTER_FORM_PANEL_MAX_WIDTH = 1600; /** - * Cap for BiS lists (class/spec | items | lists). Narrower than the unit-grid - * panels so three columns stay readable on ultrawide viewports. + * Cap for BiS lists (1-unit side columns + flexible Items + panel padding). + * Side blocks match the shared 300px unit used by other toolbar panels. */ export const TRACKER_WIDE_PANEL_MAX_WIDTH = 1280; diff --git a/src/components/tracker-toolbar-panel/index.tsx b/src/components/tracker-toolbar-panel/index.tsx index 4240d04b..e57ac5ff 100644 --- a/src/components/tracker-toolbar-panel/index.tsx +++ b/src/components/tracker-toolbar-panel/index.tsx @@ -64,6 +64,12 @@ export function TrackerToolbarPanel({ boxSizing: "border-box", // Keep unit-grid children (Character / Soft pick) from painting past the outlined edge. overflow: "hidden", + bgcolor: "background.paper", + // Gallery F — dot grid (shared with page); step blocks stay solid fill. + backgroundImage: + "radial-gradient(var(--dot-grid) 1px, transparent 1px)", + backgroundSize: "var(--dot-grid-size) var(--dot-grid-size)", + backgroundPosition: "0 0", }} > diff --git a/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.test.ts b/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.test.ts index af660f92..c407c897 100644 --- a/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.test.ts +++ b/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.test.ts @@ -8,46 +8,46 @@ describe("resolveToolbarPanelId", () => { showCharacterForm: false, showDungeonForm: false, showBisListsPanel: false, - showExportPanel: false, + showCharacterPickPanel: false, showGearPickPanel: false, showDataControlsPanel: false, }), ).toBeNull(); }); - it("prefers character over dungeon, export, gear, bis, and data", () => { + it("prefers character over dungeon, character pick, gear, bis, and data", () => { expect( resolveToolbarPanelId({ showCharacterForm: true, showDungeonForm: true, showBisListsPanel: true, - showExportPanel: true, + showCharacterPickPanel: true, showGearPickPanel: true, showDataControlsPanel: true, }), ).toBe("character"); }); - it("returns export before gear, bis, and data when character and dungeon are closed", () => { + it("returns characterPick before gear, bis, and data when character and dungeon are closed", () => { expect( resolveToolbarPanelId({ showCharacterForm: false, showDungeonForm: false, showBisListsPanel: true, - showExportPanel: true, + showCharacterPickPanel: true, showGearPickPanel: true, showDataControlsPanel: true, }), - ).toBe("export"); + ).toBe("characterPick"); }); - it("returns gear before bis and data when export is closed", () => { + it("returns gear before bis and data when character pick is closed", () => { expect( resolveToolbarPanelId({ showCharacterForm: false, showDungeonForm: false, showBisListsPanel: true, - showExportPanel: false, + showCharacterPickPanel: false, showGearPickPanel: true, showDataControlsPanel: true, }), @@ -60,7 +60,7 @@ describe("resolveToolbarPanelId", () => { showCharacterForm: false, showDungeonForm: false, showBisListsPanel: false, - showExportPanel: false, + showCharacterPickPanel: false, showGearPickPanel: false, showDataControlsPanel: true, }), diff --git a/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.ts b/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.ts index 98dc3e24..996af947 100644 --- a/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.ts +++ b/src/components/tracker-toolbar-panel/resolve-toolbar-panel-id.ts @@ -1,12 +1,12 @@ export type MainToolbarPanelId = "character" | "dungeon" | "bis" | "data"; -export type ToolbarPanelId = MainToolbarPanelId | "export" | "gear"; +export type ToolbarPanelId = MainToolbarPanelId | "characterPick" | "gear"; export function resolveToolbarPanelId(options: { showCharacterForm: boolean; showDungeonForm: boolean; showBisListsPanel: boolean; - showExportPanel: boolean; + showCharacterPickPanel: boolean; showGearPickPanel: boolean; showDataControlsPanel: boolean; }): ToolbarPanelId | null { @@ -16,8 +16,8 @@ export function resolveToolbarPanelId(options: { if (options.showDungeonForm) { return "dungeon"; } - if (options.showExportPanel) { - return "export"; + if (options.showCharacterPickPanel) { + return "characterPick"; } if (options.showGearPickPanel) { return "gear"; diff --git a/src/components/tracker-toolbar-panel/toolbar-panel-meta.ts b/src/components/tracker-toolbar-panel/toolbar-panel-meta.ts index 72d0620f..1eb08bb8 100644 --- a/src/components/tracker-toolbar-panel/toolbar-panel-meta.ts +++ b/src/components/tracker-toolbar-panel/toolbar-panel-meta.ts @@ -1,8 +1,9 @@ import type { ReactNode } from "react"; import type { TranslateFn } from "../../i18n/translate.ts"; import { + TRACKER_CHARACTER_FORM_PANEL_MAX_WIDTH, TRACKER_DATA_CONTROLS_PANEL_MAX_WIDTH, - TRACKER_NARROW_PANEL_MAX_WIDTH, + TRACKER_DUNGEON_FORM_PANEL_MAX_WIDTH, TRACKER_UNIT_GRID_PANEL_MAX_WIDTH, TRACKER_WIDE_PANEL_MAX_WIDTH, } from "./constants.ts"; @@ -26,7 +27,7 @@ type MainToolbarPanelHandlers = { }; type ToolbarPanelHandlers = MainToolbarPanelHandlers & { - closeExportPanel: () => void; + closeCharacterPickPanel: () => void; closeGearPickPanel: () => void; }; @@ -35,8 +36,8 @@ export function getToolbarPanelMeta( t: TranslateFn, handlers: ToolbarPanelHandlers, ): ToolbarPanelMeta { - if (panelId === "export") { - return getExportToolbarPanelMeta(t, handlers.closeExportPanel); + if (panelId === "characterPick") { + return getCharacterPickToolbarPanelMeta(t, handlers.closeCharacterPickPanel); } if (panelId === "gear") { return getGearPickToolbarPanelMeta(t, handlers.closeGearPickPanel); @@ -55,14 +56,14 @@ function getMainToolbarPanelMeta( title: t("characterForm.title"), closeAriaLabel: t("characterForm.closeAria"), onClose: handlers.closeCharacterForm, - maxWidth: TRACKER_NARROW_PANEL_MAX_WIDTH, + maxWidth: TRACKER_CHARACTER_FORM_PANEL_MAX_WIDTH, }; case "dungeon": return { title: t("dungeonForm.title"), closeAriaLabel: t("dungeonForm.closeAria"), onClose: handlers.closeDungeonForm, - maxWidth: TRACKER_NARROW_PANEL_MAX_WIDTH, + maxWidth: TRACKER_DUNGEON_FORM_PANEL_MAX_WIDTH, }; case "bis": return { @@ -82,13 +83,13 @@ function getMainToolbarPanelMeta( } } -function getExportToolbarPanelMeta( +function getCharacterPickToolbarPanelMeta( t: TranslateFn, onClose: () => void, ): ToolbarPanelMeta { return { - title: t("exportPanel.title"), - closeAriaLabel: t("exportPanel.closeAria"), + title: t("characterPickPanel.title"), + closeAriaLabel: t("characterPickPanel.closeAria"), onClose, maxWidth: TRACKER_UNIT_GRID_PANEL_MAX_WIDTH, }; diff --git a/src/hooks/overlay-panel-id.ts b/src/hooks/overlay-panel-id.ts index 1c6fcef3..c87278ae 100644 --- a/src/hooks/overlay-panel-id.ts +++ b/src/hooks/overlay-panel-id.ts @@ -1,7 +1,7 @@ export type OverlayPanelId = | "character" | "dungeon" - | "export" + | "characterPick" | "gear" | "bis" | "data"; diff --git a/src/hooks/use-export-session-state.ts b/src/hooks/use-character-pick-session-state.ts similarity index 91% rename from src/hooks/use-export-session-state.ts rename to src/hooks/use-character-pick-session-state.ts index dadb9fd1..c0074321 100644 --- a/src/hooks/use-export-session-state.ts +++ b/src/hooks/use-character-pick-session-state.ts @@ -17,7 +17,7 @@ import { EXPORT_MIN_GS_COMPACT_DEFAULT } from "../utils/parse-export-min-gear-sc * Session-only Character pick UI state. Owned above the overlay so switching * toolbar panels does not reset filters / specs / raid exclusions. */ -export function useExportSessionState() { +export function useCharacterPickSessionState() { const [exportSpecSelectionByCharacterId, setExportSpecSelectionByCharacterId] = useState>>({}); const [minGearScoreFilterEnabled, setMinGearScoreFilterEnabled] = @@ -33,6 +33,8 @@ export function useExportSessionState() { ); const [includeExportSpecs, setIncludeExportSpecs] = useState(true); const [includeExportGearScore, setIncludeExportGearScore] = useState(true); + const [onlyCharactersWithUpgrades, setOnlyCharactersWithUpgrades] = + useState(false); const resetAllFilters = useCallback(() => { setExportSpecSelectionByCharacterId({}); @@ -40,6 +42,7 @@ export function useExportSessionState() { setMinGearScoreCompact(EXPORT_MIN_GS_COMPACT_DEFAULT); setRoleFilter({ ...DEFAULT_EXPORT_ROLE_FILTER }); setExcludedDungeonIds(new Set()); + setOnlyCharactersWithUpgrades(false); }, []); const clearExcludedDungeonIds = useCallback(() => { @@ -107,11 +110,13 @@ export function useExportSessionState() { excludedDungeonIds, includeExportSpecs, includeExportGearScore, + onlyCharactersWithUpgrades, setMinGearScoreFilterEnabled, setMinGearScoreCompact, setRoleFilter, setIncludeExportSpecs, setIncludeExportGearScore, + setOnlyCharactersWithUpgrades, resetAllFilters, clearExcludedDungeonIds, toggleDungeonExcluded, @@ -121,4 +126,4 @@ export function useExportSessionState() { }; } -export type ExportSessionState = ReturnType; +export type CharacterPickSessionState = ReturnType; diff --git a/src/hooks/use-gear-pick-session-state.ts b/src/hooks/use-gear-pick-session-state.ts index 726e87e6..b63bc9d6 100644 --- a/src/hooks/use-gear-pick-session-state.ts +++ b/src/hooks/use-gear-pick-session-state.ts @@ -29,6 +29,8 @@ export function useGearPickSessionState() { const [includeCopyCharacterName, setIncludeCopyCharacterName] = useState(true); const [compactCopyLines, setCompactCopyLines] = useState(false); + const [onlyCharactersWithUpgrades, setOnlyCharactersWithUpgrades] = + useState(false); const handleSelectionChange = useCallback( (next: GearPickCharacterSelection) => { @@ -103,6 +105,14 @@ export function useGearPickSessionState() { setAssignmentsByItemId({}); }, []); + const resetAllFilters = useCallback(() => { + setSelection(null); + setRules(DEFAULT_SOFT_ROLL_RULES); + setAssignmentsByItemId({}); + setExcludedDungeonIds(new Set()); + setOnlyCharactersWithUpgrades(false); + }, []); + return { selection, rules, @@ -110,6 +120,7 @@ export function useGearPickSessionState() { excludedDungeonIds, includeCopyCharacterName, compactCopyLines, + onlyCharactersWithUpgrades, handleSelectionChange, handleRulesChange, handleMySoftsChange, @@ -119,8 +130,10 @@ export function useGearPickSessionState() { clearExcludedDungeonIds, pruneAssignmentsToItemIds, clearAssignments, + resetAllFilters, setIncludeCopyCharacterName, setCompactCopyLines, + setOnlyCharactersWithUpgrades, }; } diff --git a/src/hooks/use-overlay-panels.test.ts b/src/hooks/use-overlay-panels.test.ts index 70d14c8f..87c5d63f 100644 --- a/src/hooks/use-overlay-panels.test.ts +++ b/src/hooks/use-overlay-panels.test.ts @@ -20,9 +20,9 @@ describe("useOverlayPanels", () => { const { result } = renderOverlayPanels(); act(() => { - result.current.toggleExportPanel(); + result.current.toggleCharacterPickPanel(); }); - expect(result.current.showExportPanel).toBe(true); + expect(result.current.showCharacterPickPanel).toBe(true); expect(result.current.showBisListsPanel).toBe(false); expect(result.current.showGearPickPanel).toBe(false); expect(result.current.showCharacterForm).toBe(false); @@ -30,7 +30,7 @@ describe("useOverlayPanels", () => { act(() => { result.current.toggleCharacterForm(); }); - expect(result.current.showExportPanel).toBe(false); + expect(result.current.showCharacterPickPanel).toBe(false); expect(result.current.showCharacterForm).toBe(true); act(() => { @@ -65,16 +65,16 @@ describe("useOverlayPanels", () => { const { result } = renderOverlayPanels(); act(() => { - result.current.toggleExportPanel(); + result.current.toggleCharacterPickPanel(); result.current.toggleBisListsPanel(); }); expect(result.current.showBisListsPanel).toBe(true); - expect(result.current.showExportPanel).toBe(false); + expect(result.current.showCharacterPickPanel).toBe(false); act(() => { result.current.closeAllOverlayPanels(); }); - expect(result.current.showExportPanel).toBe(false); + expect(result.current.showCharacterPickPanel).toBe(false); expect(result.current.showGearPickPanel).toBe(false); expect(result.current.showBisListsPanel).toBe(false); expect(result.current.showCharacterForm).toBe(false); diff --git a/src/hooks/use-overlay-panels.ts b/src/hooks/use-overlay-panels.ts index 78c83e2c..7737c986 100644 --- a/src/hooks/use-overlay-panels.ts +++ b/src/hooks/use-overlay-panels.ts @@ -12,7 +12,7 @@ type UseOverlayPanelsOptions = { }; /** - * Toolbar overlays share one `activePanel` id (character | dungeon | export | gear | bis | data). + * Toolbar overlays share one `activePanel` id (character | dungeon | characterPick | gear | bis | data). * Derived `show*` flags keep the existing TrackerControls / RaidTrackerMain API. * Form field state still lives in the form hooks; `onSubmitted` clears the panel * after a successful add so submit does not leave a stale active id. @@ -79,15 +79,15 @@ export function useOverlayPanels({ [characterForm, dungeonForm], ); - const showExportPanel = activePanel === "export"; + const showCharacterPickPanel = activePanel === "characterPick"; const showGearPickPanel = activePanel === "gear"; const showBisListsPanel = activePanel === "bis"; const showDataControlsPanel = activePanel === "data"; const showCharacterForm = activePanel === "character"; const showDungeonForm = activePanel === "dungeon"; - const toggleExportPanel = useCallback(() => { - togglePanel("export"); + const toggleCharacterPickPanel = useCallback(() => { + togglePanel("characterPick"); }, [togglePanel]); const toggleGearPickPanel = useCallback(() => { @@ -110,8 +110,8 @@ export function useOverlayPanels({ togglePanel("dungeon"); }, [togglePanel]); - const closeExportPanel = useCallback(() => { - if (activePanelRef.current === "export") { + const closeCharacterPickPanel = useCallback(() => { + if (activePanelRef.current === "characterPick") { setActivePanel(null); } }, []); @@ -150,9 +150,9 @@ export function useOverlayPanels({ return useMemo( () => ({ - showExportPanel, - closeExportPanel, - toggleExportPanel, + showCharacterPickPanel, + closeCharacterPickPanel, + toggleCharacterPickPanel, showGearPickPanel, closeGearPickPanel, toggleGearPickPanel, @@ -179,20 +179,20 @@ export function useOverlayPanels({ closeCharacterForm, closeDataControlsPanel, closeDungeonForm, - closeExportPanel, + closeCharacterPickPanel, closeGearPickPanel, dungeonForm, showBisListsPanel, showCharacterForm, showDataControlsPanel, showDungeonForm, - showExportPanel, + showCharacterPickPanel, showGearPickPanel, toggleBisListsPanel, toggleCharacterForm, toggleDataControlsPanel, toggleDungeonForm, - toggleExportPanel, + toggleCharacterPickPanel, toggleGearPickPanel, ], ); diff --git a/src/i18n/messages/en.ts b/src/i18n/messages/en.ts index 29566a21..c81fb050 100644 --- a/src/i18n/messages/en.ts +++ b/src/i18n/messages/en.ts @@ -272,7 +272,7 @@ export const enMessages = { clearSlot: "Clear slot", clearSlotAria: "Clear {{slot}} item", }, - exportPanel: { + characterPickPanel: { title: "Character pick", closeAria: "Close character pick panel", noCharacters: "Add a character to build a character pick", @@ -311,7 +311,7 @@ export const enMessages = { selectAllSpecsAria: "Select all character specs", clearAllSpecs: "Clear all", clearAllSpecsAria: "Clear all character specs", - resetAllFilters: "Reset all filters", + resetParameters: "Reset parameters", minGearScoreEnable: "Minimum GS filter", minGearScoreEnableAria: "Enable minimum gear score filter for character pick", minGearScoreHelper: @@ -351,6 +351,11 @@ export const enMessages = { "Each soft reserve caller gets one default /roll plus one /roll per soft reserve; best roll wins", characterTitle: "Character & spec", characterHelper: "Select exactly one character and one spec for soft reserve planning", + onlyCharactersWithUpgrades: "With upgrades", + onlyCharactersWithUpgradesAria: + "Dim and disable characters without BiS upgrades in the selected raids", + characterInactiveNoUpgradesHint: + "No BiS upgrades in the selected raids", dungeonFilterTitle: "Selected raid", dungeonFilterHelper: "Search here or in the table header — both stay in sync. Soft reserve targets come from matching raid rows. Click a chip to exclude it (stays visible; click again to include)", diff --git a/src/i18n/messages/ru.ts b/src/i18n/messages/ru.ts index 513d1144..6d76e150 100644 --- a/src/i18n/messages/ru.ts +++ b/src/i18n/messages/ru.ts @@ -274,7 +274,7 @@ export const ruMessages: Messages = { clearSlot: "Очистить слот", clearSlotAria: "Очистить {{slot}}", }, - exportPanel: { + characterPickPanel: { title: "Подбор персонажа", closeAria: "Закрыть панель подбора персонажа", noCharacters: "Добавьте персонажа для работы подбора", @@ -286,9 +286,9 @@ export const ruMessages: Messages = { copyLine: "Скопировать", copyLineAria: "Скопировать строку записи для {{raid}}", copied: "Скопировано", - includeSpecs: "Спеки", + includeSpecs: "Отображать спеки", includeSpecsAria: "Включать короткие спеки в строки подбора", - includeGearScore: "GS", + includeGearScore: "Отображать GS", includeGearScoreAria: "Включать GS после каждого короткого спека", dungeonFilterTitle: "Выбранный рейд", dungeonFilterHelper: @@ -313,7 +313,7 @@ export const ruMessages: Messages = { selectAllSpecsAria: "Выбрать все спеки персонажей", clearAllSpecs: "Снять все", clearAllSpecsAria: "Снять все спеки персонажей", - resetAllFilters: "Сбросить все фильтры", + resetParameters: "Сбросить параметры", minGearScoreEnable: "Фильтр по мин. GS", minGearScoreEnableAria: "Включить фильтр по минимальному GS для подбора", minGearScoreHelper: @@ -353,6 +353,11 @@ export const ruMessages: Messages = { "Каждый, кто заявляет софт-резерв, получает 1 обычный /roll плюс /roll за каждый софт-резерв; побеждает лучший результат", characterTitle: "Персонаж и спек", characterHelper: "Выберите одного персонажа и один спек для планирования софтов", + onlyCharactersWithUpgrades: "С апгрейдами", + onlyCharactersWithUpgradesAria: + "Приглушить и отключить персонажей без BiS-апгрейдов в выбранных рейдах", + characterInactiveNoUpgradesHint: + "Нет BiS-апгрейдов в выбранных рейдах", dungeonFilterTitle: "Выбранный рейд", dungeonFilterHelper: "Текстовый поиск по таблице. Нажмите на значок рейда — исключить или добавить рейд", diff --git a/src/index.css b/src/index.css index d1dc2fcd..d42de72a 100644 --- a/src/index.css +++ b/src/index.css @@ -14,6 +14,9 @@ /* Light tokens */ --bg: #fcfbf9; --page-bg: var(--bg); + /* Shared pin-dot grid — page chrome + toolbar panel shells (gallery F). */ + --dot-grid: color-mix(in srgb, #0a0a0a 5.5%, transparent); + --dot-grid-size: 12px; --surface: #ffffff; --chip-bg: #f3f2ef; --header-bg: rgba(252, 251, 249, 0.96); @@ -63,6 +66,7 @@ :root[data-color-mode="dark"] { --bg: #1a1a1a; --page-bg: var(--bg); + --dot-grid: color-mix(in srgb, #ffffff 6%, transparent); --surface: #242424; --chip-bg: #1f1f1f; --header-bg: rgba(26, 26, 26, 0.96); @@ -124,6 +128,26 @@ body { margin: 0; min-width: 320px; min-height: 100vh; + /* Transparent so html page-bg + ::before dots show; washes stay on body. */ + background-color: transparent; + /* Keep z-index:-1 ::before inside this context (above transparent bg). */ + isolation: isolate; +} + +/* Page dots — rotated 45°. Toolbar panels keep upright dots on their Paper. */ +body::before { + content: ""; + position: fixed; + /* Extra extent so rotation still covers the viewport corners. */ + top: -50vmax; + left: -50vmax; + width: 200vmax; + height: 200vmax; + z-index: -1; + pointer-events: none; + background-image: radial-gradient(var(--dot-grid) 1px, transparent 1px); + background-size: var(--dot-grid-size) var(--dot-grid-size); + transform: rotate(45deg); } input, diff --git a/src/theme/create-app-theme.ts b/src/theme/create-app-theme.ts index 9897270a..ac21a61d 100644 --- a/src/theme/create-app-theme.ts +++ b/src/theme/create-app-theme.ts @@ -204,13 +204,34 @@ export function createAppTheme(mode: PaletteMode) { components: { MuiCssBaseline: { styleOverrides: { + html: { + backgroundColor: "var(--page-bg)", + }, body: { - // Warm paper atmosphere — brand soft tint, not SaaS blue/teal. + // Washes on body; dots on ::before (rotated). Keep body fill transparent + // so dots are not covered by an opaque page color. + backgroundColor: "transparent", + isolation: "isolate", backgroundImage: isLight ? "radial-gradient(1200px 600px at 10% -10%, rgba(154, 52, 18, 0.06), transparent 55%), radial-gradient(900px 500px at 100% 0%, rgba(194, 65, 12, 0.04), transparent 50%)" : "radial-gradient(1000px 520px at 8% -12%, rgba(251, 146, 60, 0.1), transparent 55%), radial-gradient(800px 480px at 100% 0%, rgba(61, 40, 24, 0.45), transparent 50%)", backgroundAttachment: "fixed", + backgroundRepeat: "no-repeat", lineHeight: 1.55, + "&::before": { + content: '""', + position: "fixed", + top: "-50vmax", + left: "-50vmax", + width: "200vmax", + height: "200vmax", + zIndex: -1, + pointerEvents: "none", + backgroundImage: + "radial-gradient(var(--dot-grid) 1px, transparent 1px)", + backgroundSize: "var(--dot-grid-size) var(--dot-grid-size)", + transform: "rotate(45deg)", + }, }, }, }, diff --git a/src/utils/build-export-status.test.ts b/src/utils/build-export-status.test.ts index 7ecd9213..6e663458 100644 --- a/src/utils/build-export-status.test.ts +++ b/src/utils/build-export-status.test.ts @@ -25,7 +25,7 @@ describe("buildExportStatusString", () => { dungeons: [], dungeonToggles: {}, }), - ).toBe(testTranslator("exportPanel.noDungeonsFilter")); + ).toBe(testTranslator("characterPickPanel.noDungeonsFilter")); }); it("returns message when no characters are selected", () => { @@ -36,7 +36,7 @@ describe("buildExportStatusString", () => { dungeons: [createTestDungeon()], dungeonToggles: {}, }), - ).toBe(testTranslator("exportPanel.selectCharacter")); + ).toBe(testTranslator("characterPickPanel.selectCharacter")); }); it("lists characters without CD per dungeon", () => { @@ -116,7 +116,7 @@ describe("buildExportStatusString", () => { dungeons: [dungeon], dungeonToggles: toggles, }), - ).toBe(testTranslator("exportPanel.allHaveCd")); + ).toBe(testTranslator("characterPickPanel.allHaveCd")); }); it("uses chosen export spec selection per character", () => { @@ -176,7 +176,7 @@ describe("buildExportStatusString", () => { }, }, }), - ).toBe(testTranslator("exportPanel.allHaveCd")); + ).toBe(testTranslator("characterPickPanel.allHaveCd")); }); it("defaults role filter when omitted or undefined", () => { diff --git a/src/utils/build-export-status.ts b/src/utils/build-export-status.ts index c81924a7..b90f9abd 100644 --- a/src/utils/build-export-status.ts +++ b/src/utils/build-export-status.ts @@ -68,10 +68,10 @@ export function buildExportStatus({ const effectiveRoleFilter = roleFilter ?? DEFAULT_EXPORT_ROLE_FILTER; if (dungeons.length === 0) { - return { kind: "message", message: t("exportPanel.noDungeonsFilter") }; + return { kind: "message", message: t("characterPickPanel.noDungeonsFilter") }; } if (characters.length === 0) { - return { kind: "message", message: t("exportPanel.selectCharacter") }; + return { kind: "message", message: t("characterPickPanel.selectCharacter") }; } const lines: ExportStatusLine[] = []; @@ -113,7 +113,7 @@ export function buildExportStatus({ } if (lines.length === 0) { - return { kind: "message", message: t("exportPanel.allHaveCd") }; + return { kind: "message", message: t("characterPickPanel.allHaveCd") }; } return { kind: "lines", lines }; diff --git a/src/utils/build-gear-pick-items.test.ts b/src/utils/build-gear-pick-items.test.ts index c7c4b49e..a38b6a25 100644 --- a/src/utils/build-gear-pick-items.test.ts +++ b/src/utils/build-gear-pick-items.test.ts @@ -6,7 +6,10 @@ import { ClassName, Classes } from "../types/characters.ts"; import { DungeonDifficulty } from "../types/dungeons.ts"; import { createTestCharacter, createTestDungeon } from "../test/fixtures.ts"; import { buildBisSlotMap } from "./bis-lists.ts"; -import { buildGearPickItems } from "./build-gear-pick-items.ts"; +import { + buildGearPickItems, + characterHasGearPickUpgrades, +} from "./build-gear-pick-items.ts"; const paladinClass = Classes.find( (characterClass) => characterClass.name === ClassName.Paladin, @@ -189,3 +192,67 @@ describe("buildGearPickItems", () => { expect(items.map((item) => item.itemId)).toContain(50719); }); }); + +describe("characterHasGearPickUpgrades", () => { + it("is false when no dungeons or no class", () => { + expect( + characterHasGearPickUpgrades({ + character: createTestCharacter({ + class: paladinClass, + mainSpec: { spec: "Retribution" }, + }), + dungeons: [], + getBisSlotMapForSpec: getRetributionBisSlotMap, + locale: "en", + }), + ).toBe(false); + + expect( + characterHasGearPickUpgrades({ + character: createTestCharacter({ class: undefined }), + dungeons: [rubySanctum25Heroic], + getBisSlotMapForSpec: getRetributionBisSlotMap, + locale: "en", + }), + ).toBe(false); + }); + + it("is true when main or off has Soft pick BiS targets", () => { + // Gear hints require imported gear (empty exports skip Soft pick targets). + const character = createTestCharacter({ + class: paladinClass, + mainSpec: { + spec: "Retribution", + gearItems: [ + { slot: 1, id: 37646 }, + { slot: 9, id: 37646 }, + { slot: 11, id: 37646 }, + { slot: 13, id: 37646 }, + ], + }, + }); + + expect( + characterHasGearPickUpgrades({ + character, + dungeons: [rubySanctum25Heroic], + getBisSlotMapForSpec: getRetributionBisSlotMap, + locale: "en", + }), + ).toBe(true); + }); + + it("is false when neither spec has Soft pick BiS targets", () => { + expect( + characterHasGearPickUpgrades({ + character: createTestCharacter({ + class: paladinClass, + mainSpec: { spec: "Retribution" }, + }), + dungeons: [rubySanctum25Heroic], + getBisSlotMapForSpec: () => new Map(), + locale: "en", + }), + ).toBe(false); + }); +}); diff --git a/src/utils/build-gear-pick-items.ts b/src/utils/build-gear-pick-items.ts index 3596f066..94565c9b 100644 --- a/src/utils/build-gear-pick-items.ts +++ b/src/utils/build-gear-pick-items.ts @@ -141,3 +141,37 @@ export function buildGearPickItems({ return left.itemId - right.itemId; }); } + +/** + * True when the character has at least one Soft pick BiS target (exact or + * variant) on main and/or off across the given dungeon rows. + */ +export function characterHasGearPickUpgrades({ + character, + dungeons, + getBisSlotMapForSpec, + locale, +}: Omit): boolean { + if (dungeons.length === 0 || !character.class) { + return false; + } + + const sides: GearPickSpecSide[] = []; + if (character.mainSpec) { + sides.push("main"); + } + if (character.offSpec) { + sides.push("off"); + } + + return sides.some( + (specSide) => + buildGearPickItems({ + character, + specSide, + dungeons, + getBisSlotMapForSpec, + locale, + }).length > 0, + ); +} diff --git a/src/utils/format-character-export.test.ts b/src/utils/format-character-export.test.ts index cb1a3eac..04169b9b 100644 --- a/src/utils/format-character-export.test.ts +++ b/src/utils/format-character-export.test.ts @@ -383,4 +383,38 @@ describe("getCharacterExportInactiveReason", () => { ), ).toBeNull(); }); + + it("returns noUpgrades when With upgrades is on and character has none", () => { + const character = createTestCharacter({ + id: "character-1", + class: Classes[5], + mainSpec: { spec: "Shadow", gearScore: 5800 }, + }); + + expect( + getCharacterExportInactiveReason( + character, + new Set(["character-1"]), + DEFAULT_EXPORT_ROLE_FILTER, + undefined, + { + onlyWithUpgrades: true, + charactersWithUpgradesIds: new Set(), + }, + ), + ).toBe("noUpgrades"); + + expect( + getCharacterExportInactiveReason( + character, + new Set(["character-1"]), + DEFAULT_EXPORT_ROLE_FILTER, + undefined, + { + onlyWithUpgrades: true, + charactersWithUpgradesIds: new Set(["character-1"]), + }, + ), + ).toBeNull(); + }); }); diff --git a/src/utils/format-character-export.ts b/src/utils/format-character-export.ts index 8545c145..4e42ccce 100644 --- a/src/utils/format-character-export.ts +++ b/src/utils/format-character-export.ts @@ -182,7 +182,15 @@ export function specPassesExportMinGearScore( return specGear.gearScore >= minGearScore; } -export type CharacterExportInactiveReason = "cooldown" | "filters"; +export type CharacterExportInactiveReason = + | "cooldown" + | "noUpgrades" + | "filters"; + +export type CharacterExportInactiveOptions = { + onlyWithUpgrades?: boolean; + charactersWithUpgradesIds?: ReadonlySet; +}; export function characterSpecPassesExportFilters( character: CharacterRecord, @@ -226,16 +234,27 @@ export function characterHasSpecPassingExportFilters( return mainPasses || offPasses; } -/** Why a character row is inactive in the spec filter (CD on all visible raids, or filters). */ +/** + * Why a character row is inactive in the spec filter (CD on all visible raids, + * no Soft/Character-pick BiS upgrades, or role/GS filters). + */ export function getCharacterExportInactiveReason( character: CharacterRecord, includedCharacterIds: ReadonlySet, roleFilter: ExportRoleFilter = DEFAULT_EXPORT_ROLE_FILTER, minGearScore?: number, + options: CharacterExportInactiveOptions = {}, ): CharacterExportInactiveReason | null { if (!includedCharacterIds.has(character.id)) { return "cooldown"; } + if ( + options.onlyWithUpgrades && + options.charactersWithUpgradesIds && + !options.charactersWithUpgradesIds.has(character.id) + ) { + return "noUpgrades"; + } if ( characterHasExportSpecs(character) && !characterHasSpecPassingExportFilters(character, roleFilter, minGearScore)