diff --git a/package.json b/package.json
index fd1002f..3931009 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"formik": "^2.2.9",
"git-rev-sync": "^3.0.1",
"gmail-send": "^1.8.10",
- "golos-lib-js": "^0.9.54",
+ "golos-lib-js": "^0.9.60",
"iron-session": "6.0.4",
"jspdf": "^2.3.0",
"lodash": "^4.17.11",
diff --git a/src/App.scss b/src/App.scss
index 252c87c..410e3af 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -6,9 +6,14 @@
@include foundation-everything(true);
@import "./elements/AccountMenu";
+@import "./elements/DropdownMenu";
@import "./elements/GeneratedPasswordInput";
@import "./elements/Expandable";
@import "./elements/LoadingIndicator";
+@import "./elements/nft/NFTSmallIcon";
+@import "./elements/nft/NFTTokens";
+@import "./elements/PagedDropdownMenu";
+@import "./elements/VerticalMenu";
@import "./modules/Header";
@import "./modules/LoginForm";
diff --git a/src/elements/DropdownMenu.jsx b/src/elements/DropdownMenu.jsx
new file mode 100644
index 0000000..8213e9a
--- /dev/null
+++ b/src/elements/DropdownMenu.jsx
@@ -0,0 +1,73 @@
+import React from 'react'
+
+import VerticalMenu from '@/elements/VerticalMenu'
+import { findParent } from '@/utils/DomUtils'
+
+export default class DropdownMenu extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ shown: false,
+ selected: props.selected
+ };
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.hide);
+ }
+
+ toggle = (e) => {
+ const {shown} = this.state
+ if(shown) this.hide(e)
+ else this.show(e)
+ }
+
+ show = (e) => {
+ e.preventDefault();
+ this.setState({shown: true});
+ setTimeout(() => {
+ document.addEventListener('click', this.hide)
+ }, 1)
+ };
+
+ hide = (e) => {
+ // Do not hide the dropdown if there was a click within it.
+ const inside_dropdown = !!findParent(e.target, 'VerticalMenu');
+ if (inside_dropdown) return;
+
+ e.preventDefault();
+ this.setState({shown: false});
+ document.removeEventListener('click', this.hide);
+ };
+
+ navigate = (e) => {
+ const a = e.target.nodeName.toLowerCase() === 'a' ? e.target : e.target.parentNode;
+ this.setState({show: false});
+ if (a.host !== window.location.host) return;
+ e.preventDefault();
+ window.location.href = a.pathname + a.search
+ };
+
+ getSelectedLabel = (items, selected) => {
+ const selectedEntry = items.find(i => i.value === selected)
+ const selectedLabel = selectedEntry && selectedEntry.label ? selectedEntry.label : selected
+ return selectedLabel
+ }
+
+ render() {
+ const {el, items, selected, children, className, title, href, onClick, noArrow} = this.props;
+ const hasDropdown = items.length > 0
+
+ let entry = children ||
+ {this.getSelectedLabel(items, selected)}
+ {hasDropdown && !noArrow && }
+
+
+ if(hasDropdown) entry = { onClick(e); this.toggle(e) } : this.toggle}>{entry}
+
+ const menu = ;
+ const cls = 'DropdownMenu' + (this.state.shown ? ' show' : '') + (className ? ` ${className}` : '')
+ return React.createElement(el, {className: cls}, [entry, menu]);
+ }
+}
+
diff --git a/src/elements/DropdownMenu.scss b/src/elements/DropdownMenu.scss
new file mode 100644
index 0000000..429ef04
--- /dev/null
+++ b/src/elements/DropdownMenu.scss
@@ -0,0 +1,53 @@
+.DropdownMenu {
+ position: relative;
+ display: inline-block;
+
+ .Icon.dropdown-arrow {
+ top: 2px;
+ margin-right: 0;
+ }
+
+ > .VerticalMenu {
+ visibility: hidden;
+ // min-width: 145px;
+ min-width: 232px;
+ z-index: 1000;
+
+ background-color: white;
+
+ display: block;
+ border: 1px solid $medium-gray;
+ border-radius: $global-radius;
+ opacity: 0;
+ position: absolute;
+ top: 100%;
+
+ // width: auto;
+ transform: translateY(10%);
+ transition: all 0.3s ease 0s, visibility 0s linear 0.3s;
+ box-shadow: 1px 1px 5px 0px rgba(50, 50, 50, 0.75);
+ }
+
+ &.show > .VerticalMenu {
+ visibility: visible;
+ opacity: 1;
+ transform: translateX(0%);
+ transition-delay: 0s;
+ }
+
+ .DropdownMenu.move-left {
+ .VerticalMenu {
+ left: -50%;
+ }
+ }
+
+ &.above > .VerticalMenu {
+ bottom: 100%;
+ top: auto;
+ }
+
+ &.top-most > .VerticalMenu {
+ position: fixed;
+ top: auto;
+ }
+}
diff --git a/src/elements/PagedDropdownMenu.jsx b/src/elements/PagedDropdownMenu.jsx
new file mode 100644
index 0000000..a78482e
--- /dev/null
+++ b/src/elements/PagedDropdownMenu.jsx
@@ -0,0 +1,151 @@
+import React from 'react'
+import cloneDeep from 'lodash/cloneDeep'
+import isEqual from 'lodash/isEqual'
+import tt from 'counterpart'
+
+import DropdownMenu from '@/elements/DropdownMenu'
+import LoadingIndicator from '@/elements/LoadingIndicator'
+
+const hideLastItem = true;
+
+export default class PagedDropdownMenu extends React.Component {
+ static defaultProps = {
+ page: 1
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ items: [],
+ page: props.page,
+ loading: false,
+ };
+ }
+
+ componentDidMount() {
+ const { items, page, } = this.props
+ this.initItems(this.sliceItems(items, page))
+ this.setState({ page })
+ }
+
+ componentDidUpdate(prevProps) {
+ const { items, page, } = this.props
+ if (items && (!prevProps.items || !isEqual(items, prevProps.items))) {
+ const sliced = this.sliceItems(items, 1)
+ this.initItems(sliced)
+ this.setState({ page: 1 })
+ } else if (page && prevProps.page !== page) {
+ this.setState({ page })
+ }
+ }
+
+ sliceItems = (items, page) => {
+ const { onLoadMore, perPage } = this.props
+ if (onLoadMore) {
+ return items
+ }
+ const startIdx = perPage * (page - 1)
+ const endIdx = startIdx + perPage + 1
+ const sliced = items.slice(startIdx, endIdx)
+ return sliced
+ }
+
+ initItems = (items) => {
+ if (!items || !items.length)
+ return;
+ this.setState({
+ items: cloneDeep(items),
+ });
+ };
+
+ loadMore = async (newPage) => {
+ const { items, page, } = this.state;
+ const { onLoadMore, } = this.props;
+ if (!onLoadMore) {
+ setTimeout(async () => {
+ this.setState({
+ page: newPage
+ }, () => {
+ this.initItems(this.sliceItems(this.props.items, newPage))
+ })
+ }, 10);
+ return
+ }
+ setTimeout(async () => {
+ this.setState({
+ page: newPage,
+ loading: true,
+ });
+ if (onLoadMore) {
+ const res = await onLoadMore({ page, newPage, items, });
+ this.setState({
+ loading: false,
+ });
+ this.initItems(res);
+ }
+ }, 10);
+ };
+
+ nextPage = () => {
+ const { page, } = this.state;
+ this.loadMore(page + 1);
+ };
+
+ prevPage = () => {
+ if (this.state.page === 1) return;
+ const { page, } = this.state;
+ this.loadMore(page - 1);
+ };
+
+ _renderPaginator = () => {
+ const { perPage, } = this.props;
+ const { items, page, } = this.state;
+ const hasMore = items.length > perPage;
+ if (page === 1 && !hasMore) {
+ return null;
+ }
+ const hasPrev = page > 1
+ return {
+ value:
+
+ {hasPrev ? '< ' + tt('g.back') : ''}
+
+ {hasMore ? tt('g.more_list') + ' >' : ''}
+ ,
+ };
+ };
+
+ render() {
+ const { el, selected, children, className, title, href, noArrow, perPage, renderItem, } = this.props
+ const { items, loading, } = this.state;
+
+ let itemsWithPaginator = [];
+ if (!loading) {
+ for (let i = 0; i < items.length; ++i) {
+ const rendered = renderItem(items[i])
+ itemsWithPaginator.push(rendered)
+ }
+ if (items.length > perPage && hideLastItem) {
+ itemsWithPaginator.pop();
+ }
+ const paginator = this._renderPaginator();
+ if (paginator) {
+ itemsWithPaginator.push(paginator);
+ }
+ } else {
+ itemsWithPaginator = [{value:
+
+ }];
+ }
+
+ return ()
+ }
+};
\ No newline at end of file
diff --git a/src/elements/PagedDropdownMenu.scss b/src/elements/PagedDropdownMenu.scss
new file mode 100644
index 0000000..a6c88ee
--- /dev/null
+++ b/src/elements/PagedDropdownMenu.scss
@@ -0,0 +1,16 @@
+.PagedDropdownMenu__paginator {
+ display: inline-block !important;
+ text-align: center;
+ color: #0078C4 !important;
+ font-weight: normal !important;
+ font-size: 100% !important;
+ user-select: none;
+ cursor: pointer;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ width: 50%;
+
+ &.disabled {
+ cursor: auto;
+ }
+}
diff --git a/src/elements/VerticalMenu.jsx b/src/elements/VerticalMenu.jsx
new file mode 100644
index 0000000..1cc3923
--- /dev/null
+++ b/src/elements/VerticalMenu.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+
+import LinkEx from '@/utils/LinkEx'
+
+export default class VerticalMenu extends React.Component {
+ closeMenu = (e) => {
+ // If this was not a left click, or if CTRL or CMD were held, do not close the menu.
+ if(e.button !== 0 || e.ctrlKey || e.metaKey) return;
+
+ // Simulate clicking of document body which will close any open menus
+ document.body.click();
+ }
+
+ render() {
+ const {items, title, description, className, hideValue} = this.props;
+ return
+ {title && - {title}
}
+ {description && - {description}
}
+ {items.map((i, k) => {
+ if(i.value === hideValue) return null
+ const target = i.target
+ return -
+ {i.link ?
+ {i.icon}{i.label ? i.label : i.value}
+ {i.data && {i.data}}
+ {i.addon}
+ :
+
+ {i.icon}{i.label ? i.label : i.value}
+
+ }
+
+ })}
+
;
+ }
+}
diff --git a/src/elements/VerticalMenu.scss b/src/elements/VerticalMenu.scss
new file mode 100644
index 0000000..e8cbc65
--- /dev/null
+++ b/src/elements/VerticalMenu.scss
@@ -0,0 +1,74 @@
+.VerticalMenu {
+
+ width: 200px;
+
+ .Icon {
+ padding-left: 0.1rem;
+ margin-right: 1.3rem;
+ top: 0;
+ fill: $dark-gray;
+ }
+
+ > li > a {
+ display: flex;
+ align-items: center !important;
+ line-height: 1rem;
+ position: relative;
+ padding: 0.7rem 1rem;
+
+ font-size: 14px;
+ letter-spacing: 0.4px;
+ }
+
+ > li > a:hover {
+ background-color: #f0f0f0;
+ }
+
+ > li.title {
+ padding: 0.56rem;
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: 0.4px;
+ line-height: 16px;
+ text-align: center;
+ border-bottom: 1px solid $light-gray;
+ }
+
+ > li.description {
+ padding: 0.1rem;
+ font-size: 87.5%;
+ text-align: center;
+ border-bottom: 1px solid $light-gray;
+ }
+
+ &_nav-profile {
+ width: 262px;
+
+ & > li > a {
+ padding: 0 1rem 0 3.8rem;
+ line-height: 50px;
+
+ .Icon {
+ }
+
+ &:hover {
+ .Icon {
+ fill: #3F46AD;
+ }
+ }
+ }
+
+ }
+
+ &_nav-additional {
+ width: 260px;
+ margin: 10px 0;
+
+ & > li > a {
+ padding: 0 20px;
+ line-height: 50px;
+ }
+ }
+
+}
+
diff --git a/src/elements/nft/NFTSmallIcon.jsx b/src/elements/nft/NFTSmallIcon.jsx
new file mode 100644
index 0000000..c3dba89
--- /dev/null
+++ b/src/elements/nft/NFTSmallIcon.jsx
@@ -0,0 +1,12 @@
+import React, { Component, } from 'react'
+
+class NFTSmallIcon extends Component {
+ render() {
+ const { image, ...rest } = this.props
+
+ return
+ }
+}
+
+export default NFTSmallIcon
diff --git a/src/elements/nft/NFTSmallIcon.scss b/src/elements/nft/NFTSmallIcon.scss
new file mode 100644
index 0000000..0ee5941
--- /dev/null
+++ b/src/elements/nft/NFTSmallIcon.scss
@@ -0,0 +1,11 @@
+.NFTSmallIcon {
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: 50% 50%;
+ border-radius: 50%;
+
+ width: 3rem;
+ height: 3rem;
+ display: inline-block;
+ vertical-align: top;
+}
diff --git a/src/elements/nft/NFTTokens.jsx b/src/elements/nft/NFTTokens.jsx
new file mode 100644
index 0000000..17cdf85
--- /dev/null
+++ b/src/elements/nft/NFTTokens.jsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import tt from 'counterpart'
+
+import NFTSmallIcon from '@/elements/nft/NFTSmallIcon'
+import PagedDropdownMenu from '@/elements/PagedDropdownMenu'
+
+export function NFTImageStub() {
+ return '/images/nft.png'
+}
+
+export function parseNFTImage(json_metadata, useStub = true) {
+ if (json_metadata) {
+ const meta = JSON.parse(json_metadata)
+ if (meta && meta.image) return meta.image
+ }
+ if (!useStub) return null
+ return NFTImageStub()
+}
+
+class NFTTokens extends React.Component {
+ render() {
+ let { tokens, selected } = this.props
+
+ let selectedItem
+
+ const items = []
+ for (const token of tokens) {
+ const image = parseNFTImage(token.json_metadata)
+
+ let data = {}
+ try {
+ data = JSON.parse(token.json_metadata)
+ } catch (err) {}
+
+ items.push({
+ key: token.token_id,
+ link: '#',
+ label:
+
+
+ {data.title || '#' + token.token_id}
+ ,
+ addon: {token.name},
+ value: token.token_id
+ })
+
+ if (selected === token.token_id) {
+ selectedItem =
+
+
+
+ {data.title || '#' + token.token_id}
+
+
+ }
+ }
+
+ return item}
+ selected={selected}
+ perPage={10}>
+ {selectedItem}
+
+
+ }
+}
+
+export default NFTTokens
diff --git a/src/elements/nft/NFTTokens.scss b/src/elements/nft/NFTTokens.scss
new file mode 100644
index 0000000..31767fc
--- /dev/null
+++ b/src/elements/nft/NFTTokens.scss
@@ -0,0 +1,16 @@
+.NFTTokens {
+ .NFTSmallIcon {
+ margin-top: 0.25rem;
+ margin-right: 0.25rem;
+ margin-bottom: 0.25rem;
+ width: 2rem;
+ height: 2rem;
+ }
+
+ .VerticalMenu {
+ a {
+ padding: 5px !important;
+ color: black !important;
+ }
+ }
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 1086007..9d1095e 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -191,6 +191,9 @@
"memo_is_public": "This memo is public.",
"submit": "Transfer"
},
+ "oauth_transfer_nft": {
+ "no_tokens": "You have not yet any NFT-tokens."
+ },
"oauth_donate": {
"submit": "Donate",
"balance": "TIP-balance: "
diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json
index e4d5b46..27b551c 100644
--- a/src/locales/ru-RU.json
+++ b/src/locales/ru-RU.json
@@ -191,6 +191,9 @@
"memo_is_public": "Эта заметка является публичной.",
"submit": "Перевести"
},
+ "oauth_transfer_nft": {
+ "no_tokens": "У вас пока еще нет NFT-токенов."
+ },
"oauth_donate": {
"submit": "Передать",
"balance": "TIP-баланс: "
diff --git a/src/pages/sign/transfer_nft.jsx b/src/pages/sign/transfer_nft.jsx
index c8f5bbb..5be83cb 100644
--- a/src/pages/sign/transfer_nft.jsx
+++ b/src/pages/sign/transfer_nft.jsx
@@ -4,9 +4,11 @@ import golos from 'golos-lib-js';
import { Asset, } from 'golos-lib-js/lib/utils';
import { Formik, Field, ErrorMessage, } from 'formik';
import Head from 'next/head';
-import LoadingIndicator from '@/elements/LoadingIndicator';
-import Header from '@/modules/Header';
+
+import LoadingIndicator from '@/elements/LoadingIndicator'
import LoginForm from '@/modules/LoginForm';
+import NFTTokens from '@/elements/nft/NFTTokens'
+import Header from '@/modules/Header'
import { getOAuthCfg, getChainData, } from '@/server/oauth';
import { getOAuthSession, } from '@/server/oauthSession';
import { withSecureHeadersSSR, } from '@/server/security';
@@ -14,14 +16,9 @@ import { callApi, } from '@/utils/OAuthClient';
import validate_account_name from '@/utils/validate_account_name';
const uncompose = (query, initial) => {
- initial.to = query.to || initial.to;
- const amountSym = query.amount;
- if (amountSym) {
- const [ amount, sym, ] = amountSym.split(' ');
- initial.amount = amount;
- initial.sym = sym;
- }
- initial.memo = query.memo || initial.memo;
+ initial.to = query.to || initial.to
+ if (query.token_id) initial.token_id = query.token_id
+ initial.memo = query.memo || initial.memo
};
export const getServerSideProps = withSecureHeadersSSR(async ({ req, res, resolvedUrl, query, }) => {
@@ -41,8 +38,6 @@ export const getServerSideProps = withSecureHeadersSSR(async ({ req, res, resolv
initial = {
from: session.account,
to: '',
- amount: '',
- sym: Object.keys(chainData.balances)[0],
memo: '',
};
uncompose(query, initial);
@@ -65,16 +60,21 @@ class TransferNFT extends React.Component {
state = {
};
- normalizeBalances = () => {
+ componentDidMount() {
+ const { initial } = this.props
+ if (initial.token_id) {
+ this.setState({
+ selected: initial.token_id
+ })
+ }
+ }
+
+ normalizeChainData = () => {
const { chainData, } = this.props;
if (!chainData || !golos.isNativeLibLoaded()) {
return;
}
- let balances = { ...chainData.balances, };
- for (const sym in balances) {
- balances[sym] = Asset(balances[sym]);
- }
- return balances;
+ return chainData
}
onToChange = (e, handle) => {
@@ -83,24 +83,6 @@ class TransferNFT extends React.Component {
return handle(e);
};
- onAmountChange = (e, values, handle) => {
- let value = e.target.value.trim().toLowerCase();
- if (isNaN(value) || parseFloat(value) < 0) {
- e.target.value = values.amount || '';
- return;
- }
- e.target.value = value;
- return handle(e);
- };
-
- useAllBalance = (e, sym, setFieldValue) => {
- const balances = this.normalizeBalances();
- let balance = balances && sym && balances[sym];
- if (!balance) balance = Asset(0, 3, 'GOLOS');
-
- setFieldValue('amount', balance.amountFloat.toString());
- };
-
validate = (values) => {
const errors = {};
if (!values.to) {
@@ -110,21 +92,6 @@ class TransferNFT extends React.Component {
if (err) errors.to = err;
}
- if (!values.amount) {
- errors.amount = tt('g.required');
- } else if (parseFloat(values.amount) === 0) {
- errors.amount = tt('g.required');
- } else {
- const balances = this.normalizeBalances();
- const { amount, sym, } = values;
- if (!isNaN(amount) && balances && sym && balances[sym]) {
- let balance = balances[sym].amountFloat;
- if (!isNaN(balance) && balance < parseFloat(amount)) {
- errors.amount = tt('oauth_transfer.insufficient');
- }
- }
- }
-
return errors;
};
@@ -135,24 +102,9 @@ class TransferNFT extends React.Component {
const { action, session, oauthCfg, } = this.props;
const { sign_endpoint, } = oauthCfg;
- const balances = this.normalizeBalances();
- const { from, to, sym, } = values;
-
- let amount = Asset(0, balances[sym].precision, sym);
- amount.amountFloat = values.amount
- amount = amount.toString();
+ const { from, to, } = values;
let memo = values.memo || '';
- if (action === 'donate') {
- memo = {};
- memo.app = "golos-blog";
- memo.version = 1;
- memo.comment = values.memo || '';
- memo.target = {
- author: to,
- permlink: ""
- };
- }
golos.config.set('websocket', sign_endpoint);
golos.config.set('credentials', 'include');
@@ -167,12 +119,8 @@ class TransferNFT extends React.Component {
done: true,
})
};
- if (action === 'transfer')
- golos.broadcast[action]('', from, to,
- amount, memo, callback);
- else
- golos.broadcast[action]('', from, to,
- amount, memo, [], callback);
+ golos.broadcast.nftTransfer('', from, to,
+ 'amount', memo, callback);
};
compose = (values) => {
@@ -182,18 +130,17 @@ class TransferNFT extends React.Component {
let url = window.location.href.split('?')[0];
- const balances = this.normalizeBalances();
- const { sym, to, memo, } = values;
+ const chainData = this.normalizeChainData()
+ const { to, memo, } = values;
- if (!golos.isNativeLibLoaded() || !balances || !balances[sym])
+ if (!golos.isNativeLibLoaded() || !chainData)
return '...';
- let amount = Asset(0, balances[sym].precision, sym);
- amount.amountFloat = values.amount || '0'
-
url += '?';
url += 'to=' + (to || '');
- url += '&amount=' + amount.toString();
+ if (this.state.selected) {
+ url += '&token_id=' + this.state.selected
+ }
url += '&memo=' + (memo || '');
return (
@@ -204,8 +151,13 @@ class TransferNFT extends React.Component {
render() {
const { state, } = this;
- const { done, } = state;
- const balances = this.normalizeBalances();
+ const { done, selected, } = state;
+ const chainData = this.normalizeChainData()
+
+ if (!chainData)
+ return null
+
+ const { nft_tokens } = chainData
const { action, oauthCfg, session, initial, } = this.props;
const { account, } = session;
@@ -223,20 +175,8 @@ class TransferNFT extends React.Component {
);
}
- const getBalance = (sym) => {
- let balance = balances && sym && balances[sym];
- if (!balance) balance = Asset(0, 3, 'GOLOS');
- return balance;
- };
-
- let balanceOptions = [];
- if (balances)
- for (let bal of Object.keys(balances)) {
- balanceOptions.push();
- }
-
let form = null;
- if (initial && balances) form = (
-
- this.onAmountChange(e, values, handleChange)}
- />
-
-
- {balanceOptions}
-
-
-
- this.useAllBalance(e, values.sym, setFieldValue)}>
- {tt((donate ? 'oauth_donate' : 'oauth_transfer') + '.balance') + getBalance(values.sym)}
-
-
+ {nft_tokens.length ?
+ :
+ {tt('oauth_transfer_nft.no_tokens')}
}
{isSubmitting && }
-