Skip to content

Commit

Permalink
feat: add filter to NFT page (#1639)
Browse files Browse the repository at this point in the history
Co-authored-by: Nick <ekbatanifard@gmail.com>
  • Loading branch information
AMIRKHANEF and Nick-1979 authored Nov 10, 2024
1 parent 860e98f commit c8c835e
Show file tree
Hide file tree
Showing 8 changed files with 346 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ export default function Details ({ api, itemInformation, setShowDetail, show }:
chain={chain}
title={t('Owner')}
/>
}<InfoRow
}
<InfoRow
divider={!!itemInformation.description || (!itemInformation.isCollection && !!itemInformation.collectionName)}
text={itemInformation.chainName}
title={t('Network')}
Expand Down
158 changes: 158 additions & 0 deletions packages/extension-polkagate/src/fullscreen/nft/components/Filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0

/* eslint-disable react/jsx-max-props-per-line */

import type { FilterAction, FilterSectionProps, FilterState, ItemInformation, SortAction, SortState } from '../utils/types';

import { Grid, Typography, useTheme } from '@mui/material';
import React, { useCallback, useEffect, useReducer, useState } from 'react';

import { selectableNetworks } from '@polkadot/networks';
import { isNumber } from '@polkadot/util';

import InputFilter from '../../../components/InputFilter';
import { usePrices } from '../../../hooks';
import useTranslation from '../../../hooks/useTranslation';
import NftFilter from './NftFilter';

const initialFilterState: FilterState = {
collections: false,
kusama: false,
nft: false,
polkadot: false,
unique: false
};

const initialSortState = {
highPrice: false,
lowPrice: false,
newest: false,
oldest: false
};

const filterReducer = (state: FilterState, action: FilterAction): FilterState => {
return {
...state,
[action.filter]: !state[action.filter]
};
};

const sortReducer = (state: SortState, action: SortAction): SortState => {
return {
...state,
[action.enable]: true,
[action.unable]: false
};
};

function Filters ({ items, setItemsToShow }: FilterSectionProps): React.ReactElement {
const { t } = useTranslation();
const theme = useTheme();
const prices = usePrices();

const [filters, dispatchFilter] = useReducer(filterReducer, initialFilterState);
const [searchedTxt, setSearchTxt] = useState<string | undefined>();
const [sort, dispatchSort] = useReducer(sortReducer, initialSortState);
const [count, setCount] = useState<number>();

const onSearch = useCallback((text: string) => {
setSearchTxt(text);
}, []);

const getDecimal = useCallback((chainName: string) => {
return selectableNetworks.find(({ network }) => network.toLowerCase() === chainName)?.decimals[0];
}, []);

const calculatePrice = useCallback((item: ItemInformation) => {
if (!prices?.prices || !item.price) {
return 0;
}

const currency = item.chainName.toLowerCase().includes('kusama')
? 'kusama'
: 'polkadot';
const decimal = getDecimal(currency) ?? 0;

return (item.price / (10 ** decimal)) * prices.prices[currency].value;
}, [getDecimal, prices]);

const sortItems = useCallback((itemsToSort: ItemInformation[]) => {
if (sort.highPrice) {
return [...itemsToSort].sort((a, b) => calculatePrice(b) - calculatePrice(a));
}

if (sort.lowPrice) {
return [...itemsToSort].sort((a, b) => calculatePrice(a) - calculatePrice(b));
}

return itemsToSort;
}, [calculatePrice, sort]);

useEffect(() => {
if (!items?.length) {
setItemsToShow(items);

return;
}

try {
let filtered = items.filter((item) => {
const matchesSearch = !searchedTxt ||
item.chainName.toLowerCase().includes(searchedTxt.toLowerCase()) ||
item.collectionId?.toString().toLowerCase().includes(searchedTxt.toLowerCase()) ||
item.collectionName?.toLowerCase().includes(searchedTxt.toLowerCase()) ||
item.name?.toLowerCase().includes(searchedTxt.toLowerCase()) ||
item.itemId?.toString().toLowerCase().includes(searchedTxt.toLowerCase());

const matchesNetwork = (!filters.kusama && !filters.polkadot) ||
(filters.kusama && item.chainName.toLowerCase().includes('kusama')) ||
(filters.polkadot && item.chainName.toLowerCase().includes('polkadot'));

const matchesType = (!filters.nft && !filters.unique) ||
(filters.nft && item.isNft) ||
(filters.unique && !item.isNft);

const matchesCollection = !filters.collections || item.isCollection;

return matchesSearch && matchesNetwork && matchesType && matchesCollection;
});

setCount(filtered.length);

// Apply sorting
filtered = sortItems(filtered);

setItemsToShow(filtered);
} catch (error) {
console.error('Error filtering items:', error);
setItemsToShow(items); // Fallback to original items on error
}
}, [items, filters, searchedTxt, sortItems, setItemsToShow]);

return (
<Grid alignItems='center' container item justifyContent={!isNumber(count) ? 'flex-end' : 'space-between'} sx={{ mb: '10px', mt: '20px' }}>
{isNumber(count) &&
<Typography color='text.disabled' fontSize='16px' fontWeight={500}>
{t('Items')}{`(${count})`}
</Typography>}
<Grid alignItems='center' columnGap='15px' container item width='fit-content'>
<InputFilter
autoFocus={false}
onChange={onSearch}
placeholder={t('🔍 Search')}
theme={theme}
// value={searchKeyword ?? ''}
/>
<NftFilter
dispatchFilter={dispatchFilter}
dispatchSort={dispatchSort}
filters={filters}
sort={sort}
/>
</Grid>
</Grid>
);
}

export default React.memo(Filters);
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function InfoRow ({ accountId, api, chain, divider = true, inline = true, isThum
/>
}
{notListed &&
<Typography fontSize='14px' fontWeight={400} textAlign='left'>
<Typography fontSize='14px' fontWeight={500} textAlign='left'>
{t('Not listed')}
</Typography>
}
Expand All @@ -66,12 +66,12 @@ function InfoRow ({ accountId, api, chain, divider = true, inline = true, isThum
formatted={accountId}
identiconSize={15}
showShortAddress
style={{ fontSize: '14px', maxWidth: '200px' }}
style={{ fontSize: '14px', fontWeight: 500, maxWidth: '200px' }}
/>
: <ShortAddress
address={accountId}
charsCount={6}
style={{ fontSize: '14px', width: 'fit-content' }}
style={{ fontSize: '14px', fontWeight: 500, width: 'fit-content' }}
/>
}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0

/* eslint-disable react/jsx-max-props-per-line */

import type { FilterAction, FilterState, SortAction, SortState } from '../utils/types';

import { FilterAltOutlined as FilterIcon, FilterList as FilterListIcon, ImportExport as ImportExportIcon } from '@mui/icons-material';
import { Divider, FormControl, FormControlLabel, Grid, Popover, Radio, RadioGroup, Typography, useTheme } from '@mui/material';
import React, { useCallback } from 'react';

import Checkbox2 from '../../../components/Checkbox2';
import { useTranslation } from '../../../hooks';

interface Props {
dispatchFilter: React.Dispatch<FilterAction>;
filters: FilterState;
dispatchSort: React.Dispatch<SortAction>;
sort: SortState;
}

const Filters = React.memo(function Filters ({ dispatchFilter, dispatchSort, filters, sort }: Props) {
const { t } = useTranslation();
const theme = useTheme();

const onFilters = useCallback((filter: keyof FilterState) => () => {
dispatchFilter({ filter });
}, [dispatchFilter]);

const onSort = useCallback((enable: keyof SortState, unable: keyof SortState) => () => {
dispatchSort({ enable, unable });
}, [dispatchSort]);

return (
<Grid alignItems='flex-start' container display='block' item sx={{ borderRadius: '10px', maxWidth: '300px', p: '10px 20px', width: 'max-content' }}>
<Grid alignItems='center' container item>
<FilterListIcon sx={{ color: 'secondary.light', height: '25px', mr: '10px', width: '25px' }} />
<Typography fontSize='16px' fontWeight={400}>
{t('Filters')}
</Typography>
<Divider sx={{ bgcolor: 'divider', height: '2px', mt: '5px', width: '100%' }} />
<Checkbox2
checked={filters.collections}
iconStyle={{ marginRight: '6px', width: '20px' }}
label={t('Collections')}
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
onChange={onFilters('collections')}
style={{ mt: '15px', width: '100%' }}
/>
<Checkbox2
checked={filters.nft}
iconStyle={{ marginRight: '6px', width: '20px' }}
label={t('NFTs')}
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
onChange={onFilters('nft')}
style={{ mt: '15px', width: '100%' }}
/>
<Checkbox2
checked={filters.unique}
iconStyle={{ marginRight: '6px', width: '20px' }}
label={t('Uniques')}
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
onChange={onFilters('unique')}
style={{ mt: '15px', width: '100%' }}
/>
<Checkbox2
checked={filters.kusama}
iconStyle={{ marginRight: '6px', width: '20px' }}
label={t('Kusama Asset Hub')}
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
onChange={onFilters('kusama')}
style={{ mt: '15px', width: '100%' }}
/>
<Checkbox2
checked={filters.polkadot}
iconStyle={{ marginRight: '6px', width: '20px' }}
label={t('Polkadot Asset Hub')}
labelStyle={{ fontSize: '16px', fontWeight: 400 }}
onChange={onFilters('polkadot')}
style={{ mt: '15px', width: '100%' }}
/>
</Grid>
<Grid alignItems='center' container item mt='15px'>
<ImportExportIcon sx={{ color: 'secondary.light', height: '30px', mr: '10px', width: '30px' }} />
<Typography fontSize='16px' fontWeight={400}>
{t('Sort')}
</Typography>
<Divider sx={{ bgcolor: 'divider', height: '2px', mt: '5px', width: '100%' }} />
<FormControl fullWidth>
<RadioGroup
aria-labelledby='sort-price'
name='sort-price'
>
<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' />
<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' />
</RadioGroup>
</FormControl>
</Grid>
</Grid>
);
});

function NftFilters ({ dispatchFilter, dispatchSort, filters, sort }: Props): React.ReactElement {
const theme = useTheme();

const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);

const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);

const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);

const open = Boolean(anchorEl);
const id = open ? 'simple-popover' : undefined;

return (
<>
<Grid aria-describedby={id} component='button' container item onClick={handleClick} sx={{ bgcolor: 'transparent', border: 'none', height: 'fit-content', p: 0, width: 'fit-content' }}>
<FilterIcon sx={{ color: 'secondary.light', cursor: 'pointer', height: '30px', width: '30px' }} />
</Grid>
<Popover
PaperProps={{
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)' }
}}
anchorEl={anchorEl}
anchorOrigin={{
horizontal: 'right',
vertical: 'bottom'
}}
id={id}
onClose={handleClose}
open={open}
sx={{ mt: '5px' }}
transformOrigin={{
horizontal: 'right',
vertical: 'top'
}}
>
<Filters
dispatchFilter={dispatchFilter}
dispatchSort={dispatchSort}
filters={filters}
sort={sort}
/>
</Popover>
</>
);
}

export default React.memo(NftFilters);
Loading

0 comments on commit c8c835e

Please sign in to comment.