mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-05-01 04:18:00 +00:00
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:
@@ -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 & 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);
|
||||
@@ -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);
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user