diff --git a/.vscode/settings.json b/.vscode/settings.json index eea4bc6..1ac45d6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "KRISTWALLET", "KRISTWALLETEXTENSION", "Lngs", + "Popconfirm", "Sider", "Syncable", "Transpiler", @@ -17,6 +18,7 @@ "authorised", "clientside", "dont", + "firstseen", "jwalelset", "languagedetector", "localisation", diff --git a/package.json b/package.json index 7ec95cf..e35ba65 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "antd": "^4.12.3", "base64-arraybuffer": "^0.2.0", "csv-stringify": "^5.6.1", + "dayjs": "^1.10.4", "file-saver": "^2.0.5", "i18next": "^19.7.0", "i18next-browser-languagedetector": "^6.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27da77b..60fad53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,9 +3,10 @@ '@testing-library/jest-dom': 5.11.9 '@testing-library/react': 11.2.5_react-dom@17.0.1+react@17.0.1 '@testing-library/user-event': 12.7.1 - antd: 4.12.3_react-dom@17.0.1+react@17.0.1 + antd: 4.12.3_89622fd8e4ec221151a62783d49305af base64-arraybuffer: 0.2.0 csv-stringify: 5.6.1 + dayjs: 1.10.4 file-saver: 2.0.5 i18next: 19.8.7 i18next-browser-languagedetector: 6.0.1 @@ -2653,7 +2654,7 @@ node: '>=8' resolution: integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - /antd/4.12.3_react-dom@17.0.1+react@17.0.1: + /antd/4.12.3_89622fd8e4ec221151a62783d49305af: dependencies: '@ant-design/colors': 6.0.0 '@ant-design/icons': 4.5.0_react-dom@17.0.1+react@17.0.1 @@ -2678,7 +2679,7 @@ rc-motion: 2.4.1_react-dom@17.0.1+react@17.0.1 rc-notification: 4.5.4_react-dom@17.0.1+react@17.0.1 rc-pagination: 3.1.3_react-dom@17.0.1+react@17.0.1 - rc-picker: 2.5.5_react-dom@17.0.1+react@17.0.1 + rc-picker: 2.5.5_89622fd8e4ec221151a62783d49305af rc-progress: 3.1.3_react-dom@17.0.1+react@17.0.1 rc-rate: 2.9.1_react-dom@17.0.1+react@17.0.1 rc-resize-observer: 1.0.0_react-dom@17.0.1+react@17.0.1 @@ -2701,6 +2702,7 @@ warning: 4.0.3 dev: false peerDependencies: + dayjs: '*' react: '>=16.9.0' react-dom: '>=16.9.0' resolution: @@ -4382,6 +4384,10 @@ node: '>=0.11' resolution: integrity: sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA== + /dayjs/1.10.4: + dev: false + resolution: + integrity: sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== /debug/2.6.9: dependencies: ms: 2.0.0 @@ -10218,11 +10224,12 @@ react-dom: '>=16.9.0' resolution: integrity: sha512-Z7CdC4xGkedfAwcUHPtfqNhYwVyDgkmhkvfsmoByCOwAd89p42t5O5T3ORar1wRmVWf3jxk/Bf4k0atenNvlFA== - /rc-picker/2.5.5_react-dom@17.0.1+react@17.0.1: + /rc-picker/2.5.5_89622fd8e4ec221151a62783d49305af: dependencies: '@babel/runtime': 7.12.13 classnames: 2.2.6 date-fns: 2.17.0 + dayjs: 1.10.4 moment: 2.29.1 rc-trigger: 5.2.1_react-dom@17.0.1+react@17.0.1 rc-util: 5.8.0_react-dom@17.0.1+react@17.0.1 @@ -13346,6 +13353,7 @@ base64-arraybuffer: ^0.2.0 craco-less: ^1.17.1 csv-stringify: ^5.6.1 + dayjs: ^1.10.4 eslint: ^7.20.0 eslint-config-prettier: ^7.2.0 eslint-plugin-react: ^7.22.0 diff --git a/public/locales/en.json b/public/locales/en.json index a4c8e17..0370774 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -38,6 +38,8 @@ "dialog": { "close": "Close", + "yes": "Yes", + "no": "No", "ok": "OK", "cancel": "Cancel" }, @@ -104,7 +106,11 @@ "columnFirstSeen": "First Seen", "nameCount": "{{count}} name", "nameCount_plural": "{{count}} names", - "firstSeen": "First seen {{date}}" + "firstSeen": "First seen {{date}}", + + "actionsEditTooltip": "Edit wallet", + "actionsDelete": "Delete wallet", + "actionsDeleteConfirm": "Are you sure you want to delete this wallet?" }, "myTransactions": { diff --git a/src/components/DateTime.tsx b/src/components/DateTime.tsx new file mode 100644 index 0000000..4203c90 --- /dev/null +++ b/src/components/DateTime.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Tooltip } from "antd"; + +import dayjs from "dayjs"; + +interface Props { + date?: Date | string | null; +} + +export function DateTime({ date }: Props): JSX.Element | null { + if (!date) return null; + const realDate = typeof date === "string" ? new Date(date) : date; + + return + {dayjs(realDate).format("YYYY-MM-DD HH:mm:ss")} + ; +} diff --git a/src/components/KristValue.tsx b/src/components/KristValue.tsx index e3e89c7..5a7afd1 100644 --- a/src/components/KristValue.tsx +++ b/src/components/KristValue.tsx @@ -7,14 +7,18 @@ interface OwnProps { value?: number; long?: boolean; + hideNullish?: boolean; }; type Props = React.HTMLProps & OwnProps; -export const KristValue = ({ value, long, ...props }: Props): JSX.Element => ( - - - {(value || 0).toLocaleString()} - {long && KST} - -); +export const KristValue = ({ value, long, hideNullish, ...props }: Props): JSX.Element | null => + hideNullish && (value === undefined || value === null) + ? null + : ( + + + {(value || 0).toLocaleString()} + {long && KST} + + ); diff --git a/src/krist/api/types.ts b/src/krist/api/types.ts index 1d6b4dc..7c6c312 100644 --- a/src/krist/api/types.ts +++ b/src/krist/api/types.ts @@ -5,7 +5,7 @@ totalin?: number; totalout?: number; - first_seen: string; + firstseen: string; } export type APIResponse> = T & { diff --git a/src/krist/wallets/Wallet.ts b/src/krist/wallets/Wallet.ts index 002ab6f..10d94a2 100644 --- a/src/krist/wallets/Wallet.ts +++ b/src/krist/wallets/Wallet.ts @@ -109,7 +109,7 @@ ...wallet, balance: address.balance, names: address.names, - firstSeen: address.first_seen, + firstSeen: address.firstseen, lastSynced: syncTime.toISOString() }; } @@ -158,7 +158,8 @@ dispatch(actions.syncWallets(updatedWallets)); } -/** Adds a new wallet, encrypting its privatekey and password, saving it to +/** + * Adds a new wallet, encrypting its privatekey and password, saving it to * local storage, and dispatching the changes to the Redux store. * * @param dispatch - The AppDispatch instance used to dispatch the new wallet to @@ -210,3 +211,12 @@ return newWallet; } + +/** Deletes a wallet, removing it from local storage and dispatching the change + * to the Redux store. */ +export function deleteWallet(dispatch: AppDispatch, wallet: Wallet): void { + const key = getWalletKey(wallet); + localStorage.removeItem(key); + + dispatch(actions.removeWallet(wallet.id)); +} diff --git a/src/pages/wallets/WalletsPage.less b/src/pages/wallets/WalletsPage.less new file mode 100644 index 0000000..2dea0a3 --- /dev/null +++ b/src/pages/wallets/WalletsPage.less @@ -0,0 +1,13 @@ +@import (reference) "../../App.less"; + +.wallet-actions .ant-btn { + &:not(:first-child) { + margin-left: 1px; + } + + &:first-child { + padding-left: 0; + padding-right: 0; + width: 40px; + } +} diff --git a/src/pages/wallets/WalletsPage.tsx b/src/pages/wallets/WalletsPage.tsx index 23b2d87..790e366 100644 --- a/src/pages/wallets/WalletsPage.tsx +++ b/src/pages/wallets/WalletsPage.tsx @@ -9,6 +9,8 @@ import { AddWalletModal } from "./AddWalletModal"; import { WalletsTable } from "./WalletsTable"; +import "./WalletsPage.less"; + function WalletsPageExtraButtons(): JSX.Element { const { t } = useTranslation(); const [createWalletVisible, setCreateWalletVisible] = useState(false); diff --git a/src/pages/wallets/WalletsTable.tsx b/src/pages/wallets/WalletsTable.tsx index 245d9d6..ec47746 100644 --- a/src/pages/wallets/WalletsTable.tsx +++ b/src/pages/wallets/WalletsTable.tsx @@ -1,53 +1,119 @@ import React from "react"; -import { Table } from "antd"; +import { Table, Tooltip, Dropdown, Menu, Popconfirm } from "antd"; +import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; import { useDispatch, useSelector, shallowEqual } from "react-redux"; import { RootState } from "../../store"; import { useTranslation } from "react-i18next"; import { KristValue } from "../../components/KristValue"; +import { DateTime } from "../../components/DateTime"; + +import { Wallet, deleteWallet } from "../../krist/wallets/Wallet"; + +import { keyedNullSort, localeSort } from "../../utils"; + +function WalletActions({ wallet }: { wallet: Wallet }): JSX.Element { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + function onDeleteWallet() { + deleteWallet(dispatch, wallet); + } + + return + {/* Delete button */} + + + {t("myWallets.actionsDelete")} + + + }> + {/* Edit button */} + + + + ; +} export function WalletsTable(): JSX.Element { const { t } = useTranslation(); const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual); - const dispatch = useDispatch(); + + // Required to filter by categories + const categories = [...new Set(Object.values(wallets) + .filter(w => w.category !== undefined && w.category !== "") + .map(w => w.category) as string[])]; + localeSort(categories); return a.address.localeCompare(b.address) }, + + // Balance { title: t("myWallets.columnBalance"), - dataIndex: "balance", - key: "balance", - render: balance => + dataIndex: "balance", key: "balance", + + render: balance => , + sorter: keyedNullSort("balance"), + defaultSortOrder: "descend" }, + + // Names { title: t("myWallets.columnNames"), - dataIndex: "names", - key: "names" + dataIndex: "names", key: "names", + sorter: keyedNullSort("names") }, + + // Category { title: t("myWallets.columnCategory"), - dataIndex: "category", - key: "category" + dataIndex: "category", key: "category", + + filters: categories.map(c => ({ text: c, value: c })), + onFilter: (value, record) => record.category === value, + + sorter: keyedNullSort("category", true) }, + + // First seen { title: t("myWallets.columnFirstSeen"), - dataIndex: "firstSeen", - key: "firstSeen" + dataIndex: "firstSeen", key: "firstSeen", + + render: firstSeen => , + sorter: keyedNullSort("firstSeen") + }, + + // Actions + { + key: "actions", + width: 80, + + render: (_, record) => } ]} />; diff --git a/src/utils/index.ts b/src/utils/index.ts index 716ff6f..95834cc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -45,3 +45,37 @@ numeric: true })); } + +/** + * Sorting function that pushes nullish to the end of the array. + * + * @param key - The property of T to sort by. + * @param human - Whether or not to use a human-friendly locale sort for + * string values. +*/ +export const keyedNullSort = (key: keyof T, human?: boolean) => (a: T, b: T, sortOrder?: "ascend" | "descend" | null): number => { + // We effectively reverse the sort twice when sorting in 'descend' mode, as + // ant-design will internally reverse the array, but we always want to push + // nullish values to the end. + const va = sortOrder === "descend" ? b[key] : a[key]; + const vb = sortOrder === "descend" ? a[key] : b[key]; + + // Push nullish values to the end + if (va === vb) return 0; + if (va === undefined || va === null) return 1; + if (vb === undefined || vb === null) return -1; + + if (typeof va === "string" && typeof vb === "string") { + // Use localeCompare for strings + const ret = va.localeCompare(vb, undefined, human ? { + sensitivity: "base", + numeric: true + } : undefined); + return sortOrder === "descend" ? -ret : ret; + } else { + // Use the built-in comparison for everything else (mainly numbers) + return sortOrder === "descend" + ? (vb as any) - (va as any) + : (va as any) - (vb as any); + } +};