From 95365fd12da03a988a3936a7c3531b71d51d0b25 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 26 Oct 2023 15:42:55 +0000 Subject: [PATCH] Transfer NFT, new HF 29 operations --- package.json | 2 +- src/App.scss | 5 + src/elements/DropdownMenu.jsx | 73 +++++++++++++ src/elements/DropdownMenu.scss | 53 ++++++++++ src/elements/PagedDropdownMenu.jsx | 151 ++++++++++++++++++++++++++ src/elements/PagedDropdownMenu.scss | 16 +++ src/elements/VerticalMenu.jsx | 36 +++++++ src/elements/VerticalMenu.scss | 74 +++++++++++++ src/elements/nft/NFTSmallIcon.jsx | 12 +++ src/elements/nft/NFTSmallIcon.scss | 11 ++ src/elements/nft/NFTTokens.jsx | 68 ++++++++++++ src/elements/nft/NFTTokens.scss | 16 +++ src/locales/en.json | 3 + src/locales/ru-RU.json | 3 + src/pages/sign/transfer_nft.jsx | 157 +++++++--------------------- src/server/oauth.js | 9 ++ src/utils/DomUtils.js | 5 + src/utils/LinkEx.jsx | 20 ++++ yarn.lock | 128 ++++++++++++++++++++--- 19 files changed, 708 insertions(+), 134 deletions(-) create mode 100644 src/elements/DropdownMenu.jsx create mode 100644 src/elements/DropdownMenu.scss create mode 100644 src/elements/PagedDropdownMenu.jsx create mode 100644 src/elements/PagedDropdownMenu.scss create mode 100644 src/elements/VerticalMenu.jsx create mode 100644 src/elements/VerticalMenu.scss create mode 100644 src/elements/nft/NFTSmallIcon.jsx create mode 100644 src/elements/nft/NFTSmallIcon.scss create mode 100644 src/elements/nft/NFTTokens.jsx create mode 100644 src/elements/nft/NFTTokens.scss create mode 100644 src/utils/DomUtils.js create mode 100644 src/utils/LinkEx.jsx 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 && } - {done ? diff --git a/src/server/oauth.js b/src/server/oauth.js index 498ecab..6bf9635 100644 --- a/src/server/oauth.js +++ b/src/server/oauth.js @@ -159,6 +159,15 @@ async function getChainData(account, action = 'transfer') { if (data.balances['GBG']) data.balances['GBG'] = acc.sbd_balance; + if (action === 'transfer_nft') { + data.nft_tokens = await golos.api.getNftTokensAsync({ + owner: account, + state: 'not_selling_only', + limit: 100, + }) + return data; + } + let uia = await golos.api.getAccountsBalancesAsync([account]); if (!uia[0]) { return data; diff --git a/src/utils/DomUtils.js b/src/utils/DomUtils.js new file mode 100644 index 0000000..36021a8 --- /dev/null +++ b/src/utils/DomUtils.js @@ -0,0 +1,5 @@ +export function findParent(el, class_name) { + if (el.className && el.className.indexOf && el.className.indexOf(class_name) !== -1) return el; + if (el.parentNode) return findParent(el.parentNode, class_name); + return null; +} diff --git a/src/utils/LinkEx.jsx b/src/utils/LinkEx.jsx new file mode 100644 index 0000000..62fd5f7 --- /dev/null +++ b/src/utils/LinkEx.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import Link from 'next/link' + +const isExternal = (url) => { + return /^https?:\/\//.test(url) +} + +class LinkEx extends React.Component { + render() { + const { props } = this + const { href, children } = props + if (isExternal(href) || href === '#') { + const rel = props.rel || 'noopener noreferrer' + return {children} + } + return {children} + } +} + +export default LinkEx diff --git a/yarn.lock b/yarn.lock index ca30db4..03de80c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1072,7 +1072,7 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= -assert@2.0.0, assert@^2.0.0: +assert@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/assert/-/assert-2.0.0.tgz#95fc1c616d48713510680f2eaf2d10dd22e02d32" integrity sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A== @@ -1082,6 +1082,17 @@ assert@2.0.0, assert@^2.0.0: object-is "^1.0.1" util "^0.12.0" +assert@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== + dependencies: + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" + ast-types-flow@^0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" @@ -1706,9 +1717,9 @@ core-js@^2.4.0: integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-js@^3.17.3: - version "3.32.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.32.0.tgz#7643d353d899747ab1f8b03d2803b0312a0fb3b6" - integrity sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww== + version "3.33.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.1.tgz#ef3766cfa382482d0a2c2bc5cb52c6d88805da52" + integrity sha512-qVSq3s+d4+GsqN0teRCJtM6tdEEXyWxjzbhVrCHmBS5ZTM0FS2MOS0D13dUXAWDUN6a+lHI/N1hF9Ytz6iLl9Q== core-js@^3.6.0: version "3.16.4" @@ -2005,6 +2016,15 @@ deepmerge@^2.1.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== +define-data-property@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3" + integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ== + dependencies: + get-intrinsic "^1.2.1" + gopd "^1.0.1" + has-property-descriptors "^1.0.0" + define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -2012,6 +2032,15 @@ define-properties@^1.1.3: dependencies: object-keys "^1.0.12" +define-properties@^1.1.4: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -2743,6 +2772,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" @@ -2783,6 +2817,16 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b" + integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA== + dependencies: + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + get-orientation@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/get-orientation/-/get-orientation-1.1.2.tgz#20507928951814f8a91ded0a0e67b29dfab98947" @@ -2924,10 +2968,10 @@ gmail-send@^1.8.10: lodash "^4.17.15" nodemailer "^6.3.0" -golos-lib-js@^0.9.54: - version "0.9.54" - resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.54.tgz#87399d038c948f5d447e457df768c5d3fb26c8b2" - integrity sha512-YyvWp3QL5jH+1PbYl23lCIIVz0znNWytCZPAfzk4SKbGv3PRYXRB/Jp8ZZNjmJsXMby5+ZB5imRLFZo+ggfXXg== +golos-lib-js@^0.9.60: + version "0.9.60" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.60.tgz#abf7e88499954312c817ebc38532e8e8d0451cbd" + integrity sha512-9UlH8OLTZj70godojecYTHIJ/6X+YW80zDVTYEq4qGDjZFlIAyczqu4UHlwZIxOtlZoyFMFtxMonWEXkw3nnPg== dependencies: abort-controller "^3.0.0" assert "^2.0.0" @@ -2951,6 +2995,13 @@ golos-lib-js@^0.9.54: stream-browserify "^3.0.0" ws "^8.2.3" +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + graceful-fs@4.1.15: version "4.1.15" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" @@ -3001,11 +3052,28 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-property-descriptors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340" + integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg== + dependencies: + get-intrinsic "^1.2.2" + +has-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" + integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== + has-symbols@^1.0.1, has-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" @@ -3042,6 +3110,13 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.1" +hasown@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c" + integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA== + dependencies: + function-bind "^1.1.2" + he@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -3369,7 +3444,7 @@ is-installed-globally@~0.4.0: global-dirs "^3.0.0" is-path-inside "^3.0.2" -is-nan@^1.2.1: +is-nan@^1.2.1, is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== @@ -4038,9 +4113,9 @@ node-fetch@2.6.1: integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== node-fetch@^2.6.12: - version "2.6.12" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba" - integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g== + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== dependencies: whatwg-url "^5.0.0" @@ -4172,7 +4247,7 @@ object-inspect@^1.11.0, object-inspect@^1.9.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1" integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg== -object-is@^1.0.1: +object-is@^1.0.1, object-is@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== @@ -4195,6 +4270,16 @@ object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" +object.assign@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.4" + has-symbols "^1.0.3" + object-keys "^1.1.1" + object.entries@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861" @@ -5903,6 +5988,17 @@ util@0.12.4, util@^0.12.0: safe-buffer "^5.1.2" which-typed-array "^1.1.2" +util@^0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + which-typed-array "^1.1.2" + utils-merge@1.x.x: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -6098,9 +6194,9 @@ wrappy@1: integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= ws@^8.2.3: - version "8.13.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" - integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== xtend@^4.0.2: version "4.0.2"