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
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -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);
File diff suppressed because one or more lines are too long
@@ -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'>&nbsp;</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 && (
<>&nbsp;(+{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')},&nbsp;
<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);
+228
View File
@@ -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);
+74
View File
@@ -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>&nbsp;(+{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);
+39
View File
@@ -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);
+85
View File
@@ -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);
+143
View File
@@ -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);
+178
View File
@@ -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);
+86
View File
@@ -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);
+219
View File
@@ -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>)}&nbsp;</>
];
}
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);
+68
View File
@@ -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);
+105
View File
@@ -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);
+92
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long
@@ -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';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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,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';
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

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,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';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More