mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-07-08 20:47:23 +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,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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user