feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveAccountInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId, Address } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useDeriveAccountInfo, useSystemApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
label?: React.ReactNode;
|
||||
value?: string | AccountId | Address | null | Uint8Array;
|
||||
}
|
||||
|
||||
function extractIndex ({ accountIndex }: Partial<DeriveAccountInfo> = {}): string | null {
|
||||
return accountIndex
|
||||
? accountIndex.toString()
|
||||
: null;
|
||||
}
|
||||
|
||||
function AccountIndex ({ children, className = '', defaultValue, label, value }: Props): React.ReactElement<Props> | null {
|
||||
const api = useSystemApi();
|
||||
const info = useDeriveAccountInfo(value);
|
||||
|
||||
const accountIndex = useMemo(
|
||||
() => extractIndex(info),
|
||||
[info]
|
||||
);
|
||||
|
||||
if (!api?.query.indices) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className} ui--AccountIndex`}>
|
||||
{label || ''}<div className='account-index'>{accountIndex || defaultValue || '-'}</div>{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AccountIndex);
|
||||
@@ -0,0 +1,294 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-query authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
import type { DeriveAccountRegistration } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { statics } from '@pezkuwi/react-api/statics';
|
||||
import { useApi, useDeriveAccountInfo } from '@pezkuwi/react-hooks';
|
||||
import { AccountSidebarCtx } from '@pezkuwi/react-hooks/ctx/AccountSidebar';
|
||||
import { formatNumber, isCodec, isFunction, isU8a, stringToU8a, u8aEmpty, u8aEq, u8aToBn } from '@pezkuwi/util';
|
||||
import { decodeAddress } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { getAddressName } from './util/index.js';
|
||||
import Badge from './Badge.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
defaultName?: string;
|
||||
label?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
override?: React.ReactNode;
|
||||
// this is used by app-account/addresses to toggle editing
|
||||
toggle?: unknown;
|
||||
value: AccountId | AccountIndex | Address | string | Uint8Array | null | undefined;
|
||||
withSidebar?: boolean;
|
||||
}
|
||||
|
||||
type AddrMatcher = (addr: unknown) => string | null;
|
||||
|
||||
function createAllMatcher (prefix: string, name: string): AddrMatcher {
|
||||
const test = statics.registry.createType('AccountId', stringToU8a(prefix.padEnd(32, '\0')));
|
||||
|
||||
return (addr: unknown) =>
|
||||
test.eq(addr)
|
||||
? name
|
||||
: null;
|
||||
}
|
||||
|
||||
function createNumMatcher (prefix: string, name: string, add?: string): AddrMatcher {
|
||||
const test = stringToU8a(prefix);
|
||||
|
||||
// 4 bytes for u32 (more should not hurt, LE)
|
||||
const minLength = test.length + 4;
|
||||
|
||||
return (addr: unknown): string | null => {
|
||||
try {
|
||||
const decoded = isU8a(addr) ? addr : isCodec(addr) ? addr.toU8a() : decodeAddress(addr?.toString() || '');
|
||||
const type = decoded.length === 20 ? 'AccountId20' : 'AccountId';
|
||||
const u8a = statics.registry.createType(type, decoded).toU8a();
|
||||
|
||||
return (u8a.length >= minLength) && u8aEq(test, u8a.subarray(0, test.length)) && u8aEmpty(u8a.subarray(minLength))
|
||||
? `${name} ${formatNumber(u8aToBn(u8a.subarray(test.length, minLength)))}${add ? ` (${add})` : ''}`
|
||||
: null;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const MATCHERS: AddrMatcher[] = [
|
||||
createAllMatcher('modlpy/socie', 'Society'),
|
||||
createAllMatcher('modlpy/trsry', 'Treasury'),
|
||||
createAllMatcher('modlpy/xcmch', 'XCM'),
|
||||
createNumMatcher('modlpy/cfund', 'Crowdloan'),
|
||||
// Bizinikiwi master
|
||||
createNumMatcher('modlpy/npols\x00', 'Pool', 'Stash'),
|
||||
createNumMatcher('modlpy/npols\x01', 'Pool', 'Reward'),
|
||||
// Zagros
|
||||
createNumMatcher('modlpy/nopls\x00', 'Pool', 'Stash'),
|
||||
createNumMatcher('modlpy/nopls\x01', 'Pool', 'Reward'),
|
||||
createNumMatcher('para', 'Child'),
|
||||
createNumMatcher('sibl', 'Sibling')
|
||||
];
|
||||
|
||||
const displayCache = new Map<string, React.ReactNode>();
|
||||
const indexCache = new Map<string, string>();
|
||||
const parentCache = new Map<string, string>();
|
||||
|
||||
export function getParentAccount (value: string): string | undefined {
|
||||
return parentCache.get(value);
|
||||
}
|
||||
|
||||
function defaultOrAddr (defaultName = '', _address: AccountId | AccountIndex | Address | string | Uint8Array, _accountIndex?: AccountIndex | null): [displayName: React.ReactNode, isLocal: boolean, isAddress: boolean, isSpecial: boolean] {
|
||||
let known: string | null = null;
|
||||
|
||||
for (let i = 0; known === null && i < MATCHERS.length; i++) {
|
||||
known = MATCHERS[i](_address);
|
||||
}
|
||||
|
||||
if (known) {
|
||||
return [known, false, false, true];
|
||||
}
|
||||
|
||||
const accountId = _address.toString();
|
||||
|
||||
if (!accountId) {
|
||||
return [defaultName, false, false, false];
|
||||
}
|
||||
|
||||
const [isAddressExtracted, , extracted] = getAddressName(accountId, null, defaultName);
|
||||
const accountIndex = (_accountIndex || '').toString() || indexCache.get(accountId);
|
||||
|
||||
if (isAddressExtracted && accountIndex) {
|
||||
indexCache.set(accountId, accountIndex);
|
||||
|
||||
return [accountIndex, false, true, false];
|
||||
}
|
||||
|
||||
return [extracted, !isAddressExtracted, isAddressExtracted, false];
|
||||
}
|
||||
|
||||
function defaultOrAddrNode (defaultName = '', address: AccountId | AccountIndex | Address | string | Uint8Array, accountIndex?: AccountIndex | null): React.ReactNode {
|
||||
const [node, , isAddress] = defaultOrAddr(defaultName, address, accountIndex);
|
||||
|
||||
return isAddress
|
||||
? <span className='isAddress'>{node}</span>
|
||||
: node;
|
||||
}
|
||||
|
||||
function extractName (address: string, accountIndex?: AccountIndex, defaultName?: string): React.ReactNode {
|
||||
const displayCached = displayCache.get(address);
|
||||
|
||||
if (displayCached) {
|
||||
return displayCached;
|
||||
}
|
||||
|
||||
const [displayName, isLocal, isAddress, isSpecial] = defaultOrAddr(defaultName, address, accountIndex);
|
||||
|
||||
return (
|
||||
<span className='via-identity'>
|
||||
{isSpecial && (
|
||||
<Badge
|
||||
color='green'
|
||||
icon='archway'
|
||||
isSmall
|
||||
/>
|
||||
)}
|
||||
<span className={`name${(isLocal || isSpecial) ? ' isLocal' : (isAddress ? ' isAddress' : '')}`}>{displayName}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function createIdElem (nameElem: React.ReactNode, color: 'green' | 'red' | 'gray', icon: IconName): React.ReactNode {
|
||||
return (
|
||||
<span className='via-identity'>
|
||||
<Badge
|
||||
color={color}
|
||||
icon={icon}
|
||||
isSmall
|
||||
/>
|
||||
{nameElem}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function extractIdentity (address: string, identity: DeriveAccountRegistration): React.ReactNode {
|
||||
const judgements = identity.judgements.filter(([, judgement]) => !judgement.isFeePaid);
|
||||
const isGood = judgements.some(([, judgement]) => judgement.isKnownGood || judgement.isReasonable);
|
||||
const isBad = judgements.some(([, judgement]) => judgement.isErroneous || judgement.isLowQuality);
|
||||
const displayName = isGood
|
||||
? identity.display
|
||||
: (identity.display || '').replace(/[^\x20-\x7E]/g, '');
|
||||
const displayParent = identity.displayParent && (
|
||||
isGood
|
||||
? identity.displayParent
|
||||
: identity.displayParent.replace(/[^\x20-\x7E]/g, '')
|
||||
);
|
||||
const elem = createIdElem(
|
||||
<span className={`name${isGood && !isBad ? ' isGood' : ''}`}>
|
||||
<span className='top'>{displayParent || displayName}</span>
|
||||
{displayParent && <span className='sub'>{`/${displayName || ''}`}</span>}
|
||||
</span>,
|
||||
(isBad ? 'red' : (isGood ? 'green' : 'gray')),
|
||||
identity.parent ? 'link' : (isGood && !isBad ? 'check' : 'minus')
|
||||
);
|
||||
|
||||
displayCache.set(address, elem);
|
||||
|
||||
return elem;
|
||||
}
|
||||
|
||||
function AccountName ({ children, className = '', defaultName, label, onClick, override, toggle, value, withSidebar }: Props): React.ReactElement<Props> {
|
||||
const { apiIdentity } = useApi();
|
||||
const info = useDeriveAccountInfo(value);
|
||||
const [name, setName] = useState<React.ReactNode>(() => extractName((value || '').toString(), undefined, defaultName));
|
||||
const toggleSidebar = useContext(AccountSidebarCtx);
|
||||
|
||||
// set the actual nickname, local name, accountIndex, accountId
|
||||
useEffect((): void => {
|
||||
const { accountId, accountIndex, identity, nickname } = info || {};
|
||||
const cacheAddr = (accountId || value || '').toString();
|
||||
|
||||
if (identity?.parent) {
|
||||
parentCache.set(cacheAddr, identity.parent.toString());
|
||||
}
|
||||
|
||||
if (apiIdentity && isFunction(apiIdentity.query.identity?.identityOf)) {
|
||||
setName(() =>
|
||||
identity?.display
|
||||
? extractIdentity(cacheAddr, identity)
|
||||
: extractName(cacheAddr, accountIndex)
|
||||
);
|
||||
} else if (nickname) {
|
||||
setName(nickname);
|
||||
} else {
|
||||
setName(defaultOrAddrNode(defaultName, cacheAddr, accountIndex));
|
||||
}
|
||||
}, [apiIdentity, defaultName, info, toggle, value]);
|
||||
|
||||
const _onNameEdit = useCallback(
|
||||
() => setName(defaultOrAddrNode(defaultName, (value || '').toString())),
|
||||
[defaultName, value]
|
||||
);
|
||||
|
||||
const _onToggleSidebar = useCallback(
|
||||
() => toggleSidebar && value && toggleSidebar([value.toString(), _onNameEdit]),
|
||||
[_onNameEdit, toggleSidebar, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSpan
|
||||
className={`${className} ui--AccountName ${withSidebar ? 'withSidebar' : ''}`}
|
||||
data-testid='account-name'
|
||||
onClick={
|
||||
withSidebar
|
||||
? _onToggleSidebar
|
||||
: onClick
|
||||
}
|
||||
>
|
||||
{label || ''}{override || name}{children}
|
||||
</StyledSpan>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSpan = styled.span`
|
||||
border: 1px dotted transparent;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
&.withSidebar:hover {
|
||||
border-bottom-color: #333;
|
||||
cursor: help !important;
|
||||
}
|
||||
|
||||
.isAddress {
|
||||
display: inline-block;
|
||||
min-width: var(--width-shortaddr);
|
||||
max-width: var(--width-shortaddr);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.via-identity {
|
||||
word-break: break-all;
|
||||
|
||||
.name {
|
||||
font-weight: var(--font-weight-normal) !important;
|
||||
filter: grayscale(100%);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:not(.isAddress) {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.isAddress {
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
|
||||
.sub,
|
||||
.top {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: var(--font-size-tiny);
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AccountName);
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import Button from '../Button/index.js';
|
||||
import { TransferModal } from '../modals/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
flags: AddressFlags;
|
||||
isEditingName: boolean;
|
||||
isEditing: boolean;
|
||||
toggleIsEditingName: () => void;
|
||||
toggleIsEditingTags: () => void;
|
||||
onCancel: () => void;
|
||||
onSaveName: () => void;
|
||||
onSaveTags: () => void;
|
||||
onForgetAddress: () => void;
|
||||
onUpdateName?: (() => void) | null;
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
function AccountMenuButtons ({ className = '', flags, isEditing, isEditingName, onCancel, onForgetAddress, onSaveName, onSaveTags, onUpdateName, recipientId, toggleIsEditingName, toggleIsEditingTags }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isTransferOpen, toggleIsTransferOpen] = useToggle();
|
||||
const api = useApi();
|
||||
|
||||
const _onForgetAddress = useCallback(
|
||||
(): void => {
|
||||
onForgetAddress();
|
||||
onUpdateName && onUpdateName();
|
||||
},
|
||||
[onForgetAddress, onUpdateName]
|
||||
);
|
||||
|
||||
const toggleIsEditing = useCallback(() => {
|
||||
flags.isEditable && toggleIsEditingName();
|
||||
toggleIsEditingTags();
|
||||
}, [flags.isEditable, toggleIsEditingName, toggleIsEditingTags]);
|
||||
|
||||
const _onUpdateName = useCallback(
|
||||
(): void => {
|
||||
onSaveName();
|
||||
onUpdateName && onUpdateName();
|
||||
},
|
||||
[onSaveName, onUpdateName]
|
||||
);
|
||||
|
||||
const updateName = useCallback(() => {
|
||||
if (isEditingName && (flags.isInContacts || flags.isOwned)) {
|
||||
_onUpdateName();
|
||||
toggleIsEditingName();
|
||||
}
|
||||
}, [isEditingName, flags.isInContacts, flags.isOwned, _onUpdateName, toggleIsEditingName]);
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
if (isEditing) {
|
||||
updateName();
|
||||
onSaveTags();
|
||||
}
|
||||
|
||||
toggleIsEditing();
|
||||
}, [isEditing, toggleIsEditing, updateName, onSaveTags]);
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--AddressMenu-buttons`}>
|
||||
{isEditing
|
||||
? (
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='times'
|
||||
label={t('Cancel')}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<Button
|
||||
icon='save'
|
||||
label={t('Save')}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
</Button.Group>
|
||||
)
|
||||
: (
|
||||
<Button.Group>
|
||||
{(isFunction(api.api.tx.balances?.transferAllowDeath) || isFunction(api.api.tx.balances?.transfer)) && (
|
||||
<Button
|
||||
icon='paper-plane'
|
||||
isDisabled={isEditing}
|
||||
label={t('Send')}
|
||||
onClick={toggleIsTransferOpen}
|
||||
/>
|
||||
)}
|
||||
{!flags.isOwned && !flags.isInContacts && (
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={isEditing}
|
||||
label={t('Save')}
|
||||
onClick={_onUpdateName}
|
||||
/>
|
||||
)}
|
||||
{!flags.isOwned && flags.isInContacts && (
|
||||
<Button
|
||||
icon='ban'
|
||||
isDisabled={isEditing}
|
||||
label={t('Remove')}
|
||||
onClick={_onForgetAddress}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon='edit'
|
||||
isDisabled={!flags.isEditable}
|
||||
label={t('Edit')}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
</Button.Group>
|
||||
)
|
||||
}
|
||||
{isTransferOpen && (
|
||||
<TransferModal
|
||||
key='modal-transfer'
|
||||
onClose={toggleIsTransferOpen}
|
||||
recipientId={recipientId}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
width: 100%;
|
||||
|
||||
.ui--Button-Group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AccountMenuButtons);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
|
||||
|
||||
import React from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import AccountName from '../AccountName.js';
|
||||
import Button from '../Button/index.js';
|
||||
import IdentityIcon from '../IdentityIcon/index.js';
|
||||
import Input from '../Input.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
value: string,
|
||||
editingName: boolean,
|
||||
defaultValue: string,
|
||||
onChange: (value: string) => void,
|
||||
flags: AddressFlags,
|
||||
accountIndex: string | undefined,
|
||||
}
|
||||
|
||||
function AddressSection ({ accountIndex, defaultValue, editingName, flags, onChange, value }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isCopyShown, toggleIsCopyShown] = useToggle();
|
||||
const NOOP = () => undefined;
|
||||
|
||||
return (
|
||||
<div className='ui--AddressSection'>
|
||||
<IdentityIcon
|
||||
size={80}
|
||||
value={value}
|
||||
/>
|
||||
<div className='ui--AddressSection__AddressColumn'>
|
||||
<AccountName
|
||||
override={
|
||||
editingName
|
||||
? (
|
||||
<Input
|
||||
className='name--input'
|
||||
defaultValue={defaultValue}
|
||||
label='name-input'
|
||||
onChange={onChange}
|
||||
withLabel={false}
|
||||
/>
|
||||
)
|
||||
: flags.isEditable
|
||||
? (defaultValue.toUpperCase() || t('<unknown>'))
|
||||
: undefined
|
||||
}
|
||||
value={value}
|
||||
withSidebar={false}
|
||||
/>
|
||||
<div className='ui--AddressMenu-addr'>
|
||||
{value}
|
||||
</div>
|
||||
{accountIndex && (
|
||||
<div className='ui--AddressMenu-index'>
|
||||
<label>{t('index')}:</label> {accountIndex}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='ui--AddressSection__CopyColumn'>
|
||||
<div className='ui--AddressMenu-copyaddr'>
|
||||
<CopyToClipboard
|
||||
text={value}
|
||||
>
|
||||
<span>
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon={isCopyShown ? 'check' : 'copy'}
|
||||
label={isCopyShown ? t('Copied') : t('Copy')}
|
||||
onClick={isCopyShown ? NOOP : toggleIsCopyShown }
|
||||
onMouseLeave={isCopyShown ? toggleIsCopyShown : NOOP }
|
||||
/>
|
||||
</Button.Group>
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AddressSection);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import AddressInfo from '../AddressInfo.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WITH_BALANCE = { available: true, bonded: true, free: true, locked: true, reserved: true, total: true };
|
||||
|
||||
function Balances ({ address, className }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StyledSection className={className}>
|
||||
<div className='ui--AddressMenu-sectionHeader'>
|
||||
{t('balance')}
|
||||
</div>
|
||||
<AddressInfo
|
||||
address={address}
|
||||
className='balanceExpander'
|
||||
key={address}
|
||||
withBalance={WITH_BALANCE}
|
||||
withLabel
|
||||
/>
|
||||
</StyledSection>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSection = styled.section`
|
||||
.balanceExpander {
|
||||
justify-content: flex-start;
|
||||
|
||||
.column {
|
||||
width: auto;
|
||||
max-width: 18.57rem;
|
||||
|
||||
label {
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ui--Expander-content .ui--FormatBalance-value {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Balances);
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Flag from '../Flag.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
flags: AddressFlags;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Flags ({ className = '', flags: { isCouncil, isDevelopment, isExternal, isInjected, isMultisig, isNominator, isProxied, isSociety, isSudo, isTechCommittee, isValidator } }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const hasFlags = isCouncil || isDevelopment || isExternal || isInjected || isMultisig || isProxied || isSociety || isSudo || isTechCommittee || isValidator || isNominator;
|
||||
|
||||
if (!hasFlags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--AddressMenu-flags`}>
|
||||
{
|
||||
hasFlags && (
|
||||
<h5>{t('Flags')}</h5>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
{isValidator && (
|
||||
<Flag
|
||||
color='theme'
|
||||
label={t('Validator')}
|
||||
/>
|
||||
)}
|
||||
{isNominator && (
|
||||
<Flag
|
||||
color='theme'
|
||||
label={t('Nominator')}
|
||||
/>
|
||||
)}
|
||||
{isExternal && (
|
||||
isMultisig
|
||||
? (
|
||||
<Flag
|
||||
color='green'
|
||||
label={t('Multisig')}
|
||||
/>
|
||||
)
|
||||
: isProxied
|
||||
? (
|
||||
<Flag
|
||||
color='grey'
|
||||
label={t('Proxied')}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Flag
|
||||
color='grey'
|
||||
label={t('External')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{isInjected && (
|
||||
<Flag
|
||||
color='grey'
|
||||
label={t('Injected')}
|
||||
/>
|
||||
)}
|
||||
{isDevelopment && (
|
||||
<Flag
|
||||
color='grey'
|
||||
label={t('Test account')}
|
||||
/>
|
||||
)}
|
||||
{isCouncil && (
|
||||
<Flag
|
||||
color='blue'
|
||||
label={t('Council')}
|
||||
/>
|
||||
)}
|
||||
{isSociety && (
|
||||
<Flag
|
||||
color='green'
|
||||
label={t('Society')}
|
||||
/>
|
||||
)}
|
||||
{isTechCommittee && (
|
||||
<Flag
|
||||
color='orange'
|
||||
label={t('Technical committee')}
|
||||
/>
|
||||
)}
|
||||
{isSudo && (
|
||||
<Flag
|
||||
color='pink'
|
||||
label={t('Sudo key')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.ui--Tag {
|
||||
margin: 0.2rem 1rem 0.2rem 0.571rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Flags);
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useApi, useRegistrars, useSubidentities, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { AddressIdentityOtherDiscordKey } from '@pezkuwi/react-hooks/constants';
|
||||
import { type AddressIdentity } from '@pezkuwi/react-hooks/types';
|
||||
import { isHex } from '@pezkuwi/util';
|
||||
|
||||
import AddressMini from '../AddressMini.js';
|
||||
import AvatarItem from '../AvatarItem.js';
|
||||
import Expander from '../Expander.js';
|
||||
import IconLink from '../IconLink.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Judgements from './Judgements.js';
|
||||
import RegistrarJudgement from './RegistrarJudgement.js';
|
||||
import UserIcon from './UserIcon.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
identity?: AddressIdentity;
|
||||
}
|
||||
|
||||
const SUBS_DISPLAY_THRESHOLD = 4;
|
||||
|
||||
function Identity ({ address, identity }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { apiIdentity } = useApi();
|
||||
const { isRegistrar, registrars } = useRegistrars();
|
||||
const [isJudgementOpen, toggleIsJudgementOpen] = useToggle();
|
||||
const subs = useSubidentities(address);
|
||||
|
||||
const subsList = useMemo(() =>
|
||||
subs?.map((sub) =>
|
||||
<AddressMini
|
||||
className='subs'
|
||||
isPadded={false}
|
||||
key={sub.toString()}
|
||||
value={sub}
|
||||
/>
|
||||
)
|
||||
, [subs]
|
||||
);
|
||||
|
||||
if (!identity || !identity.isExistent || !apiIdentity.query.identity?.identityOf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className='withDivider'
|
||||
data-testid='identity-section'
|
||||
>
|
||||
<div className='ui--AddressMenu-section ui--AddressMenu-identity'>
|
||||
<div className='ui--AddressMenu-sectionHeader'>
|
||||
{t('identity')}
|
||||
</div>
|
||||
<div>
|
||||
<AvatarItem
|
||||
icon={
|
||||
// This won't work - images are IPFS hashes
|
||||
// identity.image
|
||||
// ? <img src={identity.image} />
|
||||
// : <i className='icon user ui--AddressMenu-identityIcon' />
|
||||
//
|
||||
<UserIcon />
|
||||
}
|
||||
subtitle={identity.legal}
|
||||
title={identity.display}
|
||||
/>
|
||||
<Judgements address={address} />
|
||||
<div className='ui--AddressMenu-identityTable'>
|
||||
{identity.parent && (
|
||||
<div className='tr parent'>
|
||||
<div className='th'>{t('parent')}</div>
|
||||
<div className='td'>
|
||||
<AddressMini
|
||||
className='parent'
|
||||
isPadded={false}
|
||||
value={identity.parent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.email && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('email')}</div>
|
||||
<div className='td'>
|
||||
{isHex(identity.email) || !identity.isKnownGood
|
||||
? identity.email
|
||||
: (
|
||||
<a
|
||||
href={`mailto:${identity.email}`}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{identity.email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.web && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('website')}</div>
|
||||
<div className='td'>
|
||||
{isHex(identity.web) || !identity.isKnownGood
|
||||
? identity.web
|
||||
: (
|
||||
<a
|
||||
href={(identity.web).replace(/^(https?:\/\/)?/g, 'https://')}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{identity.web}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.twitter && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('twitter')}</div>
|
||||
<div className='td'>
|
||||
{isHex(identity.twitter) || !identity.isKnownGood
|
||||
? identity.twitter
|
||||
: (
|
||||
<a
|
||||
href={
|
||||
(identity.twitter).startsWith('https://twitter.com/')
|
||||
? (identity.twitter)
|
||||
: `https://twitter.com/${identity.twitter}`
|
||||
}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{identity.twitter}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.other && AddressIdentityOtherDiscordKey in identity.other && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('discord')}</div>
|
||||
<div className='td'>
|
||||
{identity.other[AddressIdentityOtherDiscordKey]}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.github && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('github')}</div>
|
||||
<div className='td'>
|
||||
{identity.github}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.matrix && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('matrix')}</div>
|
||||
<div className='td'>
|
||||
{identity.matrix}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.discord && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('discord')}</div>
|
||||
<div className='td'>
|
||||
{identity.discord}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{identity.riot && (
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('riot')}</div>
|
||||
<div className='td'>
|
||||
{identity.riot}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!subs?.length && (
|
||||
<div className='tr'>
|
||||
<div className='th top'>{t('subs')}</div>
|
||||
<div
|
||||
className='td'
|
||||
data-testid='subs'
|
||||
>
|
||||
{subs.length > SUBS_DISPLAY_THRESHOLD
|
||||
? (
|
||||
<Expander summary={subs.length}>
|
||||
{subsList}
|
||||
</Expander>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className='subs-number'>{subs.length}</div>
|
||||
{subsList}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRegistrar && (
|
||||
<div className='ui--AddressMenu-section'>
|
||||
<div className='ui--AddressMenu-actions'>
|
||||
<ul>
|
||||
<li>
|
||||
<IconLink
|
||||
icon='address-card'
|
||||
label={t('Add identity judgment')}
|
||||
onClick={toggleIsJudgementOpen}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isJudgementOpen && isRegistrar && (
|
||||
<RegistrarJudgement
|
||||
address={address}
|
||||
key='modal-judgement'
|
||||
registrars={registrars}
|
||||
toggleJudgement={toggleIsJudgementOpen}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Identity);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DisplayedJudgement, Judgement } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import AddressSmall from '../AddressSmall.js';
|
||||
import Menu from '../Menu/index.js';
|
||||
import Popup from '../Popup/index.js';
|
||||
import Tag from '../Tag.js';
|
||||
|
||||
interface Props {
|
||||
judgement: Judgement
|
||||
}
|
||||
|
||||
export function getJudgementColor (name: DisplayedJudgement): 'green' | 'red' {
|
||||
return (name === 'Erroneous' || name === 'Low quality')
|
||||
? 'red'
|
||||
: 'green';
|
||||
}
|
||||
|
||||
function JudgementTag ({ judgement: { judgementName, registrars } }: Props): React.ReactElement<Props> {
|
||||
const judgementColor = useMemo(() => getJudgementColor(judgementName), [judgementName]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
closeOnScroll
|
||||
position='middle'
|
||||
value={
|
||||
<Menu>
|
||||
{registrars.map((registrar) => registrar && (
|
||||
<AddressSmall
|
||||
key={registrar.address}
|
||||
value={registrar.address}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color={judgementColor}
|
||||
label={`${registrars.length} ${judgementName}`}
|
||||
size='tiny'
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(JudgementTag);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useJudgements } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
import Tag from '../Tag.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import JudgementTag from './JudgementTag.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Judgements ({ address, className = '' }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const judgements = useJudgements(address);
|
||||
|
||||
if (judgements.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} no-judgements`}
|
||||
data-testid='judgements'
|
||||
>
|
||||
<Tag
|
||||
color='yellow'
|
||||
key='NoJudgements'
|
||||
label={t('No judgements')}
|
||||
size='tiny'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
className={className}
|
||||
data-testid='judgements'
|
||||
>
|
||||
{judgements.map((judgement) =>
|
||||
<JudgementTag
|
||||
judgement={judgement}
|
||||
key={`${address}${judgement.judgementName}`}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
margin-top: 0.714rem;
|
||||
|
||||
&:not(.no-judgements) {
|
||||
.ui--Tag:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Judgements);
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyringJson$Meta } from '@pezkuwi/ui-keyring/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import AddressMini from '../AddressMini.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
isMultisig: boolean;
|
||||
meta?: KeyringJson$Meta;
|
||||
}
|
||||
|
||||
function Multisig ({ isMultisig, meta }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isMultisig || !meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { threshold, who } = meta;
|
||||
|
||||
return (
|
||||
<section className='ui--AddressMenu-multisig withDivider'>
|
||||
<div className='ui--AddressMenu-sectionHeader'>
|
||||
{t('multisig')}
|
||||
</div>
|
||||
<div className='ui--AddressMenu-multisigTable'>
|
||||
<div className='tr'>
|
||||
<div className='th'>{t('threshold')}</div>
|
||||
<div className='td'>
|
||||
{threshold}/{who?.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className='tr'>
|
||||
<div className='th signatories'>{t('signatories')}</div>
|
||||
<div className='td'>
|
||||
{who?.map((address) => (
|
||||
<AddressMini
|
||||
key={address}
|
||||
value={address}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Multisig);
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-query authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Bytes, Option } from '@pezkuwi/types';
|
||||
import type { PalletIdentityRegistration } from '@pezkuwi/types/lookup';
|
||||
import type { ITuple } from '@pezkuwi/types/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Dropdown from '../Dropdown.js';
|
||||
import Input from '../Input.js';
|
||||
import InputAddress from '../InputAddress/index.js';
|
||||
import MarkError from '../MarkError.js';
|
||||
import Modal from '../Modal/index.js';
|
||||
import Spinner from '../Spinner.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import TxButton from '../TxButton.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
registrars: { address: string; index: number }[];
|
||||
toggleJudgement: () => void;
|
||||
}
|
||||
|
||||
const JUDGEMENT_ENUM = [
|
||||
{ text: 'Unknown', value: 0 },
|
||||
{ text: 'Fee paid', value: 1 },
|
||||
{ text: 'Reasonable', value: 2 },
|
||||
{ text: 'Known good', value: 3 },
|
||||
{ text: 'Out of date', value: 4 },
|
||||
{ text: 'Low quality', value: 5 }
|
||||
];
|
||||
|
||||
const OPT_ID = {
|
||||
transform: (optId: Option<ITuple<[PalletIdentityRegistration, Option<Bytes>]>>): HexString | null => {
|
||||
const id = optId.isSome
|
||||
? optId.unwrap()
|
||||
: null;
|
||||
|
||||
// Backwards compatibility - https://github.com/pezkuwi-js/apps/issues/10493
|
||||
return !id
|
||||
? null
|
||||
: Array.isArray(id)
|
||||
? id[0].info.hash.toHex()
|
||||
: (id as unknown as PalletIdentityRegistration).info.hash.toHex();
|
||||
}
|
||||
};
|
||||
|
||||
function RegistrarJudgement ({ address, registrars, toggleJudgement }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { apiIdentity, enableIdentity } = useApi();
|
||||
const identityHash = useCall(apiIdentity.query.identity.identityOf, [address], OPT_ID);
|
||||
const [addresses] = useState(() => registrars.map(({ address }) => address));
|
||||
const [judgementAccountId, setJudgementAccountId] = useState<string | null>(null);
|
||||
const [judgementEnum, setJudgementEnum] = useState(2); // Reasonable
|
||||
const [registrarIndex, setRegistrarIndex] = useState(-1);
|
||||
|
||||
// find the id of our registrar in the list
|
||||
useEffect((): void => {
|
||||
const registrar = registrars.find(({ address }) => judgementAccountId === address);
|
||||
|
||||
setRegistrarIndex(
|
||||
registrar
|
||||
? registrar.index
|
||||
: -1
|
||||
);
|
||||
}, [judgementAccountId, registrars]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={t('Provide judgement')}
|
||||
onClose={toggleJudgement}
|
||||
size='small'
|
||||
>
|
||||
<Modal.Content>
|
||||
<InputAddress
|
||||
filter={addresses}
|
||||
label={t('registrar account')}
|
||||
onChange={setJudgementAccountId}
|
||||
type='account'
|
||||
/>
|
||||
<Input
|
||||
isDisabled
|
||||
label={t('registrar index')}
|
||||
value={registrarIndex === -1 ? t('invalid/unknown registrar account') : registrarIndex.toString()}
|
||||
/>
|
||||
<Dropdown
|
||||
label={t('judgement')}
|
||||
onChange={setJudgementEnum}
|
||||
options={JUDGEMENT_ENUM}
|
||||
value={judgementEnum}
|
||||
/>
|
||||
{identityHash
|
||||
? (
|
||||
<Input
|
||||
defaultValue={identityHash}
|
||||
isDisabled
|
||||
label={t('identity hash')}
|
||||
/>
|
||||
)
|
||||
: identityHash === null
|
||||
? <MarkError content={t('No identity associated with account')} />
|
||||
: <Spinner noLabel />
|
||||
}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={judgementAccountId}
|
||||
icon='check'
|
||||
isDisabled={!enableIdentity || !identityHash || registrarIndex === -1}
|
||||
label={t('Judge')}
|
||||
onStart={toggleJudgement}
|
||||
params={
|
||||
apiIdentity.tx.identity.provideJudgement.meta.args.length === 4
|
||||
? [registrarIndex, address, judgementEnum, identityHash]
|
||||
: [registrarIndex, address, judgementEnum]
|
||||
}
|
||||
tx={apiIdentity.tx.identity.provideJudgement}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RegistrarJudgement);
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
|
||||
import type { Sidebar } from '@pezkuwi/test-support/pagesElements';
|
||||
import type { RegistrationJudgement } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
import i18next from '@pezkuwi/react-components/i18n';
|
||||
import { anAccount, anAccountWithInfo, anAccountWithMeta } from '@pezkuwi/test-support/creation/account';
|
||||
import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { charlieShortAddress, ferdieShortAddress, mockRegistration, registrars } from '@pezkuwi/test-support/mockData';
|
||||
import { mockApiHooks } from '@pezkuwi/test-support/utils';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import { AccountsPage } from '../../../page-accounts/test/pages/accountsPage.js';
|
||||
|
||||
// FIXME: these all need to be wrapped in waitFor ....
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('Sidebar', () => {
|
||||
let accountsPage: AccountsPage;
|
||||
let sideBar: Sidebar;
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
|
||||
if (keyring.getAccounts().length === 0) {
|
||||
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
accountsPage = new AccountsPage();
|
||||
accountsPage.clearAccounts();
|
||||
});
|
||||
|
||||
describe('editing', () => {
|
||||
const initialName = 'INITIAL_NAME';
|
||||
const newName = 'NEW_NAME';
|
||||
const defaultTag = 'Default';
|
||||
const nameInputNotFoundError = 'Unable to find an element by: [data-testid="name-input"]';
|
||||
|
||||
describe('changes name', () => {
|
||||
beforeEach(async () => {
|
||||
// Cannot get this to work on React 18 ... the first one fails :(
|
||||
// However... with a delay, it seems to get through the queue
|
||||
accountsPage.render([[alice, anAccountWithMeta({ isDevelopment: false, name: initialName })]]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
|
||||
await sideBar.changeAccountName(newName);
|
||||
});
|
||||
|
||||
it('within keyring', () => {
|
||||
const changedAccount = keyring.getAccount(alice);
|
||||
|
||||
expect(changedAccount?.meta?.name).toEqual(newName);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('within sidebar', async () => {
|
||||
await sideBar.assertAccountName(newName);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('within account row', async () => {
|
||||
const accountRows = await accountsPage.getAccountRows();
|
||||
|
||||
await accountRows[0].assertAccountName(newName);
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot be edited if edit button has not been pressed', async () => {
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
await sideBar.clickByText('none');
|
||||
expect(sideBar.queryByRole('combobox')).toBeFalsy();
|
||||
|
||||
await expect(sideBar.typeAccountName(newName)).rejects.toThrow(nameInputNotFoundError);
|
||||
});
|
||||
|
||||
it('when isEditable is false account name is not editable', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithInfo({ flags: { isEditable: false } as AddressFlags })
|
||||
);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
sideBar.edit();
|
||||
|
||||
await expect(sideBar.typeAccountName(newName)).rejects.toThrow(nameInputNotFoundError);
|
||||
});
|
||||
|
||||
describe('on edit cancel', () => {
|
||||
beforeEach(async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithMeta({ isDevelopment: false, name: initialName, tags: [] })
|
||||
);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
|
||||
await sideBar.assertTags('none');
|
||||
sideBar.edit();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('restores tags and name to state from keyring', async () => {
|
||||
await sideBar.typeAccountName(newName);
|
||||
await sideBar.selectTag(defaultTag);
|
||||
|
||||
sideBar.cancel();
|
||||
await sideBar.assertTags('none');
|
||||
await sideBar.assertAccountName(initialName);
|
||||
});
|
||||
|
||||
it('Cancel button disappears', () => {
|
||||
sideBar.cancel();
|
||||
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('outside click', () => {
|
||||
beforeEach(async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithMeta({ name: 'alice' }),
|
||||
anAccountWithMeta({ name: 'bob' })
|
||||
);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
sideBar.edit();
|
||||
});
|
||||
|
||||
it('cancels editing', async () => {
|
||||
await sideBar.typeAccountName(newName);
|
||||
await sideBar.selectTag(defaultTag);
|
||||
|
||||
fireEvent.click(await screen.findByText('accounts'));
|
||||
|
||||
await sideBar.assertTags('none');
|
||||
await sideBar.assertAccountName('ALICE');
|
||||
|
||||
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeFalsy();
|
||||
});
|
||||
|
||||
it('within sidebar does not cancel editing', async () => {
|
||||
await sideBar.clickByText('Tags');
|
||||
|
||||
expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeTruthy();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('cancels editing and changes name when opening sidebar for another account', async () => {
|
||||
await waitFor(() => sideBar.assertAccountInput('alice'));
|
||||
|
||||
sideBar = await accountsPage.openSidebarForRow(1);
|
||||
await sideBar.assertAccountName('BOB');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity section', () => {
|
||||
describe('subs', () => {
|
||||
describe('when do not exist', () => {
|
||||
it('does not display subs', async () => {
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
const subs = await sideBar.findSubs();
|
||||
|
||||
expect(subs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when exist', () => {
|
||||
let subs: HTMLElement[];
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiHooks.setSubs([bob]);
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccount(),
|
||||
anAccountWithMeta({ name: 'Bob' })
|
||||
);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
subs = await sideBar.findSubs();
|
||||
});
|
||||
|
||||
it('displays count of subs and account names', () => {
|
||||
const subsNumber = subs[0].childNodes[0];
|
||||
const subAccount = subs[0].childNodes[1];
|
||||
|
||||
expect(subsNumber).toHaveClass('subs-number');
|
||||
expect(subsNumber).toHaveTextContent('1');
|
||||
expect(subAccount).toHaveTextContent('BOB');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('displays picked sub in sidebar', async () => {
|
||||
const subAccount = subs[0].childNodes[1];
|
||||
|
||||
fireEvent.click(await within(subAccount as HTMLElement).findByTestId('account-name'));
|
||||
|
||||
await sideBar.assertAccountName('BOB');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('judgements', () => {
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('displays several judgements', async () => {
|
||||
mockApiHooks.setJudgements(mockRegistration.judgements as RegistrationJudgement[]);
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
|
||||
await sideBar.assertJudgement('1 Known good');
|
||||
await sideBar.assertJudgement('2 Reasonable');
|
||||
await sideBar.assertJudgement('1 Erroneous');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('displays no judgements', async () => {
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
|
||||
await sideBar.assertJudgement('No judgements');
|
||||
});
|
||||
|
||||
describe('displays registrars', () => {
|
||||
beforeEach(async () => {
|
||||
mockApiHooks.setJudgements(mockRegistration.judgements as RegistrationJudgement[]);
|
||||
mockApiHooks.setRegistrars(registrars);
|
||||
accountsPage.render([
|
||||
[alice, anAccountWithMeta({ name: 'Alice' })],
|
||||
[bob, anAccountWithMeta({ name: 'Bob' })]
|
||||
]);
|
||||
sideBar = await accountsPage.openSidebarForRow(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('singular registrar', async () => {
|
||||
const judgementTag = await sideBar.getJudgement('1 Known good');
|
||||
|
||||
await judgementTag.assertRegistrars([charlieShortAddress]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('multiple registrars', async () => {
|
||||
const judgementTag = await sideBar.getJudgement('2 Reasonable');
|
||||
|
||||
await judgementTag.assertRegistrars(['BOB', ferdieShortAddress]);
|
||||
});
|
||||
|
||||
it('opens clicked registrar in sidebar and closes popup', async () => {
|
||||
const judgementTag = await sideBar.getJudgement('2 Reasonable');
|
||||
|
||||
await judgementTag.clickRegistrar('BOB');
|
||||
|
||||
expect(screen.queryByTestId('popup-window')).toBeFalsy();
|
||||
|
||||
await sideBar.assertAccountName('BOB');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockApiHooks.setJudgements([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { useAccountInfo } from '@pezkuwi/react-hooks';
|
||||
|
||||
import LinkExternal from '../LinkExternal.js';
|
||||
import Sidebar from '../Sidebar.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { colorLink } from '../styles/theme.js';
|
||||
import Balances from './Balances.js';
|
||||
import Identity from './Identity.js';
|
||||
import Multisig from './Multisig.js';
|
||||
import SidebarEditableSection from './SidebarEditableSection.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
onClose?: () => void;
|
||||
onUpdateName?: (() => void) | null;
|
||||
}
|
||||
|
||||
function FullSidebar ({ address, className = '', dataTestId, onClose, onUpdateName }: Props): React.ReactElement<Props> {
|
||||
const [inEditMode, setInEditMode] = useState<boolean>(false);
|
||||
const { accountIndex, flags, identity, meta } = useAccountInfo(address);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<StyledSidebar
|
||||
className={`${className}${inEditMode ? ' inEditMode' : ''}`}
|
||||
dataTestId={dataTestId}
|
||||
onClose={onClose}
|
||||
position='right'
|
||||
sidebarRef={sidebarRef}
|
||||
>
|
||||
<div
|
||||
className='ui--AddressMenu-header'
|
||||
data-testid='sidebar-address-menu'
|
||||
>
|
||||
<SidebarEditableSection
|
||||
accountIndex={accountIndex}
|
||||
address={address}
|
||||
isBeingEdited={setInEditMode}
|
||||
onUpdateName={onUpdateName}
|
||||
sidebarRef={sidebarRef}
|
||||
/>
|
||||
</div>
|
||||
<div className='ui--ScrollSection'>
|
||||
<Balances address={address} />
|
||||
<Identity
|
||||
address={address}
|
||||
identity={identity}
|
||||
/>
|
||||
<Multisig
|
||||
isMultisig={flags.isMultisig}
|
||||
meta={meta}
|
||||
/>
|
||||
</div>
|
||||
<section className='ui--LinkSection'>
|
||||
<LinkExternal
|
||||
data={address}
|
||||
isSidebar
|
||||
type='address'
|
||||
/>
|
||||
</section>
|
||||
</StyledSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSidebar = styled(Sidebar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-sidebar);
|
||||
max-width: 30.42rem;
|
||||
min-width: 30.42rem;
|
||||
overflow-y: hidden;
|
||||
padding: 0 0 3.286rem;
|
||||
|
||||
input {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.ui--AddressMenu-header {
|
||||
align-items: center;
|
||||
background: var(--bg-tabs);
|
||||
border-bottom: 1px solid var(--border-table);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 1.35rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.ui--AddressSection {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.ui--AddressSection__AddressColumn {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
|
||||
.ui--AccountName {
|
||||
max-width: 21.5rem;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressSection__CopyColumn {
|
||||
margin-left: 1rem;
|
||||
|
||||
.ui--AccountName {
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMenu-addr,
|
||||
.ui--AddressMenu-index {
|
||||
text-align: left;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.ui--AddressMenu-addr {
|
||||
word-break: break-all;
|
||||
width: 24ch;
|
||||
margin: 0.571rem 0;
|
||||
color: var(--color-label);
|
||||
}
|
||||
|
||||
.ui--AddressMenu-copyaddr,
|
||||
.ui--AddressMenu-index {
|
||||
text-align: left;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.ui--AddressMenu-copyaaddr {
|
||||
word-break: break-all;
|
||||
width: 12ch;
|
||||
margin: 0.371rem 0;
|
||||
color: var(--color-label);
|
||||
}
|
||||
|
||||
|
||||
.ui--AddressMenu-index {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
label {
|
||||
font-size: var(--font-size-small);
|
||||
margin-right: 0.4rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ui--AddressMenu-sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-transform: capitalize;
|
||||
|
||||
margin-bottom: 0.57rem;
|
||||
width: 100%;
|
||||
|
||||
color: var(--color-text);
|
||||
font-size: 1.143rem;
|
||||
}
|
||||
|
||||
&.withDivider {
|
||||
padding-top: 1rem;
|
||||
|
||||
::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--border-table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMenu-identity,
|
||||
.ui--AddressMenu-multisig {
|
||||
.ui--AddressMenu-identityTable,
|
||||
.ui--AddressMenu-multisigTable {
|
||||
font-size: var(--font-size-small);
|
||||
margin-top: 0.6rem;
|
||||
|
||||
.tr {
|
||||
padding: 0.25rem 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.th {
|
||||
text-transform: uppercase;
|
||||
color: var(--color-label);
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-align: left;
|
||||
flex-basis: 25%;
|
||||
font-size: var(--font-size-tiny);
|
||||
|
||||
&.top {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.td {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-left: 0.6rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMini, .subs-number {
|
||||
margin-bottom: 0.4rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.subs-number {
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: 0.714rem;
|
||||
}
|
||||
}
|
||||
|
||||
.parent {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&& .column {
|
||||
align-items: center;
|
||||
|
||||
.ui--FormatBalance:first-of-type {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.ui--FormatBalance {
|
||||
line-height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMenu-buttons {
|
||||
.ui--Button-Group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMenu-tags,
|
||||
.ui--AddressMenu-flags {
|
||||
margin: 0.75rem 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui--AddressMenu-identityIcon {
|
||||
background: ${colorLink}66;
|
||||
}
|
||||
|
||||
.ui--AddressMenu-actions {
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
padding-inline-start: 1rem;
|
||||
|
||||
li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-icon {
|
||||
cursor: pointer;
|
||||
margin: 0 0 0 0.5rem;
|
||||
color: ${colorLink};
|
||||
}
|
||||
|
||||
.name--input {
|
||||
.ui.input {
|
||||
margin: 0 !important;
|
||||
|
||||
> input {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.inEditMode {
|
||||
.ui--AddressMenu-flags {
|
||||
opacity: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMenu-multisig .th.signatories {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ui--ScrollSection {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui--LinkSection {
|
||||
border-top: 1px solid var(--border-table);
|
||||
padding: 0.5rem 0 0.571rem;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
||||
span {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(FullSidebar);
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useAccountInfo, useOutsideClick } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import Tags from '../Tags.js';
|
||||
import AccountMenuButtons from './AccountMenuButtons.js';
|
||||
import AddressSection from './AddressSection.js';
|
||||
import Flags from './Flags.js';
|
||||
|
||||
interface Props {
|
||||
accountIndex: string | undefined;
|
||||
address: string;
|
||||
isBeingEdited: (arg: boolean) => void;
|
||||
onUpdateName?: (() => void) | null;
|
||||
sidebarRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function SidebarEditableSection ({ accountIndex, address, isBeingEdited, onUpdateName, sidebarRef }: Props): React.ReactElement<Props> {
|
||||
const { flags, isEditing, isEditingName, isEditingTags, name, onForgetAddress, onSaveName, onSaveTags, setIsEditingName, setIsEditingTags, setName, setTags, tags, toggleIsEditingName, toggleIsEditingTags } = useAccountInfo(address);
|
||||
|
||||
const refs = useMemo(
|
||||
() => [sidebarRef],
|
||||
[sidebarRef]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
isBeingEdited(isEditing());
|
||||
}, [isBeingEdited, isEditing]);
|
||||
|
||||
const onCancel = useCallback(
|
||||
(): void => {
|
||||
if (isEditing()) {
|
||||
try {
|
||||
const accountOrAddress = keyring.getAccount(address) || keyring.getAddress(address);
|
||||
|
||||
setName(accountOrAddress?.meta.name || '');
|
||||
setTags(accountOrAddress?.meta.tags ? (accountOrAddress.meta.tags).sort() : []);
|
||||
setIsEditingName(false);
|
||||
setIsEditingTags(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [isEditing, setName, setTags, setIsEditingName, setIsEditingTags, address]);
|
||||
|
||||
useOutsideClick(refs, onCancel);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddressSection
|
||||
accountIndex={accountIndex}
|
||||
defaultValue={name}
|
||||
editingName={isEditingName}
|
||||
flags={flags}
|
||||
onChange={setName}
|
||||
value={address}
|
||||
/>
|
||||
<div
|
||||
className='ui--AddressMenu-tags'
|
||||
data-testid='sidebar-tags'
|
||||
>
|
||||
<Tags
|
||||
isEditable
|
||||
isEditing={isEditingTags}
|
||||
onChange={setTags}
|
||||
value={tags}
|
||||
withEditButton={false}
|
||||
withTitle
|
||||
/>
|
||||
</div>
|
||||
<Flags flags={flags} />
|
||||
<AccountMenuButtons
|
||||
flags={flags}
|
||||
isEditing={isEditing()}
|
||||
isEditingName={isEditingName}
|
||||
onCancel={onCancel}
|
||||
onForgetAddress={onForgetAddress}
|
||||
onSaveName={onSaveName}
|
||||
onSaveTags={onSaveTags}
|
||||
onUpdateName={onUpdateName}
|
||||
recipientId={address}
|
||||
toggleIsEditingName={toggleIsEditingName}
|
||||
toggleIsEditingTags={toggleIsEditingTags}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SidebarEditableSection);
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { AccountSidebarCtx } from '@pezkuwi/react-hooks/ctx/AccountSidebar';
|
||||
|
||||
import Sidebar from './Sidebar.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type State = [string | null, (() => void) | null];
|
||||
|
||||
const EMPTY_STATE: State = [null, null];
|
||||
|
||||
function AccountSidebar ({ children }: Props): React.ReactElement<Props> {
|
||||
const [[address, onUpdateName], setAddress] = useState<State>(EMPTY_STATE);
|
||||
|
||||
const onClose = useCallback(
|
||||
() => setAddress([null, null]),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountSidebarCtx.Provider value={setAddress}>
|
||||
{children}
|
||||
{address && (
|
||||
<Sidebar
|
||||
address={address}
|
||||
dataTestId='account-sidebar'
|
||||
onClose={onClose}
|
||||
onUpdateName={onUpdateName}
|
||||
/>
|
||||
)}
|
||||
</AccountSidebarCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AccountSidebar);
|
||||
@@ -0,0 +1,808 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { DeriveBalancesAccountData, DeriveBalancesAll, DeriveDemocracyLock, DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
import type { VestingInfo } from '@pezkuwi/react-hooks';
|
||||
import type { Raw } from '@pezkuwi/types';
|
||||
import type { BlockNumber, ValidatorPrefsTo145, Voting } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBalancesReserveData } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { withCalls, withMulti } from '@pezkuwi/react-api/hoc';
|
||||
import { useBestNumberRelay, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { BlockToTime, FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_MAX_INTEGER, BN_ZERO, bnMax, formatBalance, formatNumber, isObject } from '@pezkuwi/util';
|
||||
|
||||
import { recalculateVesting } from './util/calculateVesting.js';
|
||||
import CryptoType from './CryptoType.js';
|
||||
import DemocracyLocks from './DemocracyLocks.js';
|
||||
import Expander from './Expander.js';
|
||||
import Icon from './Icon.js';
|
||||
import Label from './Label.js';
|
||||
import StakingRedeemable from './StakingRedeemable.js';
|
||||
import StakingUnbonding from './StakingUnbonding.js';
|
||||
import { styled } from './styled.js';
|
||||
import Tooltip from './Tooltip.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
// true to display, or (for bonded) provided values [own, ...all extras]
|
||||
export interface BalanceActiveType {
|
||||
available?: boolean;
|
||||
bonded?: boolean | BN[];
|
||||
extraInfo?: [React.ReactNode, React.ReactNode][];
|
||||
locked?: boolean;
|
||||
nonce?: boolean;
|
||||
redeemable?: boolean;
|
||||
reserved?: boolean;
|
||||
total?: boolean;
|
||||
unlocking?: boolean;
|
||||
vested?: boolean;
|
||||
}
|
||||
|
||||
export interface CryptoActiveType {
|
||||
crypto?: boolean;
|
||||
nonce?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidatorPrefsType {
|
||||
unstakeThreshold?: boolean;
|
||||
validatorPayment?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
apiOverride?: ApiPromise;
|
||||
address: string;
|
||||
balancesAll?: DeriveBalancesAll;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
convictionLocks?: RefLock[];
|
||||
democracyLocks?: DeriveDemocracyLock[];
|
||||
extraInfo?: [string, string][];
|
||||
stakingInfo?: DeriveStakingAccount;
|
||||
vestingBestNumber?: BlockNumber;
|
||||
vestingInfo?: VestingInfo;
|
||||
votingOf?: Voting;
|
||||
withBalance?: boolean | BalanceActiveType;
|
||||
withBalanceToggle?: false;
|
||||
withExtended?: boolean | CryptoActiveType;
|
||||
withHexSessionId?: (string | null)[];
|
||||
withValidatorPrefs?: boolean | ValidatorPrefsType;
|
||||
withLabel?: boolean;
|
||||
}
|
||||
|
||||
interface RefLock {
|
||||
endBlock: BN;
|
||||
locked: string;
|
||||
refId: BN;
|
||||
total: BN;
|
||||
}
|
||||
|
||||
type TFunction = (key: string, options?: { replace: Record<string, unknown> }) => string;
|
||||
|
||||
const DEFAULT_BALANCES: BalanceActiveType = {
|
||||
available: true,
|
||||
bonded: true,
|
||||
locked: true,
|
||||
redeemable: true,
|
||||
reserved: true,
|
||||
total: true,
|
||||
unlocking: true,
|
||||
vested: true
|
||||
};
|
||||
const DEFAULT_EXTENDED = {
|
||||
crypto: true,
|
||||
nonce: true
|
||||
};
|
||||
const DEFAULT_PREFS = {
|
||||
unstakeThreshold: true,
|
||||
validatorPayment: true
|
||||
};
|
||||
|
||||
// auxiliary component that helps aligning balances details, fills up the space when no icon for a balance is specified
|
||||
function IconVoid (): React.ReactElement {
|
||||
return <span className='icon-void'> </span>;
|
||||
}
|
||||
|
||||
function lookupLock (lookup: Record<string, string>, lockId: Raw): string {
|
||||
const lockHex = lockId.toHuman() as string;
|
||||
|
||||
try {
|
||||
return lookup[lockHex] || lockHex;
|
||||
} catch {
|
||||
return lockHex;
|
||||
}
|
||||
}
|
||||
|
||||
// skip balances retrieval of none of this matches
|
||||
function skipBalancesIf ({ withBalance = true, withExtended = false }: Props): boolean {
|
||||
// NOTE Unsure why we don't have a check for balancesAll in here (check skipStakingIf). adding
|
||||
// it doesn't break on Accounts/Addresses, but this gets used a lot, so there _may_ be an actual
|
||||
// reason behind the madness. However, derives are memoized, so no issue overall.
|
||||
if (withBalance === true || withExtended === true) {
|
||||
return false;
|
||||
} else if (isObject(withBalance)) {
|
||||
// these all pull from the all balances
|
||||
if (withBalance.available || withBalance.locked || withBalance.reserved || withBalance.total || withBalance.vested) {
|
||||
return false;
|
||||
}
|
||||
} else if (isObject(withExtended)) {
|
||||
if (withExtended.nonce) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function skipStakingIf ({ stakingInfo, withBalance = true, withValidatorPrefs = false }: Props): boolean {
|
||||
if (stakingInfo) {
|
||||
return true;
|
||||
} else if (withBalance === true || withValidatorPrefs) {
|
||||
return false;
|
||||
} else if (isObject(withBalance)) {
|
||||
if (withBalance.unlocking || withBalance.redeemable) {
|
||||
return false;
|
||||
} else if (withBalance.bonded) {
|
||||
return Array.isArray(withBalance.bonded);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// calculates the bonded, first being the own, the second being nominated
|
||||
function calcBonded (stakingInfo?: DeriveStakingAccount, bonded?: boolean | BN[]): [BN, BN[]] {
|
||||
let other: BN[] = [];
|
||||
let own = BN_ZERO;
|
||||
|
||||
if (Array.isArray(bonded)) {
|
||||
other = bonded
|
||||
.filter((_, index) => index !== 0)
|
||||
.filter((value) => value.gt(BN_ZERO));
|
||||
|
||||
own = bonded[0];
|
||||
} else if (stakingInfo?.stakingLedger?.active && stakingInfo.accountId.eq(stakingInfo.stashId)) {
|
||||
own = stakingInfo.stakingLedger.active.unwrap();
|
||||
}
|
||||
|
||||
return [own, other];
|
||||
}
|
||||
|
||||
function renderExtended ({ address, balancesAll, withExtended }: Props, t: TFunction): React.ReactNode {
|
||||
const extendedDisplay = withExtended === true
|
||||
? DEFAULT_EXTENDED
|
||||
: withExtended || undefined;
|
||||
|
||||
if (!extendedDisplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='column'>
|
||||
{balancesAll && extendedDisplay.nonce && (
|
||||
<>
|
||||
<Label label={t('transactions')} />
|
||||
<div className='result'>{formatNumber(balancesAll.accountNonce)}</div>
|
||||
</>
|
||||
)}
|
||||
{extendedDisplay.crypto && (
|
||||
<>
|
||||
<Label label={t('type')} />
|
||||
<CryptoType
|
||||
accountId={address}
|
||||
className='result'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderValidatorPrefs ({ stakingInfo, withValidatorPrefs = false }: Props, t: TFunction): React.ReactNode {
|
||||
const validatorPrefsDisplay = withValidatorPrefs === true
|
||||
? DEFAULT_PREFS
|
||||
: withValidatorPrefs;
|
||||
|
||||
if (!validatorPrefsDisplay || !stakingInfo?.validatorPrefs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div />
|
||||
{validatorPrefsDisplay.unstakeThreshold && (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).unstakeThreshold && (
|
||||
<>
|
||||
<Label label={t('unstake threshold')} />
|
||||
<div className='result'>
|
||||
{(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).unstakeThreshold.toString()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{validatorPrefsDisplay.validatorPayment && (stakingInfo.validatorPrefs.commission || (stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment) && (
|
||||
(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment
|
||||
? (
|
||||
<>
|
||||
<Label label={t('commission')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
value={(stakingInfo.validatorPrefs as any as ValidatorPrefsTo145).validatorPayment}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Label label={t('commission')} />
|
||||
<span>{(stakingInfo.validatorPrefs.commission.unwrap().toNumber() / 10_000_000).toFixed(2)}%</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createBalanceItems (formatIndex: number, lookup: Record<string, string>, t: TFunction, { address, apiOverride, balanceDisplay, balancesAll, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, stakingInfo, vestingBestNumber, vestingInfo, votingOf, withBalanceToggle, withLabel }: { address: string; apiOverride: ApiPromise | undefined, balanceDisplay: BalanceActiveType; balancesAll?: DeriveBalancesAll | DeriveBalancesAccountData; bestNumber?: BlockNumber; convictionLocks?: RefLock[]; democracyLocks?: DeriveDemocracyLock[]; isAllLocked: boolean; otherBonded: BN[]; ownBonded: BN; stakingInfo?: DeriveStakingAccount; vestingBestNumber?: BlockNumber; vestingInfo?: VestingInfo; votingOf?: Voting; withBalanceToggle: boolean, withLabel: boolean }): React.ReactNode {
|
||||
const allItems: React.ReactNode[] = [];
|
||||
const deriveBalances = balancesAll as DeriveBalancesAll;
|
||||
|
||||
!withBalanceToggle && balanceDisplay.total && allItems.push(
|
||||
<React.Fragment key={0}>
|
||||
<Label label={withLabel ? t('total') : ''} />
|
||||
<FormatBalance
|
||||
className={`result ${balancesAll ? '' : '--tmp'}`}
|
||||
formatIndex={formatIndex}
|
||||
labelPost={<IconVoid />}
|
||||
value={balancesAll ? balancesAll.freeBalance.add(balancesAll.reservedBalance) : 1}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
balancesAll && balanceDisplay.available && (deriveBalances.transferable || deriveBalances.availableBalance) && allItems.push(
|
||||
<React.Fragment key={1}>
|
||||
<Label label={t('transferable')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
formatIndex={formatIndex}
|
||||
labelPost={<IconVoid />}
|
||||
value={deriveBalances.transferable || deriveBalances.availableBalance}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
// Use separate vestingInfo if provided (cross-chain vesting support),
|
||||
// otherwise fall back to vesting data from balancesAll
|
||||
const vestingData: DeriveBalancesAll | undefined = (vestingInfo || deriveBalances) as DeriveBalancesAll | undefined;
|
||||
|
||||
// Use relay chain block number for vesting calculations when provided
|
||||
// (vesting schedules use relay chain blocks even after Asset Hub migration)
|
||||
const vestingBlockNumber = vestingBestNumber || bestNumber;
|
||||
|
||||
// When we have a separate vestingBestNumber, it means vesting schedules use
|
||||
// relay chain blocks but derive calculated with wrong block number.
|
||||
// We need to recalculate the vested amounts manually.
|
||||
const vesting: DeriveBalancesAll | undefined = (vestingBestNumber && vestingData?.isVesting && vestingData.vesting.length > 0)
|
||||
? (() => {
|
||||
const recalculated = recalculateVesting(vestingData.vesting, vestingBestNumber);
|
||||
|
||||
// The original claimable (calculated with wrong blocks) represents the offset
|
||||
// between what Asset Hub thinks and reality. Add it to get actual claimable.
|
||||
const actualClaimable = recalculated.vestedBalance.add(vestingData.vestedClaimable);
|
||||
|
||||
// Override with recalculated values
|
||||
return {
|
||||
...vestingData,
|
||||
vestedBalance: recalculated.vestedBalance,
|
||||
vestedClaimable: actualClaimable,
|
||||
vestingLocked: recalculated.vestingLocked
|
||||
} as DeriveBalancesAll;
|
||||
})()
|
||||
: vestingData;
|
||||
|
||||
if (vestingBlockNumber && balanceDisplay.vested && vesting?.isVesting) {
|
||||
const allVesting = vesting.vesting.filter(({ endBlock }) => vestingBlockNumber.lt(endBlock));
|
||||
|
||||
allItems.push(
|
||||
<React.Fragment key={2}>
|
||||
<Label label={t('vested')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
formatIndex={formatIndex}
|
||||
labelPost={
|
||||
<Icon
|
||||
icon='info-circle'
|
||||
tooltip={`${address}-vested-trigger`}
|
||||
/>
|
||||
}
|
||||
value={vesting.vestedBalance}
|
||||
>
|
||||
<StyledTooltip trigger={`${address}-vested-trigger`}>
|
||||
<div className='tooltip-header'>
|
||||
{formatBalance(vesting.vestedClaimable.abs(), { forceUnit: '-' })}
|
||||
<div className='faded'>{t('available to be unlocked')}</div>
|
||||
</div>
|
||||
{allVesting.map(({ endBlock, locked, perBlock, startingBlock, vested }, index) => {
|
||||
// Recalculate vested amount for this schedule using correct block number
|
||||
let vestedAmount = vested;
|
||||
|
||||
if (vestingBestNumber) {
|
||||
if (vestingBlockNumber.lt(startingBlock)) {
|
||||
vestedAmount = BN_ZERO;
|
||||
} else if (vestingBlockNumber.gte(endBlock)) {
|
||||
vestedAmount = locked;
|
||||
} else {
|
||||
const blocksPassed = vestingBlockNumber.sub(startingBlock);
|
||||
|
||||
vestedAmount = blocksPassed.mul(perBlock);
|
||||
|
||||
if (vestedAmount.gt(locked)) {
|
||||
vestedAmount = locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inner'
|
||||
key={`item:${index}`}
|
||||
>
|
||||
<div>
|
||||
<p>{formatBalance(locked, { forceUnit: '-' })} {t('fully vested in')}</p>
|
||||
<BlockToTime
|
||||
api={apiOverride}
|
||||
value={endBlock.sub(vestingBlockNumber)}
|
||||
/>
|
||||
</div>
|
||||
<div className='middle'>
|
||||
(Block {formatNumber(endBlock)} @ {formatBalance(perBlock)}/block)
|
||||
</div>
|
||||
<div>
|
||||
{formatBalance(vestedAmount, { forceUnit: '-' })}
|
||||
<div>{t('already vested')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</StyledTooltip>
|
||||
</FormatBalance>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const allReserves = (deriveBalances?.namedReserves || []).reduce<PalletBalancesReserveData[]>((t, r) => t.concat(...r), []);
|
||||
const hasNamedReserves = !!allReserves && allReserves.length !== 0;
|
||||
|
||||
balanceDisplay.locked && balancesAll && (isAllLocked || deriveBalances.lockedBalance?.gtn(0)) && allItems.push(
|
||||
<React.Fragment key={3}>
|
||||
<Label label={t('locked')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
formatIndex={formatIndex}
|
||||
labelPost={
|
||||
<>
|
||||
<Icon
|
||||
icon='info-circle'
|
||||
tooltip={`${address}-locks-trigger`}
|
||||
/>
|
||||
<Tooltip trigger={`${address}-locks-trigger`}>
|
||||
{deriveBalances.lockedBreakdown.map(({ amount, id, reasons }, index): React.ReactNode => (
|
||||
<div
|
||||
className='row'
|
||||
key={index}
|
||||
>
|
||||
{amount?.isMax()
|
||||
? t('everything')
|
||||
: formatBalance(amount, { forceUnit: '-' })
|
||||
}{id && <div className='faded'>{lookupLock(lookup, id)}</div>}<div className='faded'>{reasons.toString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
value={isAllLocked ? 'all' : deriveBalances.lockedBalance}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
balanceDisplay.reserved && balancesAll?.reservedBalance?.gtn(0) && allItems.push(
|
||||
<React.Fragment key={4}>
|
||||
<Label label={t('reserved')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
formatIndex={formatIndex}
|
||||
labelPost={
|
||||
hasNamedReserves
|
||||
? (
|
||||
<>
|
||||
<Icon
|
||||
icon='info-circle'
|
||||
tooltip={`${address}-named-reserves-trigger`}
|
||||
/>
|
||||
<Tooltip trigger={`${address}-named-reserves-trigger`}>
|
||||
{allReserves.map(({ amount, id }, index): React.ReactNode => (
|
||||
<div key={index}>
|
||||
{formatBalance(amount, { forceUnit: '-' })
|
||||
}{id && <div className='faded'>{lookupLock(lookup, id)}</div>}
|
||||
</div>
|
||||
))}
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
: <IconVoid />
|
||||
}
|
||||
value={balancesAll.reservedBalance}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
balanceDisplay.bonded && (ownBonded.gtn(0) || otherBonded.length !== 0) && allItems.push(
|
||||
<React.Fragment key={5}>
|
||||
<Label label={t('bonded')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
formatIndex={formatIndex}
|
||||
labelPost={<IconVoid />}
|
||||
value={ownBonded}
|
||||
>
|
||||
{otherBonded.length !== 0 && (
|
||||
<> (+{otherBonded.map((bonded, index): React.ReactNode =>
|
||||
<FormatBalance
|
||||
formatIndex={formatIndex}
|
||||
key={index}
|
||||
labelPost={<IconVoid />}
|
||||
value={bonded}
|
||||
/>
|
||||
)})</>
|
||||
)}
|
||||
</FormatBalance>
|
||||
</React.Fragment>
|
||||
);
|
||||
balanceDisplay.redeemable && stakingInfo?.redeemable?.gtn(0) && allItems.push(
|
||||
<React.Fragment key={6}>
|
||||
<Label label={t('redeemable')} />
|
||||
<StakingRedeemable
|
||||
className='result'
|
||||
stakingInfo={stakingInfo}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
if (balanceDisplay.unlocking) {
|
||||
stakingInfo?.unlocking && allItems.push(
|
||||
<React.Fragment key={7}>
|
||||
<Label label={t('unbonding')} />
|
||||
<div className='result'>
|
||||
<StakingUnbonding
|
||||
iconPosition='right'
|
||||
stakingInfo={stakingInfo}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
if (democracyLocks && (democracyLocks.length !== 0)) {
|
||||
allItems.push(
|
||||
<React.Fragment key={8}>
|
||||
<Label label={t('democracy')} />
|
||||
<div className='result'>
|
||||
<DemocracyLocks value={democracyLocks} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else if (bestNumber && votingOf && votingOf.isDirect) {
|
||||
const { prior: [unlockAt, balance] } = votingOf.asDirect;
|
||||
|
||||
balance.gt(BN_ZERO) && unlockAt.gt(BN_ZERO) && allItems.push(
|
||||
<React.Fragment key={8}>
|
||||
<Label label={t('democracy')} />
|
||||
<div className='result'>
|
||||
<DemocracyLocks value={[{ balance, isFinished: bestNumber.gt(unlockAt), unlockAt }]} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (bestNumber && convictionLocks?.length) {
|
||||
const max = convictionLocks.reduce((max, { total }) => bnMax(max, total), BN_ZERO);
|
||||
|
||||
allItems.push(
|
||||
<React.Fragment key={9}>
|
||||
<Label label={t('referenda')} />
|
||||
<FormatBalance
|
||||
className='result'
|
||||
labelPost={
|
||||
<>
|
||||
<Icon
|
||||
icon='clock'
|
||||
tooltip={`${address}-conviction-locks-trigger`}
|
||||
/>
|
||||
<Tooltip trigger={`${address}-conviction-locks-trigger`}>
|
||||
{convictionLocks.map(({ endBlock, locked, refId, total }, index): React.ReactNode => (
|
||||
<div
|
||||
className='row'
|
||||
key={index}
|
||||
>
|
||||
<div className='nowrap'>#{refId.toString()} {formatBalance(total, { forceUnit: '-' })} {locked}</div>
|
||||
<div className='faded nowrap'>{
|
||||
endBlock.eq(BN_MAX_INTEGER)
|
||||
? t('ongoing referendum')
|
||||
: bestNumber.gte(endBlock)
|
||||
? t('lock expired')
|
||||
: <>{formatNumber(endBlock.sub(bestNumber))} {t('blocks')},
|
||||
<BlockToTime
|
||||
isInline
|
||||
value={endBlock.sub(bestNumber)}
|
||||
/></>
|
||||
}</div>
|
||||
</div>
|
||||
))}
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
value={max}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (balancesAll && (balancesAll as DeriveBalancesAll).accountNonce && balanceDisplay.nonce) {
|
||||
allItems.push(
|
||||
<React.Fragment key={10}>
|
||||
<Label label={t('transactions')} />
|
||||
<div className='result'>
|
||||
{formatNumber((balancesAll as DeriveBalancesAll).accountNonce)}
|
||||
<IconVoid />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (withBalanceToggle) {
|
||||
return (
|
||||
<React.Fragment key={formatIndex}>
|
||||
<Expander
|
||||
className={balancesAll ? '' : 'isBlurred'}
|
||||
summary={
|
||||
<FormatBalance
|
||||
formatIndex={formatIndex}
|
||||
value={balancesAll?.freeBalance.add(balancesAll.reservedBalance)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{allItems.length !== 0 && (
|
||||
<div className='body column'>
|
||||
{allItems}
|
||||
</div>
|
||||
)}
|
||||
</Expander>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={formatIndex}>
|
||||
{allItems}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBalances (props: Props, lookup: Record<string, string>, bestNumber: BlockNumber | undefined, apiOverride: ApiPromise | undefined, t: TFunction): React.ReactNode[] {
|
||||
const { address, balancesAll, convictionLocks, democracyLocks, stakingInfo, vestingBestNumber, vestingInfo, votingOf, withBalance = true, withBalanceToggle = false, withLabel = false }: Props = props;
|
||||
const balanceDisplay = withBalance === true
|
||||
? DEFAULT_BALANCES
|
||||
: withBalance || false;
|
||||
|
||||
if (!balanceDisplay) {
|
||||
return [null];
|
||||
}
|
||||
|
||||
const [ownBonded, otherBonded] = calcBonded(stakingInfo, balanceDisplay.bonded);
|
||||
const isAllLocked = !!balancesAll && balancesAll.lockedBreakdown.some(({ amount }): boolean => amount?.isMax());
|
||||
const baseOpts = { address, apiOverride, balanceDisplay, bestNumber, convictionLocks, democracyLocks, isAllLocked, otherBonded, ownBonded, vestingBestNumber, vestingInfo, votingOf, withBalanceToggle, withLabel };
|
||||
const items = [createBalanceItems(0, lookup, t, { ...baseOpts, balancesAll, stakingInfo })];
|
||||
|
||||
withBalanceToggle && balancesAll?.additional.length && balancesAll.additional.forEach((balancesAll, index): void => {
|
||||
items.push(createBalanceItems(index + 1, lookup, t, { ...baseOpts, balancesAll }));
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function AddressInfo (props: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const bestNumber = useBestNumberRelay();
|
||||
const { isStakingAsync, rcApi } = useStakingAsyncApis();
|
||||
const { children, className = '', extraInfo, withBalanceToggle, withHexSessionId } = props;
|
||||
|
||||
const lookup = useRef<Record<string, string>>({
|
||||
democrac: t('via Democracy/Vote'),
|
||||
phrelect: t('via Council/Vote'),
|
||||
pyconvot: t('via Referenda/Vote'),
|
||||
'staking ': t('via Staking/Bond'),
|
||||
'vesting ': t('via Vesting')
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${className} ui--AddressInfo ${withBalanceToggle ? 'ui--AddressInfo-expander' : ''}`}>
|
||||
<div className={`column${withBalanceToggle ? ' column--expander' : ''}`}>
|
||||
{renderBalances(props, lookup.current, bestNumber, isStakingAsync ? rcApi : undefined, t)}
|
||||
{withHexSessionId?.[0] && (
|
||||
<>
|
||||
<Label label={t('session keys')} />
|
||||
<div className='result'>{withHexSessionId[0]}</div>
|
||||
</>
|
||||
)}
|
||||
{withHexSessionId && withHexSessionId[0] !== withHexSessionId[1] && (
|
||||
<>
|
||||
<Label label={t('session next')} />
|
||||
<div className='result'>{withHexSessionId[1]}</div>
|
||||
</>
|
||||
)}
|
||||
{renderValidatorPrefs(props, t)}
|
||||
{extraInfo && (
|
||||
<>
|
||||
<div />
|
||||
{extraInfo.map(([label, value], index): React.ReactNode => (
|
||||
<React.Fragment key={`label:${index}`}>
|
||||
<Label label={label} />
|
||||
<div className='result'>
|
||||
{value}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{renderExtended(props, t)}
|
||||
{children && (
|
||||
<div className='column'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
min-width: 26rem;
|
||||
text-align: left;
|
||||
word-wrap: break-word;
|
||||
|
||||
.ui--BlockToTime {
|
||||
margin-top: -1rem;
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #eeeeee50;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
|
||||
.middle {
|
||||
margin-top: -1rem;
|
||||
}
|
||||
|
||||
.inner > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withMulti(
|
||||
styled(AddressInfo)`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
&:not(.ui--AddressInfo-expander) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
|
||||
.ui--FormatBalance {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
& + .ui--Button,
|
||||
& + .ui--ButtonGroup {
|
||||
margin-right: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 260px;
|
||||
&.column--expander {
|
||||
width: 17.5rem;
|
||||
|
||||
.ui--Expander {
|
||||
width: 100%;
|
||||
|
||||
.summary {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
min-width: 12rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.column--expander) {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0.5rem;
|
||||
opacity: 1;
|
||||
|
||||
div.inner {
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
grid-column: 1;
|
||||
padding-right: 0.5rem;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.help.circle.icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.result {
|
||||
grid-column: 2;
|
||||
text-align: right;
|
||||
|
||||
.ui--Icon,
|
||||
.icon-void {
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.icon-void {
|
||||
float: right;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
withCalls<Props>(
|
||||
['derive.balances.all', {
|
||||
paramName: 'address',
|
||||
propName: 'balancesAll',
|
||||
skipIf: skipBalancesIf
|
||||
}],
|
||||
['derive.staking.account', {
|
||||
paramName: 'address',
|
||||
propName: 'stakingInfo',
|
||||
skipIf: skipStakingIf
|
||||
}],
|
||||
['derive.democracy.locks', {
|
||||
paramName: 'address',
|
||||
propName: 'democracyLocks',
|
||||
skipIf: skipStakingIf
|
||||
}],
|
||||
['query.democracy.votingOf', {
|
||||
paramName: 'address',
|
||||
propName: 'votingOf',
|
||||
skipIf: skipStakingIf
|
||||
}]
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
import type { KeyringItemType } from '@pezkuwi/ui-keyring/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import IdentityIcon from './IdentityIcon/index.js';
|
||||
import AccountName from './AccountName.js';
|
||||
import BalanceDisplay from './Balance.js';
|
||||
import BondedDisplay from './Bonded.js';
|
||||
import LockedVote from './LockedVote.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
balance?: BN | BN[];
|
||||
bonded?: BN | BN[];
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
iconInfo?: React.ReactNode;
|
||||
isHighlight?: boolean;
|
||||
isPadded?: boolean;
|
||||
isShort?: boolean;
|
||||
label?: React.ReactNode;
|
||||
labelBalance?: React.ReactNode;
|
||||
nameExtra?: React.ReactNode;
|
||||
onNameClick?: () => void;
|
||||
summary?: React.ReactNode;
|
||||
type?: KeyringItemType;
|
||||
value?: AccountId | AccountIndex | Address | string | null;
|
||||
withAddress?: boolean;
|
||||
withBalance?: boolean;
|
||||
withBonded?: boolean;
|
||||
withLockedVote?: boolean;
|
||||
withSidebar?: boolean;
|
||||
withName?: boolean;
|
||||
withShrink?: boolean;
|
||||
}
|
||||
|
||||
function AddressMini ({ balance, bonded, children, className = '', iconInfo, isHighlight, isPadded = true, label, labelBalance, nameExtra, onNameClick, summary, value, withAddress = true, withBalance = false, withBonded = false, withLockedVote = false, withName = true, withShrink = false, withSidebar = true }: Props): React.ReactElement<Props> | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--AddressMini ${isHighlight ? 'isHighlight' : ''} ${isPadded ? 'padded' : ''} ${withShrink ? 'withShrink' : ''}`}>
|
||||
{label && (
|
||||
<label className='ui--AddressMini-label'>{label}</label>
|
||||
)}
|
||||
<span className='ui--AddressMini-icon'>
|
||||
<IdentityIcon value={value} />
|
||||
{iconInfo && (
|
||||
<div className='ui--AddressMini-icon-info'>
|
||||
{iconInfo}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<span className='ui--AddressMini-info'>
|
||||
{withAddress && (
|
||||
<span
|
||||
className='ui--AddressMini-address'
|
||||
onClick={onNameClick}
|
||||
>
|
||||
{withName
|
||||
? (
|
||||
<AccountName
|
||||
value={value}
|
||||
withSidebar={withSidebar}
|
||||
>
|
||||
{nameExtra}
|
||||
</AccountName>
|
||||
)
|
||||
: <span className='shortAddress'>{value.toString()}</span>
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
<div className='ui--AddressMini-balances'>
|
||||
{withBalance && (
|
||||
<BalanceDisplay
|
||||
balance={balance}
|
||||
label={labelBalance}
|
||||
params={value}
|
||||
/>
|
||||
)}
|
||||
{withBonded && (
|
||||
<BondedDisplay
|
||||
bonded={bonded}
|
||||
label=''
|
||||
params={value}
|
||||
/>
|
||||
)}
|
||||
{withLockedVote && (
|
||||
<LockedVote params={value} />
|
||||
)}
|
||||
{summary && (
|
||||
<div className='ui--AddressMini-summary'>{summary}</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
overflow-x: hidden;
|
||||
padding: 0 0.25rem 0 1rem;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.padded {
|
||||
padding: 0 1rem 0 0;
|
||||
}
|
||||
|
||||
&.summary {
|
||||
position: relative;
|
||||
top: -0.2rem;
|
||||
}
|
||||
|
||||
.ui--AddressMini-info {
|
||||
}
|
||||
|
||||
.ui--AddressMini-address {
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.shortAddress {
|
||||
min-width: var(--width-shortaddr);
|
||||
max-width: var(--width-shortaddr);
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.withShrink {
|
||||
.ui--AddressMini-address {
|
||||
min-width: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMini-label {
|
||||
margin: 0 0 -0.5rem 2.25rem;
|
||||
}
|
||||
|
||||
.ui--AddressMini-balances {
|
||||
display: grid;
|
||||
|
||||
.ui--Balance,
|
||||
.ui--Bonded,
|
||||
.ui--LockedVote {
|
||||
font-size: var(--font-size-tiny);
|
||||
margin-left: 2.25rem;
|
||||
margin-top: -0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMini-icon {
|
||||
.ui--AddressMini-icon-info {
|
||||
position: absolute;
|
||||
right: -0.5rem;
|
||||
top: -0.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ui--IdentityIcon {
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressMini-icon,
|
||||
.ui--AddressMini-info {
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ui--AddressMini-summary {
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.2;
|
||||
margin-left: 2.25rem;
|
||||
margin-top: -0.2rem;
|
||||
text-align: left;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AddressMini);
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
import type { RowProps } from './Row.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useAccountInfo } from '@pezkuwi/react-hooks';
|
||||
import BaseIdentityIcon from '@pezkuwi/react-identicon';
|
||||
|
||||
import IdentityIcon from './IdentityIcon/index.js';
|
||||
import Row from './Row.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
export interface Props extends RowProps {
|
||||
isContract?: boolean;
|
||||
isValid?: boolean;
|
||||
fullLength?: boolean;
|
||||
label?: string;
|
||||
noDefaultNameOpacity?: boolean;
|
||||
overlay?: React.ReactNode;
|
||||
value?: AccountId | AccountIndex | Address | string | null;
|
||||
withSidebar?: boolean;
|
||||
withTags?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ADDR = '5'.padEnd(48, 'x');
|
||||
const ICON_SIZE = 32;
|
||||
|
||||
function AddressRow ({ buttons, children, className, defaultName, fullLength = false, isContract = false, isDisabled, isEditableName, isInline, isValid: propsIsValid, overlay, value, withTags = false }: Props): React.ReactElement<Props> | null {
|
||||
const { accountIndex, isNull, name, onSaveName, onSaveTags, setName, setTags, tags } = useAccountInfo(value ? value.toString() : null, isContract);
|
||||
|
||||
const isValid = !isNull && (propsIsValid || value || accountIndex);
|
||||
const Icon = value ? IdentityIcon : BaseIdentityIcon;
|
||||
const address = value && isValid ? value : DEFAULT_ADDR;
|
||||
|
||||
return (
|
||||
<StyledRow
|
||||
address={address}
|
||||
buttons={buttons}
|
||||
className={className}
|
||||
defaultName={defaultName}
|
||||
icon={
|
||||
<Icon
|
||||
size={ICON_SIZE}
|
||||
value={value ? value.toString() : null}
|
||||
/>
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
isEditableName={isEditableName}
|
||||
isEditableTags
|
||||
isInline={isInline}
|
||||
isShortAddr={!fullLength}
|
||||
name={name}
|
||||
onChangeName={setName}
|
||||
onChangeTags={setTags}
|
||||
onSaveName={onSaveName}
|
||||
onSaveTags={onSaveTags}
|
||||
tags={withTags ? tags : undefined}
|
||||
>
|
||||
{children}
|
||||
{overlay}
|
||||
</StyledRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { AddressRow, DEFAULT_ADDR };
|
||||
|
||||
const StyledRow = styled(Row)`
|
||||
button.u.ui--Icon.editButton {
|
||||
padding: 0 .3em .3em .3em;
|
||||
color: #2e86ab;
|
||||
background: none;
|
||||
/*trick to let the button in the flow but keep the content centered regardless*/
|
||||
margin-left: -2em;
|
||||
position: relative;
|
||||
right: -2.3em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.editSpan {
|
||||
white-space: nowrap;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressRow-balances {
|
||||
display: flex;
|
||||
.column {
|
||||
display: block;
|
||||
|
||||
label,
|
||||
.result {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressRow-placeholder {
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AddressRow);
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, Address } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import IdentityIcon from './IdentityIcon/index.js';
|
||||
import AccountName from './AccountName.js';
|
||||
import ParentAccount from './ParentAccount.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
defaultName?: string;
|
||||
onClickName?: () => void;
|
||||
overrideName?: React.ReactNode;
|
||||
parentAddress?: string;
|
||||
withSidebar?: boolean;
|
||||
withShortAddress?: boolean;
|
||||
toggle?: unknown;
|
||||
value?: string | Address | AccountId | null;
|
||||
}
|
||||
|
||||
function AddressSmall ({ children, className = '', defaultName, onClickName, overrideName, parentAddress, toggle, value, withShortAddress = false, withSidebar = true }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--AddressSmall ${(parentAddress || withShortAddress) ? 'withPadding' : ''}`}>
|
||||
<span className='ui--AddressSmall-icon'>
|
||||
<IdentityIcon value={value as Uint8Array} />
|
||||
</span>
|
||||
<span className='ui--AddressSmall-info'>
|
||||
{parentAddress && (
|
||||
<div className='parentName'>
|
||||
<ParentAccount address={parentAddress} />
|
||||
</div>
|
||||
)}
|
||||
<AccountName
|
||||
className={`accountName ${withSidebar ? 'withSidebar' : ''}`}
|
||||
defaultName={defaultName}
|
||||
onClick={onClickName}
|
||||
override={overrideName}
|
||||
toggle={toggle}
|
||||
value={value}
|
||||
withSidebar={withSidebar}
|
||||
>
|
||||
{children}
|
||||
</AccountName>
|
||||
{value && withShortAddress && (
|
||||
<div
|
||||
className='shortAddress'
|
||||
data-testid='short-address'
|
||||
>
|
||||
{value.toString()}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.withPadding {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.ui--AddressSmall-icon {
|
||||
.ui--IdentityIcon {
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AddressSmall-info {
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
|
||||
.parentName, .shortAddress {
|
||||
font-size: var(--font-size-tiny);
|
||||
}
|
||||
|
||||
.parentName {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: -0.80rem;
|
||||
}
|
||||
|
||||
.shortAddress {
|
||||
bottom: -0.95rem;
|
||||
color: #8B8B8B;
|
||||
display: inline-block;
|
||||
left: 0;
|
||||
min-width: var(--width-shortaddr);
|
||||
max-width: var(--width-shortaddr);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AccountName {
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
&.withSidebar {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AddressSmall);
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useApi, useDeriveAccountInfo } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { checkVisibility } from './util/index.js';
|
||||
import AddressMini from './AddressMini.js';
|
||||
import { styled } from './styled.js';
|
||||
import Toggle from './Toggle.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
isHidden?: boolean;
|
||||
filter?: string;
|
||||
noToggle?: boolean;
|
||||
onChange?: (isChecked: boolean) => void;
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
function AddressToggle ({ address, className = '', filter, isHidden, noToggle, onChange, value }: Props): React.ReactElement<Props> | null {
|
||||
const { apiIdentity } = useApi();
|
||||
const info = useDeriveAccountInfo(address);
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => info ? checkVisibility(apiIdentity, address, info, filter, false) : true,
|
||||
[address, filter, info, apiIdentity]
|
||||
);
|
||||
|
||||
const _onClick = useCallback(
|
||||
() => onChange && onChange(!value),
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
className={`${className} ui--AddressToggle ${(value || noToggle) ? 'isAye' : 'isNay'} ${isHidden || !isVisible ? 'isHidden' : ''}`}
|
||||
onClick={_onClick}
|
||||
>
|
||||
<AddressMini
|
||||
className='ui--AddressToggle-address'
|
||||
value={address}
|
||||
withSidebar={false}
|
||||
/>
|
||||
{!noToggle && (
|
||||
<div className='ui--AddressToggle-toggle'>
|
||||
<Toggle
|
||||
label=''
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
align-items: flex-start;
|
||||
border: 1px solid transparent; /* #eee */
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0.125rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
.ui--AddressToggle-address {
|
||||
filter: grayscale(100%);
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
&.isHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.isDragging {
|
||||
background: white;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.ui--AddressToggle-address,
|
||||
.ui--AddressToggle-toggle {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui--AddressToggle-toggle {
|
||||
margin-top: 0.1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.isAye {
|
||||
.ui--AddressToggle-address {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AddressToggle);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Available } from '@pezkuwi/react-query';
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
label?: React.ReactNode;
|
||||
params?: AccountId | AccountIndex | Address | string | Uint8Array | null;
|
||||
}
|
||||
|
||||
function AvailableDisplay ({ className = '', label, params }: Props): React.ReactElement<Props> | null {
|
||||
if (!params) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Available
|
||||
className={`${className} ui--Available`}
|
||||
label={label}
|
||||
params={params}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AvailableDisplay);
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
color?: string;
|
||||
icon: React.ReactNode;
|
||||
isBig?: boolean;
|
||||
title: React.ReactNode;
|
||||
subtitle: React.ReactNode;
|
||||
}
|
||||
|
||||
function AvatarItem ({ children, className = '', icon, isBig, subtitle, title }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={['ui--AvatarItem', className, isBig && 'big'].join(' ')}>
|
||||
<div className='ui--AvatarItem-icon'>
|
||||
{icon}
|
||||
</div>
|
||||
<div className='ui--AvatarItem-details'>
|
||||
<div className='ui--AvatarItem-title'>
|
||||
{title}
|
||||
</div>
|
||||
<div className='ui--AvatarItem-subtitle'>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ui--AvatarItem-icon {
|
||||
margin-right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AvatarItem-details {
|
||||
.ui--AvatarItem-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.ui--AvatarItem-subtitle {
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.big {
|
||||
.ui--AvatarItem-icon {
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
margin-right: 0.6rem;
|
||||
|
||||
> .ui--Icon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 3.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui--AvatarItem-details {
|
||||
.ui--AvatarItem-name {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(AvatarItem);
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
import Tooltip from './Tooltip.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
color?: 'blue' | 'counter' | 'gray' | 'green' | 'highlight' | 'normal' | 'orange' | 'purple' | 'red' | 'transparent' | 'white';
|
||||
hover?: React.ReactNode;
|
||||
hoverAction?: React.ReactNode;
|
||||
icon?: IconName;
|
||||
info?: React.ReactNode;
|
||||
isBlock?: boolean;
|
||||
isSmall?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
let badgeId = 0;
|
||||
|
||||
function Badge ({ className = '', color = 'normal', hover, hoverAction, icon, info, isBlock, isSmall, onClick }: Props): React.ReactElement<Props> | null {
|
||||
const badgeTestId = `${icon ? `${icon}-` : ''}badge`;
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [trigger] = useState(() => `${badgeTestId}-hover-${Date.now()}-${badgeId++}`);
|
||||
const extraProps = hover
|
||||
? { 'data-for': trigger, 'data-tip': true }
|
||||
: {};
|
||||
const isHighlight = color === 'highlight';
|
||||
|
||||
const hoverContent = useMemo(() => (
|
||||
<div className='hoverContent'>
|
||||
<div>{hover}</div>
|
||||
{hoverAction && (
|
||||
<a
|
||||
className={`${color}Color`}
|
||||
onClick={onClick}
|
||||
>{hoverAction}</a>
|
||||
)}
|
||||
</div>
|
||||
), [color, hover, hoverAction, onClick]);
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
{...extraProps}
|
||||
className={`${className} ui--Badge ${hover ? 'isTooltip' : ''} ${isBlock ? 'isBlock' : ''} ${isSmall ? 'isSmall' : ''} ${onClick ? 'isClickable' : ''} ${isHighlight ? 'highlight--bg' : ''} ${color}Color ${icon ? 'withIcon' : ''} ${info ? 'withInfo' : ''} ${hoverAction ? 'withAction' : ''} ${theme}Theme`}
|
||||
data-testid={badgeTestId}
|
||||
onClick={hoverAction ? undefined : onClick}
|
||||
>
|
||||
<div className={isHighlight ? 'highlight--color-contrast' : ''}>
|
||||
{(icon && <Icon icon={icon} />)}
|
||||
{info}
|
||||
{hoverAction && (
|
||||
<Icon
|
||||
className='action-icon'
|
||||
icon='chevron-right'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hover && (
|
||||
<Tooltip
|
||||
className='accounts-badge'
|
||||
isClickable={!!hoverAction}
|
||||
text={hoverContent}
|
||||
trigger={trigger}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
// FIXME We really need to get rid of the px sizing here
|
||||
const StyledDiv = styled.div`
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
color: #eeedec;
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-tiny);
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin-right: 0.43rem;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 20px;
|
||||
|
||||
&.isTooltip {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
&.isBlock {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui--Icon {
|
||||
cursor: inherit;
|
||||
margin-top: 4px;
|
||||
vertical-align: top;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
&.isClickable:not(.withAction) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.isSmall {
|
||||
font-size: 10px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
min-width: 16px;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
|
||||
.ui--Icon {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&.blueColor {
|
||||
background: steelblue;
|
||||
}
|
||||
|
||||
&.counterColor {
|
||||
margin: 0 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&.grayColor {
|
||||
background: #eeedec !important;
|
||||
color: #aaa9a8;
|
||||
}
|
||||
|
||||
&.redColor {
|
||||
background: darkred;
|
||||
}
|
||||
|
||||
&.greenColor {
|
||||
background: green;
|
||||
}
|
||||
|
||||
&.orangeColor {
|
||||
background: darkorange;
|
||||
}
|
||||
|
||||
&.purpleColor {
|
||||
background: indigo;
|
||||
}
|
||||
|
||||
&.transparentColor {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.whiteColor {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&.recovery, &.warning, &.information, &.important {
|
||||
background-color: #FFFFFF;
|
||||
|
||||
&.darkTheme {
|
||||
background-color: #212227;
|
||||
}
|
||||
}
|
||||
|
||||
&.recovery {
|
||||
background-image: linear-gradient(0deg, rgba(17, 185, 74, 0.08), rgba(17, 185, 74, 0.08));
|
||||
color: #11B94A;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-image: linear-gradient(0deg, rgba(232, 111, 0, 0.08), rgba(232, 111, 0, 0.08));
|
||||
color: #FF7D01;
|
||||
}
|
||||
|
||||
&.information {
|
||||
background-image: linear-gradient(0deg, rgba(226, 246, 255, 0.08), rgba(226, 246, 255, 0.08));
|
||||
color: #3BBEFF;
|
||||
|
||||
&.lightTheme {
|
||||
background-color: rgba(226, 246, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.important {
|
||||
background: linear-gradient(0deg, rgba(230, 0, 122, 0.08), rgba(230, 0, 122, 0.08)), rgba(230, 0, 122, 0.01);
|
||||
color: #E6007A;
|
||||
}
|
||||
|
||||
&.withAction.withIcon:not(.withInfo) {
|
||||
width: 34px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.withInfo.withIcon:not(.withAction) {
|
||||
width: 34px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
&.withAction.withIcon.withInfo {
|
||||
width: 44px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.withInfo .ui--Icon:not(.action-icon) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.hoverContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
margin-left: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Badge);
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { BalanceFree, FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
export interface RenderProps {
|
||||
className?: string;
|
||||
label?: React.ReactNode;
|
||||
value?: BN | BN[];
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
balance?: BN | BN[];
|
||||
className?: string;
|
||||
label?: React.ReactNode;
|
||||
params?: AccountId | AccountIndex | Address | string | Uint8Array | null;
|
||||
withLabel?: boolean;
|
||||
}
|
||||
|
||||
export function renderProvided ({ className = '', label, value }: RenderProps): React.ReactNode {
|
||||
let others: undefined | React.ReactNode;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const totals = value.filter((_, index): boolean => index !== 0);
|
||||
const total = totals.reduce((total, value): BN => total.add(value), BN_ZERO).gtn(0);
|
||||
|
||||
if (total) {
|
||||
others = totals.map((balance, index): React.ReactNode =>
|
||||
<FormatBalance
|
||||
key={index}
|
||||
value={balance}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormatBalance
|
||||
className={`${className} ui--Balance`}
|
||||
label={label}
|
||||
value={Array.isArray(value) ? value[0] : value}
|
||||
>
|
||||
{others && (
|
||||
<span> (+{others})</span>
|
||||
)}
|
||||
</FormatBalance>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceDisplay (props: Props): React.ReactElement<Props> | null {
|
||||
const { balance, className = '', label, params } = props;
|
||||
|
||||
if (!params) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return balance
|
||||
? <>{renderProvided({ className, label, value: balance })}</>
|
||||
: (
|
||||
<BalanceFree
|
||||
className={`${className} ui--Balance`}
|
||||
label={label}
|
||||
params={params}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BalanceDisplay);
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import MarkWarning from './MarkWarning.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
function BatchWarning (): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
if (isFunction(api.tx.utility.batchAll)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkWarning content={t('This chain does not yet support atomic batch operations. This means that if the transaction gets executed and one of the operations do fail (due to invalid data or lack of available funds) some of the changes made may not be applied.')} />
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BatchWarning);
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Bonded } from '@pezkuwi/react-query';
|
||||
|
||||
import { renderProvided } from './Balance.js';
|
||||
|
||||
export interface Props {
|
||||
bonded?: BN | BN[];
|
||||
className?: string;
|
||||
label?: React.ReactNode;
|
||||
params?: AccountId | AccountIndex | Address | string | Uint8Array | null;
|
||||
withLabel?: boolean;
|
||||
}
|
||||
|
||||
function BondedDisplay (props: Props): React.ReactElement<Props> | null {
|
||||
const { bonded, className = '', label, params } = props;
|
||||
|
||||
if (!params) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bonded
|
||||
? <>{renderProvided({ className, label, value: bonded })}</>
|
||||
: (
|
||||
<Bonded
|
||||
className={`${className} ui--Bonded`}
|
||||
label={label}
|
||||
params={params}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BondedDisplay);
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
isCentered?: boolean;
|
||||
}
|
||||
|
||||
function ButtonGroup ({ children, className = '', isCentered }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--Button-Group ${isCentered ? 'isCentered' : ''}`}>
|
||||
{children}
|
||||
<div className='clear' />
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
margin: 1rem 0;
|
||||
text-align: right;
|
||||
|
||||
& .clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
&.isCentered {
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&+.ui--Table {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.ui--Button {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.ui--CopyButton {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ui--ToggleGroup, .ui--Dropdown {
|
||||
float: left;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(ButtonGroup);
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ButtonProps as Props } from './types.js';
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import Icon from '../Icon.js';
|
||||
import Spinner from '../Spinner.js';
|
||||
import { styled } from '../styled.js';
|
||||
import Group from './Group.js';
|
||||
|
||||
function ButtonBase ({ activeOnEnter, children, className = '', dataTestId = '', icon, isBasic, isBusy, isCircular, isDisabled, isFull, isIcon, isSelected, isToplevel, label, onClick, isReadOnly = !onClick, onMouseEnter, onMouseLeave, tabIndex, withoutLink }: Props): React.ReactElement<Props> {
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
!(isBusy || isDisabled) && onClick && Promise
|
||||
.resolve(onClick())
|
||||
.catch(console.error);
|
||||
},
|
||||
[isBusy, isDisabled, onClick]
|
||||
);
|
||||
|
||||
const _onMouseEnter = useCallback((): void => {
|
||||
onMouseEnter && Promise
|
||||
.resolve(onMouseEnter())
|
||||
.catch(console.error);
|
||||
}, [onMouseEnter]);
|
||||
|
||||
const _onMouseLeave = useCallback((): void => {
|
||||
onMouseLeave && Promise
|
||||
.resolve(onMouseLeave())
|
||||
.catch(console.error);
|
||||
}, [onMouseLeave]);
|
||||
|
||||
const listenKeyboard = useCallback((event: KeyboardEvent): void => {
|
||||
if (!isBusy && !isDisabled && event.key === 'Enter') {
|
||||
onClick && Promise
|
||||
.resolve(onClick())
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [isBusy, isDisabled, onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeOnEnter) {
|
||||
window.addEventListener('keydown', listenKeyboard, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (activeOnEnter) {
|
||||
window.removeEventListener('keydown', listenKeyboard, true);
|
||||
}
|
||||
};
|
||||
}, [activeOnEnter, listenKeyboard]);
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
className={`${className} ui--Button ${label ? 'hasLabel' : ''} ${isBasic ? 'isBasic' : ''} ${isCircular ? 'isCircular' : ''} ${isFull ? 'isFull' : ''} ${isIcon ? 'isIcon' : ''} ${(isBusy || isDisabled) ? 'isDisabled' : ''} ${isBusy ? 'isBusy' : ''} ${isReadOnly ? 'isReadOnly' : ''}${isSelected ? 'isSelected' : ''} ${isToplevel ? 'isToplevel' : ''} ${withoutLink ? 'withoutLink' : ''}`}
|
||||
data-testid={dataTestId}
|
||||
onClick={_onClick}
|
||||
onMouseEnter={_onMouseEnter}
|
||||
onMouseLeave={_onMouseLeave}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{icon && <Icon icon={icon} />}
|
||||
{label}
|
||||
{children}
|
||||
{isBusy && (
|
||||
<Spinner
|
||||
className='ui--Button-spinner'
|
||||
variant='cover'
|
||||
/>
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_PADDING = 0.5;
|
||||
|
||||
const StyledButton = styled.button`
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
|
||||
&:not(.hasLabel) {
|
||||
padding: 0.7em;
|
||||
|
||||
.ui--Icon {
|
||||
padding: 0.6rem;
|
||||
margin: -0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.isCircular) {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline:0;
|
||||
}
|
||||
|
||||
&.hasLabel {
|
||||
padding: 0.7rem 1.1rem 0.7rem ${1.1 - ICON_PADDING}rem;
|
||||
|
||||
.ui--Icon {
|
||||
margin-right: 0.425rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.isBasic {
|
||||
background: var(--bg-table);
|
||||
}
|
||||
|
||||
&.isCircular {
|
||||
border-radius: 10rem;
|
||||
}
|
||||
|
||||
&.isDisabled, &.isReadOnly {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.isBusy {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
&.isFull {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.isIcon {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ui--Button-overlay {
|
||||
background: rgba(253, 252, 251, 0.75);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ui--Icon {
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
height: 1rem;
|
||||
margin: -${ICON_PADDING}rem 0;
|
||||
padding: ${ICON_PADDING}rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
&.isDisabled {
|
||||
color: #bcbbba;
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = React.memo(ButtonBase) as unknown as typeof ButtonBase & {
|
||||
Group: typeof Group
|
||||
};
|
||||
|
||||
Button.Group = Group;
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
import type React from 'react';
|
||||
|
||||
export type Button$Callback = () => void | Promise<void>;
|
||||
|
||||
export interface ButtonProps {
|
||||
activeOnEnter?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
icon?: IconName;
|
||||
isBasic?: boolean;
|
||||
isBusy?: boolean;
|
||||
isCircular?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isFull?: boolean;
|
||||
isIcon?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isSelected?: boolean;
|
||||
isToplevel?: boolean;
|
||||
label?: React.ReactNode;
|
||||
onClick?: Button$Callback;
|
||||
onMouseEnter?: Button$Callback;
|
||||
onMouseLeave?: Button$Callback;
|
||||
tabIndex?: number;
|
||||
tooltip?: React.ReactNode;
|
||||
withoutLink?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Button from './Button/index.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
function ButtonCancel ({ className = '', isDisabled, label, onClick, tabIndex }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
icon='times'
|
||||
isDisabled={isDisabled}
|
||||
label={label || t('Cancel')}
|
||||
onClick={onClick}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ButtonCancel);
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isError?: boolean;
|
||||
isSuccess?: boolean;
|
||||
withBottomMargin?: boolean;
|
||||
}
|
||||
|
||||
function Card ({ children, className = '', isError, isSuccess, withBottomMargin }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledArticle className={`${className} ui--Card ${(isError && !isSuccess) ? 'error' : ''} ${(!isError && isSuccess) ? 'success' : ''} ${withBottomMargin ? 'withBottomMargin' : ''}`}>
|
||||
{children}
|
||||
</StyledArticle>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArticle = styled.article`
|
||||
position: relative;
|
||||
flex: 1 1;
|
||||
min-width: 24%;
|
||||
justify-content: space-around;
|
||||
|
||||
label {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
i.help.circle.icon,
|
||||
.ui.button.mini,
|
||||
.ui.button.tiny,
|
||||
.addTags {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ui--AddressSummary-buttons {
|
||||
text-align: right;
|
||||
margin-bottom: 2em;
|
||||
|
||||
button {
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
i.help.circle.icon,
|
||||
.ui.button.mini,
|
||||
.ui.button.tiny,
|
||||
.addTags {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
label {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(255, 0, 0, 0.05);
|
||||
|
||||
&, h1, h2, h3, h4, h5, h6, p {
|
||||
color: rgba(156, 0, 0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
border: 1px solid rgb(168, 255, 136);
|
||||
background: rgba(0, 255, 0, 0.05);
|
||||
|
||||
&, h1, h2, h3, h4, h5, h6, p {
|
||||
color: rgba(34, 125, 0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.withBottomMargin {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Card);
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { UInt } from '@pezkuwi/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { BlockToTime } from '@pezkuwi/react-query';
|
||||
import { BN_HUNDRED, formatNumber, isUndefined } from '@pezkuwi/util';
|
||||
|
||||
import Labelled from './Labelled.js';
|
||||
import Progress from './Progress.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface ProgressProps {
|
||||
hideGraph?: boolean;
|
||||
hideValue?: boolean;
|
||||
isBlurred?: boolean;
|
||||
isPercent?: boolean;
|
||||
total?: BN | UInt;
|
||||
value?: BN | UInt;
|
||||
withTime?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
label: React.ReactNode;
|
||||
progress?: ProgressProps;
|
||||
apiOverride?: ApiPromise;
|
||||
}
|
||||
|
||||
function CardSummary ({ apiOverride, children, className = '', label, progress }: Props): React.ReactElement<Props> | null {
|
||||
const value = progress?.value;
|
||||
const total = progress?.total;
|
||||
const left = progress && !isUndefined(value) && !isUndefined(total) && value.gten(0) && total.gtn(0)
|
||||
? (
|
||||
value.gt(total)
|
||||
? `>${
|
||||
progress.isPercent
|
||||
? '100'
|
||||
: formatNumber(total)
|
||||
}`
|
||||
: (
|
||||
progress.isPercent
|
||||
? value.mul(BN_HUNDRED).div(total).toString()
|
||||
: formatNumber(value)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (progress && isUndefined(left)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isTimed = progress && progress.withTime && !isUndefined(progress.total);
|
||||
|
||||
// We don't care about the label as much...
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const testidSuffix = (label ?? '').toString();
|
||||
|
||||
return (
|
||||
<StyledArticle
|
||||
className={className}
|
||||
data-testid={`card-summary:${testidSuffix}`}
|
||||
>
|
||||
<Labelled
|
||||
isSmall
|
||||
label={label}
|
||||
>
|
||||
{children}{
|
||||
progress && !progress.hideValue && (
|
||||
<>
|
||||
{isTimed && !children && (
|
||||
<BlockToTime
|
||||
api={apiOverride}
|
||||
className={progress.isBlurred ? '--tmp' : ''}
|
||||
value={progress.total}
|
||||
/>
|
||||
)}
|
||||
<div className={isTimed ? 'isSecondary' : 'isPrimary'}>
|
||||
{!left || isUndefined(progress.total)
|
||||
? '-'
|
||||
: !isTimed || progress.isPercent || !progress.value
|
||||
? `${left}${progress.isPercent ? '' : '/'}${
|
||||
progress.isPercent
|
||||
? '%'
|
||||
: formatNumber(progress.total)
|
||||
}`
|
||||
: (
|
||||
<BlockToTime
|
||||
api={apiOverride}
|
||||
className={`${progress.isBlurred ? '--tmp' : ''} timer`}
|
||||
value={progress.total.sub(progress.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Labelled>
|
||||
{progress && !progress.hideGraph && <Progress {...progress} />}
|
||||
</StyledArticle>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArticle = styled.article`
|
||||
align-items: center;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--color-summary);
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
.ui--FormatBalance .balance-postfix {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ui--Progress {
|
||||
margin: 0.5rem 0.125rem 0.125rem 0.75rem;
|
||||
}
|
||||
|
||||
> .ui--Labelled {
|
||||
font-size: var(--font-size-h1);
|
||||
font-weight: var(--font-weight-header);
|
||||
position: relative;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
|
||||
> .ui--Labelled-content {
|
||||
color: var(--color-header);
|
||||
}
|
||||
|
||||
> * {
|
||||
margin: 0.25rem 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.isSecondary {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
|
||||
.timer {
|
||||
min-width: 8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 767px) {
|
||||
min-height: 4.8rem;
|
||||
padding: 0.25 0.4em;
|
||||
|
||||
> div {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(CardSummary);
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { createWsEndpoints } from '@pezkuwi/apps-config';
|
||||
import { externalEmptySVG } from '@pezkuwi/apps-config/ui/logos/external';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isInline?: boolean;
|
||||
logo?: string;
|
||||
onClick?: () => any;
|
||||
withoutHl?: boolean;
|
||||
}
|
||||
|
||||
const endpoints = createWsEndpoints(() => '');
|
||||
|
||||
function ChainImg ({ className = '', isInline, logo, onClick, withoutHl }: Props): React.ReactElement<Props> {
|
||||
const { apiEndpoint } = useApi();
|
||||
const [isEmpty, img, isFa] = useMemo((): [boolean, unknown, boolean] => {
|
||||
const endpoint = endpoints.find((o) => o.info === logo);
|
||||
const found = endpoint?.ui.logo || logo || apiEndpoint?.ui.logo;
|
||||
const imgBase = found || externalEmptySVG;
|
||||
const [isFa, img] = !imgBase || imgBase === 'empty' || !(imgBase.startsWith('data:') || imgBase.startsWith('fa;'))
|
||||
? [false, externalEmptySVG]
|
||||
: imgBase.startsWith('fa;')
|
||||
? [true, imgBase.substring(3)]
|
||||
: [false, imgBase];
|
||||
|
||||
return [!found || logo === 'empty', img, isFa];
|
||||
}, [apiEndpoint, logo]);
|
||||
|
||||
const iconClassName = `${className} ui--ChainImg ${(isEmpty && !withoutHl) ? 'highlight--bg' : ''} ${isInline ? 'isInline' : ''}`;
|
||||
|
||||
return isFa
|
||||
? (
|
||||
<StyledIcon
|
||||
className={iconClassName}
|
||||
icon={img as IconName}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<StyledImg
|
||||
alt='chain logo'
|
||||
className={iconClassName}
|
||||
onClick={onClick}
|
||||
src={img as string}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const STYLE = `
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
|
||||
&.isInline {
|
||||
display: inline-block;
|
||||
height: 24px;
|
||||
margin-right: 0.75rem;
|
||||
vertical-align: middle;
|
||||
width: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled(Icon)`${STYLE}`;
|
||||
const StyledImg = styled.img`${STYLE}`;
|
||||
|
||||
export default React.memo(ChainImg);
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { chains } from '@pezkuwi/ui-settings/defaults/chains';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
import Toggle from './Toggle.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
genesisHash: string | null;
|
||||
isDisabled?: boolean;
|
||||
onChange: (genesisHash: HexString | null) => void;
|
||||
}
|
||||
|
||||
function calcLock (apiGenesis: string, genesisHash: string | null): boolean {
|
||||
if (!genesisHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Object.values(chains).find((hashes): boolean =>
|
||||
hashes.includes(apiGenesis)
|
||||
) || [apiGenesis]
|
||||
).includes(genesisHash);
|
||||
}
|
||||
|
||||
function ChainLock ({ className = '', genesisHash, isDisabled, onChange }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, isDevelopment } = useApi();
|
||||
|
||||
const isTiedToChain = useMemo(
|
||||
() => calcLock(api.genesisHash.toHex(), genesisHash),
|
||||
[api, genesisHash]
|
||||
);
|
||||
|
||||
const _onChange = useCallback(
|
||||
(isTiedToChain: boolean) =>
|
||||
onChange(
|
||||
isTiedToChain
|
||||
? api.genesisHash.toHex()
|
||||
: null
|
||||
),
|
||||
[api, onChange]
|
||||
);
|
||||
|
||||
if (isDevelopment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledToggle
|
||||
className={className}
|
||||
isDisabled={isDisabled}
|
||||
label={t('only this network')}
|
||||
onChange={_onChange}
|
||||
preventDefault
|
||||
value={isTiedToChain}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledToggle = styled(Toggle)`
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
export default React.memo(ChainLock);
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function BaseChart ({ children, className = '' }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--Chart`}>
|
||||
{children}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 1em 1em 0;
|
||||
height: 15vw;
|
||||
width: 15vw;
|
||||
`;
|
||||
|
||||
export default React.memo(BaseChart);
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
|
||||
import { bnToBn } from '@pezkuwi/util';
|
||||
|
||||
import Base from './Base.js';
|
||||
|
||||
interface DoughnutValue {
|
||||
colors: string[];
|
||||
label: string;
|
||||
value: number | BN;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
size?: number;
|
||||
values: DoughnutValue[];
|
||||
}
|
||||
|
||||
interface Options {
|
||||
colorNormal: string[];
|
||||
colorHover: string[];
|
||||
data: number[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
function ChartDoughnut ({ className = '', size = 100, values }: Props): React.ReactElement<Props> {
|
||||
const options: Options = {
|
||||
colorHover: [],
|
||||
colorNormal: [],
|
||||
data: [],
|
||||
labels: []
|
||||
};
|
||||
|
||||
values.forEach(({ colors: [normalColor = '#00f', hoverColor], label, value }): void => {
|
||||
options.colorNormal.push(normalColor);
|
||||
options.colorHover.push(hoverColor || normalColor);
|
||||
options.data.push(bnToBn(value).toNumber());
|
||||
options.labels.push(label);
|
||||
});
|
||||
|
||||
return (
|
||||
<Base className={`${className} ui--Chart-Doughnut`}>
|
||||
<Doughnut
|
||||
data={{
|
||||
datasets: [{
|
||||
backgroundColor: options.colorNormal,
|
||||
data: options.data,
|
||||
hoverBackgroundColor: options.colorHover
|
||||
}],
|
||||
labels: options.labels
|
||||
}}
|
||||
height={size}
|
||||
width={size}
|
||||
/>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChartDoughnut);
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChartData, ChartOptions, TooltipItem } from 'chart.js';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
|
||||
import { bnToBn, isNumber } from '@pezkuwi/util';
|
||||
|
||||
import { alphaColor } from './utils.js';
|
||||
|
||||
interface Value {
|
||||
colors: string[];
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
value: number | BN;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
max?: number;
|
||||
showLabels?: boolean;
|
||||
values: Value[];
|
||||
withColors?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
chartData?: ChartData;
|
||||
chartOptions?: ChartOptions;
|
||||
jsonValues?: string;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
data: number[];
|
||||
backgroundColor: string[];
|
||||
hoverBackgroundColor: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
function calculateOptions (aspectRatio: number, values: Value[], jsonValues: string, max: number, showLabels: boolean): State {
|
||||
const chartData = values.reduce((data, { colors: [normalColor = '#00f', hoverColor], label, value }): Config => {
|
||||
const dataset = data.datasets[0];
|
||||
|
||||
dataset.backgroundColor.push(alphaColor(normalColor));
|
||||
dataset.hoverBackgroundColor.push(alphaColor(hoverColor || normalColor));
|
||||
dataset.data.push(isNumber(value) ? value : bnToBn(value).toNumber());
|
||||
data.labels.push(label);
|
||||
|
||||
return data;
|
||||
}, {
|
||||
datasets: [{
|
||||
backgroundColor: [] as string[],
|
||||
data: [] as number[],
|
||||
hoverBackgroundColor: [] as string[]
|
||||
}],
|
||||
labels: [] as string[]
|
||||
});
|
||||
|
||||
return {
|
||||
chartData,
|
||||
chartOptions: {
|
||||
aspectRatio,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (item: TooltipItem<any>): string =>
|
||||
values[item.dataIndex].tooltip || values[item.dataIndex].label
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: showLabels
|
||||
? { beginAtZero: true, max }
|
||||
: { display: false }
|
||||
}
|
||||
},
|
||||
jsonValues
|
||||
};
|
||||
}
|
||||
|
||||
function ChartHorizBar ({ aspectRatio = 8, className = '', max = 100, showLabels = false, values }: Props): React.ReactElement<Props> | null {
|
||||
const [{ chartData, chartOptions, jsonValues }, setState] = useState<State>({});
|
||||
|
||||
useEffect((): void => {
|
||||
const newJsonValues = JSON.stringify(values);
|
||||
|
||||
if (newJsonValues !== jsonValues) {
|
||||
setState(calculateOptions(aspectRatio, values, newJsonValues, max, showLabels));
|
||||
}
|
||||
}, [aspectRatio, jsonValues, max, showLabels, values]);
|
||||
|
||||
if (!chartData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// HACK on width/height to get the aspectRatio to work
|
||||
return (
|
||||
<div className={`${className} ui--Chart-HorizBar`}>
|
||||
<Bar
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
data={chartData as any}
|
||||
height={null as unknown as number}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
options={chartOptions as any}
|
||||
width={null as unknown as number}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChartHorizBar);
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChartData, ChartDataset, ChartOptions, DatasetChartOptions } from 'chart.js';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import * as Chart from 'react-chartjs-2';
|
||||
|
||||
import { isBn, objectSpread } from '@pezkuwi/util';
|
||||
|
||||
import ErrorBoundary from '../ErrorBoundary.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { alphaColor } from './utils.js';
|
||||
|
||||
export interface Props {
|
||||
colors?: (string | undefined)[];
|
||||
className?: string;
|
||||
labels: string[];
|
||||
legends: string[];
|
||||
options?: ChartOptions;
|
||||
values: (number | BN)[][];
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
labels: string[];
|
||||
datasets: ChartDataset<'line'>[];
|
||||
}
|
||||
|
||||
const COLORS = ['#ff8c00', '#008c8c', '#8c008c'];
|
||||
|
||||
const BASE_OPTS: ChartOptions = {
|
||||
animation: {
|
||||
duration: 0
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
hoverRadius: 6,
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
intersect: false
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
crosshair: {
|
||||
line: {
|
||||
color: '#ff8c00',
|
||||
dashPattern: [5, 5],
|
||||
width: 2
|
||||
},
|
||||
snap: {
|
||||
enabled: true
|
||||
},
|
||||
sync: {
|
||||
enabled: true
|
||||
},
|
||||
// this would be nice, but atm just doesn't quite
|
||||
// seem or feel intuitive...
|
||||
zoom: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 60,
|
||||
minRotation: 60
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getOptions (options: ChartOptions = {}): DatasetChartOptions<'line'> {
|
||||
return objectSpread({}, BASE_OPTS, options, {
|
||||
// Re-spread plugins for deep(er) copy
|
||||
plugins: objectSpread({}, BASE_OPTS.plugins, options.plugins, {
|
||||
// Same applied to plugins, we may want specific values
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
annotation: objectSpread({}, BASE_OPTS.plugins?.annotation, options.plugins?.annotation),
|
||||
crosshair: objectSpread({}, BASE_OPTS.plugins?.crosshair, options.plugins?.crosshair),
|
||||
tooltip: objectSpread({}, BASE_OPTS.plugins?.tooltip, options.plugins?.tooltip)
|
||||
}),
|
||||
scales: objectSpread({}, BASE_OPTS.scales, options.scales, {
|
||||
x: objectSpread({}, BASE_OPTS.scales?.x, options.scales?.x),
|
||||
y: objectSpread({}, BASE_OPTS.scales?.y, options.scales?.y)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function getData (colors: (string | undefined)[] = [], legends: string[], labels: string[], values: (number | BN)[][]): ChartData<'line'> {
|
||||
return values.reduce((chartData, values, index): Config => {
|
||||
const color = colors[index] || alphaColor(COLORS[index]);
|
||||
const data = values.map((value): number => isBn(value) ? value.toNumber() : value);
|
||||
|
||||
chartData.datasets.push({
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
cubicInterpolationMode: 'default',
|
||||
data,
|
||||
fill: false,
|
||||
hoverBackgroundColor: color,
|
||||
label: legends[index],
|
||||
// @ts-expect-error The typings here doesn't reflect this one
|
||||
lineTension: 0.25
|
||||
});
|
||||
|
||||
return chartData;
|
||||
}, { datasets: [] as ChartDataset<'line'>[], labels });
|
||||
}
|
||||
|
||||
function LineChart ({ className = '', colors, labels, legends, options, title, values }: Props): React.ReactElement<Props> | null {
|
||||
const chartOptions = useMemo(
|
||||
() => getOptions(options),
|
||||
[options]
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() => getData(colors, legends, labels, values),
|
||||
[colors, labels, legends, values]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--Chart-Line`}>
|
||||
{title && <h1 className='ui--Chart-Header'>{title}</h1>}
|
||||
<ErrorBoundary>
|
||||
<Chart.Line
|
||||
data={chartData}
|
||||
options={chartOptions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
h1.ui--Chart-Header {
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: 1rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(LineChart);
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChartType } from 'chart.js';
|
||||
import type { CrosshairOptions } from 'chartjs-plugin-crosshair';
|
||||
|
||||
declare module 'chart.js' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface PluginOptionsByType<TType extends ChartType> {
|
||||
crosshair: CrosshairOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
declare module 'chart.js/helpers' {
|
||||
export const color: (c: string) => {
|
||||
alpha: (a: number) => {
|
||||
rgbString: () => string;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChartType, Plugin } from 'chart.js';
|
||||
|
||||
import { CategoryScale, Chart, LinearScale, LineElement, PointElement, Title, Tooltip } from 'chart.js';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import crosshairPlugin from 'chartjs-plugin-crosshair';
|
||||
|
||||
import Doughnut from './Doughnut.js';
|
||||
import HorizBar from './HorizBar.js';
|
||||
import Line from './Line.js';
|
||||
|
||||
interface CrosshairChart {
|
||||
crosshair?: boolean;
|
||||
}
|
||||
|
||||
function CustomCrosshairPlugin (plugin: Plugin<ChartType>): Plugin<ChartType> {
|
||||
const originalAfterDraw = plugin.afterDraw;
|
||||
|
||||
if (originalAfterDraw) {
|
||||
plugin.afterDraw = function (chart, args): void {
|
||||
if ((chart as CrosshairChart).crosshair) {
|
||||
// @ts-expect-error - Pass exactly what the original plugin expects
|
||||
originalAfterDraw.call(this, chart, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
annotationPlugin,
|
||||
CustomCrosshairPlugin(crosshairPlugin as Plugin<ChartType>)
|
||||
);
|
||||
|
||||
export default {
|
||||
Doughnut,
|
||||
HorizBar,
|
||||
Line
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as helpers from 'chart.js/helpers';
|
||||
|
||||
export function alphaColor (hexColor: string): string {
|
||||
return helpers.color(hexColor).alpha(0.65).rgbString();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
label?: React.ReactNode;
|
||||
onChange?: (isChecked: boolean) => void;
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
function Checkbox ({ className = '', isDisabled, label, onChange, value }: Props): React.ReactElement<Props> {
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
!isDisabled && onChange && onChange(!value);
|
||||
},
|
||||
[isDisabled, onChange, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
className={`${className} ui--Checkbox ${isDisabled ? 'isDisabled' : ''}`}
|
||||
onClick={_onClick}
|
||||
>
|
||||
<Icon
|
||||
color={value ? 'normal' : 'transparent'}
|
||||
icon='check'
|
||||
/>
|
||||
{label && <label>{label}</label>}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
&.isDisabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:not(.isDisabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> label {
|
||||
color: var(--color-text);
|
||||
display: inline-block;
|
||||
margin: 0 0.5rem;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> label,
|
||||
> .ui--Icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ui--Icon {
|
||||
border: 1px solid var(--color-checkbox);
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Checkbox);
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
is60?: boolean;
|
||||
is100?: boolean;
|
||||
isPadded?: boolean;
|
||||
isReverse?: boolean;
|
||||
size?: 'default' | 'small' | 'tiny';
|
||||
}
|
||||
|
||||
const MIN_WIDTH_DEFAULT = '1025px';
|
||||
const MIN_WIDTH_SMALL = '750px';
|
||||
const MIN_WIDTH_TINY = '550px';
|
||||
|
||||
const FLEX_OPTIONS = `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.is50 {
|
||||
> .ui--Column {
|
||||
max-width: 50%;
|
||||
min-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is60 {
|
||||
> .ui--Column:first-child {
|
||||
max-width: 60%;
|
||||
min-width: 60%;
|
||||
}
|
||||
|
||||
> .ui--Column:last-child {
|
||||
max-width: 40%;
|
||||
min-width: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is100 {
|
||||
> .ui--Column {
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function Column ({ children, className = '' }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={`${className} ui--Column`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumarBase ({ children, className = '', is60, is100, isPadded = true, isReverse, size = 'default' }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--Columar ${is100 ? 'is100' : (is60 ? 'is60' : 'is50')} ${isPadded ? 'isPadded' : ''} ${isReverse ? 'isReverse' : ''} ${size}Size`}>
|
||||
{children}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
&.isReverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&.defaultSize {
|
||||
@media only screen and (min-width: ${MIN_WIDTH_DEFAULT}) {
|
||||
${FLEX_OPTIONS}
|
||||
}
|
||||
|
||||
&.isPadded > .ui--Column {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.smallSize {
|
||||
@media only screen and (min-width: ${MIN_WIDTH_SMALL}) {
|
||||
${FLEX_OPTIONS}
|
||||
}
|
||||
|
||||
&isPadded > .ui--Column {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.tinySize {
|
||||
@media only screen and (min-width: ${MIN_WIDTH_TINY}) {
|
||||
${FLEX_OPTIONS}
|
||||
}
|
||||
|
||||
&.isPadded > .ui--Column {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.defaultSize, &.smallSize {
|
||||
@media only screen and (max-width: ${MIN_WIDTH_SMALL}) {
|
||||
&.isPadded > .ui--Column {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.defaultSize, &.smallSize, &.tinySize {
|
||||
@media only screen and (max-width: ${MIN_WIDTH_TINY}) {
|
||||
&.isPadded > .ui--Column {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .ui--Column {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
flex: 1 1;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Columar = React.memo(ColumarBase) as unknown as typeof ColumarBase & {
|
||||
Column: typeof Column
|
||||
};
|
||||
|
||||
Columar.Column = Column;
|
||||
|
||||
export default Columar;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { useBlockInterval } from '@pezkuwi/react-hooks';
|
||||
import { calcBlockTime } from '@pezkuwi/react-hooks/useBlockTime';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import Dropdown from './Dropdown.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
label?: React.ReactNode;
|
||||
onChange?: (value: number) => void;
|
||||
value?: number;
|
||||
voteLockingPeriod: BN;
|
||||
}
|
||||
|
||||
export const CONVICTIONS = [1, 2, 4, 8, 16, 32].map((lock, index): [value: number, duration: number, durationBn: BN] => [index + 1, lock, new BN(lock)]);
|
||||
|
||||
function createOptions (blockTime: BN, voteLockingPeriod: BN, t: (key: string, options?: { replace: Record<string, unknown> }) => string): { text: string; value: number }[] {
|
||||
return [
|
||||
{ text: t('0.1x voting balance, no lockup period'), value: 0 },
|
||||
...CONVICTIONS.map(([value, duration, durationBn]): { text: string; value: number } => ({
|
||||
text: t('{{value}}x voting balance, locked for {{duration}}x duration{{period}}', {
|
||||
replace: {
|
||||
duration,
|
||||
period: voteLockingPeriod && voteLockingPeriod.gt(BN_ZERO)
|
||||
? ` (${calcBlockTime(blockTime, durationBn.mul(voteLockingPeriod), t)[1]})`
|
||||
: '',
|
||||
value
|
||||
}
|
||||
}),
|
||||
value
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
function Convictions ({ className = '', label, onChange, value, voteLockingPeriod }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const blockTime = useBlockInterval();
|
||||
const optionsRef = useRef(createOptions(blockTime, voteLockingPeriod, t));
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className={className}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
options={optionsRef.current}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Convictions);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
||||
import { useQueue } from '@pezkuwi/react-hooks';
|
||||
import { isString } from '@pezkuwi/util';
|
||||
|
||||
import Button from './Button/index.js';
|
||||
import { styled } from './styled.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
icon?: IconName;
|
||||
label?: React.ReactNode;
|
||||
type?: string;
|
||||
isMnemonic?: boolean;
|
||||
value?: React.ReactNode | null;
|
||||
}
|
||||
|
||||
const NOOP = () => undefined;
|
||||
|
||||
function CopyButton ({ children, className = '', icon = 'copy', label, type, value }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { queueAction } = useQueue();
|
||||
|
||||
const _onCopy = useCallback(
|
||||
(): void => {
|
||||
queueAction && queueAction({
|
||||
action: t('clipboard'),
|
||||
message: t('{{type}} copied', { replace: { type: type || t('value') } }),
|
||||
status: 'queued'
|
||||
});
|
||||
},
|
||||
[type, queueAction, t]
|
||||
);
|
||||
|
||||
if (!isString(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--CopyButton`}>
|
||||
<CopyToClipboard
|
||||
onCopy={_onCopy}
|
||||
text={value as string}
|
||||
>
|
||||
<div className='copyContainer'>
|
||||
{children}
|
||||
<span className='copySpan'>
|
||||
<Button
|
||||
className='icon-button show-on-hover'
|
||||
icon={icon}
|
||||
isDisabled={!value}
|
||||
label={label}
|
||||
onClick={NOOP}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</CopyToClipboard>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.copySpan {
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(CopyButton);
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountIdIsh } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { getAccountCryptoType } from './util/index.js';
|
||||
|
||||
interface Props {
|
||||
accountId: AccountIdIsh;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function CryptoType ({ accountId, className = '', label = '' }: Props): React.ReactElement<Props> {
|
||||
const [type, setType] = useState('unknown');
|
||||
|
||||
useEffect((): void => {
|
||||
const result = getAccountCryptoType(accountId);
|
||||
|
||||
if (result !== 'unknown') {
|
||||
setType(result);
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
return (
|
||||
<div className={`${className} ui--CryptoType`}>
|
||||
{label}{type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CryptoType);
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveDemocracyLock } from '@pezkuwi/api-derive/types';
|
||||
import type { Balance } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useBestNumber } from '@pezkuwi/react-hooks';
|
||||
import { BlockToTime, FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO, bnMax, formatBalance, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
import Tooltip from './Tooltip.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value?: Partial<DeriveDemocracyLock>[];
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
details: React.ReactNode;
|
||||
headers: React.ReactNode[];
|
||||
isCountdown: boolean;
|
||||
isFinished: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
maxBalance: BN;
|
||||
sorted: Entry[];
|
||||
}
|
||||
|
||||
let id = 0;
|
||||
|
||||
// group by header & details
|
||||
// - all unlockable together
|
||||
// - all ongoing together
|
||||
// - unlocks are displayed individually
|
||||
function groupLocks (t: (key: string, options?: { replace: Record<string, unknown> }) => string, bestNumber: BN, locks: Partial<DeriveDemocracyLock>[] = []): State {
|
||||
return {
|
||||
maxBalance: bnMax(...locks.map(({ balance }) => balance).filter((b): b is Balance => !!b)),
|
||||
sorted: locks
|
||||
.map((info): [Partial<DeriveDemocracyLock>, BN] => [info, info.unlockAt && info.unlockAt.gt(bestNumber) ? info.unlockAt.sub(bestNumber) : BN_ZERO])
|
||||
.sort((a, b) => (a[0].referendumId || BN_ZERO).cmp(b[0].referendumId || BN_ZERO))
|
||||
.sort((a, b) => a[1].cmp(b[1]))
|
||||
.sort((a, b) => a[0].isFinished === b[0].isFinished ? 0 : (a[0].isFinished ? -1 : 1))
|
||||
.reduce((sorted: Entry[], [{ balance, isDelegated, isFinished = false, referendumId, vote }, blocks]): Entry[] => {
|
||||
const isCountdown = blocks.gt(BN_ZERO);
|
||||
const header = referendumId && vote
|
||||
? <div>#{referendumId.toString()} {formatBalance(balance, { forceUnit: '-' })} {vote.conviction?.toString()}{isDelegated && '/d'}</div>
|
||||
: <div>{t('Prior locked voting')}</div>;
|
||||
const prev = sorted.length ? sorted[sorted.length - 1] : null;
|
||||
|
||||
if (!prev || (isCountdown || (isFinished !== prev.isFinished))) {
|
||||
sorted.push({
|
||||
details: (
|
||||
<div className='faded'>
|
||||
{isCountdown
|
||||
? (
|
||||
<BlockToTime
|
||||
label={`${t('{{blocks}} blocks', { replace: { blocks: formatNumber(blocks) } })}, `}
|
||||
value={blocks}
|
||||
/>
|
||||
)
|
||||
: isFinished
|
||||
? t('lock expired')
|
||||
: t('ongoing referendum')
|
||||
}
|
||||
</div>
|
||||
),
|
||||
headers: [header],
|
||||
isCountdown,
|
||||
isFinished
|
||||
});
|
||||
} else {
|
||||
prev.headers.push(header);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [])
|
||||
};
|
||||
}
|
||||
|
||||
function DemocracyLocks ({ className = '', value }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const bestNumber = useBestNumber();
|
||||
const [trigger] = useState(() => `${Date.now()}-democracy-locks-${++id}`);
|
||||
const [{ maxBalance, sorted }, setState] = useState<State>({ maxBalance: BN_ZERO, sorted: [] });
|
||||
|
||||
useEffect((): void => {
|
||||
bestNumber && setState((state): State => {
|
||||
const newState = groupLocks(t, bestNumber, value);
|
||||
|
||||
// only update when the structure of new is different
|
||||
// - it has a new overall breakdown with sections
|
||||
// - one of the sections has a different number of headers
|
||||
return state.sorted.length !== newState.sorted.length || state.sorted.some((s, i) => s.headers.length !== newState.sorted[i].headers.length)
|
||||
? newState
|
||||
: state;
|
||||
});
|
||||
}, [bestNumber, t, value]);
|
||||
|
||||
if (!sorted.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<FormatBalance
|
||||
labelPost={
|
||||
<Icon
|
||||
icon='clock'
|
||||
tooltip={trigger}
|
||||
/>
|
||||
}
|
||||
value={maxBalance}
|
||||
/>
|
||||
<Tooltip trigger={trigger}>
|
||||
{sorted.map(({ details, headers }, index): React.ReactNode => (
|
||||
<div
|
||||
className='row'
|
||||
key={index}
|
||||
>
|
||||
{headers.map((header, index) => (
|
||||
<div key={index}>{header}</div>
|
||||
))}
|
||||
<div className='faded'>{details}</div>
|
||||
</div>
|
||||
))}
|
||||
</Tooltip>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
white-space: nowrap;
|
||||
|
||||
.ui--FormatBalance {
|
||||
display: inline-block;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(DemocracyLocks);
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DropdownItemProps, DropdownProps, StrictDropdownProps } from 'semantic-ui-react';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button as SUIButton, Dropdown as SUIDropdown } from 'semantic-ui-react';
|
||||
|
||||
import { isUndefined } from '@pezkuwi/util';
|
||||
|
||||
import Labelled from './Labelled.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props<Option extends DropdownItemProps> {
|
||||
allowAdd?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
defaultValue?: any;
|
||||
dropdownClassName?: string;
|
||||
isButton?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isError?: boolean;
|
||||
isFull?: boolean;
|
||||
isMultiple?: boolean;
|
||||
label?: React.ReactNode;
|
||||
labelExtra?: React.ReactNode;
|
||||
onAdd?: (value: any) => void;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: any) => void;
|
||||
onClose?: () => void;
|
||||
onSearch?: StrictDropdownProps['search'];
|
||||
options: (React.ReactNode | Option)[];
|
||||
placeholder?: string;
|
||||
renderLabel?: (item: any) => any;
|
||||
searchInput?: { autoFocus: boolean };
|
||||
tabIndex?: number;
|
||||
transform?: (value: any) => any;
|
||||
value?: unknown;
|
||||
withEllipsis?: boolean;
|
||||
withLabel?: boolean;
|
||||
}
|
||||
|
||||
export type IDropdown<Option extends DropdownItemProps> = React.ComponentType<Props<Option>> & {
|
||||
Header: React.ComponentType<{ content: React.ReactNode }>;
|
||||
}
|
||||
|
||||
function DropdownBase<Option extends DropdownItemProps> ({ allowAdd = false, children, className = '', defaultValue, dropdownClassName, isButton, isDisabled, isError, isFull, isMultiple, label, labelExtra, onAdd, onBlur, onChange, onClose, onSearch, options, placeholder, renderLabel, searchInput, tabIndex, transform, value, withEllipsis, withLabel }: Props<Option>): React.ReactElement<Props<Option>> {
|
||||
const lastUpdate = useRef<string>('');
|
||||
const [stored, setStored] = useState<string | undefined>();
|
||||
|
||||
const _setStored = useCallback(
|
||||
(value: string): void => {
|
||||
const json = JSON.stringify({ v: value });
|
||||
|
||||
if (lastUpdate.current !== json) {
|
||||
lastUpdate.current = json;
|
||||
|
||||
setStored(value);
|
||||
|
||||
onChange && onChange(
|
||||
transform
|
||||
? transform(value)
|
||||
: value
|
||||
);
|
||||
}
|
||||
},
|
||||
[onChange, transform]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
_setStored((isUndefined(value) ? defaultValue : value) as string);
|
||||
}, [_setStored, defaultValue, value]);
|
||||
|
||||
const _onAdd = useCallback(
|
||||
(_: React.SyntheticEvent<HTMLElement>, { value }: DropdownProps): void =>
|
||||
onAdd && onAdd(value),
|
||||
[onAdd]
|
||||
);
|
||||
|
||||
const _onChange = useCallback(
|
||||
(_: React.SyntheticEvent<HTMLElement> | null, { value }: DropdownProps): void =>
|
||||
_setStored(value as string),
|
||||
[_setStored]
|
||||
);
|
||||
|
||||
const dropdown = (
|
||||
<SUIDropdown
|
||||
allowAdditions={allowAdd}
|
||||
button={isButton}
|
||||
className={dropdownClassName}
|
||||
compact={isButton}
|
||||
disabled={isDisabled}
|
||||
error={isError}
|
||||
floating={isButton}
|
||||
multiple={isMultiple}
|
||||
onAddItem={_onAdd}
|
||||
onBlur={onBlur}
|
||||
onChange={_onChange}
|
||||
onClose={onClose}
|
||||
// NOTE This is not quite correct since we also pass React.ReactNode items
|
||||
// through (e.g. these are used as headers, see InputAddress). But... it works...
|
||||
options={options as Option[]}
|
||||
placeholder={placeholder}
|
||||
renderLabel={renderLabel}
|
||||
search={onSearch || allowAdd}
|
||||
searchInput={searchInput}
|
||||
selection
|
||||
tabIndex={tabIndex}
|
||||
value={stored}
|
||||
/>
|
||||
);
|
||||
|
||||
return isButton
|
||||
? <SUIButton.Group>{dropdown}{children}</SUIButton.Group>
|
||||
: (
|
||||
<StyledLabelled
|
||||
className={`${className} ui--Dropdown`}
|
||||
isFull={isFull}
|
||||
label={label}
|
||||
labelExtra={labelExtra}
|
||||
withEllipsis={withEllipsis}
|
||||
withLabel={withLabel}
|
||||
>
|
||||
{dropdown}
|
||||
{children}
|
||||
</StyledLabelled>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledLabelled = styled(Labelled)`
|
||||
.ui--Dropdown-item {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
.ui--Dropdown-icon,
|
||||
.ui--Dropdown-name {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ui--Dropdown-icon {
|
||||
height: 32px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
width: 32px;
|
||||
|
||||
&.opaque {
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
}
|
||||
|
||||
.ui--Dropdown-name {
|
||||
margin-left: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.selection.dropdown {
|
||||
> .text > .ui--Dropdown-item {
|
||||
.ui--Dropdown-icon {
|
||||
left: -2.6rem;
|
||||
top: -1.15rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ui--Dropdown-name {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Dropdown = React.memo(DropdownBase) as unknown as typeof DropdownBase & {
|
||||
Header: typeof SUIDropdown.Header
|
||||
};
|
||||
|
||||
Dropdown.Header = SUIDropdown.Header;
|
||||
|
||||
export default Dropdown;
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { colorLink } from './styles/theme.js';
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
icon?: IconName;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function EditButton ({ children, className = '', icon = 'edit', onClick }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv
|
||||
className={`${className} ui--EditButton`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<span className='editSpan'>
|
||||
<Icon
|
||||
className='icon-button'
|
||||
icon={icon}
|
||||
/>
|
||||
</span>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
cursor: pointer;
|
||||
|
||||
.ui--Icon.icon-button {
|
||||
color: ${colorLink};
|
||||
cursor: pointer;
|
||||
margin: 0 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editSpan {
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(EditButton);
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Something is seriously going wrong here...
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
|
||||
import CodeFlask from 'codeflask';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
code: string;
|
||||
isValid?: boolean;
|
||||
onEdit: (code: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name Editor
|
||||
* @summary A code editor based on the codeflask npm module
|
||||
* @description It allows to live-edit code examples and JSON files.
|
||||
*
|
||||
* @example
|
||||
* <BR>
|
||||
*
|
||||
* ```javascript
|
||||
* import {Editor} from '@pezkuwi/react-components';
|
||||
*
|
||||
* <Editor
|
||||
* className={string} // optional
|
||||
* code={string}
|
||||
* isValid={boolean}, // optional
|
||||
* onEdit={() => callbackFunction}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function Editor ({ className = '', code, isValid, onEdit }: Props): React.ReactElement<Props> {
|
||||
const [editorId] = useState(() => `flask-${Date.now()}`);
|
||||
const editorRef = useRef<CodeFlask | null>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
const editor = new CodeFlask(`#${editorId}`, {
|
||||
language: 'js',
|
||||
lineNumbers: true
|
||||
});
|
||||
|
||||
editor.updateCode(code);
|
||||
(editor as any).editorRoot.addEventListener('keydown', (): void => {
|
||||
(editor as unknown as Record<string, (value: unknown) => void>).onUpdate(onEdit);
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
editorRef.current && editorRef.current.updateCode(code);
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
className={`${className} ui-Editor ${isValid === false ? 'invalid' : ''}`}
|
||||
id={editorId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.codeflask {
|
||||
border: 1px solid var(--border-input);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
.codeflask {
|
||||
background-color: #fff6f6;
|
||||
border-color: #e0b4b4;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Editor);
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { I18nProps } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import translate from './translate.js';
|
||||
|
||||
interface Props extends I18nProps {
|
||||
children: React.ReactNode;
|
||||
doThrow?: boolean;
|
||||
error?: Error | null;
|
||||
onError?: () => void;
|
||||
trigger?: unknown;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
prevTrigger: string | null;
|
||||
}
|
||||
|
||||
function formatStack (stack = '<unknown>'): React.ReactElement | null {
|
||||
return (
|
||||
<>{stack.split('\n').map((line, index) =>
|
||||
<div key={index}>{line}</div>
|
||||
)}</>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: This is the only way to do an error boundary, via extend
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
public override state: State = { error: null, prevTrigger: null };
|
||||
|
||||
static getDerivedStateFromError (error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps ({ trigger }: Props, { prevTrigger }: State): State | null {
|
||||
const newTrigger = JSON.stringify({ trigger });
|
||||
|
||||
return (prevTrigger !== newTrigger)
|
||||
? { error: null, prevTrigger: newTrigger }
|
||||
: null;
|
||||
}
|
||||
|
||||
public override componentDidCatch (error: Error): void {
|
||||
const { doThrow, onError } = this.props;
|
||||
|
||||
onError && onError();
|
||||
|
||||
if (doThrow) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public override render (): React.ReactNode {
|
||||
const { children, error: errorProps, t } = this.props;
|
||||
const { error } = this.state;
|
||||
const displayError = errorProps || error;
|
||||
|
||||
return displayError
|
||||
? (
|
||||
<article className='error extraMargin'>
|
||||
<p>{t('Uncaught error. Something went wrong with the query and rendering of this component. Please supply all the details below when logging an issue, it may help in tracing the cause.')}</p>
|
||||
<p>{displayError.message}</p>
|
||||
{formatStack(displayError.stack)}
|
||||
</article>
|
||||
)
|
||||
: children;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate<Props>(ErrorBoundary);
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
expanded: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ExpandButton ({ className = '', expanded, onClick }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv
|
||||
className={`${className} ui--ExpandButton`}
|
||||
data-testid='row-toggle'
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon icon={expanded ? 'caret-up' : 'caret-down'} />
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border: 1px solid var(--border-table);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default React.memo(ExpandButton);
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Text } from '@pezkuwi/types';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Meta {
|
||||
docs: Text[];
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
isOpen?: boolean;
|
||||
isHeader?: boolean;
|
||||
isLeft?: boolean;
|
||||
isPadded?: boolean;
|
||||
onClick?: (isOpen: boolean) => void;
|
||||
renderChildren?: (() => React.ReactNode | undefined | null) | null;
|
||||
summary?: React.ReactNode;
|
||||
summaryHead?: React.ReactNode;
|
||||
summaryMeta?: Meta;
|
||||
summarySub?: React.ReactNode;
|
||||
withBreaks?: boolean;
|
||||
withHidden?: boolean;
|
||||
}
|
||||
|
||||
function splitSingle (value: string[], sep: string): string[] {
|
||||
return value.reduce((result: string[], value: string): string[] => {
|
||||
return value.split(sep).reduce((result: string[], value: string) => result.concat(value), result);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function splitParts (value: string): string[] {
|
||||
return ['[', ']'].reduce((result: string[], sep) => splitSingle(result, sep), [value]);
|
||||
}
|
||||
|
||||
function formatMeta (meta?: Meta): [React.ReactNode, React.ReactNode] | null {
|
||||
if (!meta?.docs.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strings = meta.docs.map((d) => d.toString().trim());
|
||||
const firstEmpty = strings.findIndex((d) => !d.length);
|
||||
const combined = (
|
||||
firstEmpty === -1
|
||||
? strings
|
||||
: strings.slice(0, firstEmpty)
|
||||
).join(' ').replace(/# ?<weight>[^<]*<\/weight>/, '');
|
||||
const parts = splitParts(combined.replace(/\\/g, '').replace(/`/g, ''));
|
||||
|
||||
return [
|
||||
parts[0].split(/[.(]/)[0],
|
||||
<>{parts.map((part, index) => index % 2 ? <em key={index}>[{part}]</em> : <span key={index}>{part}</span>)} </>
|
||||
];
|
||||
}
|
||||
|
||||
function Expander ({ children, className = '', isHeader, isLeft, isOpen, isPadded, onClick, renderChildren, summary, summaryHead, summaryMeta, summarySub, withBreaks, withHidden }: Props): React.ReactElement<Props> {
|
||||
const [isExpanded, toggleExpanded] = useToggle(isOpen, onClick);
|
||||
|
||||
const demandChildren = useMemo(
|
||||
() => isExpanded && renderChildren && renderChildren(),
|
||||
[isExpanded, renderChildren]
|
||||
);
|
||||
|
||||
const [headerSubMini, headerSub] = useMemo(
|
||||
() => formatMeta(summaryMeta) || [summarySub, summarySub],
|
||||
[summaryMeta, summarySub]
|
||||
);
|
||||
|
||||
const hasContent = useMemo(
|
||||
() => !!renderChildren || (!!children && (!Array.isArray(children) || children.length !== 0)),
|
||||
[children, renderChildren]
|
||||
);
|
||||
|
||||
const icon = useMemo(
|
||||
() => (
|
||||
<Icon
|
||||
color={
|
||||
hasContent
|
||||
? undefined
|
||||
: 'transparent'
|
||||
}
|
||||
icon={
|
||||
isExpanded
|
||||
? 'caret-up'
|
||||
: 'caret-down'
|
||||
}
|
||||
/>
|
||||
),
|
||||
[hasContent, isExpanded]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--Expander ${isExpanded ? 'isExpanded' : ''} ${isHeader ? 'isHeader' : ''} ${isPadded ? 'isPadded' : ''} ${hasContent ? 'hasContent' : ''} ${withBreaks ? 'withBreaks' : ''}`}>
|
||||
<div
|
||||
className={`ui--Expander-summary${isLeft ? ' isLeft' : ''}`}
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
{isLeft && icon}
|
||||
<div className='ui--Expander-summary-header'>
|
||||
<div className='ui--Expander-summary-title'>
|
||||
{summaryHead}
|
||||
</div>
|
||||
{summary}
|
||||
{headerSub && (
|
||||
<div className='ui--Expander-summary-header-sub'>{isExpanded ? headerSub : headerSubMini}</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLeft && icon}
|
||||
</div>
|
||||
{hasContent && (isExpanded || withHidden) && (
|
||||
<div className='ui--Expander-content'>{children || demandChildren}</div>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
max-width: 60rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:not(.isExpanded) {
|
||||
.ui--Expander-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.isExpanded {
|
||||
.ui--Expander-content {
|
||||
margin-top: 0.75rem;
|
||||
|
||||
.body.column {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isHeader {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
&.withBreaks .ui--Expander-content {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui--Expander-summary {
|
||||
margin: 0;
|
||||
min-width: 13.5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ui--Expander-summary-header {
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 2rem);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui--Expander-summary-header-sub,
|
||||
.ui--Expander-summary-title {
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
line-clamp: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui--Expander-summary-header-sub {
|
||||
font-size: var(--font-size-small);
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
}
|
||||
|
||||
.ui--Icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&:not(.isLeft) > .ui--Icon {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
&.isLeft > .ui--Icon {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.ui--LabelHelp {
|
||||
.ui--Icon {
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hasContent .ui--Expander-summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.isPadded .ui--Expander-summary {
|
||||
margin-left: 2.25rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Expander);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Props as ExpanderProps } from './Expander.js';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import Table from './Table/index.js';
|
||||
import Expander from './Expander.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props extends ExpanderProps {
|
||||
empty?: string;
|
||||
renderChildren?: (() => React.ReactNode[] | undefined | null) | null;
|
||||
}
|
||||
|
||||
function mapRow (row: React.ReactNode, key: number): React.ReactNode {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{row}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpanderScroll ({ children, className, empty, renderChildren, summary }: Props): React.ReactElement<Props> {
|
||||
const hasContent = useMemo(
|
||||
() => !!(renderChildren || children),
|
||||
[children, renderChildren]
|
||||
);
|
||||
|
||||
const innerRender = useCallback(
|
||||
(): React.ReactNode => (renderChildren || children) && (
|
||||
<div className='tableContainer'>
|
||||
<Table
|
||||
empty={empty}
|
||||
isInline
|
||||
>
|
||||
{renderChildren
|
||||
? renderChildren()?.map(mapRow)
|
||||
: Array.isArray(children)
|
||||
? children.map(mapRow)
|
||||
: <tr><td>{children}</td></tr>
|
||||
}
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
[children, empty, renderChildren]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledExpander
|
||||
className={className}
|
||||
renderChildren={hasContent ? innerRender : undefined}
|
||||
summary={summary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpander = styled(Expander)`
|
||||
.tableContainer {
|
||||
overflow-y: scroll;
|
||||
display: block;
|
||||
margin: 0 0 0 auto;
|
||||
max-height: 13.75rem;
|
||||
max-width: 25rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(ExpanderScroll);
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Input from './Input.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
filterOn: string;
|
||||
label: string;
|
||||
setFilter: (filter: string) => void;
|
||||
}
|
||||
|
||||
function Filter ({ className = '', filterOn, label, setFilter }: Props) {
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<Input
|
||||
autoFocus
|
||||
isFull
|
||||
label={label}
|
||||
onChange={setFilter}
|
||||
value={filterOn}
|
||||
/>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
width: 29.5rem;
|
||||
|
||||
.ui--Input {
|
||||
margin: 0;
|
||||
height: 3.893rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Filter);
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FilterOverlay ({ children, className }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{children}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: none;
|
||||
right: calc(50% - var(--width-half) + 1.5rem);
|
||||
|
||||
.ui--Labelled label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&& .ui--Input {
|
||||
margin: 0.29rem 0;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1150px) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
> div {
|
||||
max-width: 35rem !important;
|
||||
}
|
||||
|
||||
.ui--Labelled label {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ui.selection.dropdown {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* hardcoded: var(--width-full) doesn't work in media */
|
||||
@media only screen and (max-width: 1750px) {
|
||||
right: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(FilterOverlay);
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { FlagColor } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
import Tag from './Tag.js';
|
||||
|
||||
interface FlagProps {
|
||||
className?: string;
|
||||
color: FlagColor;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
function Flag ({ className = '', color, label }: FlagProps): React.ReactElement<FlagProps> {
|
||||
return (
|
||||
<StyledTag
|
||||
className={`${className} ${color === 'theme' ? ' highlight--color-bg highlight--bg' : ''}` }
|
||||
color={color}
|
||||
label={label}
|
||||
size='tiny'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTag = styled(Tag)`
|
||||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
padding: 0.5833em 1.25em 0.5833em 1.5em;
|
||||
font-size: var(--font-size-tiny);
|
||||
line-height: 1;
|
||||
color: #fff !important;
|
||||
|
||||
&.darkTheme {
|
||||
:after {
|
||||
background-color: var(--bg-tabs);
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
background-color: #fff;
|
||||
border-radius: 500rem;
|
||||
content: '';
|
||||
left: -0.25em;
|
||||
margin-top: -0.25em;
|
||||
position: absolute;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border-radius: 0.2rem 0 0.1rem 0;
|
||||
background-color: inherit;
|
||||
background-image: none;
|
||||
content: '';
|
||||
right: 100%;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
position: absolute;
|
||||
transform: translateY(-50%) translateX(50%) rotate(-45deg);
|
||||
top: 50%;
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Flag);
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Button from './Button/index.js';
|
||||
import Modal from './Modal/index.js';
|
||||
import AddressRow from './AddressRow.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
type Mode = 'account' | 'address' | 'contract' | 'code';
|
||||
|
||||
interface Props {
|
||||
address?: string;
|
||||
children?: React.ReactNode;
|
||||
name?: string;
|
||||
mode?: Mode;
|
||||
onClose: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function getContent (mode: Mode, t: (key: string) => string): React.ReactNode | null {
|
||||
switch (mode) {
|
||||
case 'account':
|
||||
return (
|
||||
<>
|
||||
<p>{t('You are about to remove this account from your list of available accounts. Once completed, should you need to access it again, you will have to re-create the account either via seed or via a backup file.')}</p>
|
||||
<p>{t('This operation does not remove the history of the account from the chain, nor any associated funds from the account. The forget operation only limits your access to the account on this browser.')}</p>
|
||||
</>
|
||||
);
|
||||
case 'address':
|
||||
return (
|
||||
<>
|
||||
<p>{t('You are about to remove this address from your address book. Once completed, should you need to access it again, you will have to re-add the address.')}</p>
|
||||
<p>{t('This operation does not remove the history of the account from the chain, nor any associated funds from the account. The forget operation only limits your access to the address on this browser.')}</p>
|
||||
</>
|
||||
);
|
||||
case 'contract':
|
||||
return (
|
||||
<>
|
||||
<p>{t('You are about to remove this contract from your list of available contracts. Once completed, should you need to access it again, you will have to manually add the contract\'s address in the Instantiate tab.')}</p>
|
||||
<p>{t('This operation does not remove the history of the contract from the chain, nor any associated funds from its account. The forget operation only limits your access to the contract on this browser.')}</p>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaderText (mode: Mode, t: (key: string) => string): string {
|
||||
switch (mode) {
|
||||
case 'account':
|
||||
return t('Confirm account removal');
|
||||
case 'address':
|
||||
return t('Confirm address removal');
|
||||
case 'contract':
|
||||
return t('Confirm contract removal');
|
||||
case 'code':
|
||||
return t('Confirm code removal');
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent (props: Props, t: (key: string) => string): React.ReactNode | null {
|
||||
const { address, mode = 'account' } = props;
|
||||
|
||||
switch (mode) {
|
||||
case 'account':
|
||||
case 'address':
|
||||
case 'contract':
|
||||
return (
|
||||
<AddressRow
|
||||
isInline
|
||||
value={address || ''}
|
||||
>
|
||||
{getContent(mode, t)}
|
||||
</AddressRow>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function Forget (props: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { children, mode = 'account', onClose, onForget } = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='app--accounts-Modal'
|
||||
header={getHeaderText(mode, t)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Modal.Content>{children || renderContent(props, t)}</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='trash'
|
||||
label={t('Forget')}
|
||||
onClick={onForget}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Forget);
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
import ReactMd from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
md: string;
|
||||
}
|
||||
|
||||
const rehypePlugins = [rehypeRaw];
|
||||
|
||||
function HelpOverlay ({ className = '', md }: Props): React.ReactElement<Props> {
|
||||
const [isVisible, toggleVisible] = useToggle();
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className} ui--HelpOverlay`}>
|
||||
<div className='help-button'>
|
||||
<Icon
|
||||
icon='question-circle'
|
||||
onClick={toggleVisible}
|
||||
/>
|
||||
</div>
|
||||
<div className={`help-slideout ${isVisible ? 'open' : 'closed'}`}>
|
||||
<div className='help-button'>
|
||||
<Icon
|
||||
icon='times'
|
||||
onClick={toggleVisible}
|
||||
/>
|
||||
</div>
|
||||
<ReactMd
|
||||
className='help-content'
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{md}
|
||||
</ReactMd>
|
||||
</div>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.help-button {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 2rem;
|
||||
padding: 0.35rem 1.5rem 0 0;
|
||||
}
|
||||
|
||||
> .help-button {
|
||||
position: absolute;
|
||||
right: 0rem;
|
||||
top: 0rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.help-slideout {
|
||||
background: var(--bg-page);
|
||||
box-shadow: -6px 0px 20px 0px rgba(0, 0, 0, 0.3);
|
||||
bottom: 0;
|
||||
max-width: 50rem;
|
||||
overflow-y: scroll;
|
||||
position: fixed;
|
||||
right: -50rem;
|
||||
top: 0;
|
||||
transition-duration: .5s;
|
||||
transition-property: all;
|
||||
z-index: 225; /* 5 more than menubar */
|
||||
|
||||
.help-button {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
padding: 1rem 1.5rem 5rem;
|
||||
}
|
||||
|
||||
&.open {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(HelpOverlay);
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
color?: 'gray' | 'green' | 'normal' | 'orange' | 'red' | 'transparent' | 'white' | 'darkGray';
|
||||
icon: IconName;
|
||||
isPadded?: boolean;
|
||||
isSpinning?: boolean;
|
||||
onClick?: (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => void;
|
||||
size?: '1x' | '2x';
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
// one-time init of FA libraries
|
||||
library.add(fas);
|
||||
|
||||
function Icon ({ className = '', color = 'normal', icon, isPadded, isSpinning, onClick, size = '1x', tooltip }: Props): React.ReactElement<Props> {
|
||||
const extraProps: Record<string, unknown> = {
|
||||
'data-testid': icon,
|
||||
...(tooltip
|
||||
? {
|
||||
'data-for': tooltip,
|
||||
'data-tip': true
|
||||
}
|
||||
: {}
|
||||
)
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledFAI
|
||||
{...extraProps}
|
||||
className={`${className} ui--Icon ${color}Color${onClick ? ' isClickable' : ''}${isPadded ? ' isPadded' : ''}`}
|
||||
icon={icon}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
spin={isSpinning}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledFAI = styled(FontAwesomeIcon)`
|
||||
outline: none;
|
||||
|
||||
&.isClickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.isPadded {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
&.grayColor {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&.greenColor {
|
||||
color: green;
|
||||
}
|
||||
|
||||
&.orangeColor {
|
||||
color: darkorange;
|
||||
}
|
||||
|
||||
&.redColor {
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
&.transparentColor {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&.whiteColor {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.darkGrayColor {
|
||||
color: #8B8B8B;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Icon);
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Icon from './Icon.js';
|
||||
import { styled } from './styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
href?: string;
|
||||
icon?: IconName;
|
||||
label?: React.ReactNode;
|
||||
rel?: string;
|
||||
target?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function IconLink ({ className = '', href, icon, label, onClick, rel, target }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledA
|
||||
className={className}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
rel={rel}
|
||||
target={target}
|
||||
>
|
||||
{icon && <Icon icon={icon} />}
|
||||
{label}
|
||||
</StyledA>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledA = styled.a`
|
||||
.ui--Icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(IconLink);
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Automatically generated, do not edit
|
||||
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
|
||||
export default 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAxlBMVEXE5v/F5v/D5v/D5f/C5f/B5f/A5P/H5//K6f/M6f/Q6//S7P/R7P/M6v/I6P/G5//Y7v/c8P/f8f/g8v/i8v/j8//l9P/m9P/d8P/b7//T7P/P6v/L6f/V7f/k8//n9f/o9f/n9P/l8//h8v/Z7//R6//j8v/e8f/U7P/N6v/W7v/p9f/P6//a7//X7v/S6//g8f/o9P/K6P/O6v/H6P/U7f/W7f/Z7v/q9f/i8//F5//C5v/J6P/d8f/b8P/a8P/Y7//I5//IZkYeAAAEqklEQVR4AezBgQAAAACAoP2pF6kCAAAAAAAAAAAAAAAAAAAAAAAAAAAAmFz7XkwbCeI4PjtaDmRsC2OYH83sDCWcDa4Ecpee93+o9LilERB3EvrQ/qLMl77Sf8J9VNTBiTiK/EdRFDEVLAT70l+uXInjvWp1/yCOD5NSyUdMhcDsk7haO6ofN5pNAdBsNo/rrXanW/Ged354ot5Jq9GXoBrMDB+ZWVBVDJrD0fiQiXe0giPPyUHtCWBm+BELhkH9pJKwJ0c7x3OlM1TDb1gIk797btcSuIhOz/pTrMR0MJolpZ1K4CvnMoVgNQJFq+M87QpOLpoBgtUJ1OqXxLQLmGcNM8GfEQRMDpgp97hcg2Ethtph7gtEvSvD2rTZZXKUY1ztG9YnhlrClF98AhNs5HpyE1FunWNjoo1TTznVNqQg9Pd9kecHTHJZwLUNaZF9przhkQrSM4/y9/mfJmvcMOVJ1EW69IryhOOBIV125ik3+GkjIG2DuXd0j/uMsulIkTqdOLrlKPKlj3xEGRQtDFtgnduXgC8lvb3qybIz61H2+FiwBaL1Mn8JzN1n9YbY9J8g/9JG+BFKASf1sKUCY09ETOP6QNUAEcBoTY7Ys3uelA9me4vlybIzn1XKyXPyfuM/4DUVbCdA2xFFcR2GO7QO59jTYa96Xu/bfcets/lN8sJvkoDn2J5eRAsYsGEA5/3LWe1YNRgeMNMpWot5uRStnSAZGrZEtMq1KbBpgIjji6swNciPFyOnNlnG3tN6bS8MW2OtkcqmAZhPj5rTX61SC1Sb7Rnzqp+i9+PGfcMWGbBZAOa4JWqCXxIxlckp8e+2dJZ7cdw7KN9t0XSvArZsgwCO+eVIzLAKM3tdIXY/m969vHzdkC+GtXnCTOR8R5HdAM4/7/QDVmdYlj39SJTM3iDcpjTT4ThhxzcNy3AAX2kp/owez5kdPcanraB4SCc9Txcq2Q0QVZsq+DNiUnse0WOLvsoPVm/nPQGyGoDp7TpL9AK9evm4wMh+dEsS+nXLbAB+2gpYjzbn/HB+xU8YshqAK8eKdRkWTLf4YirIEFpFFDcVGwiju5uaGTJlxfmDYBPX57f/da/yF4A3nh9iXwvwnkreAvDTYRBs6msBd2TIW4CkHgCkU4CTAXIXoK1IhY2YqGd5C8ALpOaEeS9nAZw/HRjSYmO/zFkATiYmSE2/t8hZAFqqID1heIkMBuCIiW4v7+OXSJc9QdaQp3I8rtVqo8X8ZeIjeuC1IV2GrKGDs6Fq+EgVR/svI3b0DR8Ydh7J9N6KtjYWib8twK+KEACCO2KhfrtXHff6hQjwiA6+/nt3pWeGAgYQ2Og5ffJuGIoV4Ja+fk7k/J6goAFEX38KUJtKQQNA9IK4PFEUNQBg1VLcRIEDoH/YVSlyAD3qTAsd4P/3oR06EAAAAAAA8n9thAQDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAgAEDBgwYMGDAAEMFtPbYAn6/AAAAAElFTkSuQmCC';
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Automatically generated, do not edit
|
||||
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
|
||||
export default 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAC31BMVEUjHyAkICAkISAjICAhGx8eFh4eFx8fGB8tLyM7RSc6RCc6RSc4QSYiHSAeFx47RCd1oTiGvj2GvT2FvD2DuDxhgjImIyEiHiBulzaU1EKKxD+Lxj+Mxj+Mxz+S0EEpKCJynjiPzECJwj6LxT9ATikjHiAnJiFxnDeRzkGKwz+Nxz8/TCklIiAlIyFynTiNyD8dFR4YDh0ZDx0aEB0gGh9EUypaeDFZdjBZdTBbeDFUbS8vMyQhHB8rKyJhgzNrkTZpjzVqkDVslDZLXywcFB5njTSX2UOV1UKY2UM9SCglIiEuMCN6qjqHvz6Fuz0dFh5ggTKIwT6JwT6IwD5/sTwfGR8pKSIqKiIsLSMsLiMoJyJhgzKNyUCBtTwoJyEnJSEwMySa3UOY20Oc4USFvD41PCZcejFliDRjhjNkhjNjhTNmijQuMSQTBhsRAhsRAxsTBRsaER0cEx4hHCBWby9dfDFcezFadzA+SiguMSOPy0CMyD+OykBXcy83PyY8Ryg8SCgnJiIoKCIgGx93pTl1ozl2ozl1ojl5qDpojjUwNCQ7Rig+SygvMSOBtjyNyECX2EIZDh1njDSX2EOV1EKU00JTay5YdDBffzI/TShFVipefTFXcjAfGh8bEx4bEh4rLCJojDRwmjdxmzdvmDd4pzlJXCsgGR+W1kKT0kGh6EYmJCEeGB8vMiQvMiMxNSQyNyUyNySOy0Ce40Wc4ESd4URYczAUBxwSBBsYDR1dezFliTRkhzNkiDNmizR9rzuLxD94pzoyOCVATSk0OiU2PiYzOSUlISCOyUA3PiYrLCMzOCWRz0FynDdvmTd5pzkoJiGJwj9/sTt7qzqJwz+QzEBJXCxmiTSGvT5ojTQhHSCAsjuS0UF2pDlrkjVtlTZslTZqkjWIvz5+sDtxnDh1ojiQzUCDuD2EuT1Say5Sai4tLiM/Syg/TSmQzUFznziT0UFYdTBUbi85QiYhHR86jPdPAAAFIklEQVR4AezYg57kMACA8aRd27bt3dHatm3btm3v2bbf7cxOpr/MOdP+36BfY8BTfDwej8fj8Xg8HqQ+oAFXUUrK76kArhagVNXUNTQ0tYA2R79fR1dP38DQwMjYxBRwEA1NzcwtLK0srW3UlQAHQVNbO3sHi/ccnZR+LqHc/q8Azi6ubu6O73l4sgXwMpHmDQFh0AF8PgXwZQsA/fwDmPwDleivm6g8IHkBYFBwiIBBKBJLQqnQsPCIyCh5REbHOBMXwCs2Lj6BwTzRPUk1OSglNc3QAM0QKd1BZEpcACoj0zEr+0c5jrkaeRSAtvloBYVFaGrFgLwAfiWljgwWpWXqHwK4FKOUF1eEBskQqkABkvMqHasSq6XF19TWBSVDJKBIAUIL6hsam6Q1twhagUkyBorsKQBD29o7UNo7u2K7ceRBkgN49eT39tX3o9QPCHAMDOZRJE+BoKGyYd9SJF8MI76jY93JJE8BU5NxDRkmJicwJFWG0mRvg1PFSNMV2jNKOLTJ3gVmCqNn5+alLSwuLStRNAbAqQDoAtyeAjPagNOL4MrqshIk4jKUjboMJQetrduXjiKVMm34IlhsDmyZEHAd3t7ZZdiL/3Ad7rHdHxAKfsHB4ZEyRcCDyPGJ1Env5HQ5FALnoLOgMxx5QcrnEIfj2PN5kIwnsS2mj09iShcu+ly6jOPK1Ws6FejbEAEBZD2KwuuSrJ2EGzhu3hoIuA3Z/IcBMNAVuMpNM+6osCEygDOVchfTvfsPHrJ5AAkMAPOWHz1+8hRPzZMaNo8LlAgcAbK2QaFA+ExOB7e1CQwAQ5+/QGt7GbAVII8tUwKnAB1a8Or1m3ft2jOiXmEURuF9EOc00W8ztm3bmEDa2BzAbeIuxmUb27Zts7+Ibe+1Pgzgqd/1H7Zuw9hsI7PSNyV/FqDK9wAY9cZv3NQn+mG9a27eUs/8B8YxbwACW78DQFxdtk3Z/rGm7Mh0yd/ezmb9XwNU3rXblu+p2aeSv74uxffstV4C9Nm333B+5j7AsORvzyyee+CguA4VARyOHNnRzBJVWfXGHd3X+znAMe/xRXmOKGvmib4ZsR7PAQ5POlnJNkRXzU5MCFSueeo5gP+0c8YUXTldzg7zxjKKAFLnyje1DdEGkBnzx14CrDzf3xJ9AMfeAFxoJgoBKr8BuKgd4BIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL8/AAAAAAAAAAAAAAAAAAAAAC5qB7hsKwSodOUNwFXRl0tapb3e5wDX9nnb1jItZQBWs67XJ23yFwHUuBG4WbqEYSoTMJZnDt+161YRQLrPhB7L6o00lAmYTuJ2wJ8oAqhcuUpgTSXH0iYgWQvuiCtZCBCrXPncqDOiLcOoZxuJ5wAx76bed5uJPgLrFUBs0657tijsNUAscF85QM2qAACgMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABVAQAAACndAK4hqQfPAfxKAaT6wwfdo9Fo70k6AaRa8bmzHwXPBTtWyB6pEsByhpxo2OhxwydPn4koFcg64ziu/iVEa4bx/DeFiIiIiIgKAIEU5cAYDddFAAAAAElFTkSuQmCC';
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Automatically generated, do not edit
|
||||
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
|
||||
export default 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAABR1BMVEXsbXHtbXHtbnLoa2/na27mam7rbXHXZGfQYWPRYWTTYmXSYWTpbG/haGzdZmrbZmnPYGPpbHDna2/qbHDsbXDpa2/WY2bfZ2vaZWjcZmnhaGvVY2beZ2rZZGfbZWngaGvQYWTZZWjeZ2vQYGPSYWXNX2LOYGPYZGfma27oa3DUYmXfZ2rkaW3pa3DUYmbvbnLubnLrbHDrbXDaZWnSYmXobG/UY2btbXLWY2fQYGTubnHgZ2vSYmTkam3YZGjcZmriaGzlam7WZGfUY2XZZWftbnHma2/jaW3ZZGjjaWzlam3eZmrbZWjiaWzrbHHdZ2rgaGzcZ2rnam/kaW7faGvVYmbdZ2njam3dZmnXY2fXY2bqbG/YZWjwb3Pxb3PiaW3obHDWZGbhaWzPYWPqbXDvb3PNYGLmam3kam7OX2LOYGLMX2LgZ2zyHOT9AAALjElEQVR4AezWRYKtMBCF4RSSR+Fw3d3dn7Xsf1Nt123ccvKPYFgfRIRKpVKpVHci7S0BnG4YhikFbr8sth2XBE4k6OSFPD+wQySAKI7jaPvoJpJ6HMtUOoyAADJsO9ntvLl8quA4mUQBCqAYslXazluu6LZlFeEAnGrtCFBlNIB6o8mnAFU0AL/VaHeQAaJubOgCFmB79aU9QK/PPNgCwLX9A7jd9hNpXIBELpsdjsYtFxQgM5lOJtOoWEtAAshyZjqTUoBGJN4ApEBtnpglFsgAfj9dWFZwAWQ2ZGZkgEXoYANkFYACUACsABSAAlAAXzu6DA0gcRkWAK3WhbP6nTnWH5Bt8HnV4gYIgIZ8WdXWCQjgd5Uv+xP8RQGQ039Vvu7/w1RiAFBUsPgqJ3yUAgTgKeRbNZ41CADNbPLNqmuXEABktsG3a2QQ/oDN0OJ7vXJ3p9tpI0scwKtatkRJBiSUYDAcKLtnQi7xTQRhrOQ4XphcBzsZ7Nn3fXv/N5jlm2V1tSHnEkv8Pnrnj5burmo52ML1D2DUZJJwa/0DwH6FLKZqzQNAxycL/nDtj4A22W2r9Q5g/JLJIgj/d7beAeAJk9XpGgWQVid+BtURRo/JggcI42bgZ0xepaUMAKOOJr6GmGYpVJkspqC6TNlv0+duOQPohgFlBNqLETw5Aa6n2BjmPs8XWMIAsBFQTmUHcSoH4EeA7ZByOnEZA2gx5R04CO0wEG4BO6Bemz7Hj5LSBYCvyaTyBlRjyObPeTGOzjUZDB0sWwCuNOTfANWtkAnPUrwwHx1cL1kAbnIo3ugAImM4+iOAeEhm7x0pt0wB4NZLTYI5JqemZPxNhXssjRAHYyhTAKqmScBNF0y3Qj5JwZmQIOCqKlEAyeaERPwogYf5j3ZiSDM3jtznsTwBXO6GAUn4cQOhpomzqpjOApLpLpQmgHSfrTO+9hncb32U0WqPwPU0WQQOliQAjDtMVvsp4E2QHpIVt6AsAWww2QThNkJygwJohQHZTMYlCQBGu5pshg0F1Z2M/hzP9t8jG77CsgSgNgOyCLdVflL8YorwtGKNLS7PXQCvLEezfhZjPOT8CBEf+myJrYolCsCxXQbnyrgwdqrSLsuxnY+gPAG4SZ9JwOdn0JjkP62bMcYHJDpKSjUbHH1YkaY1m4At0yd1N4W5OBeol202OBNXvUAJIz6/kVwK62XsN1TZFkTOmUwmEcZeRXqXcSoE0E3csgXwQWB8kR/j2Q7LlUHzehl3xiVcE3xToRztxalzIAXAniusl82whAFEQ/O1vB2SRM8x2daGYMpZF6jm6gJhD3DjltFe/KwS0A0PsZQBXHohV67h0N9MYcAk409QVVlXruMH7bLWBjd7Jxm9OeKMbNj/FGHvxre1ozWqDt/rMNno1pqXx6F5SwC9fAC4TgHgZ2TDw/sAn9euMroRrlOT1C6TjLuoZhxmPeiuUwDgBCTi5hhij4MsCl7hGgWAe6EYAc9AbbOpb2SNNk9jJN4IeNdVzhfCUHh9ts8n8+fSUsFDhC/Nk6FLWNhTHQQF3zBxHppnijXATTJb4pEYG7P92fFhkQNQ+y/M1T9HwTGTiX75AcKC0iRJ23JgRaDaTHmVfgIzaUlMnyxxFQC3V+wA0PHZsFTggtsh0TSBxRU8AFCfU04wQ+uy+O54mQAOn0ChjTucuwUiOD6TSM9wiQBqly4UfOdkkOU3AHsVki3VLFinXowAyb+gmI6y9qeQfjUhG73ElKD9wIsBou1u9+tthELKl8fH+baSLH+8eADhcQzoTEIddhIoJPcmUJ8z2fAAlghgECPg537npLHqElD1G++6QXMbFuB4Wd/uujDyNNk4uNQRgACYJAphtb57Gd7sid9AuFVPE2ftoZqRhX4Di2u/34kQ3gE3bev8nO5tGol56AB+zyQR2oUFvfPp6O4aAnkfwc411EArdQWNCUn0DsLiejs/vMNHtuR03mr7/GSq8IrJTHujpQJ4lADC6ilzQyDbR+K41WHzYBdjnwSzxF1uLhBFsHpx0xyA/XxNayGZcBVxHgbLlAvkI+DH1gBh1dKPQzIK62e2HjJfWhKNVOxpc38ELhvAwHdg1bZ8ErywdfSIDZH6c0iOjNG0U1g2gONwoGC1sM5iL2jLledBHJBg4gD0wmCZeZAcwIPhigPA6YRE+icFZq6lOlhpIW7kOyR0Fd8mgI6C1dplWr69Hz+vkCw4guSN5qxw4EIBA0jmZKOFbeCRTxYVDyD2hgfXdR5vYgEDwPsdTTaTBhhgm8mqfwbRTQAFDCC5qJAVf46Qd8/X9k76wZkLmAWFDABiLyQbfwsgjjJ+jhFqZBVUEaL/ZL+rmEeAe9s1oI/qwj94fJ0/cFR8wCTjY4T73/iZ7zrwX58VMAAA3K2QiH+J8d9jhDPCroJfmWTBJiQnIWeFzREswwX33QSwMSHR8yOF/fyQxt9U7kDOjduopj7dpD9OYXGYJKr9TwD+KcJqJSfySLAH6PimESKkRySaRAit0PTxZd6X6sWp9+jH5jCBVRMnr+xPU6xrMjhCuVVU90EZq4NcV7Ag90ntfdLcf7I5hZXDeSjtbzqDqfCgoFhtDFm6boizwcXHQtio/jT3DhNMYPXGHpv3hkYAUud/FZK934QGGQV9LWyYKeb/GJHO5z6qUxIcbGHcND5Q8antOVOniVvI6rCpvZ+9e/B7U4tbRlBJ2+dBHCjrb2IsYgBqw1TQnULa1STgyTQ1nR/P2wm8fiGPkfuFDMBVV5V8tyskDd/WDgw4NVw3/gv/5iLyHSxkg0TcvFkZChyEurXOOU+gromz+phUyUK3oZABqLmXMWhWQU3JRg9ijLxB5ru83X+z1GQzxbJ0iIyEQ9l+PmNX/9+qw70uRDHclUx5Qxgomzy9rT8gWjwA//ibNtyd/m8BWfgPFeQl0wnZLHMf6JEfDODOYPTLc9sBcIXmFYa2Jhk/Gy/TJBU7EdwddUEyLZbut4RJglxzlvT6fyDecTswSVhsZE4OSRLollumRkk19eXtoWh9AJnA31BlCsBN5OXvz9DWOSLQNYRytcpGj/ltHh7d0kKhaatsAUjPkAi2wAJfBWTUT9ySBSCcz+E2gg12tfTA6bIFgEdsOpSjWwKInrE8DShZu3xPG/bHK7BTH7Nw3ShdAOnGCwqyKruXcJvxIL9v0CllAIBXf+owg45SuE2yn9s5ugdQygDSre5V97raKSyiWutm7MVYzgAAcOXfZQ9g/ckB/N1+XSTGDURBGK4nRW8osgVmZoaeCceoTZg5y2zC919b0hyj6j/CJ6huAQhAAMwJ4Iwd4FkJboDNp4EboHckAAHQJgABCEAAXXaAOQd1ugvMCkAAAmBOAAIQgAAEwJwABCAAAQiAOQEIQAACEIAABMCbAAQgAAFY4AbAdAW7/ctpAWx2YNHRyjCwAmB10RBflMk9VoCzkwiwpD8bqAGqtaXgpP+AfgvQZQXA62fmAPEnAI/MIhQlSAFGVVtSsALs9dsGuyUpQNJb69VlM8QAdb21g6ekALcWT5r62aWT/gSLtpVsjhPAYU0PJrafsQKMe7qSkv4Dyt/jaA9CVTfb3NzMek8j2hVo+jzz20C9ArR3AYe1RcFYAcYlu88DKYA34enO0qlTAkShzeKcdQbT67r0Og6cAPbsz07T3/XjISfASm/Qdvdy2igBXq31mgbdmZgSABdn796dpYvZziIngFtTmN3tzOecAHDUReGUdAVQrBranBSgWjeM7sVxCVaAV2bJ4t3+bGAG6K/1loLzAlRbWTZBDIB4cn39dQReALg7wAowZ2hjBSgLcAOMHHXl9ZGTArxOLQpW/euXpADv/uPFkcfrb4esAOlwdxCbgzAfA/jk92nj/Qmm72zkzCvw7sxAWr7xo/Mb1ykrgFU72b91lDlYy2c33h6BIM6UUkoppZRS6gYNXGC98k5aBwAAAABJRU5ErkJggg==';
|
||||