{renderIcon({ status, icon })}
{title}
{subTitle &&
{subTitle}
}
diff --git a/src/components/Statistic.less b/src/components/Statistic.less
new file mode 100644
index 0000000..d34cbff
--- /dev/null
+++ b/src/components/Statistic.less
@@ -0,0 +1,15 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../App.less";
+
+.kw-statistic {
+ &-title {
+ color: @kw-text-secondary;
+ display: block;
+ }
+
+ &-value {
+ font-size: @heading-3-size;
+ }
+}
diff --git a/src/components/Statistic.tsx b/src/components/Statistic.tsx
new file mode 100644
index 0000000..8596ebb
--- /dev/null
+++ b/src/components/Statistic.tsx
@@ -0,0 +1,23 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React from "react";
+
+import { useTranslation } from "react-i18next";
+
+import "./Statistic.less";
+
+interface Props {
+ title?: string;
+ titleKey?: string;
+ value?: React.ReactNode;
+}
+
+export function Statistic({ title, titleKey, value }: Props): JSX.Element {
+ const { t } = useTranslation();
+
+ return
+ {titleKey ? t(titleKey) : title}
+ {value}
+
;
+}
diff --git a/src/components/wallets/SelectWalletCategory.tsx b/src/components/wallets/SelectWalletCategory.tsx
index 76d9a6e..fa82d1f 100644
--- a/src/components/wallets/SelectWalletCategory.tsx
+++ b/src/components/wallets/SelectWalletCategory.tsx
@@ -42,8 +42,8 @@
setCategories(newCategories);
setInput(undefined);
- // TODO: fix bug where hitting enter will _sometimes_ not set the right
- // category name on the form
+ // FIXME: hitting enter will _sometimes_ not set the right category name on
+ // the form
if (onNewCategory) onNewCategory(categoryName);
}
diff --git a/src/components/ws/SyncMOTD.tsx b/src/components/ws/SyncMOTD.tsx
index 761d532..d8bcb73 100644
--- a/src/components/ws/SyncMOTD.tsx
+++ b/src/components/ws/SyncMOTD.tsx
@@ -28,7 +28,7 @@
/** Sync the MOTD with the Krist node on startup. */
export function SyncMOTD(): JSX.Element | null {
- const syncNode = useSelector((s: RootState) => s.node.syncNode);
+ const syncNode = api.useSyncNode();
const connectionState = useSelector((s: RootState) => s.websocket.connectionState);
// All these are used to determine if we need to recalculate the addresses
diff --git a/src/components/ws/WebsocketService.tsx b/src/components/ws/WebsocketService.tsx
index ff5eef1..9405e6e 100644
--- a/src/components/ws/WebsocketService.tsx
+++ b/src/components/ws/WebsocketService.tsx
@@ -227,7 +227,7 @@
export function WebsocketService(): JSX.Element | null {
const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual);
- const syncNode = useSelector((s: RootState) => s.node.syncNode);
+ const syncNode = api.useSyncNode();
const [connection, setConnection] = useState
();
diff --git a/src/global/AppHotkeys.tsx b/src/global/AppHotkeys.tsx
new file mode 100644
index 0000000..39547a5
--- /dev/null
+++ b/src/global/AppHotkeys.tsx
@@ -0,0 +1,4 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+export const keyMap = {};
diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx
new file mode 100644
index 0000000..a173c64
--- /dev/null
+++ b/src/global/AppRouter.tsx
@@ -0,0 +1,46 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React from "react";
+import { Switch, Route } from "react-router-dom";
+
+import { DashboardPage } from "../pages/dashboard/DashboardPage";
+import { WalletsPage } from "../pages/wallets/WalletsPage";
+
+import { AddressPage } from "../pages/addresses/AddressPage";
+
+import { SettingsPage } from "../pages/settings/SettingsPage";
+import { SettingsTranslations } from "../pages/settings/SettingsTranslations";
+
+import { CreditsPage } from "../pages/credits/CreditsPage";
+
+import { NotFoundPage } from "../pages/NotFoundPage";
+
+interface AppRoute {
+ path: string;
+ name: string;
+ component?: React.ReactNode;
+}
+
+export const APP_ROUTES: AppRoute[] = [
+ { path: "/", name: "dashboard", component: },
+ { path: "/wallets", name: "wallets", component: },
+
+ { path: "/network/addresses/:address", name: "address", component: },
+
+ { path: "/settings", name: "settings", component: },
+ { path: "/settings/debug", name: "settingsDebug" },
+ { path: "/settings/debug/translations", name: "settings", component: },
+
+ { path: "/credits", name: "credits", component: },
+];
+
+export function AppRouter(): JSX.Element {
+ return
+ {APP_ROUTES.map(({ path, component }, key) => (
+ component && {component}
+ ))}
+
+
+ ;
+}
diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx
new file mode 100644
index 0000000..7b52a52
--- /dev/null
+++ b/src/global/AppServices.tsx
@@ -0,0 +1,18 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { SyncWallets } from "../components/wallets/SyncWallets";
+import { ForcedAuth } from "../components/auth/ForcedAuth";
+import { WebsocketService } from "../components/ws/WebsocketService";
+import { SyncWork } from "../components/ws/SyncWork";
+import { SyncMOTD } from "../components/ws/SyncMOTD";
+
+export function AppServices(): JSX.Element {
+ return <>
+
+
+
+
+
+ >;
+}
diff --git a/src/krist/api/index.ts b/src/krist/api/index.ts
index 87dc5f1..956417b 100644
--- a/src/krist/api/index.ts
+++ b/src/krist/api/index.ts
@@ -4,6 +4,8 @@
import { notification } from "antd";
import i18n from "../../utils/i18n";
+import { useSelector } from "react-redux";
+import { RootState } from "../../store";
import { store } from "../../App";
import { APIResponse } from "./types";
@@ -58,3 +60,7 @@
request("GET", endpoint, options);
export const post = (endpoint: string, options?: RequestOptions): Promise> =>
request("POST", endpoint, options);
+
+/** Re-usable syncNode hook, usually for refreshing things when the syncNode
+ * changes. */
+export const useSyncNode = (): string => useSelector((s: RootState) => s.node.syncNode);
diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts
index 58fe729..c006236 100644
--- a/src/krist/api/lookup.ts
+++ b/src/krist/api/lookup.ts
@@ -32,6 +32,20 @@
return {};
}
+/** Uses the lookup API to retrieve a single address. */
+export async function lookupAddress(address: string, fetchNames?: boolean): Promise {
+ const data = await api.get(
+ "lookup/addresses/"
+ + encodeURIComponent(address)
+ + (fetchNames ? "?fetchNames" : "")
+ );
+
+ const kristAddress = data.addresses[address];
+ if (!kristAddress) throw new api.APIError("address_not_found");
+
+ return kristAddress;
+}
+
interface LookupTransactionsOptions {
includeMined?: boolean;
limit?: number;
diff --git a/src/layout/AppLayout.less b/src/layout/AppLayout.less
index d737903..65ff30b 100644
--- a/src/layout/AppLayout.less
+++ b/src/layout/AppLayout.less
@@ -316,6 +316,8 @@
}
.site-layout {
+ min-height: calc(100vh - @layout-header-height);
+
margin-left: @kw-sidebar-width;
&.site-layout-mobile {
diff --git a/src/layout/AppLayout.tsx b/src/layout/AppLayout.tsx
index 7a77da5..09d8b57 100644
--- a/src/layout/AppLayout.tsx
+++ b/src/layout/AppLayout.tsx
@@ -6,7 +6,7 @@
import { AppHeader } from "./nav/AppHeader";
import { Sidebar } from "./sidebar/Sidebar";
-import { AppRouter } from "./AppRouter";
+import { AppRouter } from "../global/AppRouter";
import "./AppLayout.less";
diff --git a/src/layout/AppRouter.tsx b/src/layout/AppRouter.tsx
deleted file mode 100644
index 3bf16ed..0000000
--- a/src/layout/AppRouter.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under GPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import React from "react";
-import { Switch, Route } from "react-router-dom";
-
-import { DashboardPage } from "../pages/dashboard/DashboardPage";
-import { WalletsPage } from "../pages/wallets/WalletsPage";
-
-import { SettingsPage } from "../pages/settings/SettingsPage";
-import { SettingsTranslations } from "../pages/settings/SettingsTranslations";
-
-import { CreditsPage } from "../pages/credits/CreditsPage";
-
-import { NotFoundPage } from "../pages/NotFoundPage";
-
-interface AppRoute {
- path: string;
- name: string;
- component?: React.ReactNode;
-}
-
-export const APP_ROUTES: AppRoute[] = [
- { path: "/", name: "dashboard", component: },
- { path: "/wallets", name: "wallets", component: },
-
- { path: "/settings", name: "settings", component: },
- { path: "/settings/debug", name: "settingsDebug" },
- { path: "/settings/debug/translations", name: "settings", component: },
-
- { path: "/credits", name: "credits", component: },
-];
-
-export function AppRouter(): JSX.Element {
- return
- {APP_ROUTES.map(({ path, component }, key) => (
- component && {component}
- ))}
-
-
- ;
-}
diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less
index c673197..a659af1 100644
--- a/src/layout/PageLayout.less
+++ b/src/layout/PageLayout.less
@@ -4,11 +4,17 @@
@import (reference) "../App.less";
.page-layout {
+ height: 100%;
+
.page-layout-header.ant-page-header {
+ height: @kw-page-header-height;
+
padding-bottom: 0;
}
.page-layout-contents {
+ height: calc(100% - @kw-page-header-height);
+
padding: @padding-lg;
}
}
diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx
index fb2b1e6..b320223 100644
--- a/src/layout/nav/Search.tsx
+++ b/src/layout/nav/Search.tsx
@@ -1,10 +1,15 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import React, { useState, useMemo, useRef, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react";
+import React, { useState, useMemo, useRef, useEffect, useCallback, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react";
import { AutoComplete, Input } from "antd";
+import { RefSelectProps } from "antd/lib/select";
import { useTranslation } from "react-i18next";
+import { useHistory } from "react-router-dom";
+
+import { GlobalHotKeys } from "react-hotkeys";
+import { ctrl } from "../../utils";
import { RateLimitError } from "../../krist/api";
import { SearchResult, search, searchExtended, SearchExtendedResult } from "../../krist/api/search";
@@ -21,6 +26,8 @@
async function performAutocomplete(
query: string,
+ fetchResults: boolean,
+ fetchExtended: boolean,
waitingForRef: MutableRefObject,
setResults: (query: string, results: SearchResult | undefined) => void,
setExtendedResults: (query: string, results: SearchExtendedResult | undefined) => void,
@@ -34,8 +41,8 @@
try {
await Promise.all([
- search(query).then(r => setResults(query, r)),
- searchExtended(query).then(r => setExtendedResults(query, r)),
+ fetchResults ? search(query).then(r => setResults(query, r)) : undefined,
+ fetchExtended ? searchExtended(query).then(r => setExtendedResults(query, r)) : undefined,
]);
} catch (err) {
// Most likely error is `rate_limit_hit`:
@@ -46,31 +53,37 @@
export function Search(): JSX.Element {
const { t } = useTranslation();
+ const history = useHistory();
const [value, setValue] = useState("");
const [results, setResults] = useState();
const [extendedResults, setExtendedResults] = useState();
const [loading, setLoading] = useState(false);
const [rateLimitHit, setRateLimitHit] = useState(false);
+ const [options, setOptions] = useState<{ value: string; label: ReactNode }[]>([]);
// The latest input that we're waiting for a network request for; this avoids
// out of order search results due to network latency
const waitingForRef = useRef("");
+ // Used to focus the search when the hotkey is received, or de-focus it when
+ // a search result is selected
+ const autocompleteRef = useRef(null);
+
const debouncedAutocomplete = useMemo(() => debounce(performAutocomplete, SEARCH_THROTTLE), []);
const throttledAutocomplete = useMemo(() => throttle(performAutocomplete, SEARCH_THROTTLE), []);
// LRU cache used to keep track of known search results. This avoids
// re-fetching search results when the user hits backspaces several times.
- // The cache is cleared each time the search is focused to keep the results
- // fresh.
- const searchCache = useMemo(() => new LRU({ max: 100, maxAge : 300000 }), []);
- const searchExtendedCache = useMemo(() => new LRU({ max: 100, maxAge : 300000 }), []);
+ const searchCache = useMemo(() => new LRU({ max: 100, maxAge : 180000 }), []);
+ const searchExtendedCache = useMemo(() => new LRU({ max: 100, maxAge : 180000 }), []);
// Create a function to set the results for a given result type
const cachedSetResultsBase =
(cache: LRU, setResultsFn: Dispatch>) =>
(query: string, results: T | undefined) => {
+ debug("setting results for %s", query, results);
+
// Cowardly refuse to perform any search if the rate limit was hit
if (!results || rateLimitHit) return setResultsFn(undefined);
@@ -104,7 +117,7 @@
}
function onSearch(query: string) {
- debug("query: %s", query);
+ debug("onSearch: %s", query);
// Cowardly refuse to perform any search if the rate limit was hit
if (rateLimitHit) return;
@@ -120,7 +133,7 @@
const cached = searchCache.get(cleanQuery);
const cachedExtended = searchExtendedCache.get(cleanQuery);
if (cached || cachedExtended) {
- debug("using cached result for %s", query);
+ debug("using cached result for %s (results: %b) (extended: %b)", query, !cached, !cachedExtended, cached, cachedExtended);
// Ensure that an out of order request doesn't overwrite our cached result
waitingForRef.current = query;
@@ -133,34 +146,113 @@
if (cachedExtended) setExtendedResults(cachedExtended);
setLoading(false);
- return;
}
- setLoading(true);
+ // If we're missing one or both of the cached result sets, fetch them
+ if (!cached || !cachedExtended) {
+ debug("nothing cached for %s, (results: %b) (extended: %b), considering a fetch", query, !cached, !cachedExtended);
- // Based on this article:
- // https://www.peterbe.com/plog/how-to-throttle-and-debounce-an-autocomplete-input-in-react
- // Eagerly use `throttle` for short inputs, and patiently use `debounce` for
- // longer inputs.
- const fn = cleanQuery.length < 5 ? throttledAutocomplete : debouncedAutocomplete;
- fn(cleanQuery, waitingForRef, cachedSetResults, cachedSetExtendedResults, onRateLimitHit);
+ setLoading(true);
+
+ // Based on this article:
+ // https://www.peterbe.com/plog/how-to-throttle-and-debounce-an-autocomplete-input-in-react
+ // Eagerly use `throttle` for short inputs, and patiently use `debounce`
+ // for longer inputs.
+ const fn = cleanQuery.length < 5
+ ? throttledAutocomplete
+ : debouncedAutocomplete;
+
+ fn(
+ cleanQuery,
+ !cached, !cachedExtended,
+ waitingForRef,
+ cachedSetResults, cachedSetExtendedResults,
+ onRateLimitHit
+ );
+ }
}
- const staticResult = (value: string, label: ReactNode) => [{ value, label }];
+ /** Navigate to the selected search result. */
+ function onSelect(query: string) {
+ debug("onSelect %s", query);
- function renderResults(): { value: string; label: ReactNode }[] {
+ // Reset the search value when a result is selected. This is because,
+ // otherwise, the internal value (e.g. `exactAddress`) would remain in
+ // there, which would look pretty odd.
+ // REVIEW: Would be nice to avoid having to do it this way entirely.
+ setValue("");
+
+ // If we're still loading the results, don't search just yet.
+ // TODO: is it possible to defer this instead?
+ if (loading || !results) return;
+
+ const resultsMatches = results.matches;
+ const { exactAddress, exactName, exactBlock, exactTransaction } = resultsMatches;
+
+ debug("search selected value %s", query);
+
+ // Whether or not we actually matched a value. This should pretty much
+ // always be true.
+ let matched = true;
+
+ // Using the internal result type, navigate to the relevant page.
+ // FIXME: this is kinda wack
+ if (query === "exactAddress" && exactAddress) {
+ history.push(`/network/addresses/${encodeURIComponent(exactAddress.address)}`);
+ } else if (query === "exactName" && exactName) {
+ history.push(`/network/names/${encodeURIComponent(exactName.name)}`);
+ } else if (query === "exactBlock" && exactBlock) {
+ history.push(`/network/blocks/${encodeURIComponent(exactBlock.height)}`);
+ } else if (query === "exactTransaction" && exactTransaction) {
+ history.push(`/network/transactions/${encodeURIComponent(exactTransaction.id)}`);
+ } else if (extendedResults) {
+ if (query === "extendedTransactionsAddress") {
+ // TODO
+ } else if (query === "extendedTransactionsName") {
+ // TODO
+ } else if (query === "extendedTransactionsMetadata") {
+ // TODO
+ } else {
+ matched = false;
+ debug("warn: unknown search type %s", query);
+ }
+ } else {
+ matched = false;
+ debug("warn: unknown search type %s", query);
+ }
+
+ // De-focus the search textbox when an item is selected.
+ if (matched && autocompleteRef.current)
+ autocompleteRef.current.blur();
+ }
+
+ // When the 'enter' key is pressed while an autocomplete option isn't focused,
+ // or the user clicks the 'search' button, the autocomplete has no way of
+ // knowing which option to search with. So, we look at the first option in the
+ // list and send that to onSelect.
+ function onInputSearch() {
+ // If we're still loading the results, don't search just yet.
+ // TODO: is it possible to defer this instead?
+ if (loading || !results) return;
+
+ if (!options || !options.length) return;
+ onSelect(options[0].value);
+ }
+
+ const staticOption = (value: string, label: ReactNode) => [{ value, label }];
+ const renderOptions = useCallback(function(): { value: string; label: ReactNode }[] {
const cleanQuery = value.trim();
- debug("current state: %b %b %b %b", rateLimitHit, !cleanQuery, loading, results);
+ // debug("current state: %b %b %b %b", rateLimitHit, !cleanQuery, loading, results);
// Show a warning instead of the results if the rate limit was hit
- if (rateLimitHit) return staticResult("rateLimitHit", );
+ if (rateLimitHit) return staticOption("rateLimitHit", );
// Don't return anything if there's no query at all
if (!cleanQuery) return [];
if (!results) {
// Loading spinner, only if we don't already have some results
- if (loading) return staticResult("loading", );
- else return staticResult("noResults", );
+ if (loading) return staticOption("loading", );
+ else return staticOption("noResults", );
}
const resultsMatches = results.matches;
@@ -173,19 +265,19 @@
const { exactAddress, exactName, exactBlock, exactTransaction } = resultsMatches;
if (exactAddress) options.push({
- value: "address-" + exactAddress.address,
+ value: "exactAddress",
label:
});
if (exactName) options.push({
- value: "name-" + exactName.name,
+ value: "exactName",
label:
});
if (exactBlock) options.push({
- value: "block-" + exactBlock.height,
+ value: "exactBlock",
label:
});
if (exactTransaction) options.push({
- value: "transaction-" + exactTransaction.id,
+ value: "exactTransaction",
label:
});
@@ -210,7 +302,7 @@
&& exactName; // We definitely know the name exists
if (showAddress) options.push({
- value: "transactions-address-" + value,
+ value: "extendedTransactionsAddress",
label: {
+ setOptions(renderOptions());
+ }, [renderOptions]);
return
+
{
+ console.log(e);
+ e?.preventDefault();
+ autocompleteRef.current?.focus();
+ }
+ }}
+ />
+
true}
onChange={value => {
+ // debug("search onChange %s", value);
setLoading(true);
setValue(value);
}}
onSearch={onSearch}
- onFocus={() => {
+ onSelect={onSelect}
+
+ // NOTE: This was removed and the LRU expiry time was lowered; a definite
+ // decision on whether or not the cache should be cleared every time
+ // the search is opened hasn't been reached, but at the moment it
+ // seems to be better to just keep the cached entries around, as
+ // speed and lack of network spam is better than accuracy of the
+ // result hints. Besides, pressing enter will always take you to the
+ // up-to-date data anyway.
+ /* onFocus={() => {
debug("clearing search cache");
searchCache.reset();
- }}
+ }} */
- options={renderResults()}
+ options={options}
>
-
+
;
}
diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx
index 4a9c00f..1a66859 100644
--- a/src/layout/sidebar/Sidebar.tsx
+++ b/src/layout/sidebar/Sidebar.tsx
@@ -23,17 +23,17 @@
group?: "network";
}
const sidebarItems: SidebarItemProps[] = [
- { icon: , name: "dashboard", to: "/" },
- { icon: , name: "myWallets", to: "/wallets" },
- { icon: , name: "addressBook", to: "/friends", nyi: true },
- { icon: , name: "transactions", to: "/me/transactions", nyi: true },
- { icon: , name: "names", to: "/me/names", nyi: true },
- { icon: , name: "mining", to: "/mining", nyi: true },
+ { icon: , name: "dashboard", to: "/" },
+ { icon: , name: "myWallets", to: "/wallets" },
+ { icon: , name: "addressBook", to: "/friends", nyi: true },
+ { icon: , name: "transactions", to: "/me/transactions", nyi: true },
+ { icon: , name: "names", to: "/me/names", nyi: true },
+ { icon: , name: "mining", to: "/mining", nyi: true },
- { group: "network", icon: , name: "blocks", to: "/network/blocks", nyi: true },
- { group: "network", icon: , name: "transactions", to: "/network/transactions", nyi: true },
- { group: "network", icon: , name: "names", to: "/network/names", nyi: true },
- { group: "network", icon: , name: "statistics", to: "/network/statistics", nyi: true },
+ { group: "network", icon: , name: "blocks", to: "/network/blocks", nyi: true },
+ { group: "network", icon: , name: "transactions", to: "/network/transactions", nyi: true },
+ { group: "network", icon: , name: "names", to: "/network/names", nyi: true },
+ { group: "network", icon: , name: "statistics", to: "/network/statistics", nyi: true },
];
function getSidebarItems(t: TFunction, group?: string) {
diff --git a/src/pages/NotFoundPage.less b/src/pages/NotFoundPage.less
deleted file mode 100644
index 544062e..0000000
--- a/src/pages/NotFoundPage.less
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under GPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-@import (reference) "../App.less";
-
-.page-not-found {
- display: flex;
- align-items: center;
- justify-content: center;
-
- height: calc(100vh - @layout-header-height);
-}
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
index a73f95b..d07066f 100644
--- a/src/pages/NotFoundPage.tsx
+++ b/src/pages/NotFoundPage.tsx
@@ -10,22 +10,19 @@
import { SmallResult } from "../components/SmallResult";
-import "./NotFoundPage.less";
-
export function NotFoundPage(): JSX.Element {
const { t } = useTranslation();
const history = useHistory();
- return
- }
- status="error"
- title={t("pageNotFound.resultTitle")}
- extra={(
-
- )}
- />
-
;
+ return }
+ status="error"
+ title={t("pageNotFound.resultTitle")}
+ extra={(
+
+ )}
+ fullPage
+ />;
}
diff --git a/src/pages/addresses/AddressPage.less b/src/pages/addresses/AddressPage.less
new file mode 100644
index 0000000..51af0a0
--- /dev/null
+++ b/src/pages/addresses/AddressPage.less
@@ -0,0 +1,30 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.address-page {
+ .top-address-row {
+ display: flex;
+ align-items: center;
+
+ margin-bottom: @margin-lg;
+
+ h1.address {
+ display: inline-block;
+ margin-right: @margin-lg;
+ margin-bottom: 0;
+
+ font-size: @font-size-base * 2;
+ }
+
+ .ant-btn {
+ margin-right: @margin-md;
+ &:last-child { margin-right: 0; }
+ }
+ }
+
+ .address-info-row {
+ max-width: 768px;
+ }
+}
diff --git a/src/pages/addresses/AddressPage.tsx b/src/pages/addresses/AddressPage.tsx
new file mode 100644
index 0000000..e077b4d
--- /dev/null
+++ b/src/pages/addresses/AddressPage.tsx
@@ -0,0 +1,121 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React, { useState, useEffect } from "react";
+import { Row, Col, Skeleton, Button } from "antd";
+import { SendOutlined, UserAddOutlined } from "@ant-design/icons";
+
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+
+import { PageLayout } from "../../layout/PageLayout";
+import { AddressResult } from "./AddressResult";
+
+import { Statistic } from "../../components/Statistic";
+import { KristValue } from "../../components/KristValue";
+import { DateTime } from "../../components/DateTime";
+
+import * as api from "../../krist/api";
+import { lookupAddress, KristAddressWithNames } from "../../krist/api/lookup";
+
+import "./AddressPage.less";
+
+interface ParamTypes {
+ address: string;
+}
+
+function Page({ address }: { address: KristAddressWithNames }): JSX.Element {
+ const { t } = useTranslation();
+
+ return <>
+ {/* Address and buttons */}
+
+ {/* Address */}
+ {address.address}
+
+ {/* Send Krist button */}
+ {/* TODO: If this is one of our own wallets then say 'Transfer krist' */}
+ }>
+ {t("address.buttonSendKrist", { address: address.address })}
+
+
+ {/* Add friend button */}
+ {/* TODO: Change this to edit if they're already a friend, and if it is
+ one of our own wallets then say 'Edit wallet' */}
+ }>
+ {t("address.buttonAddFriend")}
+
+
+
+ {/* Main address info */}
+
+ {/* Current balance */}
+
+ }
+ />
+
+
+ {/* Names */}
+
+
+
+
+ {/* First seen */}
+
+ }
+ />
+
+
+ >;
+}
+
+export function AddressPage(): JSX.Element {
+ // Used to refresh the address data on syncNode change
+ const syncNode = api.useSyncNode();
+
+ const { address } = useParams();
+ const [kristAddress, setKristAddress] = useState();
+ const [error, setError] = useState();
+
+ // Load the address on page load
+ // TODO: passthrough router state to pre-load from search
+ // REVIEW: The search no longer clears the LRU cache on each open, meaning it
+ // is possible for an address's information to be up to 3 minutes
+ // out-of-date in the search box. If we passed through the state from
+ // the search and directly used it here, it would definitely be too
+ // outdated to display. It could be possible to show that state data
+ // and still lookup the most recent data, but is it worth it? The page
+ // would appear 10-200ms faster, sure, but if the data _has_ changed,
+ // then it would cause a jarring re-render, just to save a single
+ // cheap network request. Will definitely require some further
+ // usability testing.
+ useEffect(() => {
+ lookupAddress(address, true)
+ .then(setKristAddress)
+ .catch(setError);
+ }, [syncNode, address]);
+
+ // Change the page title depending on whether or not the address has loaded
+ const title = kristAddress
+ ? { siteTitle: kristAddress.address, subTitle: kristAddress.address }
+ : { siteTitleKey: "address.title" };
+
+ return
+ {error
+ ?
+ : (kristAddress
+ ?
+ : )}
+ ;
+}
diff --git a/src/pages/addresses/AddressResult.tsx b/src/pages/addresses/AddressResult.tsx
new file mode 100644
index 0000000..4ebfcf0
--- /dev/null
+++ b/src/pages/addresses/AddressResult.tsx
@@ -0,0 +1,52 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React from "react";
+import { FrownOutlined, ExclamationCircleOutlined, QuestionCircleOutlined } from "@ant-design/icons";
+
+import { useTranslation } from "react-i18next";
+
+import { SmallResult } from "../../components/SmallResult";
+import { APIError } from "../../krist/api";
+
+interface Props {
+ error: Error;
+}
+
+export function AddressResult({ error }: Props): JSX.Element {
+ const { t } = useTranslation();
+
+ // Handle the most commonly expected errors from the API
+ if (error instanceof APIError) {
+ // Invalid address
+ if (error.message === "invalid_parameter") {
+ return }
+ title={t("address.resultInvalidTitle")}
+ subTitle={t("address.resultInvalid")}
+ fullPage
+ />;
+ }
+
+ // Address not found
+ if (error.message === "address_not_found") {
+ return }
+ title={t("address.resultNotFoundTitle")}
+ subTitle={t("address.resultNotFound")}
+ fullPage
+ />;
+ }
+ }
+
+ // Unknown error
+ return }
+ title={t("address.resultUnknownTitle")}
+ subTitle={t("address.resultUnknown")}
+ fullPage
+ />;
+}
diff --git a/src/pages/dashboard/BlockDifficultyCard.tsx b/src/pages/dashboard/BlockDifficultyCard.tsx
index 6a0f763..771bc7a 100644
--- a/src/pages/dashboard/BlockDifficultyCard.tsx
+++ b/src/pages/dashboard/BlockDifficultyCard.tsx
@@ -2,6 +2,7 @@
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import React, { useState, useEffect, useMemo } from "react";
+import classNames from "classnames";
import { Card, Skeleton, Empty, Row, Col, Tooltip, Select } from "antd";
import { useSelector, shallowEqual } from "react-redux";
@@ -16,7 +17,7 @@
import { trailingThrottleState } from "../../utils/promiseThrottle";
import { SmallResult } from "../../components/SmallResult";
-import { Statistic } from "./Statistic";
+import { Statistic } from "../../components/Statistic";
import Debug from "debug";
const debug = Debug("kristweb:block-difficulty-card");
@@ -123,7 +124,7 @@
export function BlockDifficultyCard(): JSX.Element {
const { t } = useTranslation();
- const syncNode = useSelector((s: RootState) => s.node.syncNode);
+ const syncNode = api.useSyncNode();
const lastBlockID = useSelector((s: RootState) => s.node.lastBlockID);
const work = useSelector((s: RootState) => s.node.detailedWork?.work);
const constants = useSelector((s: RootState) => s.node.constants, shallowEqual);
@@ -221,8 +222,11 @@
}
const isEmpty = !loading && error;
+ const classes = classNames("kw-card", "dashboard-card-block-difficulty", {
+ "empty": isEmpty
+ });
- return
+ return
{error
?
diff --git a/src/pages/dashboard/BlockValueCard.tsx b/src/pages/dashboard/BlockValueCard.tsx
index bd04348..d6c36f2 100644
--- a/src/pages/dashboard/BlockValueCard.tsx
+++ b/src/pages/dashboard/BlockValueCard.tsx
@@ -20,7 +20,7 @@
const work = useSelector((s: RootState) => s.node.detailedWork);
const hasNames = (work?.unpaid || 0) > 0;
- return
+ return
{work && <>
{/* Main block value */}
diff --git a/src/pages/dashboard/DashboardPage.less b/src/pages/dashboard/DashboardPage.less
index 121561d..3922a74 100644
--- a/src/pages/dashboard/DashboardPage.less
+++ b/src/pages/dashboard/DashboardPage.less
@@ -10,74 +10,6 @@
& > .ant-col {
margin-bottom: @margin-md;
}
-
- & > .ant-col > .ant-card {
- display: flex;
- flex-direction: column;
-
- height: 100%;
-
- border: none;
- border-radius: @kw-big-card-border-radius;
-
- .ant-card-head {
- border-bottom: 0;
- margin-bottom: 0;
-
- border-radius: @kw-big-card-border-radius @kw-big-card-border-radius 0 0;
-
- .ant-card-head-title {
- padding-bottom: 0;
- }
- }
-
- .ant-card-body {
- padding-top: @padding-sm;
- }
-
- &.empty .ant-card-body {
- height: 100%;
- padding-top: 0;
- padding: 0;
-
- display: flex;
- align-items: center;
- justify-content: center;
-
- &::before, &::after {
- content: none;
- }
-
- .ant-empty-normal {
- margin: 0;
- }
-
- .ant-result {
- padding: @padding-sm;
-
- .ant-result-icon {
- margin-bottom: @margin-xs;
-
- .anticon { font-size: 48px; }
- }
- }
-
- @media (max-width: @screen-lg) {
- padding: @margin-md 0;
- }
- }
- }
- }
-
- .dashboard-statistic {
- &-title {
- color: @kw-text-secondary;
- display: block;
- }
-
- &-value {
- font-size: @heading-3-size;
- }
}
.dashboard-card-wallets {
diff --git a/src/pages/dashboard/Statistic.tsx b/src/pages/dashboard/Statistic.tsx
deleted file mode 100644
index a1f5b4a..0000000
--- a/src/pages/dashboard/Statistic.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under GPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import React from "react";
-
-import { useTranslation } from "react-i18next";
-
-interface Props {
- title?: string;
- titleKey?: string;
- value?: React.ReactNode;
-}
-
-export function Statistic({ title, titleKey, value }: Props): JSX.Element {
- const { t } = useTranslation();
-
- return
- {titleKey ? t(titleKey) : title}
- {value}
-
;
-}
diff --git a/src/pages/dashboard/TransactionsCard.tsx b/src/pages/dashboard/TransactionsCard.tsx
index bb1e6bf..b6cc518 100644
--- a/src/pages/dashboard/TransactionsCard.tsx
+++ b/src/pages/dashboard/TransactionsCard.tsx
@@ -2,6 +2,7 @@
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import React, { useState, useEffect, useMemo } from "react";
+import classNames from "classnames";
import { Card, Skeleton, Empty, Row } from "antd";
import { useSelector, shallowEqual } from "react-redux";
@@ -11,6 +12,7 @@
import { TransactionItem } from "./TransactionItem";
import { WalletMap } from "../../store/reducers/WalletsReducer";
+import { useSyncNode } from "../../krist/api";
import { lookupTransactions, LookupTransactionsResponse } from "../../krist/api/lookup";
import { SmallResult } from "../../components/SmallResult";
@@ -28,10 +30,10 @@
Object.values(wallets).map(w => w.address),
{ includeMined: true, limit: 5, orderBy: "id", order: "DESC" }
);
-};
+}
export function TransactionsCard(): JSX.Element {
- const syncNode = useSelector((s: RootState) => s.node.syncNode);
+ const syncNode = useSyncNode();
const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual);
const { t } = useTranslation();
@@ -68,8 +70,11 @@
}
const isEmpty = !loading && (error || !res || res.count === 0);
+ const classes = classNames("kw-card", "dashboard-card-transactions", {
+ "empty": isEmpty
+ });
- return
+ return
{error
?
diff --git a/src/pages/dashboard/WalletOverviewCard.tsx b/src/pages/dashboard/WalletOverviewCard.tsx
index b04f7ee..0c92327 100644
--- a/src/pages/dashboard/WalletOverviewCard.tsx
+++ b/src/pages/dashboard/WalletOverviewCard.tsx
@@ -12,7 +12,7 @@
import { Wallet } from "../../krist/wallets/Wallet";
import { KristValue } from "../../components/KristValue";
-import { Statistic } from "./Statistic";
+import { Statistic } from "../../components/Statistic";
import { WalletItem } from "./WalletItem";
import { keyedNullSort } from "../../utils";
@@ -34,7 +34,7 @@
topWallets.reverse();
const top4Wallets = topWallets.slice(0, 4);
- return
+ return
({ password }))();
@@ -14,7 +14,7 @@
salt: string;
tester: string;
password: string;
-};
+}
export const setMasterPassword = createAction(constants.SET_MASTER_PASSWORD,
(salt, tester, password): SetMasterPasswordPayload =>
({ salt, tester, password }))();
diff --git a/src/store/actions/WalletsActions.ts b/src/store/actions/WalletsActions.ts
index 793354f..26eea35 100644
--- a/src/store/actions/WalletsActions.ts
+++ b/src/store/actions/WalletsActions.ts
@@ -8,30 +8,30 @@
import { WalletMap } from "../reducers/WalletsReducer";
import { Wallet, WalletSyncable, WalletUpdatable } from "../../krist/wallets/Wallet";
-export interface LoadWalletsPayload { wallets: WalletMap };
+export interface LoadWalletsPayload { wallets: WalletMap }
export const loadWallets = createAction(constants.LOAD_WALLETS,
(wallets): LoadWalletsPayload => ({ wallets }))();
-export interface AddWalletPayload { wallet: Wallet };
+export interface AddWalletPayload { wallet: Wallet }
export const addWallet = createAction(constants.ADD_WALLET,
(wallet): AddWalletPayload => ({ wallet }))();
-export interface RemoveWalletPayload { id: string };
+export interface RemoveWalletPayload { id: string }
export const removeWallet = createAction(constants.REMOVE_WALLET,
(id): RemoveWalletPayload => ({ id }))();
-export interface UpdateWalletPayload { id: string; wallet: WalletUpdatable };
+export interface UpdateWalletPayload { id: string; wallet: WalletUpdatable }
export const updateWallet = createAction(constants.UPDATE_WALLET,
(id, wallet): UpdateWalletPayload => ({ id, wallet }))();
-export interface SyncWalletPayload { id: string; wallet: WalletSyncable };
+export interface SyncWalletPayload { id: string; wallet: WalletSyncable }
export const syncWallet = createAction(constants.SYNC_WALLET,
(id, wallet): SyncWalletPayload => ({ id, wallet }))();
-export interface SyncWalletsPayload { wallets: Record };
+export interface SyncWalletsPayload { wallets: Record }
export const syncWallets = createAction(constants.SYNC_WALLETS,
(wallets): SyncWalletsPayload => ({ wallets }))();
-export interface RecalculateWalletsPayload { wallets: Record };
+export interface RecalculateWalletsPayload { wallets: Record }
export const recalculateWallets = createAction(constants.RECALCULATE_WALLETS,
(wallets): RecalculateWalletsPayload => ({ wallets }))();
diff --git a/src/store/init.ts b/src/store/init.ts
new file mode 100644
index 0000000..08e93bb
--- /dev/null
+++ b/src/store/init.ts
@@ -0,0 +1,22 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { getInitialWalletManagerState } from "./reducers/WalletManagerReducer";
+import { getInitialWalletsState } from "./reducers/WalletsReducer";
+import { getInitialSettingsState } from "./reducers/SettingsReducer";
+import { getInitialNodeState } from "./reducers/NodeReducer";
+
+import { createStore } from "redux";
+import { devToolsEnhancer } from "redux-devtools-extension";
+import rootReducer from "./reducers/RootReducer";
+
+export const initStore = () => createStore(
+ rootReducer,
+ {
+ walletManager: getInitialWalletManagerState(),
+ wallets: getInitialWalletsState(),
+ settings: getInitialSettingsState(),
+ node: getInitialNodeState()
+ },
+ devToolsEnhancer({})
+);
diff --git a/src/style/card.less b/src/style/card.less
new file mode 100644
index 0000000..d4f3cef
--- /dev/null
+++ b/src/style/card.less
@@ -0,0 +1,61 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../App.less";
+
+.kw-card {
+ display: flex;
+ flex-direction: column;
+
+ height: 100%;
+
+ border: none;
+ border-radius: @kw-big-card-border-radius;
+
+ .ant-card-head {
+ border-bottom: 0;
+ margin-bottom: 0;
+
+ border-radius: @kw-big-card-border-radius @kw-big-card-border-radius 0 0;
+
+ .ant-card-head-title {
+ padding-bottom: 0;
+ }
+ }
+
+ .ant-card-body {
+ padding-top: @padding-sm;
+ }
+
+ &.empty .ant-card-body {
+ height: 100%;
+ padding-top: 0;
+ padding: 0;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &::before, &::after {
+ content: none;
+ }
+
+ .ant-empty-normal {
+ margin: 0;
+ }
+
+ .ant-result {
+ padding: @padding-sm;
+
+ .ant-result-icon {
+ margin-bottom: @margin-xs;
+
+ .anticon { font-size: 48px; }
+ }
+ }
+
+ @media (max-width: @screen-lg) {
+ padding: @margin-md 0;
+ }
+ }
+}
diff --git a/src/style/components.less b/src/style/components.less
index cbc0adf..67cc0d4 100644
--- a/src/style/components.less
+++ b/src/style/components.less
@@ -1,6 +1,8 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import "./card.less";
+
.big-menu.ant-menu.ant-menu-inline {
width: 100%;
@@ -168,3 +170,13 @@
opacity: 0.5 !important;
}
}
+
+.ant-result.full-page-result {
+ display: flex;
+ flex-direction: column;
+
+ align-items: center;
+ justify-content: center;
+
+ height: 100%;
+}
diff --git a/src/style/theme.less b/src/style/theme.less
index eca2fe9..6c03583 100644
--- a/src/style/theme.less
+++ b/src/style/theme.less
@@ -114,6 +114,8 @@
@kw-sidebar-collapse-duration: 350ms;
@kw-sidebar-backdrop-bg: @modal-mask-bg;
+@kw-page-header-height: 56px;
+
// header search
@kw-header-search-bg: @kw-light;
@kw-header-search-color: fade(@text-color, 50%);
diff --git a/src/utils/index.ts b/src/utils/index.ts
index a1d3f38..090ba07 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -87,3 +87,14 @@
// eslint-disable-next-line react-hooks/exhaustive-deps
export const useMountEffect = (fn: EffectCallback): void => useEffect(fn, []);
+
+
+/**
+ * Returns the ⌘ (command) symbol on macOS, and "Ctrl" everywhere else.
+ *
+ * NOTE: This is only evaluated on initial page load.
+ *
+ * REVIEW: This is a rather crude way to detect the platform, but it's the only
+ * method I could find online (with an admittedly non-exhaustive search)
+ */
+export const ctrl = /mac/i.test(navigator.platform) ? "\u2318" : "Ctrl";