From cbce2dfb4ec00394e349df365bbcb58ec6cd3a1d Mon Sep 17 00:00:00 2001 From: Matej Rifelj Date: Thu, 22 Jun 2023 16:29:29 +0200 Subject: [PATCH 1/2] NFT gallery user folders --- .../UserFolders/AddUserFolderDialog.tsx | 85 ++++ packages/core/src/components/index.ts | 1 + packages/gui/package-lock.json | 183 +++++-- packages/gui/package.json | 2 + .../gui/src/components/nfts/NFTBurnDialog.tsx | 7 +- packages/gui/src/components/nfts/NFTCard.tsx | 133 ++++- .../components/nfts/NFTContextualActions.tsx | 88 +++- .../src/components/nfts/NFTFilterProvider.tsx | 30 +- .../nfts/NFTMoveToProfileDialog.tsx | 7 +- .../components/nfts/NFTProfileDropdown.tsx | 454 +++++++++++++++--- .../components/nfts/NFTSaveToFilterDialog.tsx | 73 +++ .../components/nfts/gallery/NFTGallery.tsx | 99 +++- .../nfts/gallery/SelectedActionsDialog.tsx | 3 +- packages/gui/src/hooks/useFilteredNFTs.ts | 3 +- .../gui/src/util/getNFTsDataStatistics.ts | 10 +- packages/icons/src/NFTs.tsx | 71 ++- packages/icons/src/images/Icon.svg | 3 + packages/icons/src/images/filter.svg | 3 + packages/icons/src/images/folder.svg | 3 + packages/icons/src/images/inbox.svg | 3 + packages/icons/src/images/plus.svg | 3 + packages/icons/src/images/profile.svg | 4 + packages/icons/src/images/show-hide.svg | 3 + packages/icons/src/images/trash.svg | 3 + packages/icons/src/images/unassigned.svg | 3 + packages/icons/src/index.ts | 15 +- 26 files changed, 1147 insertions(+), 145 deletions(-) create mode 100644 packages/core/src/components/UserFolders/AddUserFolderDialog.tsx create mode 100644 packages/gui/src/components/nfts/NFTSaveToFilterDialog.tsx create mode 100644 packages/icons/src/images/Icon.svg create mode 100644 packages/icons/src/images/filter.svg create mode 100644 packages/icons/src/images/folder.svg create mode 100644 packages/icons/src/images/inbox.svg create mode 100644 packages/icons/src/images/plus.svg create mode 100644 packages/icons/src/images/profile.svg create mode 100644 packages/icons/src/images/show-hide.svg create mode 100644 packages/icons/src/images/trash.svg create mode 100644 packages/icons/src/images/unassigned.svg diff --git a/packages/core/src/components/UserFolders/AddUserFolderDialog.tsx b/packages/core/src/components/UserFolders/AddUserFolderDialog.tsx new file mode 100644 index 0000000000..42b87c8383 --- /dev/null +++ b/packages/core/src/components/UserFolders/AddUserFolderDialog.tsx @@ -0,0 +1,85 @@ +import { Trans } from '@lingui/macro'; +import { Dialog, DialogTitle, DialogContent } from '@mui/material'; +import React, { ReactNode } from 'react'; +import { useForm } from 'react-hook-form'; + +import Button from '../Button'; +import DialogActions from '../DialogActions'; +import Form from '../Form'; +import TextField from '../TextField'; + +export type AlertDialogProps = { + title?: ReactNode; + open?: boolean; + onClose?: (value?: any) => void; + onAdd: (value: string) => void; + placeholderName?: string; +}; + +export default function AlertDialog(props: AlertDialogProps) { + const { onClose = () => {}, open = false, title, onAdd, placeholderName = 'Title' } = props; + + const inputWrapper = React.useRef(null); + + React.useEffect(() => { + setTimeout(() => { + if (inputWrapper?.current) { + (inputWrapper?.current.querySelector('input') as HTMLElement).focus(); + } + }, 100); + }, [inputWrapper]); + + function handleClose() { + onClose?.(true); + } + + function handleHide() { + onClose?.(); + } + + type FormData = { + folderName: string; + }; + + const formMethods = useForm({ + defaultValues: { + folderName: '', + }, + }); + + function handleSubmit(objValue: any) { + onAdd(objValue.folderName); + handleClose(); + } + + return ( + +
+ {title && {title}} + + + + + + + +
+
+ ); +} diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 2cb33d7181..fa6fdfefc5 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -81,4 +81,5 @@ export { default as TooltipIcon } from './TooltipIcon'; export { default as TooltipTypography } from './TooltipTypography'; export { default as Truncate, truncateValue } from './Truncate'; export { default as UnitFormat } from './UnitFormat'; +export { default as AddUserFolderDialog } from './UserFolders/AddUserFolderDialog'; export { default as NewerAppVersionAvailable } from './LayoutDashboard/NewerAppVersionAvailable'; diff --git a/packages/gui/package-lock.json b/packages/gui/package-lock.json index c55fa92d9a..10c12e1539 100644 --- a/packages/gui/package-lock.json +++ b/packages/gui/package-lock.json @@ -13,6 +13,7 @@ "@lingui/core": "3.13.0", "@lingui/macro": "3.13.0", "@lingui/react": "3.13.0", + "@mui/base": "^5.0.0-alpha.128", "@mui/icons-material": "5.8.4", "@mui/lab": "5.0.0-alpha.94", "@mui/material": "5.10.0", @@ -58,6 +59,7 @@ "redux": "4.1.2", "regenerator-runtime": "0.13.9", "seedrandom": "3.0.5", + "sortablejs": "1.15.0", "stream-browserify": "3.0.0", "styled-components": "5.3.3", "unique-names-generator": "4.6.0", @@ -2095,8 +2097,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.20.6", - "license": "MIT", + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", + "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -3533,14 +3536,15 @@ } }, "node_modules/@mui/base": { - "version": "5.0.0-alpha.92", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.2", - "@emotion/is-prop-valid": "^1.1.3", - "@mui/types": "^7.1.5", - "@mui/utils": "^5.9.3", - "@popperjs/core": "^2.11.5", + "version": "5.0.0-alpha.128", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.128.tgz", + "integrity": "sha512-wub3wxNN+hUp8hzilMlXX3sZrPo75vsy1cXEQpqdTfIFlE9HprP1jlulFiPg5tfPst2OKmygXr2hhmgvAKRrzQ==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@emotion/is-prop-valid": "^1.2.0", + "@mui/types": "^7.2.4", + "@mui/utils": "^5.12.3", + "@popperjs/core": "^2.11.7", "clsx": "^1.2.1", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -3626,6 +3630,38 @@ } } }, + "node_modules/@mui/lab/node_modules/@mui/base": { + "version": "5.0.0-alpha.92", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.92.tgz", + "integrity": "sha512-ZgnSLrTXL4iUdLQhjp01dAOTQPQlnwrqjZRwDT3E6LZXEYn6cMv1MY6LZkWcF/zxrUnyasnsyMAgZ5d8AXS7bA==", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@emotion/is-prop-valid": "^1.1.3", + "@mui/types": "^7.1.5", + "@mui/utils": "^5.9.3", + "@popperjs/core": "^2.11.5", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.10.0", "license": "MIT", @@ -3668,6 +3704,38 @@ } } }, + "node_modules/@mui/material/node_modules/@mui/base": { + "version": "5.0.0-alpha.92", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.92.tgz", + "integrity": "sha512-ZgnSLrTXL4iUdLQhjp01dAOTQPQlnwrqjZRwDT3E6LZXEYn6cMv1MY6LZkWcF/zxrUnyasnsyMAgZ5d8AXS7bA==", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@emotion/is-prop-valid": "^1.1.3", + "@mui/types": "^7.1.5", + "@mui/utils": "^5.9.3", + "@popperjs/core": "^2.11.5", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/private-theming": { "version": "5.10.16", "license": "MIT", @@ -3825,8 +3893,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.2", - "license": "MIT", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz", + "integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==", "peerDependencies": { "@types/react": "*" }, @@ -3837,10 +3906,11 @@ } }, "node_modules/@mui/utils": { - "version": "5.10.16", - "license": "MIT", + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz", + "integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==", "dependencies": { - "@babel/runtime": "^7.20.1", + "@babel/runtime": "^7.21.0", "@types/prop-types": "^15.7.5", "@types/react-is": "^16.7.1 || ^17.0.0", "prop-types": "^15.8.1", @@ -3966,8 +4036,9 @@ } }, "node_modules/@popperjs/core": { - "version": "2.11.6", - "license": "MIT", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", + "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -14530,6 +14601,11 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sortablejs": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", + "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==" + }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", @@ -17440,7 +17516,9 @@ } }, "@babel/runtime": { - "version": "7.20.6", + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", + "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", "requires": { "regenerator-runtime": "^0.13.11" }, @@ -18408,13 +18486,15 @@ } }, "@mui/base": { - "version": "5.0.0-alpha.92", - "requires": { - "@babel/runtime": "^7.17.2", - "@emotion/is-prop-valid": "^1.1.3", - "@mui/types": "^7.1.5", - "@mui/utils": "^5.9.3", - "@popperjs/core": "^2.11.5", + "version": "5.0.0-alpha.128", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.128.tgz", + "integrity": "sha512-wub3wxNN+hUp8hzilMlXX3sZrPo75vsy1cXEQpqdTfIFlE9HprP1jlulFiPg5tfPst2OKmygXr2hhmgvAKRrzQ==", + "requires": { + "@babel/runtime": "^7.21.0", + "@emotion/is-prop-valid": "^1.2.0", + "@mui/types": "^7.2.4", + "@mui/utils": "^5.12.3", + "@popperjs/core": "^2.11.7", "clsx": "^1.2.1", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -18436,6 +18516,23 @@ "clsx": "^1.2.1", "prop-types": "^15.8.1", "react-is": "^18.2.0" + }, + "dependencies": { + "@mui/base": { + "version": "5.0.0-alpha.92", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.92.tgz", + "integrity": "sha512-ZgnSLrTXL4iUdLQhjp01dAOTQPQlnwrqjZRwDT3E6LZXEYn6cMv1MY6LZkWcF/zxrUnyasnsyMAgZ5d8AXS7bA==", + "requires": { + "@babel/runtime": "^7.17.2", + "@emotion/is-prop-valid": "^1.1.3", + "@mui/types": "^7.1.5", + "@mui/utils": "^5.9.3", + "@popperjs/core": "^2.11.5", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + } + } } }, "@mui/material": { @@ -18452,6 +18549,23 @@ "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" + }, + "dependencies": { + "@mui/base": { + "version": "5.0.0-alpha.92", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.92.tgz", + "integrity": "sha512-ZgnSLrTXL4iUdLQhjp01dAOTQPQlnwrqjZRwDT3E6LZXEYn6cMv1MY6LZkWcF/zxrUnyasnsyMAgZ5d8AXS7bA==", + "requires": { + "@babel/runtime": "^7.17.2", + "@emotion/is-prop-valid": "^1.1.3", + "@mui/types": "^7.1.5", + "@mui/utils": "^5.9.3", + "@popperjs/core": "^2.11.5", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + } + } } }, "@mui/private-theming": { @@ -18514,12 +18628,16 @@ } }, "@mui/types": { - "version": "7.2.2" + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz", + "integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==" }, "@mui/utils": { - "version": "5.10.16", + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz", + "integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==", "requires": { - "@babel/runtime": "^7.20.1", + "@babel/runtime": "^7.21.0", "@types/prop-types": "^15.7.5", "@types/react-is": "^16.7.1 || ^17.0.0", "prop-types": "^15.8.1", @@ -18579,7 +18697,9 @@ } }, "@popperjs/core": { - "version": "2.11.6" + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", + "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==" }, "@rehooks/local-storage": { "version": "2.4.4" @@ -25682,6 +25802,11 @@ "atomic-sleep": "^1.0.0" } }, + "sortablejs": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", + "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==" + }, "source-map": { "version": "0.5.7" }, diff --git a/packages/gui/package.json b/packages/gui/package.json index bf26fc3576..87fc4f4b11 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -153,6 +153,7 @@ "@lingui/core": "3.13.0", "@lingui/macro": "3.13.0", "@lingui/react": "3.13.0", + "@mui/base": "^5.0.0-alpha.128", "@mui/icons-material": "5.8.4", "@mui/lab": "5.0.0-alpha.94", "@mui/material": "5.10.0", @@ -198,6 +199,7 @@ "redux": "4.1.2", "regenerator-runtime": "0.13.9", "seedrandom": "3.0.5", + "sortablejs": "1.15.0", "stream-browserify": "3.0.0", "styled-components": "5.3.3", "unique-names-generator": "4.6.0", diff --git a/packages/gui/src/components/nfts/NFTBurnDialog.tsx b/packages/gui/src/components/nfts/NFTBurnDialog.tsx index 10ef594b39..71a8c662cc 100644 --- a/packages/gui/src/components/nfts/NFTBurnDialog.tsx +++ b/packages/gui/src/components/nfts/NFTBurnDialog.tsx @@ -1,5 +1,5 @@ import { type NFTInfo } from '@chia-network/api'; -import { useTransferNFTMutation, useLocalStorage } from '@chia-network/api-react'; +import { useTransferNFTMutation } from '@chia-network/api-react'; import { Button, ButtonLoading, @@ -18,6 +18,7 @@ import React, { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import useBurnAddress from '../../hooks/useBurnAddress'; +import useNFTFilter from '../../hooks/useNFTFilter'; import NFTSummary from './NFTSummary'; import NFTTransferConfirmationDialog from './NFTTransferConfirmationDialog'; @@ -38,7 +39,7 @@ export default function NFTBurnDialog(props: NFTPreviewDialogProps) { const openDialog = useOpenDialog(); const showError = useShowError(); const [transferNFT] = useTransferNFTMutation(); - const [, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const filter = useNFTFilter(); const methods = useForm({ defaultValues: { @@ -104,7 +105,7 @@ export default function NFTBurnDialog(props: NFTPreviewDialogProps) { fee: feeInMojos, }).unwrap(); - setSelectedNFTIds([]); + filter.setSelectedNFTIds([]); onClose(); } catch (error) { diff --git a/packages/gui/src/components/nfts/NFTCard.tsx b/packages/gui/src/components/nfts/NFTCard.tsx index f58f532eb6..de1164a700 100644 --- a/packages/gui/src/components/nfts/NFTCard.tsx +++ b/packages/gui/src/components/nfts/NFTCard.tsx @@ -1,11 +1,13 @@ import { IconButton, Flex } from '@chia-network/core'; +import Portal from '@mui/base/Portal'; import { MoreVert } from '@mui/icons-material'; -import { Card, CardActionArea, CardContent, Checkbox, Typography } from '@mui/material'; +import { Card, CardActionArea, CardContent, Checkbox, Typography, Box } from '@mui/material'; import React, { useMemo, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import useHiddenNFTs from '../../hooks/useHiddenNFTs'; import useNFT from '../../hooks/useNFT'; +import useNFTFilter from '../../hooks/useNFTFilter'; import getNFTId from '../../util/getNFTId'; import NFTContextualActions, { NFTContextualActionTypes } from './NFTContextualActions'; import NFTPreview from './NFTPreview'; @@ -19,6 +21,7 @@ export type NFTCardProps = { onSelect?: (nftId: string) => Promise; search?: string; selected?: boolean; + userFolder?: string | null; ratio?: number; }; @@ -31,18 +34,26 @@ function NFTCard(props: NFTCardProps) { onSelect, search, selected = false, + userFolder, ratio = 4 / 3, } = props; const nftId = useMemo(() => getNFTId(id), [id]); - const [isNFTHidden] = useHiddenNFTs(); const navigate = useNavigate(); + const filter = useNFTFilter(); + + const [, setMouseDownState] = React.useState(false); + const [dragState, setDragState] = React.useState(false); + + const draggableContainerRef = React.useRef(null); + const { nft, isLoading } = useNFT(nftId); const isHidden = useMemo(() => isNFTHidden(nftId), [nftId, isNFTHidden]); async function handleClick() { + if (dragState) return; if (onSelect) { const canContinue = await onSelect(nftId); if (!canContinue) { @@ -55,8 +66,8 @@ function NFTCard(props: NFTCardProps) { } } - return ( - + function renderCard() { + return ( {onSelect && ( @@ -92,6 +103,120 @@ function NFTCard(props: NFTCardProps) { + ); + } + + const widthBeforeDrag = React.useRef(null); + const heightBeforeDrag = React.useRef(null); + + const draggableRef = React.useRef(null); + + function renderPortal() { + return ( + + + {renderCard()} + + + ); + } + + const startPositionLeft = React.useRef(); + const startPositionTop = React.useRef(); + const draggingInterval = React.useRef(); + const countDownSpeed = React.useRef(30); /* less is faster */ + const countDown = React.useRef(countDownSpeed.current); + + const mouseMoveEvent = React.useCallback( + (e: any) => { + if (!dragState) { + setDragState(true); + if (!draggingInterval.current) { + draggingInterval.current = setInterval(() => { + countDown.current--; + if (draggableRef.current) { + adjustPositionBeforeAdjusted(e); + } + if (countDown.current === 1) { + clearInterval(draggingInterval.current); + } + }, 1); + } + } + if (draggableRef.current) { + if (countDown.current === 1) { + (draggableRef.current as HTMLElement).style.left = `${e.pageX - (widthBeforeDrag.current || 0) / 4 + 10}px`; + (draggableRef.current as HTMLElement).style.top = `${e.pageY - 81}px`; + } else { + adjustPositionBeforeAdjusted(e); + } + } + }, + [dragState] + ); + + const mouseUpEvent = React.useCallback(() => { + setTimeout(() => { + document.body.removeEventListener('mouseup', mouseUpEvent); + }, 0); + document.body.removeEventListener('mousemove', mouseMoveEvent); + setMouseDownState(false); + setDragState(false); + filter.setDraggedNFT(null); + countDown.current = countDownSpeed.current; + if (draggingInterval.current) { + clearInterval(draggingInterval.current); + draggingInterval.current = null; + } + }, [filter, mouseMoveEvent]); + + function adjustPositionBeforeAdjusted(e: any) { + (draggableRef.current as HTMLElement).style.left = `${ + e.pageX + + ((startPositionLeft.current - e.pageX) * countDown.current) / countDownSpeed.current - + (widthBeforeDrag.current || 0) / 4 + + 10 + }px`; + (draggableRef.current as HTMLElement).style.top = `${ + e.pageY + ((startPositionTop.current - e.pageY) * countDown.current) / countDownSpeed.current - 81 + }px`; + } + + const mouseDownEvent = React.useCallback( + (e: MouseEvent) => { + /* drag and drop to folders */ + if (userFolder) return; + const rect = (draggableContainerRef.current as HTMLElement).getBoundingClientRect(); + if (!startPositionLeft.current) startPositionLeft.current = rect.left; + if (!startPositionTop.current) startPositionTop.current = rect.top; + heightBeforeDrag.current = rect.height; + widthBeforeDrag.current = rect.width; + e.preventDefault(); + setMouseDownState(true); + document.body.addEventListener('mouseup', mouseUpEvent); + document.body.addEventListener('mousemove', mouseMoveEvent); + filter.setDraggedNFT(nftId); + countDown.current = countDownSpeed.current; + }, + [userFolder, nftId, mouseUpEvent, mouseMoveEvent, filter] + ); + + return ( + +
+ {dragState && renderPortal()} + {renderCard()} +
); } diff --git a/packages/gui/src/components/nfts/NFTContextualActions.tsx b/packages/gui/src/components/nfts/NFTContextualActions.tsx index b551663f77..43d534c9d8 100644 --- a/packages/gui/src/components/nfts/NFTContextualActions.tsx +++ b/packages/gui/src/components/nfts/NFTContextualActions.tsx @@ -1,7 +1,6 @@ /* eslint-disable no-bitwise -- enable bitwise operators for this file */ - import type { NFTInfo } from '@chia-network/api'; -import { useSetNFTStatusMutation, useLocalStorage } from '@chia-network/api-react'; +import { usePrefs, useGetLoggedInFingerprintQuery, useSetNFTStatusMutation } from '@chia-network/api-react'; import { AlertDialog, DropdownActions, MenuItem, useOpenDialog, isValidURL } from '@chia-network/core'; import { Burn as BurnIcon, @@ -19,6 +18,7 @@ import { Visibility as VisibilityIcon, VisibilityOff as VisibilityOffIcon, Refresh as RefreshIcon, + FolderOpen as FolderIcon, } from '@mui/icons-material'; import { ListItemIcon, Typography } from '@mui/material'; import React, { useMemo, ReactNode } from 'react'; @@ -27,6 +27,7 @@ import { useCopyToClipboard } from 'react-use'; import useBurnAddress from '../../hooks/useBurnAddress'; import useHiddenNFTs from '../../hooks/useHiddenNFTs'; +import useNFTFilter from '../../hooks/useNFTFilter'; import useNFTs from '../../hooks/useNFTs'; import useOpenUnsafeLink from '../../hooks/useOpenUnsafeLink'; import useViewNFTOnExplorer, { NFTExplorer } from '../../hooks/useViewNFTOnExplorer'; @@ -36,6 +37,7 @@ import removeHexPrefix from '../../util/removeHexPrefix'; import MultipleDownloadDialog from './MultipleDownloadDialog'; import NFTBurnDialog from './NFTBurnDialog'; import NFTMoveToProfileDialog from './NFTMoveToProfileDialog'; +import NFTSaveToFilterDialog from './NFTSaveToFilterDialog'; import { NFTTransferDialog, NFTTransferResult } from './NFTTransferAction'; /* ========================================================================== */ @@ -53,9 +55,10 @@ export enum NFTContextualActionTypes { Burn = 64, CopyNFTId = 128, CopyURL = 256, - ViewOnExplorer = 512, - OpenInBrowser = 1024, - Download = 2048, + AddToGallery = 512, + ViewOnExplorer = 1024, + OpenInBrowser = 2048, + Download = 4096, All = CreateOffer | Transfer | @@ -68,7 +71,8 @@ export enum NFTContextualActionTypes { Download | Hide | Burn | - Invalidate, + Invalidate | + AddToGallery, } type NFTContextualActionProps = { @@ -116,7 +120,7 @@ type NFTCreateOfferContextualActionProps = NFTContextualActionProps; function NFTCreateOfferContextualAction(props: NFTCreateOfferContextualActionProps) { const { selection } = props; const navigate = useNavigate(); - const [, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const filter = useNFTFilter(); const selectedNft: NFTInfo | undefined = selection?.items[0]; const disabled = !selection?.items?.length || selectedNft?.pendingTransaction || selection?.items?.length > 10; @@ -127,7 +131,7 @@ function NFTCreateOfferContextualAction(props: NFTCreateOfferContextualActionPro throw new Error('No NFT selected'); } - setSelectedNFTIds([]); + filter.setSelectedNFTIds([]); navigate('/dashboard/offers/builder', { state: { @@ -160,14 +164,14 @@ type NFTTransferContextualActionProps = NFTContextualActionProps; function NFTTransferContextualAction(props: NFTTransferContextualActionProps) { const { selection } = props; const openDialog = useOpenDialog(); - const [, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const filter = useNFTFilter(); const disabled = selection?.items.reduce((p, c) => p || c?.pendingTransaction, false); function handleComplete(result?: NFTTransferResult) { if (result) { if (!result.error) { - setSelectedNFTIds([]); + filter.setSelectedNFTIds([]); openDialog( NFT Transfer Pending}> The NFT transfer transaction has been successfully submitted to the blockchain. @@ -339,7 +343,7 @@ function NFTCopyURLContextualAction(props: NFTCopyURLContextualActionProps) { } return ( - + @@ -396,7 +400,7 @@ function NFTDownloadContextualAction(props: NFTDownloadContextualActionProps) { const disabled = !selectedNft; const dataUrl = selectedNft?.dataUris?.[0]; const openDialog = useOpenDialog(); - const [, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const filter = useNFTFilter(); async function handleDownload() { const { ipcRenderer } = window as any; @@ -420,7 +424,7 @@ function NFTDownloadContextualAction(props: NFTDownloadContextualActionProps) { } return { ...nft, hash }; }); - setSelectedNFTIds([]); + filter.setSelectedNFTIds([]); ipcRenderer.invoke('startMultipleDownload', { folder: folder.filePaths[0], nfts }); await openDialog(); } @@ -464,7 +468,7 @@ function NFTHideContextualAction(props: NFTHideContextualActionProps) { const disabled = !selectedNft; const dataUrl = selectedNft?.dataUris?.[0]; const [isNFTHidden, setIsNFTHidden, , setHiddenMultiple] = useHiddenNFTs(); - const [, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const filter = useNFTFilter(); const isHidden = isMultiSelect && showOrHide === 1 ? true : isNFTHidden(selectedNft?.$nftId); @@ -478,7 +482,7 @@ function NFTHideContextualAction(props: NFTHideContextualActionProps) { selection?.items.map((nft: NFTInfo) => nft.$nftId), !isHidden ); - setSelectedNFTIds([]); + filter.setSelectedNFTIds([]); } else { setIsNFTHidden(selectedNft.$nftId, !isHidden); } @@ -519,7 +523,7 @@ function NFTBurnContextualAction(props: NFTBurnContextualActionProps) { } return ( - + @@ -571,6 +575,54 @@ function NFTInvalidateContextualAction(props: NFTInvalidateContextualActionProps ); } +/* ========================================================================== */ +/* "Add To"/ "Remove from" Filter NFT */ +/* ========================================================================== */ + +type NFTAddToGalleryContextualActionProps = NFTContextualActionProps; + +function NFTAddToGalleryContextualAction(props: NFTAddToGalleryContextualActionProps) { + const { selection } = props; + + const openDialog = useOpenDialog(); + const disabled = selection?.items.length === 0; + const [userFoldersNFTs, setUserFoldersNFTs] = usePrefs('user-folders-nfts', {}); + const { data: fingerprint } = useGetLoggedInFingerprintQuery(); + const filter = useNFTFilter(); + + async function handleAddToUserFolder() { + if (!selection?.items) { + return; + } + if (!filter.userFolder) { + await openDialog( nft.$nftId) || []} />); + } else { + const copyUserFoldersNFTs = { ...userFoldersNFTs }; + if (userFoldersNFTs[fingerprint] && userFoldersNFTs[fingerprint][filter.userFolder]) { + copyUserFoldersNFTs[fingerprint] = { + ...copyUserFoldersNFTs[fingerprint], + [filter.userFolder as string]: userFoldersNFTs[fingerprint][filter.userFolder].filter( + (nftId: string) => selection.items.map((nft: any) => nft.$nftId).indexOf(nftId) === -1 + ), + }; + setUserFoldersNFTs(copyUserFoldersNFTs); + filter.setSelectedNFTIds([]); + } + } + } + + return ( + + + + + + {filter.userFolder === '' ? Add To Folder : Remove From Folder} + + + ); +} + /* ========================================================================== */ /* Contextual Actions */ /* ========================================================================== */ @@ -666,6 +718,10 @@ export default function NFTContextualActions(props: NFTContextualActionsProps) { props: {}, key: NFTContextualActionTypes.CopyURL, }, + [`${NFTContextualActionTypes.AddToGallery}`]: { + action: NFTAddToGalleryContextualAction, + props: {}, + }, [`${NFTContextualActionTypes.Download}`]: { action: NFTDownloadContextualAction, props: {}, diff --git a/packages/gui/src/components/nfts/NFTFilterProvider.tsx b/packages/gui/src/components/nfts/NFTFilterProvider.tsx index 74bdbb3860..3114d94b3d 100644 --- a/packages/gui/src/components/nfts/NFTFilterProvider.tsx +++ b/packages/gui/src/components/nfts/NFTFilterProvider.tsx @@ -8,11 +8,17 @@ export interface NFTFilterContextData { types: FileType[]; visibility: NFTVisibility; search: string | undefined; + userFolder: string | null; + selectedNFTIds: string[]; + draggedNFT: string | null; setWalletIds: (value: number[]) => void; setTypes: (value: FileType[]) => void; setVisibility: (value: NFTVisibility) => void; setSearch: (value: string | undefined) => void; + setUserFolder: (value: string | null) => void; + setSelectedNFTIds: (value: string[]) => void; + setDraggedNFT: (value: string | null) => void; } export const NFTFilterContext = createContext(undefined); @@ -35,6 +41,9 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) { ]); const [visibility, setVisibility] = useState(NFTVisibility.ALL); const [search, setSearch] = useState(''); + const [userFolder, setUserFolder] = useState(null); + const [selectedNFTIds, setSelectedNFTIds] = useState([]); + const [draggedNFT, setDraggedNFT] = useState(null); const value = useMemo( () => ({ @@ -42,13 +51,32 @@ export default function NFTFilterProvider(props: NFTFilterProviderProps) { types, visibility, search, + userFolder, + selectedNFTIds, + draggedNFT, setWalletIds, setTypes, setVisibility, setSearch, + setUserFolder, + setSelectedNFTIds, + setDraggedNFT, }), - [walletIds, types, visibility, search, setWalletIds, setTypes, setVisibility, setSearch] + [ + walletIds, + types, + visibility, + search, + userFolder, + setWalletIds, + setTypes, + setVisibility, + setSearch, + setUserFolder, + selectedNFTIds, + draggedNFT, + ] ); return {children}; diff --git a/packages/gui/src/components/nfts/NFTMoveToProfileDialog.tsx b/packages/gui/src/components/nfts/NFTMoveToProfileDialog.tsx index 115730b786..52c6be60e5 100644 --- a/packages/gui/src/components/nfts/NFTMoveToProfileDialog.tsx +++ b/packages/gui/src/components/nfts/NFTMoveToProfileDialog.tsx @@ -1,6 +1,6 @@ import { NFTInfo } from '@chia-network/api'; import type { Wallet } from '@chia-network/api'; -import { useGetDIDsQuery, useGetNFTWallets, useSetNFTDIDMutation, useLocalStorage } from '@chia-network/api-react'; +import { useGetDIDsQuery, useGetNFTWallets, useSetNFTDIDMutation } from '@chia-network/api-react'; import { AlertDialog, Button, @@ -23,6 +23,7 @@ import React, { useMemo } from 'react'; import { useForm } from 'react-hook-form'; import styled from 'styled-components'; +import useNFTFilter from '../../hooks/useNFTFilter'; import { didToDIDId } from '../../util/dids'; import removeHexPrefix from '../../util/removeHexPrefix'; import DIDProfileDropdown from '../did/DIDProfileDropdown'; @@ -99,7 +100,7 @@ export function NFTMoveToProfileAction(props: NFTMoveToProfileActionProps) { const { data: didWallets, isLoading: isLoadingDIDs } = useGetDIDsQuery(); const { wallets: nftWallets, isLoading: isLoadingNFTWallets } = useGetNFTWallets(); const currentDIDId = nfts[0].ownerDid ? didToDIDId(removeHexPrefix(nfts[0].ownerDid)) : undefined; - const [, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const filter = useNFTFilter(); const inbox: Wallet | undefined = useMemo(() => { if (isLoadingNFTWallets) { @@ -176,7 +177,7 @@ export function NFTMoveToProfileAction(props: NFTMoveToProfileActionProps) { if (Array.isArray(response)) { const successTransfers = response.filter((r: any) => r?.success === true); const failedTransfers = response.filter((r: any) => r?.success !== true); - setSelectedNFTIds([]); + filter.setSelectedNFTIds([]); openDialog( NFT Move Pending}> diff --git a/packages/gui/src/components/nfts/NFTProfileDropdown.tsx b/packages/gui/src/components/nfts/NFTProfileDropdown.tsx index b70ff4caa2..bac5b0a661 100644 --- a/packages/gui/src/components/nfts/NFTProfileDropdown.tsx +++ b/packages/gui/src/components/nfts/NFTProfileDropdown.tsx @@ -1,13 +1,30 @@ import type { Wallet } from '@chia-network/api'; -import { useGetDIDsQuery, useGetNFTWallets, useGetNFTWalletsWithDIDsQuery } from '@chia-network/api-react'; -import { DropdownActions, MenuItem } from '@chia-network/core'; -import { NFTsSmall as NFTsSmallIcon } from '@chia-network/icons'; +import { + usePrefs, + useGetLoggedInFingerprintQuery, + useGetDIDsQuery, + useGetNFTWallets, + useGetNFTWalletsWithDIDsQuery, + useLazyGetNFTsCountQuery, +} from '@chia-network/api-react'; +import { useOpenDialog, AddUserFolderDialog, Flex, ConfirmDialog } from '@chia-network/core'; +import { + Profile as ProfileIcon, + Folder as FolderIcon, + Plus as PlusIcon, + ShowHide as ShowHideIcon, + Inbox as InboxIcon, + Unassigned as UnassignedIcon, + Trash as TrashIcon, +} from '@chia-network/icons'; import { Trans } from '@lingui/macro'; -import { AutoAwesome as AutoAwesomeIcon, PermIdentity as PermIdentityIcon } from '@mui/icons-material'; -import { ListItemIcon } from '@mui/material'; +import { Divider, MenuItem, Collapse, Box } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; import { orderBy } from 'lodash'; import React, { useMemo } from 'react'; +import useFilteredNFTs from '../../hooks/useFilteredNFTs'; +import useNFTFilter from '../../hooks/useNFTFilter'; import useNachoNFTs from '../../hooks/useNachoNFTs'; import { getNFTInbox } from './utils'; @@ -41,24 +58,54 @@ function useProfiles() { export type NFTGallerySidebarProps = { walletId?: number; - onChange: (walletId?: number) => void; + onChange: (walletId: number | undefined) => void; + onSelectUserFolder: (folderName: string) => void; + setUserFolder: (folderName: string) => void; }; export default function NFTProfileDropdown(props: NFTGallerySidebarProps) { - const { onChange, walletId } = props; + const { onChange, walletId, onSelectUserFolder, setUserFolder } = props; const { isLoading: isLoadingProfiles, data: profiles } = useProfiles(); const { wallets: nftWallets, isLoading: isLoadingNFTWallets } = useGetNFTWallets(); const { data: nachoNFTs, isLoading: isLoadingNachoNFTs } = useNachoNFTs(); const haveNachoNFTs = !isLoadingNachoNFTs && nachoNFTs?.length > 0; + const openDialog = useOpenDialog(); + const [userFolders, setUserFolders] = usePrefs('user-folders', {}); + const [userFoldersNFTs, setUserFoldersNFTs] = usePrefs('user-folders-nfts', {}); + const { data: fingerprint } = useGetLoggedInFingerprintQuery(); + const theme: any = useTheme(); + const [nftsCounts, setNftsCounts] = React.useState({}); + const [showMenuItems, toggleShowMenuItems] = React.useState(true); + const [editFolderName, setEditFolderName] = React.useState(null); + const [editingFolder, setEditingFolder] = React.useState(null); + const filter = useNFTFilter(); + const { statistics } = useFilteredNFTs(); + const [getNFTsCount /* immutable */] = useLazyGetNFTsCountQuery(); const inbox: Wallet | undefined = useMemo(() => { if (isLoadingNFTWallets) { return undefined; } - return getNFTInbox(nftWallets); }, [nftWallets, isLoadingNFTWallets]); + React.useEffect(() => { + function fetchWalletCounts() { + const nftWalletIds = profiles.map((profile: Profile) => profile.nftWalletId); + const fetchCountPromises = nftWalletIds.map((id) => getNFTsCount({ walletIds: [id] }).unwrap()); + if (inbox) { + fetchCountPromises.push(getNFTsCount({ walletIds: [inbox.id] }).unwrap()); + } + Promise.all(fetchCountPromises).then((counts) => { + const countsObject = counts.reduce((p: any, c: any) => ({ ...p, [Object.keys(c)[0]]: c.total }), {}); + setNftsCounts(countsObject); + }); + } + if (profiles.length > 0) { + fetchWalletCounts(); + } + }, [profiles, getNFTsCount, inbox]); + const remainingNFTWallets = useMemo(() => { if (isLoadingProfiles || isLoadingNFTWallets || !inbox) { return undefined; @@ -73,83 +120,340 @@ export default function NFTProfileDropdown(props: NFTGallerySidebarProps) { return nftWalletsWithoutDIDs; }, [profiles, nftWallets, inbox, isLoadingProfiles, isLoadingNFTWallets]); - const label = useMemo(() => { - if (isLoadingProfiles || isLoadingNFTWallets) { - return 'Loading...'; - } - - if (walletId === -1) { - return 'Nacho NFTs'; - } + function handleWalletChange(newWalletId?: number) { + setUserFolder(null); + onChange?.(newWalletId); + } - if (inbox && inbox.id === walletId) { - return Unassigned NFTs; - } + async function addUserFolder() { + await openDialog( + { + if (!userFolders[fingerprint] || userFolders[fingerprint].indexOf(newFolderName) === -1) { + setUserFolders({ + [fingerprint]: [...(userFolders[fingerprint] || []), newFolderName], + }); + } + }} + title={Add New Folder} + /> + ); + } - const profile = profiles?.find((item: Profile) => item.nftWalletId === walletId); + const MenuItemChildren = React.useCallback( + (props2: any) => ( + { + if ( + filter.draggedNFT && + filter.draggedNFT.length === 62 && + filter.draggedNFT.substring(0, 3) === 'nft' && + props2.folderName + ) { + const copyUserFoldersNFTs = { ...userFoldersNFTs }; + if (!copyUserFoldersNFTs[fingerprint]) { + copyUserFoldersNFTs[fingerprint] = { [props2.folderName]: [] }; + } + if (!copyUserFoldersNFTs[fingerprint][props2.folderName]) { + copyUserFoldersNFTs[fingerprint][props2.folderName] = []; + } + if (copyUserFoldersNFTs[fingerprint][props2.folderName].indexOf(filter.draggedNFT) === -1) { + copyUserFoldersNFTs[fingerprint] = { + ...copyUserFoldersNFTs[fingerprint], + [props2.folderName]: copyUserFoldersNFTs[fingerprint][props2.folderName].concat(filter.draggedNFT), + }; + } + setUserFoldersNFTs(copyUserFoldersNFTs); + filter.setDraggedNFT(null); + } + }} + > + {props2.children} + + ), + [userFoldersNFTs, setUserFoldersNFTs, fingerprint, filter] + ); - if (profile) { - return profile.name; - } + async function deleteUserFolder(folder) { + await openDialog( + Delete Folder Confirmation} + confirmTitle={Delete} + confirmColor="danger" + onConfirm={() => { + const userFoldersCopy = { ...userFolders }; + userFoldersCopy[fingerprint] = (userFolders[fingerprint] || []).filter((f: string) => f !== folder); + setUserFolders(userFoldersCopy); + // setSelectedUserFolder(undefined); + // setWalletId(undefined); + }} + > + Are you sure you want to remove this gallery? This action cannot be undone. + + ); + } - const nftWallet = remainingNFTWallets?.find((wallet: Wallet) => wallet.id === walletId); + function saveRenamedUserFolder(folderName: string) { + setEditFolderName(null); + if (editFolderName === '') return; + setUserFolders({ + ...userFolders, + [fingerprint]: (userFolders[fingerprint] || []).map((f: string) => (f === folderName ? editFolderName : f)), + }); + const fingerprintFolder = userFoldersNFTs[fingerprint]; + const nftIdsArray = fingerprintFolder[folderName] ? [...fingerprintFolder[folderName]] : []; + delete fingerprintFolder[folderName]; + setUserFoldersNFTs({ + ...userFoldersNFTs, + [fingerprint]: { + ...fingerprintFolder, + [editFolderName as any]: nftIdsArray, + }, + }); + setUserFolder(editFolderName as string); + } - if (nftWallet) { - return `${nftWallet.name} ${nftWallet.id}`; + function renderFolderName(folderName: string) { + if (editingFolder === folderName) { + return ( + setEditFolderName(e.target.value)} + onBlur={() => { + setEditFolderName(null); + setEditingFolder(null); + }} + onKeyDown={(e: any) => { + if (e.key === 'Enter') { + saveRenamedUserFolder(folderName); + setEditingFolder(null); + } + if (e.key === 'Escape') { + setEditFolderName(null); + setEditingFolder(null); + } + }} + autoFocus + /> + ); } + return ( +
{ + if (filter.userFolder === folderName) { + setEditFolderName(folderName); + setEditingFolder(folderName); + } + }} + > + {folderName} +
+ ); + } - return All NFTs; - }, [profiles, remainingNFTWallets, isLoadingProfiles, isLoadingNFTWallets, walletId, inbox]); + function renderMenuItems() { + return ( + + + handleWalletChange()} + selected={walletId === undefined && !filter.userFolder} + > + + + + All NFTs + + {statistics.total} + + + {inbox && ( + handleWalletChange(inbox.id)} + selected={walletId === inbox.id && !filter.userFolder} + > + + + + Unassigned NFTs + + {nftsCounts[inbox.id]} + + + )} + {(remainingNFTWallets ?? []).map((wallet: Wallet) => ( + handleWalletChange(wallet.id)} + selected={walletId === wallet.id && !filter.userFolder} + > + + {wallet.name} {wallet.id} + + + ))} + {(profiles ?? []).map((profile: Profile) => ( + handleWalletChange(profile.nftWalletId)} + selected={profile.nftWalletId === walletId && !filter.userFolder} + > + + + + {profile.name} + + {nftsCounts[profile.nftWalletId] >= 0 ? nftsCounts[profile.nftWalletId] : ''} + + + ))} + {haveNachoNFTs && ( + handleWalletChange(-1)} selected={walletId === -1}> + + + + Nacho NFTs + + {nachoNFTs?.length} + + + )} + + + Folders {userFolders[fingerprint] ? `(${userFolders[fingerprint].length})` : '(0)'} +
+ addUserFolder()} /> +
+
+ {Array.isArray(userFolders[fingerprint]) && userFolders[fingerprint].length > 0 + ? userFolders[fingerprint].map((folderName: string) => ( + { + onSelectUserFolder(folderName); + setUserFolder(folderName); + }} + selected={folderName === filter.userFolder} + sx={{ + '.folder-trash': { + display: 'none', + height: '20px', + width: '18px', + paddingTop: '1px', + marginRight: '10px', + position: 'absolute', + right: '25px', + }, + ':hover': { + '.folder-trash': { + display: editingFolder ? 'none' : 'block', + }, + }, + }} + > + + + + {renderFolderName(folderName)} + + +
{ + deleteUserFolder(folderName); + e.stopPropagation(); + }} + > + +
+ {userFoldersNFTs[fingerprint] && userFoldersNFTs[fingerprint][folderName] + ? userFoldersNFTs[fingerprint][folderName].length + : 0} +
+
+
+ )) + : null} + {userFolders[fingerprint] && userFolders[fingerprint].length > 0 ? : null} +
+
+ ); + } - function handleWalletChange(newWalletId?: number) { - onChange?.(newWalletId); + function renderShowHide() { + return ( + { + toggleShowMenuItems(!showMenuItems); + }} + > + + {showMenuItems ? Hide : Show} + + ); } return ( - - handleWalletChange()} selected={walletId === undefined} close> - - - - All NFTs - - {inbox && ( - handleWalletChange(inbox.id)} selected={walletId === inbox.id} close> - - - - Unassigned NFTs - - )} - {(remainingNFTWallets ?? []).map((wallet: Wallet) => ( - handleWalletChange(wallet.id)} selected={walletId === wallet.id} close> - - - - {wallet.name} {wallet.id} - - ))} - {(profiles ?? []).map((profile: Profile) => ( - handleWalletChange(profile.nftWalletId)} - selected={profile.nftWalletId === walletId} - close - > - - - - {profile.name} - - ))} - {haveNachoNFTs && ( - handleWalletChange(-1)} selected={walletId === -1} close> - - - - Nacho NFTs - - )} - + + {renderShowHide()} + {renderMenuItems()} + ); } diff --git a/packages/gui/src/components/nfts/NFTSaveToFilterDialog.tsx b/packages/gui/src/components/nfts/NFTSaveToFilterDialog.tsx new file mode 100644 index 0000000000..c62f8893f9 --- /dev/null +++ b/packages/gui/src/components/nfts/NFTSaveToFilterDialog.tsx @@ -0,0 +1,73 @@ +import { useGetLoggedInFingerprintQuery, usePrefs } from '@chia-network/api-react'; +import { DropdownActions, MenuItem } from '@chia-network/core'; +import { Trans } from '@lingui/macro'; +import { FolderOpen as FolderIcon } from '@mui/icons-material'; +import { Dialog, DialogTitle, DialogContent, DialogActions, ListItemIcon, Button } from '@mui/material'; +import React from 'react'; + +import useNFTFilter from '../../hooks/useNFTFilter'; + +interface NFTSaveToFilterDialogProps { + nftIds: string[]; +} + +export default function AppVersionWarning(props: NFTSaveToFilterDialogProps) { + const [open, setOpen] = React.useState(true); + const { nftIds } = props; + const [userFolders] = usePrefs('user-folders', {}); + const [userFoldersNFTs, setUserFoldersNFTs] = usePrefs('user-folders-nfts', {}); + const { data: fingerprint } = useGetLoggedInFingerprintQuery(); + const filter = useNFTFilter(); + + function addSelectedNFTsToGallery(folderName: string) { + const fingerprintNFTs = { ...userFoldersNFTs[fingerprint] }; + const tempNFTs: string[] = userFoldersNFTs[fingerprint][folderName] + ? [...userFoldersNFTs[fingerprint][folderName]] + : []; + nftIds.forEach((nftId: string) => { + if (tempNFTs.indexOf(nftId) === -1) { + tempNFTs.push(nftId); + } + }); + fingerprintNFTs[folderName] = tempNFTs; + setUserFoldersNFTs({ + [fingerprint]: fingerprintNFTs, + }); + filter.setSelectedNFTIds([]); + setOpen(false); + } + + return ( + + + Save NFTs to filter + + + Choose filter}> + {Array.isArray(userFolders[fingerprint]) && + userFolders[fingerprint].map((folderName: string) => ( + addSelectedNFTsToGallery(folderName)}> + + + + {folderName} + + ))} + + + +
+ +
+
+
+ ); +} diff --git a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx index 4f22442f99..539d6627a3 100644 --- a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx +++ b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx @@ -1,6 +1,6 @@ // eslint-ignore-file - in progress import type { NFTInfo } from '@chia-network/api'; -import { useLocalStorage } from '@chia-network/api-react'; +import { usePrefs, useGetLoggedInFingerprintQuery } from '@chia-network/api-react'; import { Button, FormatLargeNumber, @@ -27,6 +27,7 @@ import { styled } from '@mui/styles'; import { xor, intersection /* , sortBy */ } from 'lodash'; import React, { useMemo, useCallback, useRef, useEffect } from 'react'; import { VirtuosoGrid } from 'react-virtuoso'; +import Sortable from 'sortablejs'; // eslint-ignore-file - in progress import NFTVisibility from '../../../@types/NFTVisibility'; import FileType from '../../../constants/FileType'; @@ -100,7 +101,13 @@ export default function NFTGallery() { visibility, setVisibility, + userFolder, + setUserFolder, + statistics, + + selectedNFTIds, + setSelectedNFTIds, } = useFilteredNFTs(); const [scrollPosition, setScrollPosition] = useNFTGalleryScrollPosition(); @@ -142,7 +149,7 @@ export default function NFTGallery() { setWalletIds(walletId ? [walletId] : []); } - const [selectedNFTIds, setSelectedNFTIds] = useLocalStorage('gallery-selected-nfts', []); + const [foldersSortedNFTs, setFoldersSortedNFTs] = usePrefs('user-folders-nfts', {}); const selectedVisibleNFTs = useMemo( () => nfts.filter((nft: NFTInfo) => selectedNFTIds.includes(nft.$nftId)), @@ -151,6 +158,48 @@ export default function NFTGallery() { const selectedAll = useMemo(() => selectedVisibleNFTs.length === nfts.length, [nfts, selectedVisibleNFTs]); + const nftsSorted = useRef(null); + + const lastSelectedUserFolder = useRef(); + + const isSortableActive = useRef(false); + + const { data: fingerprint } = useGetLoggedInFingerprintQuery(); + + const VirtuosoParentRef = useRef(null); + + React.useEffect(() => { + if ( + lastSelectedUserFolder.current && + userFolder !== lastSelectedUserFolder.current && + nftsSorted.current?.destroy && + isSortableActive.current + ) { + isSortableActive.current = false; + nftsSorted.current?.destroy(); + } + lastSelectedUserFolder.current = userFolder; + if (VirtuosoParentRef.current) { + const sortableParent = VirtuosoParentRef.current.querySelector('[data-test-id=virtuoso-item-list]'); + if (userFolder && sortableParent && isSortableActive.current === false) { + isSortableActive.current = true; + nftsSorted.current = new Sortable(sortableParent, { + onEnd: () => { + const newArray = Array.from(sortableParent.children).map( + (node: any) => node.querySelector('[data-testid]').attributes['data-testid'].value + ); + const copySorted = { ...foldersSortedNFTs }; + if (!copySorted[fingerprint]) { + copySorted[fingerprint] = {}; + } + copySorted[fingerprint][userFolder as string] = newArray; + setFoldersSortedNFTs(copySorted); + }, + }); + } + } + }, [fingerprint, userFolder, foldersSortedNFTs, setFoldersSortedNFTs]); + const handleSelectNFT = useCallback( async (nftId: string) => { setSelectedNFTIds((prevSelectedNFTIds) => xor(prevSelectedNFTIds, [nftId])); @@ -232,6 +281,31 @@ export default function NFTGallery() { } } + function onSelectUserFolder() { + handleSetWalletId(undefined); + } + + const sortedNFTs = React.useMemo(() => { + if (userFolder) { + if ( + nfts && + foldersSortedNFTs[fingerprint] && + Array.isArray(foldersSortedNFTs[fingerprint][userFolder]) && + foldersSortedNFTs[fingerprint][userFolder].length > 0 + ) { + const sorted = foldersSortedNFTs[fingerprint][userFolder] + .map((nftId: string) => { + const found = nfts.find((nft: any) => nft.$nftId === nftId); + return found || null; + }) + .filter((nft: any) => nft && !!nft.$nftId); + return sorted; + } + return []; + } + return nfts; + }, [fingerprint, foldersSortedNFTs, nfts, userFolder]); + function renderNFTCard(index: number, nft: NFTInfo) { return ( ); } @@ -254,9 +329,8 @@ export default function NFTGallery() { // onScroll={handleOnScroll} header={ - - - + + {statistics.total > 0 && ( - Showing {nfts.length}  + Showing {sortedNFTs.length}  of {statistics.total}  items )} - {progress < 100 && ( <> {statistics.total > 0 && <>, } @@ -435,6 +508,14 @@ export default function NFTGallery() { } + sidebar={ + + } > @@ -445,10 +526,10 @@ export default function NFTGallery() { {!nfts?.length && !isLoading ? ( ) : ( - + nft.launcherId} components={COMPONENTS} diff --git a/packages/gui/src/components/nfts/gallery/SelectedActionsDialog.tsx b/packages/gui/src/components/nfts/gallery/SelectedActionsDialog.tsx index 2f26ba6f71..8e29de8976 100644 --- a/packages/gui/src/components/nfts/gallery/SelectedActionsDialog.tsx +++ b/packages/gui/src/components/nfts/gallery/SelectedActionsDialog.tsx @@ -54,7 +54,8 @@ export default function SelectedActionsDialog(props: SelectedActionsDialogProps) NFTContextualActionTypes.Hide + NFTContextualActionTypes.Download + NFTContextualActionTypes.Transfer + - NFTContextualActionTypes.Burn; + NFTContextualActionTypes.Burn + + NFTContextualActionTypes.AddToGallery; const menuWithoutHide = menuWithHide - NFTContextualActionTypes.Hide; return ( diff --git a/packages/gui/src/hooks/useFilteredNFTs.ts b/packages/gui/src/hooks/useFilteredNFTs.ts index 730b033ec7..693c3e69a7 100644 --- a/packages/gui/src/hooks/useFilteredNFTs.ts +++ b/packages/gui/src/hooks/useFilteredNFTs.ts @@ -6,7 +6,7 @@ export default function useFilteredNFTs() { const filter = useNFTFilter(); const [hideSensitiveContent, setHideSensitiveContent] = useHideObjectionableContent(); - const { search, visibility, types, walletIds } = filter; + const { search, visibility, types, walletIds, userFolder } = filter; const nftsResult = useNFTs({ // filter props @@ -14,6 +14,7 @@ export default function useFilteredNFTs() { visibility, types, walletIds, + userFolder, // additional props hideSensitiveContent, diff --git a/packages/gui/src/util/getNFTsDataStatistics.ts b/packages/gui/src/util/getNFTsDataStatistics.ts index 7d39cdd358..cb5f18b4b3 100644 --- a/packages/gui/src/util/getNFTsDataStatistics.ts +++ b/packages/gui/src/util/getNFTsDataStatistics.ts @@ -5,7 +5,10 @@ import hasSensitiveContent from './hasSensitiveContent'; export default function getNFTsDataStatistics( data: NFTData[], - isHidden: (nftId: string) => boolean + isHidden: (nftId: string) => boolean, + fingerprint: number | undefined, + userFolder: string | undefined, + userFoldersNFTs: any ): NFTsDataStatistics { const stats: NFTsDataStatistics = { [FileType.IMAGE]: 0, @@ -22,6 +25,11 @@ export default function getNFTsDataStatistics( data.forEach((item) => { const { nft, type, metadata } = item; + + if (userFolder && fingerprint && userFoldersNFTs?.[fingerprint]?.[userFolder]?.indexOf(nft?.$nftId) === -1) { + return; + } + if (type) { stats[type] = (stats[type] ?? 0) + 1; } diff --git a/packages/icons/src/NFTs.tsx b/packages/icons/src/NFTs.tsx index 6b4246ef6c..1043c8f2de 100644 --- a/packages/icons/src/NFTs.tsx +++ b/packages/icons/src/NFTs.tsx @@ -4,7 +4,15 @@ import React from 'react'; import NFTsIcon from './images/NFTs.svg'; import NFTsSmallIcon from './images/NFTsSmall.svg'; import CopyIcon from './images/copy.svg'; +import FilterIcon from './images/filter.svg'; +import FolderIcon from './images/folder.svg'; +import InboxIcon from './images/inbox.svg'; +import PlusIcon from './images/plus.svg'; +import ProfileIcon from './images/profile.svg'; import ReloadIcon from './images/reload.svg'; +import ShowHideIcon from './images/show-hide.svg'; +import TrashIcon from './images/trash.svg'; +import UnassignedIcon from './images/unassigned.svg'; export function NFTsSmall(props: SvgIconProps) { return ; @@ -22,6 +30,67 @@ function CopyIconWithoutFill() { // this icons looks bad when filling any color return ; } + export function Copy(props: SvgIconProps) { - return ; + return ; +} + +function FilterIconWithoutFill() { + return ; +} + +export function Filter(props: SvgIconProps) { + return ; +} + +function FolderIconWithoutFill() { + return ; +} + +export function Folder(props: SvgIconProps) { + return ; +} + +function InboxIconWithoutFill(props: SvgIconProps) { + return ; +} + +export function Inbox(props: SvgIconProps) { + return ; +} + +function ProfileIconWithoutFill(props: SvgIconProps) { + return ; +} + +export function Profile(props: SvgIconProps) { + return ; +} + +function UnassignedIconWithoutFill(props: SvgIconProps) { + return ; +} + +export function Unassigned(props: SvgIconProps) { + return ; +} + +function ShowHideWithoutFill(props: SvgIconProps) { + return ; +} + +export function ShowHide(props: SvgIconProps) { + return ; +} + +export function Plus(props: SvgIconProps) { + return ; +} + +function TrashIconWithoutFill() { + return ; +} + +export function Trash(props: SvgIconProps) { + return ; } diff --git a/packages/icons/src/images/Icon.svg b/packages/icons/src/images/Icon.svg new file mode 100644 index 0000000000..5839cb43df --- /dev/null +++ b/packages/icons/src/images/Icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/filter.svg b/packages/icons/src/images/filter.svg new file mode 100644 index 0000000000..a8b121209a --- /dev/null +++ b/packages/icons/src/images/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/folder.svg b/packages/icons/src/images/folder.svg new file mode 100644 index 0000000000..abab234c26 --- /dev/null +++ b/packages/icons/src/images/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/inbox.svg b/packages/icons/src/images/inbox.svg new file mode 100644 index 0000000000..0c1ceddc87 --- /dev/null +++ b/packages/icons/src/images/inbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/plus.svg b/packages/icons/src/images/plus.svg new file mode 100644 index 0000000000..d5d63ee2f6 --- /dev/null +++ b/packages/icons/src/images/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/profile.svg b/packages/icons/src/images/profile.svg new file mode 100644 index 0000000000..6c7272f80a --- /dev/null +++ b/packages/icons/src/images/profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/images/show-hide.svg b/packages/icons/src/images/show-hide.svg new file mode 100644 index 0000000000..7c7d6f3046 --- /dev/null +++ b/packages/icons/src/images/show-hide.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/trash.svg b/packages/icons/src/images/trash.svg new file mode 100644 index 0000000000..7eb466e8c8 --- /dev/null +++ b/packages/icons/src/images/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/images/unassigned.svg b/packages/icons/src/images/unassigned.svg new file mode 100644 index 0000000000..04dc9466be --- /dev/null +++ b/packages/icons/src/images/unassigned.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 9707e4154d..0be9d0bcfc 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -7,7 +7,20 @@ export { default as FullNode } from './FullNode'; export { default as Home } from './Home'; export { default as Keys } from './Keys'; export { default as LinkSmall } from './Link'; -export { default as NFTs, NFTsSmall, Reload, Copy } from './NFTs'; +export { + default as NFTs, + NFTsSmall, + Reload, + Copy, + Filter, + Folder, + Inbox, + Profile, + Unassigned, + Plus, + ShowHide, + Trash, +} from './NFTs'; export { default as Offering } from './Offering'; export { default as Offers, OffersSmall } from './Offers'; export { default as Plot } from './Plot'; From 854d858fbb5d507ee68317a70f46a760abe71de2 Mon Sep 17 00:00:00 2001 From: Matej Rifelj Date: Thu, 22 Jun 2023 16:49:56 +0200 Subject: [PATCH 2/2] bugfix: "User folders" - showing total items --- .../components/nfts/gallery/NFTGallery.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx index 539d6627a3..4017c7cc77 100644 --- a/packages/gui/src/components/nfts/gallery/NFTGallery.tsx +++ b/packages/gui/src/components/nfts/gallery/NFTGallery.tsx @@ -321,6 +321,25 @@ export default function NFTGallery() { ); } + function showingVisibleOfTotal() { + if (userFolder && Array.isArray(foldersSortedNFTs[fingerprint][userFolder])) { + return ( + + Showing {foldersSortedNFTs[fingerprint][userFolder].length}  items + + ); + } + if (statistics.total > 0) { + return ( + + Showing {sortedNFTs.length}  + of {statistics.total}  items + + ); + } + return null; + } + return ( - {statistics.total > 0 && ( - - Showing {sortedNFTs.length}  - of {statistics.total}  items - - )} + {showingVisibleOfTotal()} {progress < 100 && ( <> {statistics.total > 0 && <>, }