Skip to content

Commit c8c835e

Browse files
feat: add filter to NFT page (#1639)
Co-authored-by: Nick <[email protected]>
1 parent 860e98f commit c8c835e

File tree

8 files changed

+346
-101
lines changed

8 files changed

+346
-101
lines changed

packages/extension-polkagate/src/fullscreen/nft/components/Details.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,8 @@ export default function Details ({ api, itemInformation, setShowDetail, show }:
284284
chain={chain}
285285
title={t('Owner')}
286286
/>
287-
}<InfoRow
287+
}
288+
<InfoRow
288289
divider={!!itemInformation.description || (!itemInformation.isCollection && !!itemInformation.collectionName)}
289290
text={itemInformation.chainName}
290291
title={t('Network')}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/* eslint-disable react/jsx-max-props-per-line */
5+
6+
import type { FilterAction, FilterSectionProps, FilterState, ItemInformation, SortAction, SortState } from '../utils/types';
7+
8+
import { Grid, Typography, useTheme } from '@mui/material';
9+
import React, { useCallback, useEffect, useReducer, useState } from 'react';
10+
11+
import { selectableNetworks } from '@polkadot/networks';
12+
import { isNumber } from '@polkadot/util';
13+
14+
import InputFilter from '../../../components/InputFilter';
15+
import { usePrices } from '../../../hooks';
16+
import useTranslation from '../../../hooks/useTranslation';
17+
import NftFilter from './NftFilter';
18+
19+
const initialFilterState: FilterState = {
20+
collections: false,
21+
kusama: false,
22+
nft: false,
23+
polkadot: false,
24+
unique: false
25+
};
26+
27+
const initialSortState = {
28+
highPrice: false,
29+
lowPrice: false,
30+
newest: false,
31+
oldest: false
32+
};
33+
34+
const filterReducer = (state: FilterState, action: FilterAction): FilterState => {
35+
return {
36+
...state,
37+
[action.filter]: !state[action.filter]
38+
};
39+
};
40+
41+
const sortReducer = (state: SortState, action: SortAction): SortState => {
42+
return {
43+
...state,
44+
[action.enable]: true,
45+
[action.unable]: false
46+
};
47+
};
48+
49+
function Filters ({ items, setItemsToShow }: FilterSectionProps): React.ReactElement {
50+
const { t } = useTranslation();
51+
const theme = useTheme();
52+
const prices = usePrices();
53+
54+
const [filters, dispatchFilter] = useReducer(filterReducer, initialFilterState);
55+
const [searchedTxt, setSearchTxt] = useState<string | undefined>();
56+
const [sort, dispatchSort] = useReducer(sortReducer, initialSortState);
57+
const [count, setCount] = useState<number>();
58+
59+
const onSearch = useCallback((text: string) => {
60+
setSearchTxt(text);
61+
}, []);
62+
63+
const getDecimal = useCallback((chainName: string) => {
64+
return selectableNetworks.find(({ network }) => network.toLowerCase() === chainName)?.decimals[0];
65+
}, []);
66+
67+
const calculatePrice = useCallback((item: ItemInformation) => {
68+
if (!prices?.prices || !item.price) {
69+
return 0;
70+
}
71+
72+
const currency = item.chainName.toLowerCase().includes('kusama')
73+
? 'kusama'
74+
: 'polkadot';
75+
const decimal = getDecimal(currency) ?? 0;
76+
77+
return (item.price / (10 ** decimal)) * prices.prices[currency].value;
78+
}, [getDecimal, prices]);
79+
80+
const sortItems = useCallback((itemsToSort: ItemInformation[]) => {
81+
if (sort.highPrice) {
82+
return [...itemsToSort].sort((a, b) => calculatePrice(b) - calculatePrice(a));
83+
}
84+
85+
if (sort.lowPrice) {
86+
return [...itemsToSort].sort((a, b) => calculatePrice(a) - calculatePrice(b));
87+
}
88+
89+
return itemsToSort;
90+
}, [calculatePrice, sort]);
91+
92+
useEffect(() => {
93+
if (!items?.length) {
94+
setItemsToShow(items);
95+
96+
return;
97+
}
98+
99+
try {
100+
let filtered = items.filter((item) => {
101+
const matchesSearch = !searchedTxt ||
102+
item.chainName.toLowerCase().includes(searchedTxt.toLowerCase()) ||
103+
item.collectionId?.toString().toLowerCase().includes(searchedTxt.toLowerCase()) ||
104+
item.collectionName?.toLowerCase().includes(searchedTxt.toLowerCase()) ||
105+
item.name?.toLowerCase().includes(searchedTxt.toLowerCase()) ||
106+
item.itemId?.toString().toLowerCase().includes(searchedTxt.toLowerCase());
107+
108+
const matchesNetwork = (!filters.kusama && !filters.polkadot) ||
109+
(filters.kusama && item.chainName.toLowerCase().includes('kusama')) ||
110+
(filters.polkadot && item.chainName.toLowerCase().includes('polkadot'));
111+
112+
const matchesType = (!filters.nft && !filters.unique) ||
113+
(filters.nft && item.isNft) ||
114+
(filters.unique && !item.isNft);
115+
116+
const matchesCollection = !filters.collections || item.isCollection;
117+
118+
return matchesSearch && matchesNetwork && matchesType && matchesCollection;
119+
});
120+
121+
setCount(filtered.length);
122+
123+
// Apply sorting
124+
filtered = sortItems(filtered);
125+
126+
setItemsToShow(filtered);
127+
} catch (error) {
128+
console.error('Error filtering items:', error);
129+
setItemsToShow(items); // Fallback to original items on error
130+
}
131+
}, [items, filters, searchedTxt, sortItems, setItemsToShow]);
132+
133+
return (
134+
<Grid alignItems='center' container item justifyContent={!isNumber(count) ? 'flex-end' : 'space-between'} sx={{ mb: '10px', mt: '20px' }}>
135+
{isNumber(count) &&
136+
<Typography color='text.disabled' fontSize='16px' fontWeight={500}>
137+
{t('Items')}{`(${count})`}
138+
</Typography>}
139+
<Grid alignItems='center' columnGap='15px' container item width='fit-content'>
140+
<InputFilter
141+
autoFocus={false}
142+
onChange={onSearch}
143+
placeholder={t('🔍 Search')}
144+
theme={theme}
145+
// value={searchKeyword ?? ''}
146+
/>
147+
<NftFilter
148+
dispatchFilter={dispatchFilter}
149+
dispatchSort={dispatchSort}
150+
filters={filters}
151+
sort={sort}
152+
/>
153+
</Grid>
154+
</Grid>
155+
);
156+
}
157+
158+
export default React.memo(Filters);

packages/extension-polkagate/src/fullscreen/nft/components/InfoRow.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function InfoRow ({ accountId, api, chain, divider = true, inline = true, isThum
4444
/>
4545
}
4646
{notListed &&
47-
<Typography fontSize='14px' fontWeight={400} textAlign='left'>
47+
<Typography fontSize='14px' fontWeight={500} textAlign='left'>
4848
{t('Not listed')}
4949
</Typography>
5050
}
@@ -66,12 +66,12 @@ function InfoRow ({ accountId, api, chain, divider = true, inline = true, isThum
6666
formatted={accountId}
6767
identiconSize={15}
6868
showShortAddress
69-
style={{ fontSize: '14px', maxWidth: '200px' }}
69+
style={{ fontSize: '14px', fontWeight: 500, maxWidth: '200px' }}
7070
/>
7171
: <ShortAddress
7272
address={accountId}
7373
charsCount={6}
74-
style={{ fontSize: '14px', width: 'fit-content' }}
74+
style={{ fontSize: '14px', fontWeight: 500, width: 'fit-content' }}
7575
/>
7676
}
7777
</>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/* eslint-disable react/jsx-max-props-per-line */
5+
6+
import type { FilterAction, FilterState, SortAction, SortState } from '../utils/types';
7+
8+
import { FilterAltOutlined as FilterIcon, FilterList as FilterListIcon, ImportExport as ImportExportIcon } from '@mui/icons-material';
9+
import { Divider, FormControl, FormControlLabel, Grid, Popover, Radio, RadioGroup, Typography, useTheme } from '@mui/material';
10+
import React, { useCallback } from 'react';
11+
12+
import Checkbox2 from '../../../components/Checkbox2';
13+
import { useTranslation } from '../../../hooks';
14+
15+
interface Props {
16+
dispatchFilter: React.Dispatch<FilterAction>;
17+
filters: FilterState;
18+
dispatchSort: React.Dispatch<SortAction>;
19+
sort: SortState;
20+
}
21+
22+
const Filters = React.memo(function Filters ({ dispatchFilter, dispatchSort, filters, sort }: Props) {
23+
const { t } = useTranslation();
24+
const theme = useTheme();
25+
26+
const onFilters = useCallback((filter: keyof FilterState) => () => {
27+
dispatchFilter({ filter });
28+
}, [dispatchFilter]);
29+
30+
const onSort = useCallback((enable: keyof SortState, unable: keyof SortState) => () => {
31+
dispatchSort({ enable, unable });
32+
}, [dispatchSort]);
33+
34+
return (
35+
<Grid alignItems='flex-start' container display='block' item sx={{ borderRadius: '10px', maxWidth: '300px', p: '10px 20px', width: 'max-content' }}>
36+
<Grid alignItems='center' container item>
37+
<FilterListIcon sx={{ color: 'secondary.light', height: '25px', mr: '10px', width: '25px' }} />
38+
<Typography fontSize='16px' fontWeight={400}>
39+
{t('Filters')}
40+
</Typography>
41+
<Divider sx={{ bgcolor: 'divider', height: '2px', mt: '5px', width: '100%' }} />
42+
<Checkbox2
43+
checked={filters.collections}
44+
iconStyle={{ marginRight: '6px', width: '20px' }}
45+
label={t('Collections')}
46+
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
47+
onChange={onFilters('collections')}
48+
style={{ mt: '15px', width: '100%' }}
49+
/>
50+
<Checkbox2
51+
checked={filters.nft}
52+
iconStyle={{ marginRight: '6px', width: '20px' }}
53+
label={t('NFTs')}
54+
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
55+
onChange={onFilters('nft')}
56+
style={{ mt: '15px', width: '100%' }}
57+
/>
58+
<Checkbox2
59+
checked={filters.unique}
60+
iconStyle={{ marginRight: '6px', width: '20px' }}
61+
label={t('Uniques')}
62+
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
63+
onChange={onFilters('unique')}
64+
style={{ mt: '15px', width: '100%' }}
65+
/>
66+
<Checkbox2
67+
checked={filters.kusama}
68+
iconStyle={{ marginRight: '6px', width: '20px' }}
69+
label={t('Kusama Asset Hub')}
70+
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
71+
onChange={onFilters('kusama')}
72+
style={{ mt: '15px', width: '100%' }}
73+
/>
74+
<Checkbox2
75+
checked={filters.polkadot}
76+
iconStyle={{ marginRight: '6px', width: '20px' }}
77+
label={t('Polkadot Asset Hub')}
78+
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
79+
onChange={onFilters('polkadot')}
80+
style={{ mt: '15px', width: '100%' }}
81+
/>
82+
</Grid>
83+
<Grid alignItems='center' container item mt='15px'>
84+
<ImportExportIcon sx={{ color: 'secondary.light', height: '30px', mr: '10px', width: '30px' }} />
85+
<Typography fontSize='16px' fontWeight={400}>
86+
{t('Sort')}
87+
</Typography>
88+
<Divider sx={{ bgcolor: 'divider', height: '2px', mt: '5px', width: '100%' }} />
89+
<FormControl fullWidth>
90+
<RadioGroup
91+
aria-labelledby='sort-price'
92+
name='sort-price'
93+
>
94+
<FormControlLabel checked={sort.highPrice} control={<Radio style={{ color: theme.palette.secondary.main }} />} label={t('Price: High to Low')} onClick={onSort('highPrice', 'lowPrice')} slotProps={{ typography: { fontWeight: 400 } }} value='highPrice' />
95+
<FormControlLabel checked={sort.lowPrice} control={<Radio style={{ color: theme.palette.secondary.main }} />} label={t('Price: Low to High')} onClick={onSort('lowPrice', 'highPrice')} slotProps={{ typography: { fontWeight: 400 } }} value='lowPrice' />
96+
</RadioGroup>
97+
</FormControl>
98+
</Grid>
99+
</Grid>
100+
);
101+
});
102+
103+
function NftFilters ({ dispatchFilter, dispatchSort, filters, sort }: Props): React.ReactElement {
104+
const theme = useTheme();
105+
106+
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
107+
108+
const handleClose = useCallback(() => {
109+
setAnchorEl(null);
110+
}, []);
111+
112+
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
113+
setAnchorEl(event.currentTarget);
114+
}, []);
115+
116+
const open = Boolean(anchorEl);
117+
const id = open ? 'simple-popover' : undefined;
118+
119+
return (
120+
<>
121+
<Grid aria-describedby={id} component='button' container item onClick={handleClick} sx={{ bgcolor: 'transparent', border: 'none', height: 'fit-content', p: 0, width: 'fit-content' }}>
122+
<FilterIcon sx={{ color: 'secondary.light', cursor: 'pointer', height: '30px', width: '30px' }} />
123+
</Grid>
124+
<Popover
125+
PaperProps={{
126+
sx: { backgroundImage: 'none', bgcolor: 'background.paper', border: '1px solid', borderColor: theme.palette.mode === 'dark' ? 'secondary.main' : 'transparent', borderRadius: '7px', boxShadow: theme.palette.mode === 'dark' ? '0px 4px 4px rgba(255, 255, 255, 0.25)' : '0px 0px 25px 0px rgba(0, 0, 0, 0.50)' }
127+
}}
128+
anchorEl={anchorEl}
129+
anchorOrigin={{
130+
horizontal: 'right',
131+
vertical: 'bottom'
132+
}}
133+
id={id}
134+
onClose={handleClose}
135+
open={open}
136+
sx={{ mt: '5px' }}
137+
transformOrigin={{
138+
horizontal: 'right',
139+
vertical: 'top'
140+
}}
141+
>
142+
<Filters
143+
dispatchFilter={dispatchFilter}
144+
dispatchSort={dispatchSort}
145+
filters={filters}
146+
sort={sort}
147+
/>
148+
</Popover>
149+
</>
150+
);
151+
}
152+
153+
export default React.memo(NftFilters);

0 commit comments

Comments
 (0)