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
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BareProps as Props } from '@pezkuwi/react-components/types';
import React, { useMemo } from 'react';
import { AccountSidebar, styled } from '@pezkuwi/react-components';
import GlobalStyle from '@pezkuwi/react-components/styles';
import { useApi, useTheme } from '@pezkuwi/react-hooks';
import Signer from '@pezkuwi/react-signer';
import Content from './Content/index.js';
import Menu from './Menu/index.js';
import BottomOverlay from './overlays/Bottom.js';
import ConnectingOverlay from './overlays/Connecting.js';
import WarmUp from './WarmUp.js';
export const PORTAL_ID = 'portals';
function Apps ({ className = '' }: Props): React.ReactElement<Props> {
const { themeClassName } = useTheme();
const { apiEndpoint, isDevelopment } = useApi();
const uiHighlight = useMemo(
() => isDevelopment
? undefined
: apiEndpoint?.ui.color,
[apiEndpoint, isDevelopment]
);
return (
<>
<GlobalStyle uiHighlight={uiHighlight} />
<StyledDiv className={`${className} apps--Wrapper ${themeClassName}`}>
<Menu />
<AccountSidebar>
<Signer>
<Content />
</Signer>
<ConnectingOverlay />
<BottomOverlay />
<div id={PORTAL_ID} />
</AccountSidebar>
</StyledDiv>
<WarmUp />
</>
);
}
const StyledDiv = styled.div`
background: var(--bg-page);
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 100vh;
${[
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21, 22, 23, 24
].map((n) => `
.greyAnim-${n} {
animation: greyAnim${n} 2s;
}
@keyframes greyAnim${n} {
0% { background: #a6a6a6; }
50% { background: darkorange; }
100% { background: #a6a6a6; }
}
`).join('')}
`;
export default React.memo(Apps);
@@ -0,0 +1,47 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';
import { MarkWarning, styled } from '@pezkuwi/react-components';
const BrowserCheckAlert: React.FC = () => {
const [isTargetBrowser, setIsTargetBrowser] = useState(false);
useEffect(() => {
const ua = navigator.userAgent;
// Detect Firefox
const firefoxMatch = ua.match(/Firefox\/(\d+\.\d+)/);
if (firefoxMatch) {
// Check for Firefox 145.0 specifically
if (ua.includes('145.0')) {
setIsTargetBrowser(true);
}
}
}, []);
if (!isTargetBrowser) {
return null;
}
return (
<StyledBanner
className='warning centered'
withIcon={false}
>
The app is having some trouble running on Firefox v145.0. To keep everything running smoothly, please upgrade Firefox to the latest version or try using a different browser.
</StyledBanner>
);
};
const StyledBanner = styled(MarkWarning)`
border: 1px solid #ffc107;
background: #ffc10720;
font-size: 1rem !important;
margin-bottom: 5rem !important;
`;
export default BrowserCheckAlert;
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RouteProps } from '@pezkuwi/apps-routing/types';
import React from 'react';
import { Navigate } from 'react-router';
interface Props extends RouteProps {
missingApis?: (string | string[])[];
}
function NotFound ({ basePath, missingApis = [] }: Props): React.ReactElement {
console.log(`Redirecting from route "${basePath}" to "/explorer"${missingApis.length ? `, missing the following APIs: ${JSON.stringify(missingApis)}` : ''}`);
return (
<Navigate to='/explorer' />
);
}
export default React.memo(NotFound);
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { EventRecord } from '@pezkuwi/types/interfaces';
import type { KeyringOptions } from '@pezkuwi/ui-keyring/options/types';
import React, { useEffect } from 'react';
import { Status as StatusDisplay } from '@pezkuwi/react-components';
import { useAccounts, useApi, useCall, useQueue } from '@pezkuwi/react-hooks';
import { stringToU8a } from '@pezkuwi/util';
import { xxhashAsHex } from '@pezkuwi/util-crypto';
import { useTranslation } from '../translate.js';
interface Props {
optionsAll?: KeyringOptions;
}
let prevEventHash: string;
function filterEvents (allAccounts: string[], t: (key: string, options?: { replace: Record<string, unknown> }) => string, optionsAll?: KeyringOptions, events?: EventRecord[]): ActionStatus[] | null {
const eventHash = xxhashAsHex(stringToU8a(JSON.stringify(events)));
if (!optionsAll || !events || eventHash === prevEventHash) {
return null;
}
prevEventHash = eventHash;
return events
.map(({ event: { data, method, section } }): ActionStatus | null => {
if (section === 'balances' && method === 'Transfer') {
const account = data[1].toString();
if (allAccounts.includes(account)) {
return {
account,
action: `${section}.${method}`,
message: t('transfer received'),
status: 'event'
};
}
} else if (section === 'democracy') {
const index = data[0].toString();
return {
action: `${section}.${method}`,
message: t('update on #{{index}}', {
replace: {
index
}
}),
status: 'event'
};
}
return null;
})
.filter((item): item is ActionStatus => !!item);
}
function Status ({ optionsAll }: Props): React.ReactElement<Props> {
const { queueAction } = useQueue();
const { api, isApiReady } = useApi();
const { allAccounts } = useAccounts();
const { t } = useTranslation();
const events = useCall<EventRecord[]>(isApiReady && api.query.system?.events);
useEffect((): void => {
const filtered = filterEvents(allAccounts, t, optionsAll, events);
filtered && queueAction(filtered);
}, [allAccounts, events, optionsAll, queueAction, t]);
return (
<StatusDisplay />
);
}
export default React.memo(Status);
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Route } from '@pezkuwi/apps-routing/types';
import React, { Suspense, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import createRoutes from '@pezkuwi/apps-routing';
import { ErrorBoundary, Spinner, styled } from '@pezkuwi/react-components';
import { useApi, useQueue } from '@pezkuwi/react-hooks';
import { TabsCtx } from '@pezkuwi/react-hooks/ctx/Tabs';
import { findMissingApis } from '../endpoint.js';
import { useTranslation } from '../translate.js';
import BrowserCheckAlert from './BrowserCheckAlert.js';
import NotFound from './NotFound.js';
import Status from './Status.js';
interface Props {
className?: string;
}
const NOT_FOUND: Route = {
Component: NotFound,
display: {},
group: 'settings',
icon: 'times',
isIgnored: false,
name: 'unknown',
text: 'Unknown'
};
function Content ({ className }: Props): React.ReactElement<Props> {
const location = useLocation();
const { t } = useTranslation();
const { api, isApiConnected, isApiReady, isDevelopment } = useApi();
const { queueAction } = useQueue();
const { Component, display: { needsApi, needsApiCheck, needsApiInstances }, icon, name, text } = useMemo(
(): Route => {
const app = location.pathname.slice(1) || '';
return createRoutes(t).find((r) =>
r &&
app.startsWith(r.name) &&
(isDevelopment || !r.display.isDevelopment)
) || NOT_FOUND;
},
[isDevelopment, location, t]
);
const missingApis = useMemo(
() => needsApi
? isApiReady && isApiConnected
? findMissingApis(api, needsApi, needsApiInstances, needsApiCheck)
: null
: [],
[api, isApiConnected, isApiReady, needsApi, needsApiCheck, needsApiInstances]
);
return (
<StyledDiv className={className}>
{!missingApis
? (
<div className='connecting'>
<BrowserCheckAlert />
<Spinner label={t('Initializing connection')} />
</div>
)
: (
<>
<Suspense fallback='...'>
<ErrorBoundary trigger={name}>
<TabsCtx.Provider value={{ icon, text }}>
{missingApis.length
? (
<NotFound
basePath={`/${name}`}
location={location}
missingApis={missingApis}
onStatusChange={queueAction}
/>
)
: (
<Component
basePath={`/${name}`}
location={location}
onStatusChange={queueAction}
/>
)
}
</TabsCtx.Provider>
</ErrorBoundary>
</Suspense>
<Status />
</>
)
}
</StyledDiv>
);
}
const StyledDiv = styled.div`
flex-grow: 1;
overflow: hidden auto;
padding: 0 0 1rem 0;
position: relative;
width: 100%;
.connecting {
padding: 3.5rem 0;
}
& main > *:not(header):not(.hasOwnMaxWidth) {
max-width: var(--width-full);
margin-right: auto;
margin-left: auto;
width: 100%;
padding: 0 1.5rem;
@media only screen and (max-width: 1100px) {
padding: 0 1rem;
}
@media only screen and (max-width: 800px) {
padding: 0 0.75rem;
}
}
`;
export default React.memo(Content);
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Group, IFavoriteChainProps, IFavoriteChainsStorage } from './types.js';
import React, { useCallback, useMemo } from 'react';
import { Icon, styled } from '@pezkuwi/react-components';
import Network from './Network.js';
import { getContrastingColor, isFavoriteChain } from './utils.js';
interface Props {
affinities: Record<string, string>;
apiUrl: string;
children?: React.ReactNode;
className?: string;
index: number;
isSelected: boolean;
favoriteChains: IFavoriteChainsStorage,
toggleFavoriteChain: (chainInfo: IFavoriteChainProps) => void;
setApiUrl: (network: string, apiUrl: string) => void;
setGroup: (groupIndex: number) => void;
value: Group;
highlightColor: string;
}
function GroupDisplay ({ affinities, apiUrl, children, className = '', favoriteChains, highlightColor, index, isSelected, setApiUrl, setGroup, toggleFavoriteChain, value: { header, isSpaced, networks } }: Props): React.ReactElement<Props> {
const _setGroup = useCallback(
() => setGroup(isSelected ? -1 : index),
[index, isSelected, setGroup]
);
const isFavoriteHeader = useMemo(() => header?.toString().includes('Favorite'), [header]);
const filtered = useMemo(
() => networks.filter(({ isUnreachable }) => !isUnreachable),
[networks]
);
if (isFavoriteHeader && Object.keys(favoriteChains).length === 0) {
return <></>;
}
return (
<StyledDiv
className={`${className}${isSelected ? ' isSelected' : ''}`}
highlightColor={highlightColor}
>
<div
className={`groupHeader${isSpaced ? ' isSpaced' : ''}${isFavoriteHeader ? ' isFavoriteHeader' : ''}`}
onClick={_setGroup}
>
<Icon icon={isSelected ? 'caret-up' : 'caret-down'} />
{header}
</div>
{isSelected && (
<>
<div className='groupNetworks'>
{filtered.map((network, index): React.ReactNode => (
<Network
affinity={affinities[network.name]}
apiUrl={apiUrl}
isFavorite={isFavoriteChain(favoriteChains, {
chainName: network.name,
paraId: network.paraId,
relay: network.nameRelay
})}
key={index}
setApiUrl={setApiUrl}
toggleFavoriteChain={toggleFavoriteChain}
value={network}
/>
))}
</div>
{children}
</>
)}
</StyledDiv>
);
}
const StyledDiv = styled.div<{ highlightColor: string; }>`
.groupHeader {
border-radius: 0.25rem;
cursor: pointer;
line-height: 1;
padding: 0.75rem 1rem;
position: relative;
text-transform: uppercase;
&:hover {
background: var(--bg-table);
}
&.isSpaced {
margin-top: 0.75rem;
}
.ui--Icon {
margin-right: 0.5rem;
}
&.isFavoriteHeader {
&:hover {
background: linear-gradient(
135deg,
${(props) => props.highlightColor}f2 0%,
${(props) => props.highlightColor}99 100%
);
color: ${(props) => getContrastingColor(props.highlightColor)};
}
&::after {
content: '⭐';
margin-left: 8px;
font-size: 16px;
}
}
}
.groupNetworks {
padding: 0.25rem 0 0.5rem 1rem;
}
`;
export default React.memo(GroupDisplay);
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IFavoriteChainProps, Network } from './types.js';
import React, { useCallback, useMemo } from 'react';
import { ChainImg, Dropdown, Icon, styled } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
interface Props {
affinity?: string; // unused - previous selection
apiUrl: string;
className?: string;
setApiUrl: (network: string, apiUrl: string) => void;
value: Network;
isFavorite: boolean;
toggleFavoriteChain: (chainInfo: IFavoriteChainProps) => void;
}
function NetworkDisplay ({ apiUrl, className = '', isFavorite, setApiUrl, toggleFavoriteChain, value: { isChild, isRelay, isUnreachable, name, nameRelay: relay, paraId, providers, ui } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const isSelected = useMemo(
() => providers.some(({ url }) => url === apiUrl),
[apiUrl, providers]
);
const providersOptions = useMemo(() => {
return providers.map(({ name, url }) => ({
text: name,
value: url
}));
}, [providers]);
const _selectUrl = useCallback(
() => {
const filteredProviders = providers.filter(({ url }) => !url.startsWith('light://'));
if (filteredProviders.length === 0) {
alert('No WebSocket (wss://) provider available');
return;
}
return setApiUrl(name, filteredProviders[Math.floor(Math.random() * filteredProviders.length)].url);
},
[name, providers, setApiUrl]
);
const _setApiUrl = useCallback(
(apiUrl: string) => setApiUrl(name, apiUrl),
[name, setApiUrl]
);
const _toggleFavoriteChain = useCallback((e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteChain({ chainName: name, paraId, relay });
}, [name, paraId, relay, toggleFavoriteChain]);
return (
<StyledDiv className={`${className}${isSelected ? ' isSelected highlight--border' : ''}${isUnreachable ? ' isUnreachable' : ''}`}>
<div
className={`markFavoriteSection${isChild ? ' isChild' : ''}`}
onClick={isUnreachable ? undefined : _selectUrl}
>
<div className='endpointSection'>
<ChainImg
className='endpointIcon'
isInline
logo={ui.logo || 'empty'}
withoutHl
/>
<div className='endpointValue'>
<div>{name}</div>
{isSelected && (isRelay || !!paraId) && (
<div className='endpointExtra'>
{isRelay
? t('Relay chain')
: paraId && paraId < 1000
? t('{{relay}} System', { replace: { relay } })
: paraId && paraId < 2000
? t('{{relay}} Common', { replace: { relay } })
: t('{{relay}} Teyrchain', { replace: { relay } })
}
</div>
)}
</div>
</div>
<Icon
className={isFavorite ? 'isFavorite' : ''}
icon='star'
onClick={_toggleFavoriteChain}
/>
</div>
{isSelected &&
<Dropdown
className='isSmall'
onChange={_setApiUrl}
options={providersOptions}
value={apiUrl}
withLabel={false}
/>
}
</StyledDiv>
);
}
const StyledDiv = styled.div`
border-left: 0.25rem solid transparent;
border-radius: 0.25rem;
cursor: pointer;
margin: 0 0 0.25rem 0;
padding: 0.375rem 0.5rem 0.375rem 1rem;
position: relative;
&.isUnreachable {
opacity: var(--opacity-light);
}
&.isSelected {
.markFavoriteSection {
gap: 1rem;
padding-bottom: 1rem;
}
}
.markFavoriteSection {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
&:hover .ui--Icon {
opacity: 0.5;
}
.ui--Icon {
scale: 1.1;
opacity: 0;
transition: color 0.2s ease;
&:hover {
opacity: 0.5;
stroke: darkorange;
color: darkorange;
}
&.isFavorite {
opacity: 1;
stroke: darkorange;
color: darkorange;
}
}
}
&.isSelected,
&:hover {
background: var(--bg-table);
}
.endpointSection {
align-items: center;
display: flex;
justify-content: flex-start;
position: relative;
&+.ui--Toggle {
margin-top: 1rem;
}
&+.endpointProvider {
margin-top: -0.125rem;
}
.endpointValue {
.endpointExtra {
font-size: var(--font-size-small);
opacity: var(--opacity-light);
}
}
}
// we jiggle our labels somewhat...
label {
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
text-transform: none;
}
`;
export default React.memo(NetworkDisplay);
+446
View File
@@ -0,0 +1,446 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { LinkOption } from '@pezkuwi/apps-config/endpoints/types';
import type { Group, IFavoriteChainProps } from './types.js';
// ok, this seems to be an eslint bug, this _is_ a package import
import punycode from 'punycode/';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import store from 'store';
import { createWsEndpoints, CUSTOM_ENDPOINT_KEY } from '@pezkuwi/apps-config';
import { Button, Input, Sidebar, styled } from '@pezkuwi/react-components';
import { defaultHighlight } from '@pezkuwi/react-components/styles';
import { useApi } from '@pezkuwi/react-hooks';
import { settings } from '@pezkuwi/ui-settings';
import { isAscii } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import GroupDisplay from './Group.js';
import { getFavoriteChains, isFavoriteChain, toggleFavoriteChain } from './utils.js';
interface Props {
className?: string;
offset?: number | string;
onClose: () => void;
}
interface UrlState {
apiUrl: string;
groupIndex: number;
hasUrlChanged: boolean;
isUrlValid: boolean;
}
const STORAGE_AFFINITIES = 'network:affinities';
function isValidUrl (url: string): boolean {
return (
// some random length... we probably want to parse via some lib
(url.length >= 7) &&
// check that it starts with a valid ws identifier
(url.startsWith('ws://') || url.startsWith('wss://') || url.startsWith('light://'))
);
}
function combineEndpoints (endpoints: LinkOption[]): Group[] {
const favoriteChains = getFavoriteChains();
let favoriteGroupIndex = -1;
const combinedEndpoints = endpoints.reduce((result: Group[], e): Group[] => {
if (e.isHeader) {
const isFavoriteHeader =
typeof e.text === 'string' && e.text.includes('Favorite chains');
result.push({ header: e.text, isDevelopment: e.isDevelopment, isSpaced: e.isSpaced, networks: [] });
if (isFavoriteHeader) {
favoriteGroupIndex = result.length - 1;
}
} else {
const prev = result[result.length - 1];
const prov = { isLightClient: e.isLightClient, name: e.textBy, url: e.value };
const isFavorite = isFavoriteChain(favoriteChains,
{ chainName: e.text?.toString() ?? '',
paraId: e.paraId,
relay: e.textRelay?.toString() });
if (isFavorite && favoriteGroupIndex !== -1 && !e.isUnreachable) {
const favGroup = result[favoriteGroupIndex];
const lastFav = favGroup.networks[favGroup.networks.length - 1];
if (lastFav && lastFav.name === e.text && lastFav.nameRelay === e.textRelay && lastFav.paraId === e.paraId) {
lastFav.providers.push(prov);
} else {
favGroup.networks.push({
isChild: e.isChild,
isRelay: !!e.genesisHash,
name: e.text as string,
nameRelay: e.textRelay as string,
paraId: e.paraId,
providers: [prov],
ui: e.ui
});
}
}
if (prev.networks[prev.networks.length - 1] && e.text === prev.networks[prev.networks.length - 1].name) {
prev.networks[prev.networks.length - 1].providers.push(prov);
} else if (!e.isUnreachable) {
prev.networks.push({
isChild: e.isChild,
isRelay: !!e.genesisHash,
name: e.text as string,
nameRelay: e.textRelay as string,
paraId: e.paraId,
providers: [prov],
ui: e.ui
});
}
}
return result;
}, []);
// Swap first two items in `networks` if first item is relay chain
combinedEndpoints.forEach((r) => {
if (r.networks.length >= 2 && r.networks[0].isRelay && r.header?.toString().includes('teyrchains')) {
[r.networks[0], r.networks[1]] = [r.networks[1], r.networks[0]];
}
});
return combinedEndpoints;
}
function getCustomEndpoints (): string[] {
try {
const storedAsset = localStorage.getItem(CUSTOM_ENDPOINT_KEY);
if (storedAsset) {
return JSON.parse(storedAsset) as string[];
}
} catch (e) {
console.error(e);
// ignore error
}
return [];
}
function extractUrlState (apiUrl: string, groups: Group[]): UrlState {
let groupIndex = groups.findIndex(({ networks }) =>
networks.some(({ providers }) =>
providers.some(({ url }) => url === apiUrl)
)
);
if (groupIndex === -1) {
groupIndex = groups.findIndex(({ isDevelopment }) => isDevelopment);
}
return {
apiUrl,
groupIndex,
hasUrlChanged: settings.get().apiUrl !== apiUrl,
isUrlValid: isValidUrl(apiUrl)
};
}
function loadAffinities (groups: Group[]): Record<string, string> {
return Object
.entries<string>(store.get(STORAGE_AFFINITIES) as Record<string, string> || {})
.filter(([network, apiUrl]) =>
groups.some(({ networks }) =>
networks.some(({ name, providers }) =>
name === network && providers.some(({ url }) => url === apiUrl)
)
)
)
.reduce((result: Record<string, string>, [network, apiUrl]): Record<string, string> => ({
...result,
[network]: apiUrl
}), {});
}
function isSwitchDisabled (hasUrlChanged: boolean, apiUrl: string, isUrlValid: boolean, isLocalFork?: boolean): boolean {
if (!hasUrlChanged) {
if (isLocalFork) {
return false;
} else {
return true;
}
} else if (apiUrl.startsWith('light://')) {
return false;
} else if (isUrlValid) {
return false;
}
return true;
}
function isLocalForkDisabled (hasUrlChanged: boolean, apiUrl: string, isUrlValid: boolean, isLocalFork?: boolean): boolean {
if (!hasUrlChanged) {
if (isLocalFork) {
return true;
} else {
return false;
}
} else if (apiUrl.startsWith('light://')) {
return true;
} else if (isUrlValid) {
return false;
}
return true;
}
function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const linkOptions = createWsEndpoints(t);
const { apiEndpoint, isLocalFork } = useApi();
const [favoriteChains, setFavoriteChains] = useState(() => getFavoriteChains());
const [groups, setGroups] = useState(() => combineEndpoints(linkOptions));
const [{ apiUrl, groupIndex, hasUrlChanged, isUrlValid }, setApiUrl] = useState<UrlState>(() => extractUrlState(settings.get().apiUrl, groups));
const [storedCustomEndpoints, setStoredCustomEndpoints] = useState<string[]>(() => getCustomEndpoints());
const [affinities, setAffinities] = useState(() => loadAffinities(groups));
const sidebarRef = useRef<HTMLDivElement>(null);
const isKnownUrl = useMemo(() => {
let result = false;
linkOptions.some((endpoint) => {
if (endpoint.value === apiUrl) {
result = true;
return true;
}
return false;
});
return result;
}, [apiUrl, linkOptions]);
const isSavedCustomEndpoint = useMemo(() => {
let result = false;
storedCustomEndpoints.some((endpoint) => {
if (endpoint === apiUrl) {
result = true;
return true;
}
return false;
});
return result;
}, [apiUrl, storedCustomEndpoints]);
const _changeGroup = useCallback(
(groupIndex: number) => setApiUrl((state) => ({ ...state, groupIndex })),
[]
);
const _toggleFavoriteChain = useCallback((chainInfo: IFavoriteChainProps) => {
toggleFavoriteChain(chainInfo);
setFavoriteChains(getFavoriteChains());
setGroups(combineEndpoints(createWsEndpoints(t)));
}, [t]);
const _removeApiEndpoint = useCallback(
(): void => {
if (!isSavedCustomEndpoint) {
return;
}
const newStoredCurstomEndpoints = storedCustomEndpoints.filter((url) => url !== apiUrl);
try {
localStorage.setItem(CUSTOM_ENDPOINT_KEY, JSON.stringify(newStoredCurstomEndpoints));
setGroups(combineEndpoints(createWsEndpoints(t)));
setStoredCustomEndpoints(getCustomEndpoints());
} catch (e) {
console.error(e);
// ignore error
}
},
[apiUrl, isSavedCustomEndpoint, storedCustomEndpoints, t]
);
const _setApiUrl = useCallback(
(network: string, apiUrl: string): void => {
setAffinities((affinities): Record<string, string> => {
const newValue = { ...affinities, [network]: apiUrl };
store.set(STORAGE_AFFINITIES, newValue);
return newValue;
});
setApiUrl((state) => ({ ...extractUrlState(apiUrl, groups), groupIndex: state.groupIndex }));
},
[groups]
);
const _onChangeCustom = useCallback(
(apiUrl: string): void => {
if (!isAscii(apiUrl)) {
apiUrl = punycode.toASCII(apiUrl);
}
setApiUrl((state) => ({ ...extractUrlState(apiUrl, groups), groupIndex: state.groupIndex }));
},
[groups]
);
const _onApply = useCallback(
(): void => {
store.set('localFork', '');
settings.set({ ...(settings.get()), apiUrl });
window.location.assign(`${window.location.origin}${window.location.pathname}?rpc=${encodeURIComponent(apiUrl)}${window.location.hash}`);
if (!hasUrlChanged) {
window.location.reload();
}
onClose();
},
[apiUrl, onClose, hasUrlChanged]
);
const _onLocalFork = useCallback(
(): void => {
store.set('localFork', apiUrl);
settings.set({ ...(settings.get()), apiUrl });
window.location.assign(`${window.location.origin}${window.location.pathname}?rpc=${encodeURIComponent(apiUrl)}${window.location.hash}`);
if (!hasUrlChanged) {
window.location.reload();
}
onClose();
},
[apiUrl, onClose, hasUrlChanged]
);
const _saveApiEndpoint = useCallback(
(): void => {
try {
localStorage.setItem(CUSTOM_ENDPOINT_KEY, JSON.stringify([...storedCustomEndpoints, apiUrl]));
_onApply();
} catch (e) {
console.error(e);
// ignore error
}
},
[_onApply, apiUrl, storedCustomEndpoints]
);
const canSwitch = useMemo(
() => isSwitchDisabled(hasUrlChanged, apiUrl, isUrlValid, isLocalFork),
[hasUrlChanged, apiUrl, isUrlValid, isLocalFork]
);
const canLocalFork = useMemo(
() => isLocalForkDisabled(hasUrlChanged, apiUrl, isUrlValid, isLocalFork),
[hasUrlChanged, apiUrl, isUrlValid, isLocalFork]
);
return (
<StyledSidebar
buttons={
<>
<Button
icon='code-fork'
isDisabled={canLocalFork}
label={t('Fork Locally')}
onClick={_onLocalFork}
tooltip='fork-locally-btn'
/>
<Button
icon='sync'
isDisabled={canSwitch}
label={t('Switch')}
onClick={_onApply}
/>
</>
}
className={className}
offset={offset}
onClose={onClose}
position='left'
sidebarRef={sidebarRef}
>
{groups.map((group, index): React.ReactNode => (
<GroupDisplay
affinities={affinities}
apiUrl={apiUrl}
favoriteChains={favoriteChains}
highlightColor={apiEndpoint?.ui.color || defaultHighlight}
index={index}
isSelected={groupIndex === index}
key={index}
setApiUrl={_setApiUrl}
setGroup={_changeGroup}
toggleFavoriteChain={_toggleFavoriteChain}
value={group}
>
{group.isDevelopment && (
<div className='endpointCustomWrapper'>
<Input
className='endpointCustom'
isError={!isUrlValid}
isFull
label={t('custom endpoint')}
onChange={_onChangeCustom}
value={apiUrl}
/>
{isSavedCustomEndpoint
? (
<Button
className='customButton'
icon='trash-alt'
onClick={_removeApiEndpoint}
/>
)
: (
<Button
className='customButton'
icon='save'
isDisabled={!isUrlValid || isKnownUrl}
onClick={_saveApiEndpoint}
/>
)
}
</div>
)}
</GroupDisplay>
))}
</StyledSidebar>
);
}
const StyledSidebar = styled(Sidebar)`
color: var(--color-text);
padding-top: 3.5rem;
.customButton {
position: absolute;
top: 1rem;
right: 1rem;
}
.endpointCustom {
input {
padding-right: 4rem;
}
}
.endpointCustomWrapper {
position: relative;
}
`;
export default React.memo(Endpoints);
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type React from 'react';
export interface IFavoriteChainProps {
chainName: string;
relay?: string;
paraId?: number;
}
export type IFavoriteChainsStorage = Record<string, {relay: string, paraId: number}[]>
export interface Network {
isChild?: boolean;
isLightClient?: boolean;
isRelay?: boolean;
isUnreachable?: boolean;
name: string;
nameRelay?: string;
paraId?: number;
providers: {
name: string;
url: string;
}[];
ui: {
color?: string;
logo?: string;
}
}
export interface Group {
header: React.ReactNode;
isDevelopment?: boolean;
isSpaced?: boolean;
networks: Network[];
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IFavoriteChainProps, IFavoriteChainsStorage } from './types.js';
import { createWsEndpoints } from '@pezkuwi/apps-config';
export const FAVORITE_CHAINS_KEY = 'pezkuwi-app-favorite-chains';
const chainsConfig = createWsEndpoints((k, v) => v?.toString() || k);
export const toggleFavoriteChain = (
chainInfo: IFavoriteChainProps
) => {
try {
const chainName = chainInfo.chainName;
const meta = { paraId: chainInfo.paraId ?? -1, relay: chainInfo.relay ?? 'Unknown' };
const favoriteChains = getFavoriteChains();
const existingEntries = favoriteChains[chainName] ?? [];
const alreadyExists = existingEntries.some(
(entry) => entry.relay === meta.relay && entry.paraId === meta.paraId
);
let updatedEntries: IFavoriteChainsStorage[string];
if (alreadyExists) {
// Remove the matching entry
updatedEntries = existingEntries.filter(
(entry) => entry.relay !== meta.relay || entry.paraId !== meta.paraId
);
} else {
// Add new entry
updatedEntries = [...existingEntries, meta];
}
const updatedChains: IFavoriteChainsStorage = { ...favoriteChains };
if (updatedEntries.length === 0) {
delete updatedChains[chainName];
} else {
updatedChains[chainName] = updatedEntries;
}
localStorage.setItem(FAVORITE_CHAINS_KEY, JSON.stringify(updatedChains));
} catch {}
};
export const getFavoriteChains = (): IFavoriteChainsStorage => {
try {
const favoriteChains = localStorage.getItem(FAVORITE_CHAINS_KEY);
if (!favoriteChains) {
return {};
}
const parsed: unknown = JSON.parse(favoriteChains);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Invalid favorite chains format');
}
const result: IFavoriteChainsStorage = {};
for (const [key, value] of Object.entries(parsed) as [string, IFavoriteChainsStorage[string]][]) {
if (!Array.isArray(value)) {
throw new Error(`Invalid value for key "${key}": not an array`);
}
const allValid = value.every(
(entry: IFavoriteChainsStorage[string][number]) =>
typeof entry === 'object' &&
entry !== null &&
typeof entry.relay === 'string' &&
typeof entry.paraId === 'number'
);
if (!allValid) {
throw new Error(`Invalid entries under key "${key}"`);
}
// Make sure key exists in chain config
if (chainsConfig.find((e) => e.text === key)) {
// Make sure "relay" value also exists in chain config
const matchingFavorites = value.filter((v) => v.relay === 'Unknown' ? true : !!chainsConfig.find((e) => e.text === v.relay));
if (matchingFavorites.length > 0) {
result[key] = matchingFavorites;
}
}
}
// Set filtered result to localStorage
localStorage.setItem(FAVORITE_CHAINS_KEY, JSON.stringify(result));
return result;
} catch (e) {
console.error('Failed to parse favorite chains:', e);
localStorage.removeItem(FAVORITE_CHAINS_KEY);
return {};
}
};
export const isFavoriteChain = (
favoriteChains: IFavoriteChainsStorage,
chainInfo: IFavoriteChainProps
): boolean => {
try {
const chainName = chainInfo.chainName;
const meta = { paraId: chainInfo.paraId ?? -1, relay: chainInfo.relay ?? 'Unknown' };
const list = favoriteChains[chainName];
if (!Array.isArray(list)) {
return false;
}
return list.some(
(entry) =>
entry.relay === meta.relay && entry.paraId === meta.paraId
);
} catch (e) {
console.error('Failed to check favorite chain:', e);
return false;
}
};
export function getContrastingColor (hexColor: string): string {
if (typeof hexColor !== 'string') {
return '#000000';
}
let hex = hexColor.replace('#', '').trim();
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length !== 6 || /[^0-9a-f]/i.test(hex)) {
return '#000000';
}
try {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
} catch {
return '#000000';
}
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { RuntimeVersion } from '@pezkuwi/types/interfaces';
import React from 'react';
import { ChainImg, Icon, styled } from '@pezkuwi/react-components';
import { useApi, useCall, useIpfs, useToggle } from '@pezkuwi/react-hooks';
import { BestNumber, Chain } from '@pezkuwi/react-query';
import Endpoints from '../Endpoints/index.js';
interface Props {
className?: string;
}
function ChainInfo ({ className }: Props): React.ReactElement<Props> {
const { api, isApiReady } = useApi();
const runtimeVersion = useCall<RuntimeVersion>(isApiReady && api.rpc.state.subscribeRuntimeVersion);
const { ipnsChain } = useIpfs();
const [isEndpointsVisible, toggleEndpoints] = useToggle();
const canToggle = !ipnsChain;
return (
<StyledDiv className={className}>
<div
className={`apps--SideBar-logo-inner${canToggle ? ' isClickable' : ''} highlight--color-contrast`}
onClick={toggleEndpoints}
>
<ChainImg />
<div className='info media--1000'>
<Chain className='chain' />
{runtimeVersion && (
<div className='runtimeVersion'>{runtimeVersion.specName.toString()}/{runtimeVersion.specVersion.toNumber()}</div>
)}
<BestNumber
className='bestNumber'
label='#'
/>
</div>
{canToggle && (
<Icon
className='dropdown'
icon={isEndpointsVisible ? 'caret-right' : 'caret-down'}
/>
)}
</div>
{isEndpointsVisible && (
<Endpoints onClose={toggleEndpoints} />
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
box-sizing: border-box;
padding: 0.5rem 1rem 0.5rem 0;
margin: 0;
.apps--SideBar-logo-inner {
display: flex;
align-items: center;
justify-content: space-between;
&.isClickable {
cursor: pointer;
}
.ui--ChainImg {
height: 3rem;
margin-right: 0.5rem;
width: 3rem;
}
.ui--Icon.dropdown,
> div.info {
text-align: right;
vertical-align: middle;
}
.ui--Icon.dropdown {
flex: 0;
margin: 0;
width: 1rem;
}
.info {
flex: 1;
font-size: var(--font-size-tiny);
line-height: 1.2;
padding-right: 0.5rem;
text-align: right;
.chain {
font-size: var(--font-size-small);
max-width: 16rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.runtimeVersion {
letter-spacing: -0.01em;
}
}
}
`;
export default React.memo(ChainInfo);
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Group } from './types.js';
import React from 'react';
import { Icon, styled } from '@pezkuwi/react-components';
import Item from './Item.js';
interface Props extends Group {
className?: string;
isActive: boolean;
}
const SHA_COL = 'rgba(34, 36, 38, 0.12)';
const SHA_OFF = '5px';
function Grouping ({ className = '', isActive, name, routes }: Props): React.ReactElement<Props> {
if (routes.length === 1 && routes[0].group === 'settings') {
return (
<Item
className={isActive ? 'isActive' : ''}
classNameText='smallHide'
isToplevel
route={routes[0]}
/>
);
}
return (
<StyledLi className={`${className} ${isActive ? 'isActive' : ''}`}>
<div className={`groupHdr ${!isActive ? 'highlight--color-contrast' : ''}`}>
<span className='smallHide'>{name}</span>
<Icon
className='smallShow'
icon={routes[0].icon}
/>
<Icon icon='caret-down' />
</div>
<ul className='groupMenu'>
{routes.map((route): React.ReactNode => (
<Item
key={route.name}
route={route}
/>
))}
</ul>
</StyledLi>
);
}
const StyledLi = styled.li`
cursor: pointer;
position: relative;
.groupHdr {
border-radius: 0.25rem;
padding: 0.857rem 1.375rem;
font-weight: var(--font-weight-normal);
line-height: 1.214rem;
> .ui--Icon {
margin-left: 0.75rem;
}
}
&.isActive .groupHdr {
background-color: var(--bg-tabs);
font-weight: var(--font-weight-normal);
margin-bottom: 0;
}
.groupMenu {
border-radius: 0.25rem;
box-shadow: 0 ${SHA_OFF} ${SHA_OFF} -${SHA_OFF} ${SHA_COL}, ${SHA_OFF} 0 ${SHA_OFF} -${SHA_OFF} ${SHA_COL}, -${SHA_OFF} 0 ${SHA_OFF} -${SHA_OFF} ${SHA_COL};
display: none;
margin: 0;
overflow: hidden;
padding: 0;
position: absolute;
top: 2.9rem;
z-index: 250;
> li {
z-index: 1;
a {
padding-right: 4rem;
}
}
&::before {
bottom: 0;
content: ' ';
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: -1;
}
}
&:hover {
.groupHdr {
box-shadow: 0px 4px 37px rgba(0, 0, 0, 0.08);
padding-bottom: 2rem;
margin-bottom: -2rem;
}
.groupMenu {
display: block;
> li:hover {
background: var(--bg-menu-hover);
}
}
}
`;
export default React.memo(Grouping);
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ItemRoute } from './types.js';
import React from 'react';
import { Badge, Icon, styled } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
interface Props {
className?: string;
classNameText?: string;
isLink?: boolean;
isToplevel?: boolean;
route: ItemRoute;
}
const DUMMY_COUNTER = () => 0;
function Item ({ className = '', classNameText, isLink, isToplevel, route: { Modal, href, icon, name, text, useCounter = DUMMY_COUNTER } }: Props): React.ReactElement<Props> {
const [isModalVisible, toggleModal] = useToggle();
const count = useCounter();
return (
<StyledLi className={`${className} ui--MenuItem ${count ? 'withCounter' : ''} ${isLink ? 'isLink' : ''} ${isToplevel ? 'topLevel highlight--color-contrast' : ''}`}>
<a
href={Modal ? undefined : (href || `#/${name}`)}
onClick={Modal ? toggleModal : undefined}
rel='noopener noreferrer'
target={href ? '_blank' : undefined}
>
<Icon icon={icon} />
<span className={classNameText}>{text}</span>
{!!count && (
<Badge
color='white'
info={count}
/>
)}
</a>
{Modal && isModalVisible && (
<Modal onClose={toggleModal} />
)}
</StyledLi>
);
}
const StyledLi = styled.li`
cursor: pointer;
position: relative;
white-space: nowrap;
&.topLevel {
font-weight: var(--font-weight-normal);
line-height: 1.214rem;
border-radius: 0.15rem;
a {
padding: 0.857rem 0.857em 0.857rem 1rem;
line-height: 1.214rem;
border-radius: 0.25rem;
}
&.isActive.highlight--color-contrast {
font-weight: var(--font-weight-normal);
color: var(--color-text);
a {
background-color: var(--bg-tabs);
}
}
&.isActive {
border-radius: 0.15rem 0.15rem 0 0;
a {
padding: 0.857rem 1.429rem 0.857rem;
cursor: default;
}
&&.withCounter a {
padding-right: 3.2rem;
}
}
.ui--Badge {
top: 0.7rem;
}
}
&&.withCounter a {
padding-right: 3.2rem;
}
a {
color: inherit !important;
display: block;
padding: 0.5rem 1.15rem 0.57rem;
text-decoration: none;
line-height: 1.5rem;
}
.ui--Badge {
position: absolute;
right: 0.5rem;
}
.ui--Icon {
margin-right: 0.5rem;
}
`;
export default React.memo(Item);
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BareProps as Props } from '@pezkuwi/react-components/types';
import React from 'react';
import { packageInfo } from '@pezkuwi/apps-config';
import { styled } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { NodeName, NodeVersion } from '@pezkuwi/react-query';
const appsVersion = `apps v${packageInfo.version.replace('-x', '')}`;
function NodeInfo ({ className = '' }: Props): React.ReactElement<Props> {
const { api, isApiReady } = useApi();
return (
<StyledDiv className={`${className} media--1400 highlight--color-contrast ui--NodeInfo`}>
{isApiReady && (
<div className='node'>
<NodeName />&nbsp;
<NodeVersion label='v' />
</div>
)}
<div>{api.libraryInfo.replace('@pezkuwi/', '')}</div>
<div>{appsVersion}</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
background: transparent;
font-size: var(--font-size-tiny);
line-height: 1.2;
padding: 0 0 0 1rem;
text-align: right;
> div {
margin-bottom: -0.125em;
> div {
display: inline-block;
}
}
`;
export default React.memo(NodeInfo);
+258
View File
@@ -0,0 +1,258 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Route, Routes } from '@pezkuwi/apps-routing/types';
import type { ApiProps } from '@pezkuwi/react-api/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { Group, Groups, ItemRoute } from './types.js';
import React, { useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import createRoutes from '@pezkuwi/apps-routing';
import { styled } from '@pezkuwi/react-components';
import { useAccounts, useApi, useCall, useTeleport } from '@pezkuwi/react-hooks';
import { findMissingApis } from '../endpoint.js';
import { useTranslation } from '../translate.js';
import ChainInfo from './ChainInfo.js';
import Grouping from './Grouping.js';
import Item from './Item.js';
import NodeInfo from './NodeInfo.js';
interface Props {
className?: string;
}
function createExternals (t: (key: string, optionsOrText?: string | { replace: Record<string, unknown> }, options?: { ns: string }) => string): ItemRoute[] {
return [
{
href: 'https://github.com/pezkuwi-js/apps',
icon: 'code-branch',
name: 'github',
text: t('nav.github', 'GitHub', { ns: 'apps-routing' })
},
{
href: 'https://wiki.pezkuwi.network',
icon: 'book',
name: 'wiki',
text: t('nav.wiki', 'Wiki', { ns: 'apps-routing' })
}
];
}
function checkVisible ({ api, isApiConnected, isApiReady, isDevelopment: isApiDevelopment }: ApiProps, allowTeleport: boolean, hasAccounts: boolean, hasSudo: boolean, { isDevelopment, isHidden, needsAccounts, needsApi, needsApiCheck, needsApiInstances, needsSudo, needsTeleport }: Route['display']): boolean {
if (isHidden) {
return false;
} else if (needsAccounts && !hasAccounts) {
return false;
} else if (!needsApi) {
return true;
} else if (!isApiReady || !isApiConnected) {
return false;
} else if (needsSudo && !hasSudo) {
return false;
} else if (needsTeleport && !allowTeleport) {
return false;
} else if (!isApiDevelopment && isDevelopment) {
return false;
}
return findMissingApis(api, needsApi, needsApiInstances, needsApiCheck).length === 0;
}
function extractGroups (routing: Routes, groupNames: Record<string, string>, apiProps: ApiProps, allowTeleport: boolean, hasAccounts: boolean, hasSudo: boolean): Group[] {
return Object
.values(
routing.reduce((all: Groups, route): Groups => {
if (!all[route.group]) {
all[route.group] = {
name: groupNames[route.group],
routes: [route]
};
} else {
all[route.group].routes.push(route);
}
return all;
}, {})
)
.map(({ name, routes }): Group => ({
name,
routes: routes.filter(({ display }) =>
checkVisible(apiProps, allowTeleport, hasAccounts, hasSudo, display)
)
}))
.filter(({ routes }) => routes.length);
}
function Menu ({ className = '' }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { allAccounts, hasAccounts } = useAccounts();
const apiProps = useApi();
const { allowTeleport } = useTeleport();
const sudoKey = useCall<AccountId>(apiProps.isApiReady && apiProps.api.query.sudo?.key);
const location = useLocation();
const externalRef = useRef(createExternals(t));
const routeRef = useRef(createRoutes(t));
const groupRef = useRef({
accounts: t('Accounts'),
developer: t('Developer'),
files: t('Files'),
governance: t('Governance'),
network: t('Network'),
settings: t('Settings')
});
const hasSudo = useMemo(
() => !!sudoKey && allAccounts.some((a) => sudoKey.eq(a)),
[allAccounts, sudoKey]
);
const visibleGroups = useMemo(
() => extractGroups(routeRef.current, groupRef.current, apiProps, allowTeleport, hasAccounts, hasSudo),
[allowTeleport, apiProps, hasAccounts, hasSudo]
);
const activeRoute = useMemo(
() => routeRef.current.find(({ name }) =>
location.pathname.startsWith(`/${name}`)
) || null,
[location]
);
return (
<StyledDiv className={`${className}${(!apiProps.isApiReady || !apiProps.isApiConnected) ? ' isLoading' : ''} highlight--bg`}>
<div className='menuContainer'>
<div className='menuSection'>
<ChainInfo />
<ul className='menuItems'>
{visibleGroups.map(({ name, routes }): React.ReactNode => (
<Grouping
isActive={!!activeRoute && activeRoute.group === name.toLowerCase()}
key={name}
name={name}
routes={routes}
/>
))}
</ul>
</div>
<div className='menuSection media--1200'>
<ul className='menuItems'>
{externalRef.current.map((route): React.ReactNode => (
<Item
isLink
isToplevel
key={route.name}
route={route}
/>
))}
</ul>
</div>
<NodeInfo className='media--1400' />
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
width: 100%;
padding: 0;
z-index: 220;
position: relative;
.smallShow {
display: none;
}
& .menuContainer {
flex-direction: row;
align-items: center;
display: flex;
justify-content: space-between;
padding: 0 1.5rem;
width: 100%;
max-width: var(--width-full);
margin: 0 auto;
}
&.isLoading {
background: #999 !important;
.menuActive {
background: var(--bg-page);
}
&:before {
filter: grayscale(1);
}
.menuItems {
filter: grayscale(1);
}
}
.menuSection {
align-items: center;
display: flex;
}
.menuActive {
background: var(--bg-tabs);
border-bottom: none;
border-radius: 0.25rem 0.25rem 0 0;
color: var(--color-text);
padding: 1rem 1.5rem;
margin: 0 1rem -1px;
z-index: 1;
.ui--Icon {
margin-right: 0.5rem;
}
}
.menuItems {
flex: 1 1;
list-style: none;
margin: 0 1rem 0 0;
padding: 0;
> li {
display: inline-block;
}
> li + li {
margin-left: 0.375rem
}
}
.ui--NodeInfo {
align-self: center;
}
@media only screen and (max-width: 800px) {
.groupHdr {
padding: 0.857rem 0.75rem;
}
.smallShow {
display: initial;
}
.smallHide {
display: none;
}
.menuItems {
margin-right: 0;
> li + li {
margin-left: 0.25rem;
}
}
}
`;
export default React.memo(Menu);
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IconName } from '@fortawesome/fontawesome-svg-core';
import type React from 'react';
import type { Routes } from '@pezkuwi/apps-routing/types';
export interface ItemRoute {
Modal?: React.ComponentType<any>;
href?: string;
icon: IconName;
name: string;
text: string;
useCounter?: () => number | string | null;
}
export interface Group {
name: string;
routes: Routes;
}
export type Groups = Record<string, Group>;
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ThemeDef } from '@pezkuwi/react-components/types';
import type { KeyringStore } from '@pezkuwi/ui-keyring/types';
import React, { Suspense, useEffect, useState } from 'react';
import { HashRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { ApiCtxRoot } from '@pezkuwi/react-api';
import { ApiStatsCtxRoot, BlockAuthorsCtxRoot, BlockEventsCtxRoot, KeyringCtxRoot, PayWithAssetCtxRoot, QueueCtxRoot, StakingAsyncApisCtxRoot, WindowSizeCtxRoot } from '@pezkuwi/react-hooks';
import { settings } from '@pezkuwi/ui-settings';
import BeforeApiInit from './overlays/BeforeInit.js';
import Apps from './Apps.js';
interface Props {
isElectron: boolean;
store?: KeyringStore;
}
function createTheme ({ uiTheme }: { uiTheme: string }): ThemeDef {
const theme = uiTheme === 'dark'
? 'dark'
: 'light';
document?.documentElement?.setAttribute('data-theme', theme);
return { theme };
}
function Root ({ isElectron, store }: Props): React.ReactElement<Props> {
const [theme, setTheme] = useState(() => createTheme(settings));
useEffect((): void => {
settings.on('change', (settings) => setTheme(createTheme(settings)));
}, []);
// The ordering here is critical. It defines the hierarchy of dependencies,
// i.e. Block* depends on Api. Certainly no cross-deps allowed
return (
<Suspense fallback='...'>
<ThemeProvider theme={theme}>
<QueueCtxRoot>
<ApiCtxRoot
apiUrl={settings.apiUrl}
beforeApiInit={<BeforeApiInit />}
isElectron={isElectron}
store={store}
>
<KeyringCtxRoot>
<ApiStatsCtxRoot>
<BlockAuthorsCtxRoot>
<BlockEventsCtxRoot>
<HashRouter>
<WindowSizeCtxRoot>
<PayWithAssetCtxRoot>
<StakingAsyncApisCtxRoot>
<Apps />
</StakingAsyncApisCtxRoot>
</PayWithAssetCtxRoot>
</WindowSizeCtxRoot>
</HashRouter>
</BlockEventsCtxRoot>
</BlockAuthorsCtxRoot>
</ApiStatsCtxRoot>
</KeyringCtxRoot>
</ApiCtxRoot>
</QueueCtxRoot>
</ThemeProvider>
</Suspense>
);
}
export default React.memo(Root);
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Unused atm, experiment as a replacement for NodeInfo on the SideBar
import React from 'react';
import { styled } from '@pezkuwi/react-components';
import { BestNumber, Chain, NodeName, NodeVersion } from '@pezkuwi/react-query';
interface Props {
className?: string;
}
function TopBar ({ className }: Props): React.ReactElement<Props> {
return (
<StyledDiv className={className}>
<div>
<NodeName />&nbsp;
<NodeVersion label='v' />
</div>
<div>
<Chain />&nbsp;
<BestNumber label='#' />
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
background: #f2f2f2;
font-size: var(--font-size-small);
line-height: 1rem;
overflow: hidden;
padding: 0.5rem 1rem;
position: fixed;
right: 0;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
top: 0;
div {
display: inline-block;
vertical-align: middle;
}
> div {
border-left: 1px solid #ccc;
padding: 0 0.5rem;
&:first-child {
border-width: 0;
}
}
`;
export default React.memo(TopBar);
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';
import { useApi, useCall } from '@pezkuwi/react-hooks';
function WarmUp (): React.ReactElement {
const { api, apiIdentity, isApiReady } = useApi();
const indexes = useCall<unknown>(isApiReady && api.derive.accounts?.indexes);
const registrars = useCall<unknown>(isApiReady && apiIdentity.query.identity?.registrars);
const issuance = useCall<unknown>(isApiReady && api.query.balances?.totalIssuance);
const [hasValues, setHasValues] = useState(false);
useEffect((): void => {
setHasValues(!!indexes || !!issuance || !!registrars);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={`apps--api-warm ${hasValues.toString()}`} />
);
}
export default React.memo(WarmUp);
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import { isFunction, isObject } from '@pezkuwi/util';
type ApiMapper = Record<string, Record<string, Record<string, unknown>>>;
function hasEndpoint (api: ApiPromise, endpoint: string, needsApiInstances: boolean): boolean {
const [area, _section, method] = endpoint.split('.');
const [section] = (needsApiInstances && api.registry.getModuleInstances(api.runtimeVersion.specName.toString(), _section)) || [_section];
const resolvedSection = (api as unknown as ApiMapper)[area][section]
? section
: _section;
try {
return area === 'consts'
? isObject((api as unknown as ApiMapper)[area][resolvedSection][method])
: isFunction((api as unknown as ApiMapper)[area][resolvedSection][method]);
} catch {
return false;
}
}
export function findMissingApis (api: ApiPromise, needsApi?: (string | string[])[], needsApiInstances = false, needsApiCheck?: (api: ApiPromise) => boolean): (string | string[])[] {
if (!needsApi) {
return [];
}
const missing = needsApi.filter((endpoint: string | string[]): boolean => {
const hasApi = Array.isArray(endpoint)
? endpoint.reduce((hasApi, endpoint) => hasApi || hasEndpoint(api, endpoint, needsApiInstances), false)
: hasEndpoint(api, endpoint, needsApiInstances);
return !hasApi;
});
if (!missing.length && needsApiCheck && !needsApiCheck(api)) {
return ['needsApiCheck'];
}
return missing;
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
// setup these right at front
import './initBufferHack.js';
import './initSettings.js';
import 'semantic-ui-css/semantic.min.css';
import '@pezkuwi/react-components/i18n';
import '@pezkuwi/api-augment/bizinikiwi';
import React from 'react';
import { createRoot } from 'react-dom/client';
import Root from './Root.js';
const rootId = 'root';
const rootElement = document.getElementById(rootId);
if (!rootElement) {
throw new Error(`Unable to find element with id '${rootId}'`);
}
createRoot(rootElement).render(
<Root isElectron={false} />
);
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { xglobal } from '@pezkuwi/x-global';
try {
// HACK: Construct a Buffer to ensure that it is actually available in our context
if (Buffer.from([1, 2, 3]).length === 3) {
xglobal.Buffer = Buffer;
}
} catch {
// ignore
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import queryString from 'query-string';
import store from 'store';
import { createWsEndpoints } from '@pezkuwi/apps-config';
import { extractIpfsDetails } from '@pezkuwi/react-hooks/useIpfs';
import { settings } from '@pezkuwi/ui-settings';
import { assert } from '@pezkuwi/util';
function networkOrUrl (apiUrl: string): void {
if (apiUrl.startsWith('light://')) {
console.log('Light endpoint=', apiUrl.replace('light://', ''));
} else {
console.log('WS endpoint=', apiUrl);
}
}
function getApiUrl (): string {
// we split here so that both these forms are allowed
// - http://localhost:3000/?rpc=wss://bizinikiwi-rpc.parity.io/#/explorer
// - http://localhost:3000/#/explorer?rpc=wss://bizinikiwi-rpc.parity.io
const urlOptions = queryString.parse(location.href.split('?')[1]);
// if specified, this takes priority
if (urlOptions.rpc) {
assert(!Array.isArray(urlOptions.rpc), 'Invalid WS endpoint specified');
// https://pezkuwi.js.org/apps/?rpc=ws://127.0.0.1:9944#/explorer;
const url = decodeURIComponent(urlOptions.rpc.split('#')[0]);
assert(url.startsWith('ws://') || url.startsWith('wss://') || url.startsWith('light://'), 'Non-prefixed ws/wss/light url');
return url;
}
const endpoints = createWsEndpoints(<T = string>(): T => ('' as unknown as T));
const { ipnsChain } = extractIpfsDetails();
// check against ipns domains (could be expanded to others)
if (ipnsChain) {
const option = endpoints.find(({ dnslink }) => dnslink === ipnsChain);
if (option) {
return option.value;
}
}
const stored = store.get('settings') as Record<string, unknown> || {};
const fallbackUrl = endpoints.find(({ value }) => !!value);
// via settings, or the default chain
return [stored.apiUrl, process.env.WS_URL].includes(settings.apiUrl)
? settings.apiUrl // keep as-is
: fallbackUrl
? fallbackUrl.value // grab the fallback
: 'ws://127.0.0.1:9944'; // nothing found, go local
}
// There cannot be a Bizinikiwi Connect light client default (expect only jrpc EndpointType)
const apiUrl = getApiUrl();
// set the default as retrieved here
settings.set({ apiUrl });
networkOrUrl(apiUrl);
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IconName } from '@fortawesome/fontawesome-svg-core';
import React, { useCallback, useEffect } from 'react';
import { Button, Icon, styled } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
interface Props {
children: React.ReactNode;
className?: string;
icon: IconName;
isBottom?: boolean;
isFull?: boolean;
type: 'error' | 'info';
isDev?: boolean;
}
function BaseOverlay ({ children, className = '', icon, isBottom = false, isDev, isFull = false, type }: Props): React.ReactElement<Props> | null {
const [isHidden, toggleHidden] = useToggle();
const checkLcValue = useCallback(() => {
if (isDev) {
localStorage.setItem('dev:notification', new Date().toString());
}
toggleHidden();
}, [isDev, toggleHidden]);
useEffect(() => {
const item = localStorage.getItem('dev:notification');
if (item) {
const date = new Date(item);
date.setMonth(date.getMonth() + 1);
// 1 month has passed - remove the localStorage
// and resume the notification
if (date.getTime() <= new Date().getTime()) {
localStorage.removeItem('dev:notification');
} else {
toggleHidden();
}
}
}, [toggleHidden]);
if (isHidden) {
return null;
}
return (
<StyledDiv className={`${className} ${type === 'error' ? 'isError' : 'isInfo'} ${isBottom ? 'isBottom' : 'isTop'} ${isFull ? 'isFull' : 'isPartial'}`}>
<div className='content'>
<Icon
className='contentIcon'
icon={icon}
size='2x'
/>
<div className='contentItem'>
{children}
</div>
<Button
className='closeIcon'
icon='times'
isBasic
isCircular
onClick={checkLcValue}
/>
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
background: var(--bg-menu);
border: 1px solid transparent;
border-radius: 0.25rem;
border-left-width: 0.25rem;
line-height: 1.5em;
padding: 0 1rem;
position: fixed;
right: 0.75rem;
top: 0.75rem;
z-index: 500;
&.isBottom {
position: static;
z-index: 0;
}
&.isFull {
left: 0.75rem;
}
&.isPartial {
max-width: 42rem;
width: 42rem;
.content {
max-width: 50rem;
}
}
&:before {
border-radius: 0.25rem;
bottom: 0;
content: ' ';
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: -1;
}
&.isError {
&:before {
background: rgba(255, 12, 12, 0.05);
}
border-color: rgba(255, 12, 12, 1);
}
&.isInfo {
&:before {
background: rgba(255, 196, 12, 0.05);
}
border-color: rgba(255, 196, 12, 1);
}
.content {
align-items: center;
display: flex;
margin: 0 auto;
padding: 1em 3rem 1rem 0.5rem;
position: relative;
.contentIcon {
flex: 0;
}
.contentItem {
flex: 1;
padding: 0 1rem;
> div+div {
margin-top: 0.5rem;
}
}
}
.closeIcon {
cursor: pointer;
position: absolute;
right: 0em;
top: 0.75rem;
}
`;
export default React.memo(BaseOverlay);
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { Spinner, styled } from '@pezkuwi/react-components';
import GlobalStyle from '@pezkuwi/react-components/styles';
import { useTheme } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import BaseOverlay from './Base.js';
const BeforeApiInit = () => {
const { themeClassName } = useTheme();
const { t } = useTranslation();
return (
<>
<GlobalStyle />
<StyledDiv className={` apps--Wrapper ${themeClassName}`}>
<BaseOverlay
icon='globe'
type='info'
>
<div>{t('Waiting to establish a connection with the remote endpoint.')}</div>
</BaseOverlay>
<div className='connecting'>
<Spinner label='Initializing connection' />
</div>
<div id={'portals'} />
</StyledDiv>
</>
);
};
const StyledDiv = styled.div`
background: var(--bg-page);
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 100vh;
.connecting {
padding-block: calc(3.5rem + 56px);
}
${[
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21, 22, 23, 24
].map((n) => `
.greyAnim-${n} {
animation: greyAnim${n} 2s;
}
@keyframes greyAnim${n} {
0% { background: #a6a6a6; }
50% { background: darkorange; }
100% { background: #a6a6a6; }
}
`).join('')}
`;
export default BeforeApiInit;
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '@pezkuwi/react-components';
import DotApps from './DotApps.js';
import LocalFork from './LocalFork.js';
interface Props {
className?: string;
}
function Bottom ({ className }: Props): React.ReactElement<Props> | null {
return (
<StyledDiv className={className}>
<LocalFork />
<DotApps />
</StyledDiv>
);
}
const StyledDiv = styled.div`
position: fixed;
bottom: 0.75rem;
right: 0.75rem;
left: 0.75rem;
top: auto;
padding: 1rem;
z-index: 500;
display: flex;
flex-direction: column;
row-gap: 0.75rem;;
div.isInfo:before {
content: none;
}
`;
export default React.memo(Bottom);
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { useApi } from '@pezkuwi/react-hooks';
import { settings } from '@pezkuwi/ui-settings';
import { useTranslation } from '../translate.js';
import BaseOverlay from './Base.js';
const wsUrl = settings.apiType.param;
const isWs = settings.apiType.type === 'json-rpc' && typeof wsUrl === 'string' && wsUrl.startsWith('ws://');
const isWsLocal = typeof wsUrl === 'string' && wsUrl.includes('127.0.0.1');
const isHttps = window.location.protocol.startsWith('https:');
interface Props {
className?: string;
}
function Connecting ({ className }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { apiError, isApiConnected, isApiReady, isWaitingInjected } = useApi();
if (apiError) {
return (
<BaseOverlay
className={className}
icon='globe'
type='error'
>
<div>{apiError}</div>
</BaseOverlay>
);
} else if (!isApiReady) {
return (
<BaseOverlay
className={className}
icon='globe'
type='info'
>
<div>
{
isApiConnected
? t('Waiting to complete metadata retrieval from remote endpoint.')
: t('Waiting to establish a connection with the remote endpoint.')
}
</div>
</BaseOverlay>
);
} else if (isWaitingInjected) {
return (
<BaseOverlay
className={className}
icon='puzzle-piece'
type='info'
>
<div>{t('Waiting for authorization from the extension. Please open the installed extension and approve or reject access.')}</div>
</BaseOverlay>
);
} else if (!isApiConnected) {
return (
<BaseOverlay
className={className}
icon='globe'
type='error'
>
<div>{t('You are not connected to a node. Ensure that your node is running and that the Websocket endpoint is reachable.')}</div>
{
isWs && !isWsLocal && isHttps
? <div>{t('You are connecting from a secure location to an insecure WebSocket ({{wsUrl}}). Due to browser mixed-content security policies this connection type is not allowed. Change the RPC service to a secure \'wss\' endpoint.', { replace: { wsUrl } })}</div>
: undefined
}
</BaseOverlay>
);
}
return null;
}
export default React.memo(Connecting);
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';
import { createWsEndpoints } from '@pezkuwi/apps-config/endpoints';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import BaseOverlay from './Base.js';
const isDev = window.location.host.startsWith('pezkuwi.js.org');
const dnsLinks = createWsEndpoints(() => '')
.map((e) => e.dnslink)
.reduce((all: string[], dnslink) => {
if (dnslink && !all.includes(dnslink)) {
all.push(dnslink);
}
return all;
}, []);
interface Props {
className?: string;
}
function DotApps ({ className }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { systemChain } = useApi();
const appsUrl = useMemo(
() => {
const lowerChain = systemChain?.toLowerCase();
return (lowerChain && dnsLinks.includes(lowerChain))
? `https://${lowerChain}.dotapps.io`
: 'https://dotapps.io';
},
[systemChain]
);
if (isDev) {
return (
<BaseOverlay
className={className}
icon='link'
isBottom
isDev
isFull
type='info'
>
<div>
{t('You are connected to the development instance of the UI. For a fully decentralized experience, you are encouraged to use the IPFS deployed version as the canonical URL: ')}
<a
href={appsUrl}
rel='noreferrer'
target='_blank'
>
{appsUrl.replace('https://', '')}
</a>
</div>
</BaseOverlay>
);
}
return null;
}
export default React.memo(DotApps);
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import BaseOverlay from './Base.js';
interface Props {
className?: string;
}
function LocalFork ({ className }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { isLocalFork } = useApi();
if (isLocalFork) {
return (
<BaseOverlay
className={className}
icon='link'
isBottom
isFull
type='info'
>
<div>
{t('Local fork powered by ')}
<a
href='https://github.com/AcalaNetwork/chopsticks'
rel='noreferrer'
target='_blank'
>
Chopsticks
</a>
.
</div>
</BaseOverlay>
);
}
return null;
}
export default React.memo(LocalFork);
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @pezkuwi/dev
// (packageInfo imports will be kept as-is, user-editable)
import { detectPackage } from '@pezkuwi/util';
import { packageInfo } from './packageInfo.js';
detectPackage(packageInfo, null, []);
+6
View File
@@ -0,0 +1,6 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @pezkuwi/dev
export const packageInfo = { name: '@pezkuwi/apps', path: 'auto', type: 'auto', version: '0.168.2-4-x' };
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
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('apps') as unknown as Translation;
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
declare module '*.gif' {
const content: unknown;
export default content;
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
declare module '*.md' {
const content: unknown;
export default content;
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
declare module '*.png' {
const content: unknown;
export default content;
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
declare module '*.svg' {
const content: unknown;
export default content;
}