Skip to content

Commit 3172428

Browse files
VmMadevavirsedamsarcev
authored
feat(dapp): add right side panel UI (#434)
* feat: improve layout and add nft name page * fix: cleanup debris code * fix: move cleanup * feat: add logic to button segment * feat: improve filters ux * fix: layout * refactor: remove code duplications * chore: drop useMemo * chore: apply simplification * refactor: improve displayed title in cards * feat: add right side panel UI * feat: simplify layour * fix: disable subnames button when necessary * feat: improve layour * feat: add subname panel * feat: add right side panel tile * refactor: separation of components * fix: colors in name card indicators * refactor: move availability check component to correct place * fix: build * fix: break workds * fix: improve header * fix: apply suggested changes * chore: rename function * chore: fix build errors * fix: remove log * chore: remove another log * feat: upgrade apps-ui-kit * feat: menu button and root navigation * fix: bring back missing useMemo * feat: add comment * feat: add linked address * fix(dapp): Delete NFT menu option hidden logic * fix --------- Co-authored-by: evavirseda <evirseda@boxfish.studio> Co-authored-by: Mario Sarcevic <mario.sarcevic@iota.org>
1 parent 1cc5161 commit 3172428

34 files changed

+866
-386
lines changed

dapp/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
"lint:fix": "pnpm run eslint:fix && pnpm run prettier:fix"
1515
},
1616
"dependencies": {
17-
"@iota/apps-ui-icons": "^0.3.1",
18-
"@iota/apps-ui-kit": "0.0.0-experimental-names-20250708084918",
17+
"@iota/apps-ui-icons": "0.0.0-experimental-names-20250716112630",
18+
"@iota/apps-ui-kit": "0.0.0-experimental-names-20250716112630",
1919
"@iota/dapp-kit": "0.0.0-experimental-20250620081851",
2020
"@iota/graphql-transport": "0.0.0-experimental-20250620081851",
2121
"@iota/iota-names-sdk": "workspace:^",
@@ -29,8 +29,8 @@
2929
"next": "14.2.28",
3030
"react": "^18.3.1",
3131
"react-dom": "^18.3.1",
32-
"zustand": "^5.0.6",
33-
"react-hot-toast": "^2.4.1"
32+
"react-hot-toast": "^2.4.1",
33+
"zustand": "^5.0.6"
3434
},
3535
"devDependencies": {
3636
"@tanstack/eslint-plugin-query": "^5.35.6",
748 Bytes
Loading
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2025 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
'use client';
5+
6+
import { Info, StarHex, Warning } from '@iota/apps-ui-icons';
7+
import { ButtonUnstyled, truncate } from '@iota/apps-ui-kit';
8+
import { useCurrentAccount } from '@iota/dapp-kit';
9+
import clsx from 'clsx';
10+
import { Fragment } from 'react';
11+
12+
import { MenuButton } from '@/components/buttons/MenuButton';
13+
import { ContextMenuDropdown } from '@/components/ContextMenu';
14+
import { NameManageDialogs } from '@/components/dialogs/NameManageDialogs';
15+
import { useNameRecord } from '@/hooks';
16+
import { useGetDefaultName } from '@/hooks/useGetDefaultName';
17+
import { useNameContextMenu } from '@/hooks/useNameContextMenu';
18+
import { useNameManageDialog } from '@/hooks/useNameMenuOptions';
19+
import type { RegistrationNft } from '@/lib/interfaces/registration.interfaces';
20+
import { formatExpirationDate } from '@/lib/utils/format/formatExpirationDate';
21+
import { formatNameLabel } from '@/lib/utils/format/formatNames';
22+
import { getNameMenuOptions } from '@/lib/utils/getNameMenuOptions';
23+
import { isNameRecordCloseToExpiration, isNameRecordExpired } from '@/lib/utils/names';
24+
25+
import { PanelTileType } from './enums';
26+
import { PanelTile } from './PanelTile';
27+
28+
interface NamePanelTileProps {
29+
registration: RegistrationNft;
30+
hasSubnames: boolean;
31+
onClick: () => void;
32+
onRenewClick?: () => void;
33+
}
34+
export function NamePanelTile({
35+
registration,
36+
hasSubnames,
37+
onClick,
38+
onRenewClick,
39+
}: NamePanelTileProps) {
40+
const { isVisible, position, toggleMenu, dropdownRef, triggerRef } = useNameContextMenu();
41+
const { openDialogId, openDialog, closeDialog } = useNameManageDialog();
42+
43+
const account = useCurrentAccount();
44+
const { data: defaultName } = useGetDefaultName(account?.address ?? '');
45+
const { data: nameRecord } = useNameRecord(registration.name);
46+
47+
const linkedAddress =
48+
nameRecord?.type === 'unavailable' ? nameRecord?.nameRecord.targetAddress : undefined;
49+
50+
const isDefaultName = defaultName === registration.name;
51+
const isCloseToExpiration = isNameRecordCloseToExpiration(registration);
52+
const isExpired = isNameRecordExpired(registration);
53+
54+
const menuOptions = getNameMenuOptions(registration, hasSubnames, openDialog);
55+
56+
const panelType = (() => {
57+
if (isCloseToExpiration) return PanelTileType.Warning;
58+
if (isExpired) return PanelTileType.Destructive;
59+
return PanelTileType.Default;
60+
})();
61+
62+
const expirationDate = formatExpirationDate(new Date(registration.expirationTimestampMs));
63+
const panelTitle = formatNameLabel(registration.name, { truncateLongSubnames: true });
64+
65+
return (
66+
<>
67+
<PanelTile
68+
type={panelType}
69+
icon={isDefaultName ? <StarHex className="w-4 h-4 text-names-primary-80" /> : null}
70+
title={panelTitle}
71+
subtitle={linkedAddress ? truncate(linkedAddress, 4, 4) : undefined}
72+
onClick={onClick}
73+
menuButton={<MenuButton variant="ghost" onClick={toggleMenu} ref={triggerRef} />}
74+
footer={
75+
isCloseToExpiration || isExpired ? (
76+
<PanelFooter
77+
isCloseToExpiration={isCloseToExpiration}
78+
isExpired={isExpired}
79+
expirationDate={expirationDate}
80+
onRenewClick={onRenewClick}
81+
/>
82+
) : null
83+
}
84+
/>
85+
86+
<ContextMenuDropdown
87+
visible={isVisible}
88+
position={position}
89+
options={menuOptions}
90+
dropdownRef={dropdownRef}
91+
/>
92+
93+
<NameManageDialogs
94+
nft={registration}
95+
openDialogId={openDialogId}
96+
onClose={closeDialog}
97+
/>
98+
</>
99+
);
100+
}
101+
102+
interface PanelFooterProps {
103+
isCloseToExpiration: boolean;
104+
isExpired: boolean;
105+
expirationDate: string;
106+
onRenewClick?: () => void;
107+
}
108+
function PanelFooter({
109+
isCloseToExpiration,
110+
isExpired,
111+
expirationDate,
112+
onRenewClick,
113+
}: PanelFooterProps) {
114+
const Icon = isCloseToExpiration ? Info : isExpired ? Warning : Fragment;
115+
return (
116+
<div className="flex flex-row w-full gap-x-xs text-body-md justify-between px-sm py-xs">
117+
<p
118+
className={clsx(
119+
'flex flex-row items-center gap-x-xs [&>svg]:w-4 [&>svg]:h-4',
120+
isCloseToExpiration && 'text-names-warning-90',
121+
isExpired && 'text-names-error-90',
122+
)}
123+
>
124+
<Icon />
125+
{isCloseToExpiration ? 'Expires' : 'Expired'} {'\u2022'} {expirationDate}
126+
</p>
127+
128+
<ButtonUnstyled
129+
className="px-xs leading-5 rounded-md hover:opacity-80 transition-opacity"
130+
onClick={onRenewClick}
131+
>
132+
Renew &rarr;
133+
</ButtonUnstyled>
134+
</div>
135+
);
136+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2025 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import {
5+
ButtonUnstyled,
6+
Card,
7+
CardImage,
8+
CardType,
9+
Divider,
10+
ImageShape,
11+
ImageType,
12+
} from '@iota/apps-ui-kit';
13+
import clsx from 'clsx';
14+
15+
import { AvatarDisplay } from '@/components/name-record/AvatarDisplay';
16+
17+
import { PanelTileType } from './enums';
18+
19+
const CARD_BG_COLOR: Record<PanelTileType, string> = {
20+
[PanelTileType.Default]: 'bg-transparent',
21+
[PanelTileType.Warning]: 'bg-[#342109]',
22+
[PanelTileType.Destructive]: 'bg-[#47000C]',
23+
};
24+
25+
interface PanelTileProps {
26+
title: string;
27+
type?: PanelTileType;
28+
subtitle?: string;
29+
footer?: React.ReactNode;
30+
icon?: React.ReactNode;
31+
onClick?: React.ComponentProps<typeof Card>['onClick'];
32+
menuButton?: React.ReactNode;
33+
}
34+
35+
export function PanelTile({
36+
title,
37+
type = PanelTileType.Default,
38+
subtitle,
39+
footer,
40+
icon,
41+
onClick,
42+
menuButton,
43+
}: PanelTileProps) {
44+
return (
45+
<div className={clsx('flex flex-col rounded-xl overflow-hidden', CARD_BG_COLOR[type])}>
46+
<div className="relative">
47+
<Card type={CardType.Default}>
48+
<div className="flex flex-row items-center w-full gap-sm max-w-full">
49+
<ButtonUnstyled
50+
className="state-layer flex flex-row w-full items-center max-w-full min-w-0 gap-sm"
51+
onClick={onClick}
52+
>
53+
<CardImage
54+
type={ImageType.BgTransparent}
55+
shape={ImageShape.SquareRounded}
56+
>
57+
<AvatarDisplay
58+
name={title}
59+
size="full"
60+
fallbackUrl="/subname-card-fallback.png"
61+
/>
62+
</CardImage>
63+
64+
<div className="flex flex-col gap-xxs w-full min-w-0">
65+
<div className="flex flex-row items-center max-w-full">
66+
<div className="text-title-md font-medium leading-[120%] tracking-[-0.15px] text-names-neutral-92 break-words text-left min-w-0">
67+
{title}
68+
</div>
69+
70+
{icon && <span className="ml-2">{icon}</span>}
71+
</div>
72+
73+
{subtitle && (
74+
<div className="text-left text-title-sm font-normal text-names-neutral-60 break-words">
75+
{subtitle}
76+
</div>
77+
)}
78+
</div>
79+
</ButtonUnstyled>
80+
81+
{menuButton}
82+
</div>
83+
</Card>
84+
</div>
85+
86+
{footer && <Divider />}
87+
{footer}
88+
</div>
89+
);
90+
}
Lines changed: 70 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,102 @@
11
// Copyright (c) 2025 IOTA Stiftung
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { Card, CardBody, CardType, Header, Panel } from '@iota/apps-ui-kit';
5-
import { useEffect, useState } from 'react';
4+
import { Add } from '@iota/apps-ui-icons';
5+
import { Button, ButtonType, Header, Panel } from '@iota/apps-ui-kit';
6+
import { useEffect, useMemo, useState } from 'react';
67

8+
import { CreateSubnameDialog } from '@/components/dialogs/CreateSubnameDialog';
9+
import { useRegistrationNfts } from '@/hooks';
710
import { useNameTree } from '@/hooks/useNameTree';
811
import { RegistrationNft } from '@/lib/interfaces';
9-
import { NameTree } from '@/lib/utils/buildNameTree';
12+
import { traverseNameTree } from '@/lib/utils/buildNameTree';
1013
import { formatNameLabel, normalizeName } from '@/lib/utils/format/formatNames';
1114

15+
import { NamePanelTile } from './NamePanelTile';
16+
1217
interface SubnamesPanelProps {
1318
selectedName: RegistrationNft;
1419
onClose: () => void;
1520
}
1621

1722
export function SubnamesPanel({ selectedName, onClose }: SubnamesPanelProps) {
18-
const nftName = normalizeName(selectedName.name);
19-
const rootTree = useNameTree(nftName);
23+
const { data: subnames } = useRegistrationNfts('subname');
2024

21-
const [navigationStack, setNavigationStack] = useState<NameTree[]>([]);
25+
const rootName = normalizeName(selectedName.name);
26+
const initialNameTree = useNameTree(rootName);
2227

23-
useEffect(() => {
24-
if (rootTree) {
25-
setNavigationStack([rootTree]);
26-
}
27-
}, [rootTree]);
28+
const [namePaths, setNamePaths] = useState<string[]>([]);
29+
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
2830

29-
const currentNode = navigationStack[navigationStack.length - 1];
31+
const isAtRoot = namePaths.length === 1;
3032

31-
const goBack = () => {
32-
setNavigationStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
33-
};
33+
const currentNode = useMemo(
34+
() => traverseNameTree(initialNameTree, namePaths),
35+
[initialNameTree, namePaths],
36+
);
3437

35-
const goDeeper = (childName: string) => {
36-
const child = currentNode?.subnames.find((n) => n.name === childName);
37-
if (child) {
38-
setNavigationStack((prev) => [...prev, child]);
38+
useEffect(() => {
39+
if (initialNameTree) {
40+
const isNewRoot = namePaths[0] !== initialNameTree.name;
41+
// Reset name paths only if the initial name has changed
42+
if (!namePaths.length || isNewRoot) {
43+
setNamePaths([initialNameTree.name]);
44+
}
3945
}
40-
};
46+
}, [initialNameTree]);
4147

4248
if (!currentNode) return null;
4349

44-
const headerTitle = `Subnames for ${formatNameLabel(navigationStack.length > 1 ? currentNode.name : selectedName.name)}`;
45-
const isOnRoot = navigationStack.length === 1;
50+
const headerTitle = `Subnames for ${formatNameLabel(
51+
isAtRoot ? selectedName.name : currentNode.name,
52+
{ onlyFirstSubname: true, truncateLongSubnames: true },
53+
)}`;
54+
55+
const subnamesRegistrations = currentNode.subnames
56+
.map((sub) => subnames?.find((s) => s.name === sub.name))
57+
.filter((sub) => !!sub);
58+
59+
function goDeeper(name: string) {
60+
setNamePaths((prev) => [...prev, name]);
61+
}
62+
63+
function goBack() {
64+
setNamePaths((prev) => prev.slice(0, -1));
65+
}
4666

4767
return (
48-
<div className="max-w-[360px] w-full">
68+
<>
4969
<Panel>
50-
<div className="w-full flex flex-row items-center justify-between">
51-
<Header
52-
onBack={isOnRoot ? undefined : goBack}
53-
title={headerTitle}
54-
onClose={onClose}
55-
/>
56-
</div>
70+
<Header
71+
onBack={isAtRoot ? undefined : goBack}
72+
title={headerTitle}
73+
onClose={onClose}
74+
/>
5775

58-
<div className="flex flex-col gap-lg px-md">
59-
{currentNode.subnames.map((nft) => (
60-
<Card
61-
key={nft.name}
62-
type={CardType.Filled}
63-
onClick={() => goDeeper(nft.name)}
64-
>
65-
<CardBody title={nft.name} />
66-
</Card>
76+
<div className="flex flex-col gap-xxs px-sm w-full">
77+
{subnamesRegistrations.map((sub) => (
78+
<NamePanelTile
79+
key={sub.name}
80+
registration={sub}
81+
onClick={() => goDeeper(sub.name)}
82+
hasSubnames={currentNode.subnames.length > 0}
83+
/>
6784
))}
6885
</div>
86+
87+
<div className="flex flex-col items-center justify-center py-sm">
88+
<Button
89+
text="New Subname"
90+
type={ButtonType.Outlined}
91+
onClick={() => setIsAddDialogOpen(true)}
92+
icon={<Add />}
93+
/>
94+
</div>
6995
</Panel>
70-
</div>
96+
97+
{isAddDialogOpen && (
98+
<CreateSubnameDialog name={currentNode.name} setOpen={setIsAddDialogOpen} />
99+
)}
100+
</>
71101
);
72102
}

0 commit comments

Comments
 (0)