diff --git a/config/i18n.json b/config/i18n.json index 80dabe6c13..e7fa0b2707 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -1040,7 +1040,7 @@ "Unlock": "Unlock", "ShiftTip": "Tip: Hold the Shift key and click on a cell to filter items", "NoMobile": "Turn your phone sideways to use the Organizer.", - "NoItems": "No items selected", + "NoItems": "No items match the filters. If you have a search query, try clearing it.", "Note": "Set Notes", "Columns": { "Stats": "Stats", @@ -1050,6 +1050,9 @@ "Name": "Name", "Power": "Power", "Damage": "Damage", + "Intrinsics": "Intrinsic", + "OriginTraits": "Origin Trait", + "Shaders": "Cosmetics", "Locked": "Locked", "Energy": "Energy", "Location": "Location", @@ -1067,7 +1070,8 @@ "Event": "Event", "ModSlot": "Mod Slot", "Archetype": "Archetype", - "PerksMods": "Perks, Mods & Shaders", + "PerksMods": "Perks & Mods", + "OtherPerks": "Other Perks", "Traits": "Weapon Traits", "CustomTotal": "Custom Total", "MasterworkTier": "Masterwork Tier", diff --git a/src/app/organizer/Columns.tsx b/src/app/organizer/Columns.tsx index c714ab34ad..cf8edf849c 100644 --- a/src/app/organizer/Columns.tsx +++ b/src/app/organizer/Columns.tsx @@ -27,13 +27,12 @@ import { editLoadout } from 'app/loadout-drawer/loadout-events'; import InGameLoadoutIcon from 'app/loadout/ingame/InGameLoadoutIcon'; import { InGameLoadout, Loadout, isInGameLoadout } from 'app/loadout/loadout-types'; import { LoadoutsByItem } from 'app/loadout/selectors'; -import { weaponMasterworkY2SocketTypeHash } from 'app/search/d2-known-values'; +import { breakerTypeNames, weaponMasterworkY2SocketTypeHash } from 'app/search/d2-known-values'; import { quoteFilterString } from 'app/search/query-parser'; import { statHashByName } from 'app/search/search-filter-values'; import { getColor, percent } from 'app/shell/formatters'; import { AppIcon, - faCheck, lockIcon, powerIndicatorIcon, thumbsDownIcon, @@ -41,36 +40,45 @@ import { } from 'app/shell/icons'; import { RootState } from 'app/store/types'; import { filterMap } from 'app/utils/collections'; -import { compareBy } from 'app/utils/comparators'; +import { Comparator, compareBy } from 'app/utils/comparators'; import { getInterestingSocketMetadatas, getItemDamageShortName, getItemKillTrackerInfo, getItemYear, getMasterworkStatNames, + isArtificeSocket, isD1Item, isKillTrackerSocket, } from 'app/utils/item-utils'; import { getDisplayedItemSockets, + getExtraIntrinsicPerkSockets, getIntrinsicArmorPerkSocket, getSocketsByIndexes, getWeaponArchetype, getWeaponArchetypeSocket, isEnhancedPerk, + socketContainsIntrinsicPlug, } from 'app/utils/socket-utils'; import { LookupTable } from 'app/utils/util-types'; import { InventoryWishListRoll } from 'app/wishlists/wishlists'; import clsx from 'clsx'; import { D2EventInfo } from 'data/d2/d2-event-info-v2'; -import { PlugCategoryHashes, StatHashes } from 'data/d2/generated-enums'; +import { + BreakerTypeHashes, + ItemCategoryHashes, + PlugCategoryHashes, + StatHashes, +} from 'data/d2/generated-enums'; import shapedOverlay from 'images/shapedOverlay.png'; import _ from 'lodash'; import React from 'react'; import { useSelector } from 'react-redux'; import { createCustomStatColumns } from './CustomStatColumns'; -// eslint-disable-next-line css-modules/no-unused-class -import styles from './ItemTable.m.scss'; + +import { DeepsightHarmonizerIcon } from 'app/item-popup/DeepsightHarmonizerIcon'; +import styles from './ItemTable.m.scss'; // eslint-disable-line css-modules/no-unused-class import { ColumnDefinition, ColumnGroup, SortDirection, Value } from './table-types'; /** @@ -91,7 +99,23 @@ export const statLabels: LookupTable = { [StatHashes.AirborneEffectiveness]: tl('Organizer.Stats.Airborne'), }; -// const booleanCell = (value: any) => (value ? : undefined); +const perkStringSort: Comparator = (a, b) => { + const aParts = (a ?? '').split(','); + const bParts = (b ?? '').split(','); + let ai = 0; + let bi = 0; + while (ai < aParts.length && bi < bParts.length) { + const aPart = aParts[ai]; + const bPart = bParts[bi]; + if (aPart === bPart) { + ai++; + bi++; + continue; + } + return aPart.localeCompare(bPart) as 1 | 0 | -1; + } + return 0; +}; /** * This function generates the columns. @@ -278,8 +302,7 @@ export function getColumns( defaultSort: SortDirection.DESC, filter: (value) => `power:>=${value}`, }), - !isGhost && - (destinyVersion === 2 || isWeapon) && + isWeapon && c({ id: 'dmg', header: t('Organizer.Columns.Damage'), @@ -310,7 +333,7 @@ export function getColumns( header: t('Organizer.Columns.Tag'), value: (item) => getTag(item) ?? '', cell: (value) => value && , - sort: compareBy((tag) => (tag && tagConfig[tag] ? tagConfig[tag].sortOrder : 1000)), + sort: compareBy((tag) => (tag && tag in tagConfig ? tagConfig[tag].sortOrder : 1000)), filter: (value) => `tag:${value || 'none'}`, }), c({ @@ -322,6 +345,7 @@ export function getColumns( filter: (value) => `${value ? '' : '-'}is:new`, }), destinyVersion === 2 && + isWeapon && c({ id: 'crafted', header: t('Organizer.Columns.Crafted'), @@ -363,36 +387,6 @@ export function getColumns( value: (i) => i.tier, filter: (value) => `is:${value}`, }), - destinyVersion === 2 && - c({ - id: 'source', - header: t('Organizer.Columns.Source'), - value: source, - filter: (value) => `source:${value}`, - }), - c({ - id: 'year', - header: t('Organizer.Columns.Year'), - value: (item) => getItemYear(item), - filter: (value) => `year:${value}`, - }), - destinyVersion === 2 && - c({ - id: 'season', - header: t('Organizer.Columns.Season'), - value: (i) => getSeason(i), - filter: (value) => `season:${value}`, - }), - destinyVersion === 2 && - c({ - id: 'event', - header: t('Organizer.Columns.Event'), - value: (item) => { - const event = getEvent(item); - return event ? D2EventInfo[event].name : undefined; - }, - filter: (value) => `event:${value}`, - }), destinyVersion === 2 && isArmor && c({ @@ -453,7 +447,8 @@ export function getColumns( }, filter: (value) => (value ? `exactperk:${quoteFilterString(value)}` : undefined), }), - (destinyVersion === 2 || isWeapon) && + destinyVersion === 2 && + isWeapon && c({ id: 'breaker', header: t('Organizer.Columns.Breaker'), @@ -465,21 +460,44 @@ export function getColumns( src={item.breakerType!.displayProperties.icon} /> ), - filter: (_val, item) => `is:${getItemDamageShortName(item)}`, + filter: (_val, item) => + item.breakerType + ? `breaker:${breakerTypeNames[item.breakerType.hash as BreakerTypeHashes]}` + : undefined, + }), + destinyVersion === 2 && + isArmor && + c({ + id: 'intrinsics', + header: t('Organizer.Columns.Intrinsics'), + value: (item) => perkString(getIntrinsicSockets(item)), + cell: (_val, item) => ( + + ), + sort: perkStringSort, + filter: (value) => + typeof value === 'string' ? `exactperk:${quoteFilterString(value)}` : undefined, }), c({ id: 'perks', header: - destinyVersion === 2 ? t('Organizer.Columns.PerksMods') : t('Organizer.Columns.Perks'), - value: () => 0, // TODO: figure out a way to sort perks + destinyVersion === 2 + ? isWeapon + ? t('Organizer.Columns.OtherPerks') + : t('Organizer.Columns.PerksMods') + : t('Organizer.Columns.Perks'), + value: (item) => perkString(getSockets(item, 'all')), cell: (_val, item) => isD1Item(item) ? ( ) : ( - + ), - noSort: true, - gridWidth: 'minmax(324px,max-content)', + sort: perkStringSort, filter: (value) => typeof value === 'string' ? `exactperk:${quoteFilterString(value)}` : undefined, }), @@ -488,12 +506,49 @@ export function getColumns( c({ id: 'traits', header: t('Organizer.Columns.Traits'), - value: () => 0, // TODO: figure out a way to sort perks + value: (item) => perkString(getSockets(item, 'traits')), cell: (_val, item) => ( - + ), - noSort: true, - gridWidth: 'minmax(180px,max-content)', + sort: perkStringSort, + filter: (value) => + typeof value === 'string' ? `exactperk:${quoteFilterString(value)}` : undefined, + }), + + destinyVersion === 2 && + isWeapon && + c({ + id: 'originTrait', + header: t('Organizer.Columns.OriginTraits'), + value: (item) => perkString(getSockets(item, 'origin')), + cell: (_val, item) => ( + + ), + sort: perkStringSort, + filter: (value) => + typeof value === 'string' ? `exactperk:${quoteFilterString(value)}` : undefined, + }), + destinyVersion === 2 && + c({ + id: 'shaders', + header: t('Organizer.Columns.Shaders'), + value: (item) => perkString(getSockets(item, 'shaders')), + cell: (_val, item) => ( + + ), + sort: perkStringSort, filter: (value) => typeof value === 'string' ? `exactperk:${quoteFilterString(value)}` : undefined, }), @@ -540,7 +595,7 @@ export function getColumns( id: 'harmonizable', header: t('Organizer.Columns.Harmonizable'), value: (item) => isHarmonizable(item), - cell: (value) => (value ? : undefined), + cell: (value, item) => (value ? : undefined), }), destinyVersion === 2 && isWeapon && @@ -561,6 +616,36 @@ export function getColumns( }, defaultSort: SortDirection.DESC, }), + destinyVersion === 2 && + c({ + id: 'source', + header: t('Organizer.Columns.Source'), + value: source, + filter: (value) => `source:${value}`, + }), + c({ + id: 'year', + header: t('Organizer.Columns.Year'), + value: (item) => getItemYear(item), + filter: (value) => `year:${value}`, + }), + destinyVersion === 2 && + c({ + id: 'season', + header: t('Organizer.Columns.Season'), + value: (i) => getSeason(i), + filter: (value) => `season:${value}`, + }), + destinyVersion === 2 && + c({ + id: 'event', + header: t('Organizer.Columns.Event'), + value: (item) => { + const event = getEvent(item); + return event ? D2EventInfo[event].name : undefined; + }, + filter: (value) => `event:${value}`, + }), c({ id: 'location', header: t('Organizer.Columns.Location'), @@ -662,55 +747,17 @@ function LoadoutsCell({ function PerksCell({ item, - traitsOnly, + sockets, onPlugClicked, }: { item: DimItem; - traitsOnly?: boolean; + sockets: DimSocket[]; onPlugClicked?: (value: { item: DimItem; socket: DimSocket; plugHash: number }) => void; }) { - if (!item.sockets) { - return null; - } - - let sockets = []; - const { modSocketsByCategory, perks } = getDisplayedItemSockets( - item, - /* excludeEmptySockets */ true, - )!; - - if (perks) { - sockets.push(...getSocketsByIndexes(item.sockets, perks.socketIndexes)); - } - if (traitsOnly) { - sockets = sockets.filter( - (s) => - s.plugged && - (s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Frames || - s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Intrinsics), - ); - } else { - // Improve this when we use iterator-helpers - sockets.push(...[...modSocketsByCategory.values()].flat()); - } - - // Intrinsics are perks and are added here for displaying - const intrinsicSocket = getIntrinsicArmorPerkSocket(item); - if (intrinsicSocket) { - sockets.push(intrinsicSocket); - } - - sockets = sockets.filter( - (s) => - // we have a separate column for the kill tracker - !isKillTrackerSocket(s) && - // and for the regular weapon masterworks - s.socketDefinition.socketTypeHash !== weaponMasterworkY2SocketTypeHash, - ); - if (!sockets.length) { return null; } + return ( <> {sockets.map((socket) => ( @@ -814,3 +861,102 @@ function StoreLocation({ storeId }: { storeId: string }) { ); } + +function perkString(sockets: DimSocket[]): string | undefined { + if (!sockets.length) { + return undefined; + } + + return sockets + .flatMap((socket) => socket.plugOptions.map((p) => p.plugDef.displayProperties.name)) + .filter(Boolean) + .join(','); +} + +function getSockets( + item: DimItem, + type?: 'all' | 'traits' | 'barrel' | 'shaders' | 'origin', +): DimSocket[] { + if (!item.sockets) { + return []; + } + + let sockets = []; + const { modSocketsByCategory, perks } = getDisplayedItemSockets( + item, + /* excludeEmptySockets */ true, + )!; + + if (perks) { + sockets.push(...getSocketsByIndexes(item.sockets, perks.socketIndexes)); + } + switch (type) { + case 'traits': + sockets = sockets.filter( + (s) => + s.plugged && + (s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Frames || + s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Intrinsics), + ); + break; + + case 'origin': + sockets = sockets.filter((s) => + s.plugged?.plugDef.itemCategoryHashes?.includes(ItemCategoryHashes.WeaponModsOriginTraits), + ); + break; + + case 'shaders': { + sockets.push(...[...modSocketsByCategory.values()].flat()); + sockets = sockets.filter( + (s) => + s.plugged && + (s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Shader || + s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Mementos || + s.plugged.plugDef.plug.plugCategoryIdentifier.includes('skin')), + ); + break; + } + + default: { + // Improve this when we use iterator-helpers + sockets.push(...[...modSocketsByCategory.values()].flat()); + sockets = sockets.filter( + (s) => + !( + s.plugged && + (s.plugged?.plugDef.itemCategoryHashes?.includes( + ItemCategoryHashes.WeaponModsOriginTraits, + ) || + s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Frames || + s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Intrinsics || + s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Shader || + s.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.Mementos || + s.plugged.plugDef.plug.plugCategoryIdentifier.includes('skin')) + ), + ); + break; + } + } + + sockets = sockets.filter( + (s) => + // we have a separate column for the kill tracker + !isKillTrackerSocket(s) && + // and for the regular weapon masterworks + s.socketDefinition.socketTypeHash !== weaponMasterworkY2SocketTypeHash && + // Remove "extra intrinsics" for exotic class items + (!item.bucket.inArmor || !(s.isPerk && s.visibleInGame && socketContainsIntrinsicPlug(s))), + ); + return sockets; +} + +function getIntrinsicSockets(item: DimItem) { + const intrinsicSocket = getIntrinsicArmorPerkSocket(item); + const extraIntrinsicSockets = getExtraIntrinsicPerkSockets(item); + return intrinsicSocket && + // artifice already shows up in the "modslot" column + !isArtificeSocket(intrinsicSocket) + ? [intrinsicSocket, ...extraIntrinsicSockets] + : extraIntrinsicSockets; +} diff --git a/src/app/organizer/DropDown.m.scss b/src/app/organizer/DropDown.m.scss index f5a5b95ffa..4815e0b9ab 100644 --- a/src/app/organizer/DropDown.m.scss +++ b/src/app/organizer/DropDown.m.scss @@ -21,12 +21,15 @@ $dropdown-menu: 10; position: absolute; z-index: 5; left: 0; - max-height: calc(var(--viewport-height) - 120px - var(--header-height)); + max-height: calc(var(--viewport-height) - 50px - var(--header-height)); overflow: auto; margin-top: 2px; width: max-content; background: var(--theme-dropdown-menu-bg); + columns: 4; + column-gap: 0; + &.right { left: initial; right: 0; @@ -41,12 +44,12 @@ $dropdown-menu: 10; cursor: pointer; display: flex; flex-direction: row; - justify-content: space-between; - font-family: 'Open Sans', sans-serif; font-size: 12px; margin: 0; - padding: 8px 12px; + padding: 8px; text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.25); + min-width: 15em; + gap: 8px; &:hover, &:active { @@ -71,11 +74,4 @@ $dropdown-menu: 10; vertical-align: middle; } } - &:first-child { - border-radius: 2px 2px 0 0; - } - - &:last-child { - border-radius: 0 0 2px 2px; - } } diff --git a/src/app/organizer/DropDown.tsx b/src/app/organizer/DropDown.tsx index 303791dde1..b31901ea08 100644 --- a/src/app/organizer/DropDown.tsx +++ b/src/app/organizer/DropDown.tsx @@ -18,6 +18,9 @@ export interface DropDownItem { function MenuItem({ item, forClass }: { item: DropDownItem; forClass?: DestinyClass }) { return (
+ {item.checked !== undefined && ( + + )} - {item.checked !== undefined && ( - - )}
); } diff --git a/src/app/organizer/ItemActions.m.scss b/src/app/organizer/ItemActions.m.scss index 51fd653a09..4241989462 100644 --- a/src/app/organizer/ItemActions.m.scss +++ b/src/app/organizer/ItemActions.m.scss @@ -1,9 +1,11 @@ .itemActions { flex: 1; + composes: flexRow from '../dim-ui/common.m.scss'; + align-items: center; + gap: 4px; } .actionButton { - margin: 0 2px; display: inline-block; } diff --git a/src/app/organizer/ItemTable.m.scss b/src/app/organizer/ItemTable.m.scss index d7608bde61..2fcc0abdd1 100644 --- a/src/app/organizer/ItemTable.m.scss +++ b/src/app/organizer/ItemTable.m.scss @@ -13,9 +13,8 @@ $content-cells: 5; } .table { - min-width: 1270px; display: grid; - margin: 8px 0 16px 0 !important; + margin: 8px 0 16px 0; > div { padding: 4px 8px; @@ -39,7 +38,7 @@ $content-cells: 5; flex-direction: row; align-items: center; position: sticky; - left: calc(4px + env(safe-area-inset-left)); + left: calc(8px + env(safe-area-inset-left)); width: calc(98vw - env(safe-area-inset-left) - env(safe-area-inset-right)); gap: 4px; } @@ -182,7 +181,11 @@ $content-cells: 5; } .perks, -.traits { +.traits, +.barrels, +.intrinsics, +.originTrait, +.shaders { padding-top: calc(var(--item-size) * 0.5 * 0.5 - 5px) !important; } .perks { @@ -227,6 +230,7 @@ $content-cells: 5; flex-direction: row; align-items: flex-start; gap: 3px; + white-space: nowrap; } .miniPerkContainer { position: relative; diff --git a/src/app/organizer/ItemTable.m.scss.d.ts b/src/app/organizer/ItemTable.m.scss.d.ts index d8aef80ae9..85bc644bb9 100644 --- a/src/app/organizer/ItemTable.m.scss.d.ts +++ b/src/app/organizer/ItemTable.m.scss.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'archetype': string; + 'barrels': string; 'base2715839340': string; 'customstat': string; 'dmg': string; @@ -12,6 +13,7 @@ interface CssExports { 'icon': string; 'importButton': string; 'inlineIcon': string; + 'intrinsics': string; 'isPerk': string; 'killTrackerDisplay': string; 'loadout': string; @@ -27,6 +29,7 @@ interface CssExports { 'negative': string; 'new': string; 'noItems': string; + 'originTrait': string; 'perkSelectable': string; 'perkSelected': string; 'perks': string; @@ -35,6 +38,7 @@ interface CssExports { 'rating': string; 'season': string; 'selection': string; + 'shaders': string; 'shapedIcon': string; 'shapedIconOverlay': string; 'shiftHeld': string; diff --git a/src/app/organizer/ItemTable.tsx b/src/app/organizer/ItemTable.tsx index 8b4a1a86fe..9a016a478d 100644 --- a/src/app/organizer/ItemTable.tsx +++ b/src/app/organizer/ItemTable.tsx @@ -1,6 +1,6 @@ import { destinyVersionSelector } from 'app/accounts/selectors'; import { StatInfo } from 'app/compare/Compare'; -import { settingSelector } from 'app/dim-api/selectors'; +import { languageSelector, settingSelector } from 'app/dim-api/selectors'; import UserGuideLink from 'app/dim-ui/UserGuideLink'; import useBulkNote from 'app/dim-ui/useBulkNote'; import useConfirm from 'app/dim-ui/useConfirm'; @@ -32,7 +32,7 @@ import { toggleSearchQueryComponent } from 'app/shell/actions'; import { AppIcon, faCaretDown, faCaretUp, spreadsheetIcon, uploadIcon } from 'app/shell/icons'; import { loadingTracker } from 'app/shell/loading-tracker'; import { useThunkDispatch } from 'app/store/thunk-dispatch'; -import { chainComparator, compareBy, reverseComparator } from 'app/utils/comparators'; +import { Comparator, chainComparator, compareBy, reverseComparator } from 'app/utils/comparators'; import { emptyArray, emptyObject } from 'app/utils/empty'; import { useSetCSSVarToHeight, useShiftHeld } from 'app/utils/hooks'; import { LookupTable, StringLookup } from 'app/utils/util-types'; @@ -55,6 +55,9 @@ import { useTableColumnSorts } from 'app/dim-ui/table-columns'; import { filterMap } from 'app/utils/collections'; import { errorMessage } from 'app/utils/errors'; import { createPortal } from 'react-dom'; + +import { DimLanguage } from 'app/i18n'; +import { localizedSorter } from 'app/utils/intl'; // eslint-disable-next-line css-modules/no-unused-class import styles from './ItemTable.m.scss'; import { ItemCategoryTreeNode, armorTopLevelCatHashes } from './ItemTypeSelector'; @@ -80,6 +83,8 @@ const downloadButtonSettings = [ const MemoRow = memo(TableRow); +const EXPAND_INCREMENT = 20; + export default function ItemTable({ categories }: { categories: ItemCategoryTreeNode[] }) { const [columnSorts, toggleColumnSort] = useTableColumnSorts([ { columnId: 'name', sort: SortDirection.ASC }, @@ -88,6 +93,11 @@ export default function ItemTable({ categories }: { categories: ItemCategoryTree // Track the last selection for shift-selecting const lastSelectedId = useRef(null); const [socketOverrides, onPlugClicked] = useSocketOverridesForItems(); + const [maxItems, setMaxItems] = useState(EXPAND_INCREMENT); + useEffect(() => { + setMaxItems(EXPAND_INCREMENT); + }, [categories]); + const expandItems = useCallback(() => setMaxItems((m) => m + EXPAND_INCREMENT), []); const allItems = useSelector(allItemsSelector); const searchFilter = useSelector(searchFilterSelector); @@ -226,9 +236,10 @@ export default function ItemTable({ categories }: { categories: ItemCategoryTree () => buildRows(items, filteredColumns), [filteredColumns, items], ); + const language = useSelector(languageSelector); const rows = useMemo( - () => sortRows(unsortedRows, columnSorts, filteredColumns), - [unsortedRows, filteredColumns, columnSorts], + () => sortRows(unsortedRows, columnSorts, filteredColumns, language), + [unsortedRows, filteredColumns, columnSorts, language], ); const shiftHeld = useShiftHeld(); @@ -280,7 +291,7 @@ export default function ItemTable({ categories }: { categories: ItemCategoryTree if (e.shiftKey) { if ((e.target as Element).hasAttribute('data-perk-name')) { const filter = column.filter!( - (e.target as Element).getAttribute('data-perk-name'), + (e.target as Element).getAttribute('data-perk-name') ?? undefined, row.item, ); if (filter) { @@ -434,114 +445,118 @@ export default function ItemTable({ categories }: { categories: ItemCategoryTree useSetCSSVarToHeight(toolbarRef, '--item-table-toolbar-height'); return ( -
- {confirmDialog} - {bulkNoteDialog} -
-
- - - - {({ getRootProps, getInputProps }) => ( -
- -
- {t('Settings.CsvImport')} + <> +
+ {confirmDialog} + {bulkNoteDialog} +
+
+ + + + {({ getRootProps, getInputProps }) => ( +
+ +
+ {t('Settings.CsvImport')} +
-
- )} - - {downloadAction} - -
- {createPortal(, document.head)} -
-
-
- - el && - (el.indeterminate = selectedItems.length !== rows.length && selectedItems.length > 0) - } - onChange={selectAllItems} - /> -
-
- {filteredColumns.map((column: ColumnDefinition) => { - const isStatsColumn = ['stats', 'baseStats'].includes(column.columnGroup?.id ?? ''); - return ( -
-
- {column.header} - {!column.noSort && columnSorts.some((c) => c.columnId === column.id) && ( - c.columnId === column.id)!.sort === SortDirection.DESC - ? faCaretDown - : faCaretUp - } - /> )} -
+ + {downloadAction} +
- ); - })} - {rows.length === 0 &&
{t('Organizer.NoItems')}
} - {rows.map((row) => ( - -
+ {createPortal(, document.head)} +
+
+
selectItem(e, row.item)} + checked={selectedItems.length === rows.length} + ref={(el) => + el && + (el.indeterminate = + selectedItems.length !== rows.length && selectedItems.length > 0) + } + onChange={selectAllItems} />
- - - ))} -
+
+ {filteredColumns.map((column: ColumnDefinition) => { + const isStatsColumn = ['stats', 'baseStats'].includes(column.columnGroup?.id ?? ''); + return ( +
+
+ {column.header} + {!column.noSort && columnSorts.some((c) => c.columnId === column.id) && ( + c.columnId === column.id)!.sort === SortDirection.DESC + ? faCaretDown + : faCaretUp + } + /> + )} +
+
+ ); + })} + {rows.length === 0 &&
{t('Organizer.NoItems')}
} + {rows.slice(0, maxItems).map((row) => ( + +
+ selectItem(e, row.item)} + /> +
+ +
+ ))} +
+ {rows.length > maxItems && } + ); } @@ -566,17 +581,21 @@ function sortRows( unsortedRows: Row[], columnSorts: ColumnSort[], filteredColumns: ColumnDefinition[], + language: DimLanguage, ) { const comparator = chainComparator( ...columnSorts.map((sorter) => { const column = filteredColumns.find((c) => c.id === sorter.columnId); if (column) { - const compare = column.sort - ? (row1: Row, row2: Row) => column.sort!(row1.values[column.id], row2.values[column.id]) - : compareBy((row: Row) => row.values[column.id] ?? 0); + const sort = column.sort; + const compare: Comparator = sort + ? (row1, row2) => sort(row1.values[column.id], row2.values[column.id]) + : unsortedRows.some((row) => typeof row.values[column.id] === 'string') + ? localizedSorter(language, (row) => (row.values[column.id] ?? '') as string) + : compareBy((row) => row.values[column.id] ?? 0); // Always sort undefined values to the end return chainComparator( - compareBy((row: Row) => row.values[column.id] === undefined), + compareBy((row) => row.values[column.id] === undefined), sorter.sort === SortDirection.ASC ? compare : reverseComparator(compare), ); } @@ -667,3 +686,34 @@ function columnSetting(itemType: 'weapon' | 'armor' | 'ghost') { return 'organizerColumnsGhost'; } } + +function ItemListExpander({ onExpand }: { onExpand: () => void }) { + const ref = useRef(null); + + useEffect(() => { + const elem = ref.current; + if (!elem) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + onExpand(); + } + } + }, + { + root: null, + rootMargin: '16px', + threshold: 0, + }, + ); + + observer.observe(elem); + return () => observer.unobserve(elem); + }, [onExpand]); + + return
; +} diff --git a/src/app/organizer/ItemTypeSelector.m.scss b/src/app/organizer/ItemTypeSelector.m.scss index 2a6d30e558..805c57c655 100644 --- a/src/app/organizer/ItemTypeSelector.m.scss +++ b/src/app/organizer/ItemTypeSelector.m.scss @@ -1,9 +1,12 @@ @use '../variables' as *; .selector { - background-color: rgba(0, 0, 0, 0); + position: sticky; + box-sizing: border-box; + left: calc(8px + env(safe-area-inset-left)); + width: calc(98vw - env(safe-area-inset-left) - env(safe-area-inset-right)); padding: 8px; - margin-top: 16px; + margin: 16px 8px 8px 8px; display: flex; flex-direction: column; align-items: center; diff --git a/src/app/organizer/ItemTypeSelector.tsx b/src/app/organizer/ItemTypeSelector.tsx index f2aad99fbb..c34844456b 100644 --- a/src/app/organizer/ItemTypeSelector.tsx +++ b/src/app/organizer/ItemTypeSelector.tsx @@ -79,6 +79,7 @@ const d2SelectionTree: ItemCategoryTreeNode = { { id: 'weapons', itemCategoryHash: ItemCategoryHashes.Weapon, + terminal: true, subCategories: [ { diff --git a/src/app/organizer/Organizer.m.scss b/src/app/organizer/Organizer.m.scss index 455128ddab..5cd635bdbf 100644 --- a/src/app/organizer/Organizer.m.scss +++ b/src/app/organizer/Organizer.m.scss @@ -1,6 +1,8 @@ @use '../variables.scss' as *; .organizer { + width: min-content; + :global(.issue-banner-shown) & { padding-bottom: $issue-banner-height; @@ -9,10 +11,6 @@ } } - > * { - margin: 8px; - } - :global(#spreadsheets) { max-width: 500px; background-color: #222; diff --git a/src/app/organizer/table-types.ts b/src/app/organizer/table-types.ts index 843a5ed85b..d9df7e3abe 100644 --- a/src/app/organizer/table-types.ts +++ b/src/app/organizer/table-types.ts @@ -5,7 +5,7 @@ import React from 'react'; export { SortDirection, type ColumnSort } from 'app/dim-ui/table-columns'; -export type Value = string | number | boolean | undefined | null; +export type Value = string | number | boolean | undefined; /** * Columns can optionally belong to a column group - if so, they're shown/hidden as a group. @@ -46,7 +46,7 @@ export interface ColumnDefinition { /** A generator for search terms matching this item. Default: No filtering. */ filter?(value: V, item: DimItem): string | undefined; /** A custom sort function. Default: Something reasonable. */ - sort?(firstValue: V, secondValue: V): 0 | 1 | -1; + sort?(this: void, firstValue: V, secondValue: V): 0 | 1 | -1; /** * a column def needs to exist all the time, so enabledness setting is aware of it, * but sometimes a custom stat should be limited to only displaying for a certain class diff --git a/src/app/search/d2-known-values.ts b/src/app/search/d2-known-values.ts index b120758f47..4aa8e2b7e3 100644 --- a/src/app/search/d2-known-values.ts +++ b/src/app/search/d2-known-values.ts @@ -287,15 +287,22 @@ export type ItemTierName = export const breakerTypes = { any: [BreakerTypeHashes.Stagger, BreakerTypeHashes.Disruption, BreakerTypeHashes.ShieldPiercing], - barrier: [BreakerTypeHashes.ShieldPiercing], antibarrier: [BreakerTypeHashes.ShieldPiercing], shieldpiercing: [BreakerTypeHashes.ShieldPiercing], - overload: [BreakerTypeHashes.Disruption], + barrier: [BreakerTypeHashes.ShieldPiercing], disruption: [BreakerTypeHashes.Disruption], - unstoppable: [BreakerTypeHashes.Stagger], + overload: [BreakerTypeHashes.Disruption], stagger: [BreakerTypeHashes.Stagger], + unstoppable: [BreakerTypeHashes.Stagger], }; +export const breakerTypeNames = Object.entries(breakerTypes) + .filter(([, hashes]) => hashes.length === 1) + .reduce>>((memo, [name, [hash]]) => { + memo[hash] = name; + return memo; + }, {}); + export const enum ModsWithConditionalStats { ElementalCapacitor = 3511092054, // InventoryItem "Elemental Capacitor" EchoOfPersistence = 2272984671, // InventoryItem "Echo of Persistence" diff --git a/src/app/settings/initial-settings.ts b/src/app/settings/initial-settings.ts index 8ccf6c95d2..2904fd6cfe 100644 --- a/src/app/settings/initial-settings.ts +++ b/src/app/settings/initial-settings.ts @@ -11,4 +11,31 @@ export interface Settings extends DimApiSettings { export const initialSettingsState: Settings = { ...defaultSettings, language: defaultLanguage(), + organizerColumnsWeapons: [ + 'icon', + 'name', + 'dmg', + 'power', + 'tag', + 'wishList', + 'archetype', + 'perks', + 'traits', + 'originTrait', + 'notes', + ], + organizerColumnsArmor: [ + 'icon', + 'name', + 'power', + 'energy', + 'tag', + 'modslot', + 'intrinsics', + 'perks', + 'baseStats', + 'customstat', + 'notes', + ], + organizerColumnsGhost: ['icon', 'name', 'tag', 'perks', 'notes'], }; diff --git a/src/app/utils/intl.ts b/src/app/utils/intl.ts index ad0e531981..53d5e81385 100644 --- a/src/app/utils/intl.ts +++ b/src/app/utils/intl.ts @@ -4,6 +4,7 @@ import { DimLanguage, browserLangToDimLang } from 'app/i18n'; import _, { stubTrue } from 'lodash'; import memoizeOne from 'memoize-one'; +import { Comparator } from './comparators'; import { LookupTable } from './util-types'; // Our locale names don't line up with the BCP 47 tags for Chinese @@ -36,9 +37,12 @@ const cachedSearchCollator = memoizeOne( * @example * ["foo10", "foo9"].sort(localizedSorter("en")) // ["foo9", "foo10"] */ -export function localizedSorter(language: DimLanguage, iteratee: (input: T) => string) { +export function localizedSorter( + language: DimLanguage, + iteratee: (input: T) => string, +): Comparator { const sortCollator = cachedSortCollator(language); - return (a: T, b: T) => sortCollator.compare(iteratee(a), iteratee(b)); + return (a: T, b: T) => sortCollator.compare(iteratee(a), iteratee(b)) as 0 | 1 | -1; } /** diff --git a/src/app/utils/item-utils.ts b/src/app/utils/item-utils.ts index 411d4f9a15..8100460bff 100644 --- a/src/app/utils/item-utils.ts +++ b/src/app/utils/item-utils.ts @@ -365,17 +365,18 @@ export function getStatValuesByHash(item: DimItem, byWhichValue: 'base' | 'value * the user to bump a stat by a small amount? */ export function isArtifice(item: DimItem) { + return Boolean(item.sockets?.allSockets.some(isArtificeSocket)); +} + +export function isArtificeSocket(socket: DimSocket) { + // exotic armor has the artifice slot all the time, and it's usable when it's reported as visible return Boolean( - item.sockets?.allSockets.some( - (socket) => - // exotic armor has the artifice slot all the time, and it's usable when it's reported as visible - socket.visibleInGame && - socket.plugged && - // in a better world, you'd only need to check this, because there's a "empty mod slot" item specifically for artifice slots. - (socket.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.EnhancementsArtifice || - // but some of those have the *generic* "empty mod slot" item plugged in, so we fall back to keeping an eye out for the intrinsic - socket.plugged.plugDef.hash === ARTIFICE_PERK_HASH), - ), + socket.visibleInGame && + socket.plugged && + // in a better world, you'd only need to check this, because there's a "empty mod slot" item specifically for artifice slots. + (socket.plugged.plugDef.plug.plugCategoryHash === PlugCategoryHashes.EnhancementsArtifice || + // but some of those have the *generic* "empty mod slot" item plugged in, so we fall back to keeping an eye out for the intrinsic + socket.plugged.plugDef.hash === ARTIFICE_PERK_HASH), ); } diff --git a/src/locale/en.json b/src/locale/en.json index ca72b7aac6..549256ad1e 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -1028,6 +1028,7 @@ "Event": "Event", "Harmonizable": "Harmonizable", "Icon": "Icon", + "Intrinsics": "Intrinsic", "KillTracker": "Kill Tracker", "Level": "Level", "Loadouts": "Loadouts", @@ -1039,13 +1040,16 @@ "Name": "Name", "New": "New", "Notes": "Notes", + "OriginTraits": "Origin Trait", + "OtherPerks": "Other Perks", "PercentComplete": "% Complete", "Perks": "Perks", - "PerksMods": "Perks, Mods & Shaders", + "PerksMods": "Perks & Mods", "Power": "Power", "Quality": "Quality %", "Recency": "Recency", "Season": "Season", + "Shaders": "Cosmetics", "Source": "Source", "StatQuality": "Stat Quality", "StatQualityStat": "{{stat}}%", @@ -1059,7 +1063,7 @@ }, "EnabledColumns": "Enabled Columns", "Lock": "Lock", - "NoItems": "No items selected", + "NoItems": "No items match the filters. If you have a search query, try clearing it.", "NoMobile": "Turn your phone sideways to use the Organizer.", "Note": "Set Notes", "OpenIn": "Show in Organizer",