feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ItemInfo } from './types.js';
import React from 'react';
import { AddressSmall, IconLink, Table } from '@pezkuwi/react-components';
interface Props {
className?: string;
collectionName: string;
value: ItemInfo;
}
function Item ({ className, collectionName, value: { account, id, ipfsData } }: Props): React.ReactElement<Props> {
const name = ipfsData?.name || collectionName;
let imageLink = '';
if (ipfsData?.image) {
imageLink = ipfsData.image.toLowerCase().startsWith('http') ? ipfsData.image : `https://ipfs.io/ipfs/${ipfsData.image}`;
}
return (
<tr className={className}>
<Table.Column.Id value={id} />
<td className='together all'>
{ name && imageLink
? (
<IconLink
href={imageLink}
icon='braille'
label={name}
rel='noopener'
target='_blank'
/>)
: name
}
</td>
<td className='address media--1000'><AddressSmall value={account} /></td>
</tr>
);
}
export default React.memo(Item);
@@ -0,0 +1,105 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { CollectionInfo, CollectionInfoComplete } from '../types.js';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Dropdown, styled, Table } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import Item from './Item.js';
import useAccountItems from './useAccountItems.js';
import useItemsInfos from './useItemsInfos.js';
interface Props {
className?: string;
infos?: CollectionInfo[];
}
function AccountItems ({ className, infos = [] }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const NO_NAME = ` - ${t('no name')} -`;
const [infoIndex, setInfoIndex] = useState(0);
const [info, setInfo] = useState<CollectionInfoComplete | null>(null);
const accountItems = useAccountItems();
const collectionItems = useMemo(
() => !info || !accountItems
? []
: accountItems.filter(({ collectionId }) => collectionId.eq(info.id))
, [info, accountItems]
);
const itemsInfos = useItemsInfos(collectionItems);
const collectionName = info?.ipfsData?.name || NO_NAME;
const completeInfos = useMemo(
() => !accountItems
? []
: infos
.filter((i): i is CollectionInfoComplete => !!(i.details && i.metadata) && accountItems.some(({ collectionId }) => i.id.eq(collectionId)))
.sort((a, b) => a.id.cmp(b.id)),
[infos, accountItems]
);
const collectionOptions = useMemo(
() => completeInfos.map(({ id, ipfsData }, index) => ({
text: `${ipfsData?.name || NO_NAME} (ID: ${formatNumber(id)})`,
value: index
})),
[completeInfos, NO_NAME]
);
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('items'), 'start', 2],
[t('owner'), 'address media--1000']
]);
useEffect((): void => {
setInfo(() =>
infoIndex >= 0 && infoIndex < completeInfos.length
? completeInfos[infoIndex]
: null
);
}, [completeInfos, infoIndex]);
return (
<StyledDiv className={className}>
<Table
empty={!info && accountItems && t('No accounts with items found for the collection')}
filter={collectionOptions.length
? (
<Dropdown
isFull
label={t('the collection to query for items')}
onChange={setInfoIndex}
options={collectionOptions}
value={infoIndex}
/>
)
: undefined
}
header={headerRef.current}
>
{itemsInfos?.map((info) => (
<Item
collectionName={collectionName}
key={info.key}
value={info}
/>
))}
</Table>
</StyledDiv>
);
}
const StyledDiv = styled.div`
table {
overflow: auto;
}
`;
export default React.memo(AccountItems);
@@ -0,0 +1,19 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { PalletUniquesItemMetadata } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
export interface ItemSupportedMetadata {
name: string | null;
image: string | null;
}
export interface ItemInfo {
account: AccountId,
id: BN;
key: string;
metadata: PalletUniquesItemMetadata | null;
ipfsData: ItemSupportedMetadata | null;
}
@@ -0,0 +1,49 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey, u32 } from '@pezkuwi/types';
import type { AccountId32 } from '@pezkuwi/types/interfaces';
import type { AccountItem } from '../types.js';
import { useEffect, useState } from 'react';
import { createNamedHook, useAccounts, useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
function transformResults (results: StorageKey<[AccountId32, u32, u32]>[][]): AccountItem[] {
return results
.filter((r) => !!r.length)
.map((r) => r.map((item) => {
const [accountId, collectionId, itemId] = item.args;
return {
accountId,
collectionId,
itemId
};
}))
.flat();
}
function useAccountItemsImpl (): AccountItem[] | undefined {
const mountedRef = useIsMountedRef();
const { api } = useApi();
const { allAccounts } = useAccounts();
const [state, setState] = useState<AccountItem[] | undefined>();
useEffect((): void => {
if (!allAccounts.length) {
return;
}
const promises = allAccounts.map((account) => api.query.uniques.account.keys(account));
Promise.all(promises)
.then((results) => mountedRef.current && setState(transformResults(results)))
.catch(console.error);
}, [allAccounts, api.query.uniques.account, mountedRef]);
return state;
}
export default createNamedHook('useAccountItems', useAccountItemsImpl);
@@ -0,0 +1,107 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { PalletUniquesItemMetadata } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { AccountItem } from '../types.js';
import type { ItemInfo, ItemSupportedMetadata } from './types.js';
import { useEffect, useMemo, useState } from 'react';
import { createNamedHook, useApi, useCall, useMetadataFetch } from '@pezkuwi/react-hooks';
import { normalizeMetadataLink } from '@pezkuwi/react-hooks/useMetadataFetch';
type FetchedMetadata = Map<string, ItemSupportedMetadata | null>;
const QUERY_OPTS = { withParams: true };
const METADATA_FETCH_OPTIONS = {
transform: (data: string | undefined): ItemSupportedMetadata | null => {
if (!data) {
return null;
}
try {
const result = JSON.parse(data) as Record<string, any>;
if (result && typeof result === 'object') {
return {
image: typeof result.image === 'string' ? normalizeMetadataLink(result.image) : null,
name: typeof result.name === 'string' ? result.name : null
};
}
} catch {}
return null;
}
};
function extractInfo ([, itemId]: [BN, BN], metadata: Option<PalletUniquesItemMetadata>, accountItems: AccountItem[]): ItemInfo {
const item = accountItems.find(({ itemId: _itemId }) => _itemId.eq(itemId));
if (!item) {
throw new Error('Unable to extract accountId');
}
return {
account: item.accountId,
id: itemId,
ipfsData: null,
key: itemId.toString(),
metadata: metadata.unwrapOr(null)
};
}
const addFetchedMetadata = (fetchedMetadata: FetchedMetadata) => (itemInfo: ItemInfo): ItemInfo => {
const metadataLink = normalizeMetadataLink(itemInfo.metadata?.data.toPrimitive() as string);
return {
...itemInfo,
ipfsData: (metadataLink && fetchedMetadata.has(metadataLink) && fetchedMetadata.get(metadataLink)) || null
};
};
function useItemsInfosImpl (accountItems: AccountItem[]): ItemInfo[] | undefined {
const { api } = useApi();
const [state, setState] = useState<ItemInfo[] | undefined>();
const ids = useMemo(
() => accountItems.map(({ collectionId, itemId }) => [collectionId, itemId]),
[accountItems]
);
const metadata = useCall<[[[BN, BN][]], Option<PalletUniquesItemMetadata>[]]>(api.query.uniques.instanceMetadataOf.multi, [ids], QUERY_OPTS);
const metadataLinks = useMemo((): string[] | undefined => {
if (metadata?.[1].length) {
return metadata[1].map((o) =>
o.isSome
? o.unwrap().data.toPrimitive() as string
: ''
);
}
return undefined;
}, [metadata]);
const fetchedMetadata = useMetadataFetch<ItemSupportedMetadata | null>(metadataLinks, METADATA_FETCH_OPTIONS);
useEffect((): void => {
if (fetchedMetadata && accountItems.length && metadata?.[0][0].length) {
const [collectionId] = metadata[0][0][0];
if (!collectionId.eq(ids[0][0])) {
return;
}
const itemsInfos = metadata[0][0].map((id, index) => extractInfo(id, metadata[1][index], accountItems));
setState(itemsInfos.map(addFetchedMetadata(fetchedMetadata)));
}
}, [accountItems, ids, fetchedMetadata, metadata]);
return state;
}
export default createNamedHook('useItemsInfos', useItemsInfosImpl);
@@ -0,0 +1,51 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { CollectionInfo } from '../types.js';
import React from 'react';
import { AddressSmall, IconLink, Table } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
value: CollectionInfo;
}
function Collection ({ className, value: { details, id, ipfsData } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const name = ipfsData?.name || '';
let imageLink = '';
if (ipfsData?.image) {
imageLink = ipfsData.image.toLowerCase().startsWith('http') ? ipfsData.image : `https://ipfs.io/ipfs/${ipfsData.image}`;
}
return (
<tr className={className}>
<Table.Column.Id value={id} />
<td className='together all'>
{ name && imageLink
? (
<IconLink
href={imageLink}
icon='braille'
label={name}
rel='noopener'
target='_blank'
/>)
: name
}
</td>
<td className='address media--1000'>{details && <AddressSmall value={details.owner} />}</td>
<td className='string'>{details && details.isFrozen.isTrue && t('Frozen')}</td>
<td className='number'>{details && formatNumber(details.items || (details as unknown as { instances: BN }).instances)}</td>
</tr>
);
}
export default React.memo(Collection);
@@ -0,0 +1,44 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { CollectionInfo } from '../types.js';
import React, { useRef } from 'react';
import { Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Collection from './Collection.js';
interface Props {
className?: string;
infos?: CollectionInfo[];
}
function Collections ({ className, infos }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('collections'), 'start', 2],
[t('owner'), 'address media--1000'],
[t('status')],
[t('items')]
]);
return (
<Table
className={className}
empty={infos && t('No collections found')}
header={headerRef.current}
>
{infos?.map((info) => (
<Collection
key={info.key}
value={info}
/>
))}
</Table>
);
}
export default React.memo(Collections);
@@ -0,0 +1,28 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
numCollections?: number;
}
function Summary ({ className, numCollections }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<CardSummary label={t('collections')}>
{formatNumber(numCollections)}
</CardSummary>
</SummaryBox>
);
}
export default React.memo(Summary);
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import type { CollectionInfo } from '../types.js';
import React from 'react';
import Collections from './Collections.js';
import Summary from './Summary.js';
interface Props {
className?: string;
ids?: BN[];
infos?: CollectionInfo[];
}
function Overview ({ className, ids, infos }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<Summary numCollections={ids?.length} />
<Collections infos={infos} />
</div>
);
}
export default React.memo(Overview);
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import '@pezkuwi/api-augment/bizinikiwi';
import React, { useMemo, useRef } from 'react';
import { Route, Routes } from 'react-router';
import { Tabs } from '@pezkuwi/react-components';
import { useAccounts } from '@pezkuwi/react-hooks';
import AccountItems from './AccountItems/index.js';
import Overview from './Overview/index.js';
import { useTranslation } from './translate.js';
import useCollectionIds from './useCollectionIds.js';
import useCollectionInfos from './useCollectionInfos.js';
interface Props {
basePath: string;
className?: string;
}
function NftApp ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { hasAccounts } = useAccounts();
const ids = useCollectionIds();
const infos = useCollectionInfos(ids);
const tabsRef = useRef([
{
isRoot: true,
name: 'overview',
text: t('Overview')
},
{
name: 'my-nfts',
text: t('My NFTs')
}
]);
const hidden = useMemo(
() => (hasAccounts && infos && infos.some(({ details, metadata }) => !!(details && metadata)))
? []
: ['my-nfts'],
[hasAccounts, infos]
);
return (
<main className={className}>
<Tabs
basePath={basePath}
hidden={hidden}
items={tabsRef.current}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<AccountItems infos={infos} />
}
path='my-nfts'
/>
<Route
element={
<Overview
ids={ids}
infos={infos}
/>
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(NftApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-nfts');
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { PalletUniquesCollectionDetails, PalletUniquesCollectionMetadata } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
export interface CollectionSupportedMetadata {
name: string | null;
image: string | null;
}
export interface CollectionInfo {
details: PalletUniquesCollectionDetails | null;
id: BN;
isAdminMe: boolean;
isIssuerMe: boolean;
isFreezerMe: boolean;
isOwnerMe: boolean;
key: string;
metadata: PalletUniquesCollectionMetadata | null;
ipfsData: CollectionSupportedMetadata | null;
}
export interface CollectionInfoComplete extends CollectionInfo {
details: PalletUniquesCollectionDetails;
metadata: PalletUniquesCollectionMetadata;
}
export interface AccountItem {
accountId: AccountId;
collectionId: BN;
itemId: BN;
}
@@ -0,0 +1,43 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
import type { StorageKey, u32 } from '@pezkuwi/types';
import type { EventRecord } from '@pezkuwi/types/interfaces';
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
const OPT_KEYS = {
transform: (keys: StorageKey<[u32]>[]): u32[] =>
keys
.map(({ args: [id] }) => id)
.sort((a, b) => a.cmp(b))
};
function filter (records: EventRecord[]): Changes<u32> {
const added: u32[] = [];
const removed: u32[] = [];
records.forEach(({ event: { data: [id], method } }): void => {
if (method === 'Created' || method === 'ForceCreated') {
added.push(id as u32);
} else {
removed.push(id as u32);
}
});
return { added, removed };
}
function useCollectionIdsImpl (): u32[] | undefined {
const { api } = useApi();
const startValue = useMapKeys(api.query.uniques.class, [], OPT_KEYS);
return useEventChanges([
api.events.uniques.Created,
api.events.uniques.Destroyed,
api.events.uniques.ForceCreated
], filter, startValue);
}
export default createNamedHook('useCollectionIds', useCollectionIdsImpl);
@@ -0,0 +1,116 @@
// Copyright 2017-2025 @pezkuwi/app-nfts authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { PalletUniquesCollectionDetails, PalletUniquesCollectionMetadata } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { CollectionInfo, CollectionSupportedMetadata } from './types.js';
import { useEffect, useMemo, useState } from 'react';
import { createNamedHook, useAccounts, useApi, useCall, useMetadataFetch } from '@pezkuwi/react-hooks';
import { normalizeMetadataLink } from '@pezkuwi/react-hooks/useMetadataFetch';
type FetchedMetadata = Map<string, CollectionSupportedMetadata | null>;
const EMPTY_FLAGS = {
isAdminMe: false,
isFreezerMe: false,
isIssuerMe: false,
isOwnerMe: false
};
const QUERY_OPTS = { withParams: true };
const METADATA_FETCH_OPTIONS = {
transform: (data: string | undefined): CollectionSupportedMetadata | null => {
if (!data) {
return null;
}
try {
const result = JSON.parse(data) as Record<string, any>;
if (result && typeof result === 'object') {
return {
image: typeof result.image === 'string' ? normalizeMetadataLink(result.image) : null,
name: typeof result.name === 'string' ? result.name : null
};
}
} catch {}
return null;
}
};
function isAccount (allAccounts: string[], accountId: AccountId): boolean {
const address = accountId.toString();
return allAccounts.some((a) => a === address);
}
function extractInfo (allAccounts: string[], id: BN, optDetails: Option<PalletUniquesCollectionDetails>, metadata: Option<PalletUniquesCollectionMetadata>): CollectionInfo {
const details = optDetails.unwrapOr(null);
return {
...(details
? {
isAdminMe: isAccount(allAccounts, details.admin),
isFreezerMe: isAccount(allAccounts, details.freezer),
isIssuerMe: isAccount(allAccounts, details.issuer),
isOwnerMe: isAccount(allAccounts, details.owner)
}
: EMPTY_FLAGS
),
details,
id,
ipfsData: null,
key: id.toString(),
metadata: metadata.unwrapOr(null)
};
}
const addFetchedMetadata = (fetchedMetadata: FetchedMetadata) => (collectionInfo: CollectionInfo): CollectionInfo => {
const metadataLink = normalizeMetadataLink(collectionInfo.metadata?.data.toPrimitive() as string);
return {
...collectionInfo,
ipfsData: (metadataLink && fetchedMetadata.has(metadataLink) && fetchedMetadata.get(metadataLink)) || null
};
};
function useCollectionInfosImpl (ids?: BN[]): CollectionInfo[] | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const metadata = useCall<[[BN[]], Option<PalletUniquesCollectionMetadata>[]]>(api.query.uniques.classMetadataOf.multi, [ids], QUERY_OPTS);
const details = useCall<[[BN[]], Option<PalletUniquesCollectionDetails>[]]>(api.query.uniques.class.multi, [ids], QUERY_OPTS);
const [state, setState] = useState<CollectionInfo[] | undefined>();
const metadataLinks = useMemo(
() => metadata?.[1].length
? metadata[1].map((o) =>
o.isSome
? o.unwrap().data.toPrimitive() as string
: ''
)
: [],
[metadata]
);
const fetchedMetadata = useMetadataFetch<CollectionSupportedMetadata | null>(metadataLinks, METADATA_FETCH_OPTIONS);
useEffect((): void => {
if (fetchedMetadata && details && metadata && (details[0][0].length === metadata[0][0].length)) {
const collectionInfos = details[0][0].map((id, index) =>
extractInfo(allAccounts, id, details[1][index], metadata[1][index])
);
setState(collectionInfos.map(addFetchedMetadata(fetchedMetadata)));
}
}, [allAccounts, details, ids, fetchedMetadata, metadata]);
return state;
}
export default createNamedHook('useCollectionInfos', useCollectionInfosImpl);