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
+228
View File
@@ -0,0 +1,228 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import React, { useCallback, useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import store from 'store';
import { decodeUrlTypes, encodeUrlTypes } from '@pezkuwi/react-api/urlTypes';
import { Button, CopyButton, Editor, InputFile, styled } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { isJsonObject, stringToU8a, u8aToString } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
const EMPTY_CODE = '{\n\n}';
const EMPTY_TYPES = {};
interface AllState {
code: string;
isJsonValid: boolean;
isTypesValid: boolean;
types: Record<string, any>;
typesPlaceholder: string | null;
}
interface Props {
className?: string;
onStatusChange: (status: ActionStatus) => void;
}
function Developer ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [code, setCode] = useState(EMPTY_CODE);
const [isJsonValid, setIsJsonValid] = useState(true);
const [isTypesValid, setIsTypesValid] = useState(true);
const [types, setTypes] = useState<Record<string, any>>(EMPTY_TYPES);
const [typesPlaceholder, setTypesPlaceholder] = useState<string | null>(null);
const [sharedUrl, setSharedUrl] = useState<string | null>(null);
useEffect((): void => {
const types = decodeUrlTypes() || store.get('types') as Record<string, unknown> || {};
if (Object.keys(types).length) {
setCode(JSON.stringify(types, null, 2));
setTypes({});
setTypesPlaceholder(Object.keys(types).join(', '));
setSharedUrl(encodeUrlTypes(types));
}
}, []);
const _setState = useCallback(
({ code, isJsonValid, isTypesValid, types, typesPlaceholder }: AllState): void => {
setCode(code);
setIsJsonValid(isJsonValid);
setIsTypesValid(isTypesValid);
setTypes(types);
setTypesPlaceholder(typesPlaceholder);
},
[]
);
const _clearTypes = useCallback(
(): void => {
store.remove('types');
_setState({
code: EMPTY_CODE,
isJsonValid: true,
isTypesValid: true,
types: EMPTY_TYPES,
typesPlaceholder: null
});
},
[_setState]
);
const _onChangeTypes = useCallback(
(data: Uint8Array): void => {
const code = u8aToString(data);
try {
const types = JSON.parse(code) as Record<string, unknown>;
const typesPlaceholder = Object.keys(types).join(', ');
console.log('Detected types:', typesPlaceholder);
_setState({
code,
isJsonValid: true,
isTypesValid: true,
types: Object.keys(types).length === 0 ? {} : types,
typesPlaceholder
});
} catch (error) {
console.error('Error registering types:', error);
_setState({
code,
isJsonValid: false,
isTypesValid: false,
types: {},
typesPlaceholder: (error as Error).message
});
}
},
[_setState]
);
const _onEditTypes = useCallback(
(code: string): void => {
try {
if (!isJsonObject(code)) {
throw Error('This is not a valid JSON object.');
}
_onChangeTypes(stringToU8a(code));
} catch (error) {
setCode(code);
setIsJsonValid(false);
setTypesPlaceholder((error as Error).message);
}
},
[_onChangeTypes]
);
const _saveDeveloper = useCallback(
(): void => {
let url = null;
try {
api.registerTypes(types);
store.set('types', types);
setIsTypesValid(true);
onStatusChange({
action: t('Your custom types have been added'),
status: 'success'
});
if (Object.keys(types).length) {
url = encodeUrlTypes(types);
console.log(url);
}
} catch (error) {
console.error(error);
setIsTypesValid(false);
onStatusChange({
action: t(`Error saving your custom types. ${(error as Error).message}`),
status: 'error'
});
}
setSharedUrl(url);
},
[api, onStatusChange, t, types]
);
const typesHasNoEntries = Object.keys(types).length === 0;
// Trans component
/* eslint-disable react/jsx-max-props-per-line */
return (
<StyledDiv className={className}>
<div className='ui--row'>
<div className='full'>
<InputFile
clearContent={typesHasNoEntries && isTypesValid}
isError={!isTypesValid}
label={t('Additional types as a JSON file (or edit below)')}
onChange={_onChangeTypes}
placeholder={typesPlaceholder}
/>
</div>
</div>
<div className='ui--row'>
<div className='full'>
<Editor
className='editor'
code={code}
isValid={isJsonValid}
onEdit={_onEditTypes}
/>
</div>
</div>
<div className='ui--row'>
<div className='full'>
<Trans i18nKey='devConfig'><div className='help'>If you are a development team with at least a test network available, consider adding the types directly <a href='https://github.com/pezkuwi-js/apps/tree/master/packages/apps-config' rel='noopener noreferrer' target='_blank'>to the apps-config</a>, allowing out of the box operation for your spec &amp; chains, both for you and anybody trying to connect to it. This is not a replacement for your chain-specific UI, however doing so does help in allowing users to easily discover and use with zero-config.</div></Trans>
</div>
</div>
<Button.Group>
<CopyButton
label={t('Share')}
type={t('url')}
value={sharedUrl}
/>
<Button
icon='sync'
label={t('Reset')}
onClick={_clearTypes}
/>
<Button
icon='save'
isDisabled={!isTypesValid || !isJsonValid}
label={t('Save')}
onClick={_saveDeveloper}
/>
</Button.Group>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.editor {
height: 21rem;
margin-left: 2rem;
position: relative;
}
.help {
padding: 0.5rem 2rem;
}
`;
export default React.memo(Developer);
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/apps-config/settings/types';
import type { SettingsStruct } from '@pezkuwi/ui-settings/types';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createLanguages, createSs58 } from '@pezkuwi/apps-config';
import { allNetworks } from '@pezkuwi/networks';
import { Button, Dropdown, MarkWarning } from '@pezkuwi/react-components';
import { useApi, useIpfs, useLedger } from '@pezkuwi/react-hooks';
import { settings } from '@pezkuwi/ui-settings';
import { useTranslation } from './translate.js';
import { createIdenticon, createOption, save, saveAndReload } from './util.js';
interface Props {
className?: string;
}
const _ledgerConnOptions = settings.availableLedgerConn;
const _ledgerAppOptions = settings.availableLedgerApp;
function General ({ className = '' }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { chainSS58, isApiReady, isElectron } = useApi();
const { isIpfs } = useIpfs();
const { hasLedgerChain, hasWebUsb } = useLedger();
// tri-state: null = nothing changed, false = no reload, true = reload required
const [changed, setChanged] = useState<boolean | null>(null);
const [state, setSettings] = useState((): SettingsStruct => {
const values = settings.get();
return { ...values, uiTheme: values.uiTheme === 'dark' ? 'dark' : 'light' };
});
const ledgerConnOptions = useMemo(
() => _ledgerConnOptions.filter(({ value }) => !isElectron || value !== 'webusb'),
[isElectron]
);
const iconOptions = useMemo(
() => settings.availableIcons
.map((o): Option => createIdenticon(o, ['default']))
.concat(createIdenticon({ info: 'robohash', text: 'RoboHash', value: 'robohash' })),
[]
);
const prefixOptions = useMemo(
(): (Option | React.ReactNode)[] => {
const network = allNetworks.find(({ prefix }) => prefix === chainSS58);
return createSs58(t).map((o) =>
createOption(o, ['default'], 'empty', (o.value === -1
? isApiReady
? network
? ` (${network.displayName}, ${chainSS58 || 0})`
: ` (${chainSS58 || 0})`
: undefined
: ` (${o.value})`
))
);
},
[chainSS58, isApiReady, t]
);
const storageOptions = useMemo(
() => [
{ text: t('Allow local in-browser account storage'), value: 'on' },
{ text: t('Do not allow local in-browser account storage'), value: 'off' }
],
[t]
);
const themeOptions = useMemo(
() => [
{ text: t('Light theme'), value: 'light' },
{ text: t('Dark theme'), value: 'dark' }
],
[t]
);
const translateLanguages = useMemo(
() => createLanguages(t),
[t]
);
useEffect((): void => {
const prev = settings.get() as unknown as Record<string, unknown>;
const hasChanges = Object.entries(state).some(([key, value]) => prev[key] !== value);
const needsReload = prev.apiUrl !== state.apiUrl || prev.prefix !== state.prefix;
setChanged(
hasChanges
? needsReload
: null
);
}, [state]);
const _handleChange = useCallback(
(key: keyof SettingsStruct) => <T extends string | number>(value: T) =>
setSettings((state) => ({ ...state, [key]: value })),
[]
);
const _saveAndReload = useCallback(
() => saveAndReload(state),
[state]
);
const _save = useCallback(
(): void => {
save(state);
setChanged(null);
},
[state]
);
return (
<div className={className}>
<h1>{t('UI options')}</h1>
<div className='ui--row'>
<Dropdown
defaultValue={state.icon}
label={t('default icon theme')}
onChange={_handleChange('icon')}
options={iconOptions}
/>
</div>
<div className='ui--row'>
<Dropdown
defaultValue={state.uiTheme}
label={t('default interface theme')}
onChange={_handleChange('uiTheme')}
options={themeOptions}
/>
</div>
<div className='ui--row'>
<Dropdown
defaultValue={state.i18nLang}
label={t('default interface language')}
onChange={_handleChange('i18nLang')}
options={translateLanguages}
/>
</div>
<h1>{t('account options')}</h1>
<div className='ui--row'>
<Dropdown
defaultValue={state.prefix}
label={t('address prefix')}
onChange={_handleChange('prefix')}
options={prefixOptions}
/>
</div>
{!isIpfs && !isElectron && (
<>
<div className='ui--row'>
<Dropdown
defaultValue={state.storage}
label={t('in-browser account creation')}
onChange={_handleChange('storage')}
options={storageOptions}
/>
</div>
{state.storage === 'on' && (
<div className='ui--row'>
<MarkWarning content={t('It is recommended that you store all keys externally to the in-page browser local storage, either on browser extensions, signers operating via QR codes or hardware devices. This option is provided for advanced users with strong backup policies.')} />
</div>
)}
</>
)}
{hasLedgerChain && (
<>
<div className='ui--row'>
<Dropdown
defaultValue={
hasWebUsb
? state.ledgerConn
: ledgerConnOptions[0].value
}
isDisabled={!hasWebUsb}
label={t('manage hardware connections')}
onChange={_handleChange('ledgerConn')}
options={ledgerConnOptions}
/>
</div>
<div className='ui--row'>
<Dropdown
defaultValue={state.ledgerApp}
isDisabled={!hasLedgerChain}
label={t('manage ledger app')}
onChange={_handleChange('ledgerApp')}
options={_ledgerAppOptions}
/>
</div>
{hasWebUsb
? state.ledgerConn !== 'none'
? (
<div className='ui--row'>
<MarkWarning content={t('Ledger support is still experimental and some issues may remain. Trust, but verify the addresses on your devices before transferring large amounts. There are some features that will not work, including batch calls (used extensively in staking and democracy) as well as any identity operations.')} />
</div>
)
: null
: (
<div className='ui--row'>
<MarkWarning content={t('Ledger hardware device support is only available on Chromium-based browsers where WebUSB and WebHID support is available in the browser.')} />
</div>
)
}
</>
)}
<Button.Group>
<Button
icon='save'
isDisabled={changed === null}
label={
changed
? t('Save & Reload')
: t('Save')
}
onClick={
changed
? _saveAndReload
: _save
}
/>
</Button.Group>
</div>
);
}
export default React.memo(General);
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import { Input, styled } from '@pezkuwi/react-components';
interface Props {
className?: string;
onChange: (key: string, val: string) => void;
original: string;
tkey: string;
tval: string;
}
function StringInput ({ className = '', onChange, original, tkey, tval }: Props): React.ReactElement<Props> {
const _onChange = useCallback(
(value: string) => onChange(tkey, value),
[onChange, tkey]
);
return (
<StyledDiv className={className}>
<div className='label'>{original}</div>
<Input
onChange={_onChange}
value={tval}
withLabel={false}
/>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.label {
font-style: italic;
margin-top: 0.5rem;
+div {
margin-left: 1rem;
}
}
`;
export default React.memo(StringInput);
+311
View File
@@ -0,0 +1,311 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import FileSaver from 'file-saver';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Columar, Dropdown, Progress, Spinner, styled, Toggle } from '@pezkuwi/react-components';
import i18n from '@pezkuwi/react-components/i18n';
import languageCache from '@pezkuwi/react-components/i18n/cache';
import { useToggle } from '@pezkuwi/react-hooks';
import { settings } from '@pezkuwi/ui-settings';
import { useTranslation } from '../translate.js';
import StringInput from './StringInput.js';
type ProgressType = [[number, number, number], Record<string, [number, number, number]>];
type Strings = Record<string, string>;
type StringsMod = Record<string, Strings>;
interface Props {
className?: string;
}
interface Option {
text: string;
value: string;
}
interface Defaults {
english: StringsMod;
keys: Option[];
modules: Option[];
}
const cache = new Map<string, unknown>();
async function retrieveJson (url: string): Promise<any> {
if (cache.has(url)) {
return cache.get(url);
}
const json = await fetch(`locales/${url}`)
.then((response) => response.json())
.catch((e) => console.error(e)) as unknown;
cache.set(url, json);
return json || {};
}
async function retrieveEnglish (): Promise<StringsMod> {
const paths = await retrieveJson('en/index.json') as string[];
const strings: Strings[] = await Promise.all(paths.map((path) => retrieveJson(`en/${path}`) as Promise<Strings>));
return strings.reduce((language: StringsMod, strings, index): StringsMod => {
language[paths[index]] = strings;
return language;
}, {});
}
async function retrieveAll (): Promise<Defaults> {
const _keys = await retrieveJson('index.json') as string[];
const keys = _keys.filter((lng) => lng !== 'en');
const missing = keys.filter((lng) => !languageCache[lng]);
const english = await retrieveEnglish();
const translations = missing.length
? await Promise.all(missing.map((lng) => retrieveJson(`${lng}/translation.json`)))
: [];
// setup the language cache
missing.forEach((lng, index): void => {
languageCache[lng] = translations[index] as Record<string, string>;
});
// fill in all empty values (useful for download, filling in)
keys.forEach((lng): void => {
Object.keys(english).forEach((record): void => {
Object.keys(english[record]).forEach((key): void => {
if (!languageCache[lng][key]) {
languageCache[lng][key] = '';
}
});
});
});
return {
english,
keys: keys.map((text) => ({ text, value: text })),
modules: Object
.keys(english)
.map((text) => ({ text: text.replace('.json', '').replace('app-', 'page-'), value: text }))
.sort((a, b) => a.text.localeCompare(b.text))
};
}
function calcProgress (english: StringsMod, language: Strings): ProgressType {
const breakdown: Record<string, [number, number, number]> = {};
let done = 0;
let total = 0;
Object.keys(english).forEach((record): void => {
const mod = english[record];
const mtotal = Object.keys(mod).length;
let mdone = 0;
Object.keys(mod).forEach((key): void => {
if (language[key]) {
mdone++;
}
});
done += mdone;
total += mtotal;
breakdown[record] = [mdone, mtotal, 0];
});
return [[done, total, 0], breakdown];
}
function doDownload (strings: Strings, withEmpty: boolean): void {
const sanitized = Object.keys(strings).sort().reduce((result: Strings, key): Strings => {
const sanitized = strings[key].trim();
if (sanitized || withEmpty) {
result[key] = sanitized;
}
return result;
}, {});
// eslint-disable-next-line deprecation/deprecation
FileSaver.saveAs(
new Blob([JSON.stringify(sanitized, null, 2)], { type: 'application/json; charset=utf-8' }),
'translation.json'
);
}
function progressDisplay ([done, total, _]: [number, number, number] = [0, 0, 0]): { done: number; progress: string; total: number } {
return {
done,
progress: (total ? (done * 100 / total) : 100).toFixed(2),
total
};
}
function Translate ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [withEmpty, toggleWithEmpty] = useToggle();
const [{ english, keys, modules }, setDefaults] = useState<Defaults>({ english: {}, keys: [], modules: [] });
const [lng, setLng] = useState<string>('zh');
const [[modProgress, allProgress], setProgress] = useState<ProgressType>([[0, 0, 0], {}]);
const [record, setRecord] = useState<string>('app-accounts.json');
const [strings, setStrings] = useState<Strings | null>(null);
useEffect((): void => {
retrieveAll().then(setDefaults).catch(console.error);
}, []);
useEffect((): void => {
setStrings(languageCache[lng]);
setProgress(calcProgress(english, languageCache[lng]));
}, [english, lng]);
useEffect((): void => {
setLng(
keys.some(({ value }) => value === settings.i18nLang)
? settings.i18nLang
: 'zh'
);
}, [keys]);
const _setString = useCallback(
(key: string, value: string): void => {
setStrings((strings: Strings | null): Strings | null =>
strings
? { ...strings, [key]: value }
: null
);
const hasPrevVal = !!languageCache[lng][key];
const sanitized = value.trim();
languageCache[lng][key] = value;
if (hasPrevVal !== !!sanitized) {
const [progress, breakdown] = calcProgress(english, languageCache[lng]);
setProgress(([counters]): ProgressType => {
progress[2] = Math.max(0, progress[0] - counters[0]);
return [progress, breakdown];
});
}
},
[english, lng]
);
const _doApply = useCallback(
(): void => {
i18n.reloadResources().catch(console.error);
},
[]
);
const _onDownload = useCallback(
() => doDownload(strings || {}, withEmpty),
[strings, withEmpty]
);
if (!keys.length) {
return <Spinner />;
}
return (
<StyledMain className={className}>
<header>
<Columar>
<Columar.Column>
<div>
<Dropdown
isFull
label={t('the language to display translations for')}
onChange={setLng}
options={keys}
value={lng}
/>
{t('{{done}}/{{total}}, {{progress}}% done', { replace: progressDisplay(modProgress) })}
</div>
<Progress
total={modProgress[1]}
value={modProgress[0]}
/>
</Columar.Column>
<Columar.Column>
<div>
<Dropdown
isFull
label={t('the module to display strings for')}
onChange={setRecord}
options={modules}
value={record}
/>
{t('{{done}}/{{total}}, {{progress}}% done', { replace: progressDisplay(allProgress[record]) })}
</div>
<Progress
total={allProgress[record]?.[1]}
value={allProgress[record]?.[0]}
/>
</Columar.Column>
</Columar>
</header>
<div className='toggleWrapper'>
<Toggle
label={
withEmpty
? t('include all empty strings in the generated file')
: t('do not include empty strings in the generated file')
}
onChange={toggleWithEmpty}
value={withEmpty}
/>
</div>
<Button.Group>
<Button
icon='sync'
label={t('Apply to UI')}
onClick={_doApply}
/>
<Button
icon='download'
label={t('Generate {{lng}}/translation.json', { replace: { lng } })}
onClick={_onDownload}
/>
</Button.Group>
{record && strings && Object.keys(english[record]).map((key, index) =>
<StringInput
key={index}
onChange={_setString}
original={english[record][key]}
tkey={key}
tval={strings[key]}
/>
)}
</StyledMain>
);
}
const StyledMain = styled.main`
.ui--Column {
display: flex;
> div:first-child {
flex: 1;
text-align: right;
}
}
.ui--Progress {
margin: 0 0 0 0.25rem;
}
.toggleWrapper {
display: flex;
justify-content: flex-end;
margin-top: 0.75rem;
}
`;
export default React.memo(Translate);
@@ -0,0 +1,29 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '@pezkuwi/react-components';
interface Props {
color?: string;
className?: string;
}
function ChainColorIndicator ({ className, color }: Props): React.ReactElement<Props> {
return (
<StyledDiv
className={className}
color={color}
/>
);
}
const StyledDiv = styled.div(({ color }: Props): string => `
background-color: ${color || 'white'} !important;
width: 100px;
flex: 1;
border-radius: 4px;
`);
export default React.memo(ChainColorIndicator);
@@ -0,0 +1,114 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
import type { HexString } from '@pezkuwi/util/types';
import type { ChainInfo } from '../types.js';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { knownExtensions } from '@pezkuwi/apps-config';
import { externalEmptySVG } from '@pezkuwi/apps-config/ui/logos/external';
import { Button, Dropdown, Spinner, styled, Table } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import { objectSpread } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import useExtensions from '../useExtensions.js';
import iconOption from './iconOption.js';
interface Props {
chainInfo: ChainInfo | null;
className?: string;
rawMetadata: HexString | null;
}
function Extensions ({ chainInfo, className, rawMetadata }: Props): React.ReactElement<Props> {
const isMetadataReady = rawMetadata !== null;
const { t } = useTranslation();
const { extensions } = useExtensions();
const [selectedIndex, setSelectedIndex] = useState(0);
const [isBusy, toggleBusy] = useToggle(true);
useEffect((): void => {
if (isMetadataReady) {
toggleBusy();
}
}, [isMetadataReady, toggleBusy]);
const options = useMemo(
() => (extensions || []).map(({ extension: { name, version } }, value) =>
iconOption(`${name} ${version}`, value, knownExtensions[name]?.ui.logo || externalEmptySVG)
),
[extensions]
);
const _updateMeta = useCallback(
(): void => {
if (chainInfo && extensions?.[selectedIndex]) {
toggleBusy();
const rawDef: MetadataDef = objectSpread<MetadataDef>({}, { ...chainInfo, rawMetadata });
extensions[selectedIndex]
.update(rawDef)
.catch(() => false)
.then(() => toggleBusy())
.catch(console.error);
}
},
[chainInfo, extensions, rawMetadata, selectedIndex, toggleBusy]
);
const headerRef = useRef<[React.ReactNode?, string?, number?][]>([
[t('Extensions'), 'start']
]);
return (
<StyledTable
className={className}
empty={t('No Upgradable extensions')}
header={headerRef.current}
>
{extensions
? options.length !== 0 && (
<>
<tr className='isExpanded isFirst'>
<td>
<Dropdown
label={t('upgradable extensions')}
onChange={setSelectedIndex}
options={options}
value={selectedIndex}
/>
</td>
</tr>
<tr className='isExpanded isLast'>
<td>
<Button.Group>
<Button
icon='upload'
isDisabled={isBusy}
label={t('Update metadata')}
onClick={_updateMeta}
/>
</Button.Group>
</td>
</tr>
</>
)
: <Spinner />
}
</StyledTable>
);
}
const StyledTable = styled(Table)`
table {
overflow: visible;
z-index: 2;
}
`;
export default React.memo(Extensions);
@@ -0,0 +1,308 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BlockNumber, RuntimeVersion } from '@pezkuwi/types/interfaces';
import type { NetworkSpecsStruct } from '@pezkuwi/ui-settings/types';
import type { ChainInfo, ChainType } from '../types.js';
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { ChainImg, Input, QrNetworkSpecs, Spinner, styled, Table } from '@pezkuwi/react-components';
import { useApi, useCall, useDebounce } from '@pezkuwi/react-hooks';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import ChainColorIndicator from './ChainColorIndicator.js';
interface Props {
chainInfo: ChainInfo | null;
className?: string;
}
// TODO-MOONBEAM: update NetworkSpecsStruct in @pezkuwi/ui-settings/types
interface NetworkSpecsStructWithType extends NetworkSpecsStruct{
chainType: ChainType
}
function getRandomColor (): string {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
const initialState = {
chainType: 'bizinikiwi' as ChainType,
color: '#FFFFFF',
decimals: 0,
genesisHash: '',
prefix: 0,
title: '',
unit: 'UNIT'
};
function NetworkSpecs ({ chainInfo, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isApiReady, systemChain } = useApi();
const [qrData, setQrData] = useState<NetworkSpecsStructWithType>(initialState);
const debouncedQrData = useDebounce(qrData, 500);
const runtimeVersion = useCall<RuntimeVersion>(isApiReady && api.rpc.state.subscribeRuntimeVersion);
const blockNumber = useCall<BlockNumber>(isApiReady && api.derive.chain.bestNumber);
const reducer = (state: NetworkSpecsStructWithType, delta: Partial<NetworkSpecsStructWithType>): NetworkSpecsStructWithType => {
const newState = {
...state,
...delta
};
setQrData(newState);
return newState;
};
const [networkSpecs, setNetworkSpecs] = useReducer(reducer, initialState);
useEffect((): void => {
chainInfo && setNetworkSpecs({
chainType: chainInfo.chainType,
color: chainInfo.color || getRandomColor(),
decimals: chainInfo.tokenDecimals,
genesisHash: chainInfo.genesisHash,
prefix: chainInfo.ss58Format,
title: systemChain,
unit: chainInfo.tokenSymbol
});
}, [chainInfo, systemChain]);
const _onChangeColor = useCallback(
(color: string): void => setNetworkSpecs({ color }),
[]
);
const _onSetRandomColor = useCallback(
(event: React.MouseEvent<unknown>): void => {
event.preventDefault();
event.stopPropagation();
setNetworkSpecs({ color: getRandomColor() });
},
[]
);
const _checkColorValid = useCallback(
(): boolean => /^#[\da-fA-F]{6}|#[\da-fA-F]{3}$/.test(networkSpecs.color),
[networkSpecs]
);
const headerRef = useRef<[React.ReactNode?, string?, number?][]>([
[t('chain specifications'), 'start', 2]
]);
if (!isApiReady) {
return <Spinner />;
}
return (
<StyledTable
className={className}
empty={t('No open tips')}
header={headerRef.current}
>
<tr>
<td>
<div className='settings--networkSpecs-name'>
<Input
className='full'
isDisabled
label={t('Network Name')}
value={networkSpecs.title}
/>
<ChainImg className='settings--networkSpecs-logo' />
</div>
</td>
<td rowSpan={9}>
{qrData.genesisHash && (
<QrNetworkSpecs
className='settings--networkSpecs-qr'
networkSpecs={debouncedQrData}
/>
)}
</td>
</tr>
<tr>
<td>
<div className='settings--networkSpecs-color'>
<div>
<Input
className='full settings--networkSpecs-colorInput'
isError={!_checkColorValid()}
label={t('Color')}
onChange={_onChangeColor}
value={networkSpecs.color}
/>
<a
className='settings--networkSpecs-colorChangeButton'
onClick={_onSetRandomColor}
>
{t('generate random color')}
</a>
</div>
<ChainColorIndicator
className='settings--networkSpecs-colorBar'
color={networkSpecs.color}
/>
</div>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Genesis Hash')}
value={networkSpecs.genesisHash}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Unit')}
value={networkSpecs.unit}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Address Prefix')}
value={networkSpecs.prefix.toString()}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Decimals')}
value={networkSpecs.decimals.toString()}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Chain Type')}
value={networkSpecs.chainType}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Runtime Version')}
value={runtimeVersion ? `${runtimeVersion.specName.toString()}/${runtimeVersion.specVersion.toNumber()}` : ''}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Current Block')}
value={blockNumber ? formatNumber(blockNumber) : ''}
/>
</td>
</tr>
</StyledTable>
);
}
const StyledTable = styled(Table)`
td {
padding: 0;
.input.ui--Input input {
border: none !important;
background: transparent;
}
}
@media (max-width: 900px) {
tr {
&:first-child {
display: flex;
flex-direction: column;
}
}
}
.settings--networkSpecs-name {
position: relative;
.settings--networkSpecs-logo {
height: 32px;
left: 12px;
position: absolute;
top: 1rem;
width: 32px;
}
}
.settings--networkSpecs-color {
position: relative;
> div:first-child {
display: flex;
.settings--networkSpecs-colorInput {
min-width: 124px;
}
.settings--networkSpecs-colorChangeButton {
user-select: none;
cursor: pointer;
background: transparent;
border: none;
outline: none;
align-self: flex-end;
padding-bottom: 0.9rem;
}
}
.settings--networkSpecs-colorBar {
border-radius: 50%;
border: 1px solid grey;
height: 32px;
left: 12px;
position: absolute;
top: 1rem;
width: 32px;
}
}
.settings--networkSpecs-qr {
margin: 0.25rem auto;
max-width: 15rem;
img {
border: 1px solid white;
}
}
`;
export default React.memo(NetworkSpecs);
@@ -0,0 +1,95 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BareProps as Props } from '@pezkuwi/react-components/types';
import React, { useRef } from 'react';
import { packageInfo } from '@pezkuwi/apps-config';
import { Input, Spinner, styled, Table } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
const appsVersion = `apps v${packageInfo.version.replace('-x', '')}`;
function SystemVersion ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isApiReady, systemName, systemVersion } = useApi();
const headerRef = useRef<[React.ReactNode?, string?, number?][]>([
[t('system version'), 'start', 2]
]);
if (!isApiReady) {
return <Spinner />;
}
return (
<StyledTable
className={className}
empty={t('No version information available')}
header={headerRef.current}
>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Node version')}
value={systemName + ' v' + systemVersion}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('API Version')}
value={`${api.libraryInfo.replace('@pezkuwi/api ', '')}`}
/>
</td>
</tr>
<tr>
<td>
<Input
className='full'
isDisabled
label={t('Apps Version')}
value={`${appsVersion.replace('apps ', '')}`}
/>
</td>
</tr>
</StyledTable>
);
}
const StyledTable = styled(Table)`
td {
padding: 0;
text-align: left;
.input.ui--Input input {
border: none !important;
background: transparent;
padding: 0;
margin-left: 0;
}
.ui--Labelled-content .ui.input > input {
padding-left: 0 !important;
}
.ui--Labelled:not(.isSmall) {
padding-left: 0;
}
.ui--Labelled > label, .ui--Labelled > .ui--Labelled-content {
left: 0 !important;
padding-left: 0;
}
}
`;
export default React.memo(SystemVersion);
@@ -0,0 +1,28 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
interface Option {
text: React.ReactNode;
value: number | string;
}
export default function itemOption (label: string, value: string | number, img: string): Option {
return {
text: (
<div
className='ui--Dropdown-item'
key={value}
>
<img
alt={label}
className='ui--Dropdown-icon'
src={img}
/>
<div className='ui--Dropdown-name'>{label}</div>
</div>
),
value
};
}
@@ -0,0 +1,31 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { useApi } from '@pezkuwi/react-hooks';
import useChainInfo from '../useChainInfo.js';
import useRawMetadata from '../useRawMetadata.js';
import Extensions from './Extensions.js';
import NetworkSpecs from './NetworkSpecs.js';
import SystemVersion from './SystemVersion.js';
export default function Metadata (): React.ReactElement {
const { isDevelopment } = useApi();
const rawMetadata = useRawMetadata();
const chainInfo = useChainInfo();
return (
<>
{!isDevelopment && (
<Extensions
chainInfo={chainInfo}
rawMetadata={rawMetadata}
/>
)}
<NetworkSpecs chainInfo={chainInfo} />
<SystemVersion />
</>
);
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AppProps as Props } from '@pezkuwi/react-components/types';
import React, { useMemo } from 'react';
import { Route, Routes } from 'react-router';
import { Tabs } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import I18n from './I18n/index.js';
import Metadata from './Metadata/index.js';
import Developer from './Developer.js';
import General from './General.js';
import { useTranslation } from './translate.js';
import useCounter from './useCounter.js';
export { useCounter };
function SettingsApp ({ basePath, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isApiConnected, isApiReady, isDevelopment } = useApi();
const numExtensions = useCounter();
const items = useMemo(() => [
{
isRoot: true,
name: 'general',
text: t('General')
},
{
count: numExtensions,
name: 'metadata',
text: t('Metadata')
},
{
name: 'developer',
text: t('Developer')
},
{
name: 'i18n',
text: t('Translate')
}
], [numExtensions, t]);
const hidden = useMemo(
() => (isApiConnected && isApiReady)
? isDevelopment || (api.runtimeMetadata.version <= 13)
? []
: ['developer']
: ['metadata', 'i18n'],
[api, isApiConnected, isApiReady, isDevelopment]
);
return (
<main className='settings--App'>
<Tabs
basePath={basePath}
hidden={hidden}
items={items}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Developer onStatusChange={onStatusChange} />
}
path='developer'
/>
<Route
element={
<I18n />
}
path='i18n'
/>
<Route
element={
<Metadata />
}
path='metadata'
/>
<Route
element={
<General />
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(SettingsApp);
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export interface TOptions {
ns?: string;
replace?: Record<string, unknown>
}
interface Translation {
t: (key: string, optionsOrText?: string | TOptions, options?: TOptions) => string
}
export function useTranslation (): Translation {
return useTranslationBase('app-settings') as unknown as Translation;
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
export type ChainType = 'bizinikiwi' | 'ethereum';
export interface ChainInfo extends MetadataDef {
color: string | undefined;
chainType: ChainType;
}
@@ -0,0 +1,42 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChainInfo } from './types.js';
import { useMemo } from 'react';
import { getSystemIcon } from '@pezkuwi/apps-config';
import { DEFAULT_DECIMALS, DEFAULT_SS58 } from '@pezkuwi/react-api';
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
import { getSpecTypes } from '@pezkuwi/types-known';
import { formatBalance, isNumber } from '@pezkuwi/util';
import { base64Encode } from '@pezkuwi/util-crypto';
function useChainInfoImpl (): ChainInfo | null {
const { api, apiEndpoint, isApiReady, isEthereum, specName, systemChain, systemName } = useApi();
return useMemo(
() => isApiReady
? {
chain: systemChain,
chainType: isEthereum
? 'ethereum'
: 'bizinikiwi',
color: apiEndpoint?.ui.color,
genesisHash: api.genesisHash.toHex(),
icon: getSystemIcon(systemName, specName),
metaCalls: base64Encode(api.runtimeMetadata.asCallsOnly.toU8a()),
specVersion: api.runtimeVersion.specVersion.toNumber(),
ss58Format: isNumber(api.registry.chainSS58)
? api.registry.chainSS58
: DEFAULT_SS58.toNumber(),
tokenDecimals: (api.registry.chainDecimals || [DEFAULT_DECIMALS.toNumber()])[0],
tokenSymbol: (api.registry.chainTokens || formatBalance.getDefaults().unit)[0],
types: getSpecTypes(api.registry, systemChain, api.runtimeVersion.specName, api.runtimeVersion.specVersion) as unknown as Record<string, string>
}
: null,
[api, apiEndpoint, isApiReady, specName, systemChain, systemName, isEthereum]
);
}
export default createNamedHook('useChainInfo', useChainInfoImpl);
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { createNamedHook } from '@pezkuwi/react-hooks';
import useExtensions from './useExtensions.js';
function useCounterImpl (): number {
const { count } = useExtensions();
return count;
}
export default createNamedHook('useCounter', useCounterImpl);
+170
View File
@@ -0,0 +1,170 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { InjectedExtension, InjectedMetadataKnown, MetadataDef } from '@pezkuwi/extension-inject/types';
import { useEffect, useMemo, useState } from 'react';
import store from 'store';
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
interface ExtensionKnown {
extension: InjectedExtension;
known: InjectedMetadataKnown[];
update: (def: MetadataDef) => Promise<boolean>;
}
interface ExtensionInfo extends ExtensionKnown {
current: InjectedMetadataKnown | null;
}
interface Extensions {
count: number;
extensions: ExtensionInfo[];
}
interface ExtensionProperties {
extensionVersion: string;
tokenDecimals: number;
tokenSymbol: string;
ss58Format?: number;
}
type SavedProperties = Record<string, ExtensionProperties>;
type TriggerFn = (counter: number) => void;
let triggerCount = 0;
const triggers = new Map<string, TriggerFn>();
function triggerAll (): void {
[...triggers.values()].forEach((trigger) => trigger(Date.now()));
}
// save the properties for a specific extension
function saveProperties (api: ApiPromise, { name, version }: InjectedExtension): void {
const storeKey = `properties:${api.genesisHash.toHex()}`;
const allProperties = store.get(storeKey, {}) as SavedProperties;
allProperties[name] = {
extensionVersion: version,
ss58Format: api.registry.chainSS58,
tokenDecimals: api.registry.chainDecimals[0],
tokenSymbol: api.registry.chainTokens[0]
};
store.set(storeKey, allProperties);
}
// determines if the extension has current properties
function hasCurrentProperties (api: ApiPromise, { extension }: ExtensionKnown): boolean {
const allProperties = store.get(`properties:${api.genesisHash.toHex()}`, {}) as SavedProperties;
// when we don't have properties yet, assume nothing has changed and store
if (!allProperties[extension.name]) {
saveProperties(api, extension);
return true;
}
const { ss58Format, tokenDecimals, tokenSymbol } = allProperties[extension.name];
return ss58Format === api.registry.chainSS58 &&
tokenDecimals === api.registry.chainDecimals[0] &&
tokenSymbol === api.registry.chainTokens[0];
}
// filter extensions based on the properties we have available
function filterAll (api: ApiPromise, all: ExtensionKnown[]): Extensions {
const extensions = all
.map((info): ExtensionInfo | null => {
const current = info.known.find(({ genesisHash }) => api.genesisHash.eq(genesisHash)) || null;
// if we cannot find it as known, or either the specVersion or properties mismatches, mark it as upgradable
return !current || api.runtimeVersion.specVersion.gtn(current.specVersion) || !hasCurrentProperties(api, info)
? { ...info, current }
: null;
})
.filter((info): info is ExtensionInfo => !!info);
return {
count: extensions.length,
extensions
};
}
async function getExtensionInfo (api: ApiPromise, extension: InjectedExtension): Promise<ExtensionKnown | null> {
if (!extension.metadata) {
return null;
}
try {
const metadata = extension.metadata;
const known = await metadata.get();
return {
extension,
known,
update: async (def: MetadataDef): Promise<boolean> => {
let isOk = false;
try {
isOk = await metadata.provide(def);
if (isOk) {
saveProperties(api, extension);
triggerAll();
}
} catch {
// ignore
}
return isOk;
}
};
} catch {
return null;
}
}
async function getKnown (api: ApiPromise, extensions: InjectedExtension[], _: number): Promise<ExtensionKnown[]> {
const all = await Promise.all(
extensions.map((extension) => getExtensionInfo(api, extension))
);
return all.filter((info): info is ExtensionKnown => !!info);
}
const EMPTY_STATE = { count: 0, extensions: [] };
function useExtensionsImpl (): Extensions {
const { api, extensions, isApiReady, isDevelopment } = useApi();
const [all, setAll] = useState<ExtensionKnown[] | undefined>();
const [trigger, setTrigger] = useState(0);
useEffect((): () => void => {
const myId = `${++triggerCount}-${Date.now()}`;
triggers.set(myId, setTrigger);
return (): void => {
triggers.delete(myId);
};
}, []);
useEffect((): void => {
extensions && getKnown(api, extensions, trigger)
.then(setAll)
.catch(console.error);
}, [api, extensions, trigger]);
return useMemo(
() => isDevelopment || !isApiReady || !all
? EMPTY_STATE
: filterAll(api, all),
[all, api, isApiReady, isDevelopment]
);
}
export default createNamedHook('useExtensions', useExtensionsImpl);
@@ -0,0 +1,31 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
// import type { RawMetadataDef } from '@pezkuwi/extension-inject/types';
import type { HexString } from '@pezkuwi/util/types';
import { useEffect, useState } from 'react';
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
function useRawMetadataImpl (): HexString | null {
const { api, isApiReady } = useApi();
const [state, setState] = useState<HexString | null>(null);
useEffect(
(): void => {
isApiReady &&
api.call.metadata.metadataAtVersion &&
api.call.metadata.metadataAtVersion(15).then((opaque) => {
const raw = opaque.toHex();
setState(raw);
}).catch(console.error);
},
[api, isApiReady]
);
return state;
}
export default createNamedHook('useRawMetadata', useRawMetadataImpl);
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/apps-config/settings/types';
import type { SettingsStruct } from '@pezkuwi/ui-settings/types';
import React from 'react';
import { ChainImg, Dropdown, IdentityIcon } from '@pezkuwi/react-components';
import { settings } from '@pezkuwi/ui-settings';
export function createOption ({ info, isHeader, text, value }: Option, overrides: string[] = [], override = 'empty', extra?: string): Option | React.ReactNode {
if (isHeader) {
return (
<Dropdown.Header
content={text}
key={text as string}
/>
);
}
return {
text: (
<div
className='ui--Dropdown-item'
key={value}
>
<ChainImg
className='ui--Dropdown-icon'
logo={
info && overrides.includes(info)
? override
: info
}
/>
<div className='ui--Dropdown-name'>{text}{extra}</div>
</div>
),
value
};
}
export function createIdenticon ({ info, text, value }: Option, overrides: string[] = [], override = 'empty'): Option {
const theme = info && overrides.includes(info)
? override as 'empty'
: info as 'bizinikiwi';
return {
text: (
<div
className='ui--Dropdown-item'
key={value}
>
{theme === 'empty'
? (
<ChainImg
className='ui--Dropdown-icon'
logo='empty'
/>
)
: (
<IdentityIcon
className='ui--Dropdown-icon'
size={32}
theme={theme}
value='5F9999K9UgTUgSsbXZQcEmRMvQqwJoBUHMv9e1k2MdgghuRA'
/>
)}
<div className='ui--Dropdown-name'>{text}</div>
</div>
),
value
};
}
export function save (state: SettingsStruct): void {
settings.set(state);
}
export function saveAndReload (state: SettingsStruct): void {
save(state);
// HACK This is terrible, but since the API needs to re-connect and
// the API does not yet handle re-connections properly, it is what it is
window.location.reload();
}