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);
+ }
+};