From ffcac1e2a0907d44f360fded344dc45724e94d50 Mon Sep 17 00:00:00 2001 From: mtyszczak Date: Mon, 1 Dec 2025 12:16:32 +0100 Subject: [PATCH 01/29] Add single user HTM view --- src/components/htm/HTMTokenBalancesTable.vue | 283 ++++++++++++++++++ src/components/htm/HTMUserCard.vue | 211 ++++++++++++++ src/components/htm/HTMUserHeader.vue | 144 ++++++++++ src/components/htm/HTMUserKeys.vue | 38 +++ src/components/htm/HTMUserMetadata.vue | 31 ++ src/components/navigation/AppSidebar.vue | 7 +- src/components/ui/badge/Badge.vue | 17 ++ src/components/ui/badge/index.ts | 26 ++ src/pages/tokens/my-balance.vue | 250 +--------------- src/pages/tokens/users/[id].vue | 200 +++++++++++++ src/pages/tokens/users/index.vue | 285 +++++++++++++++++++ src/stores/tokens.store.ts | 127 ++++++++- 12 files changed, 1371 insertions(+), 248 deletions(-) create mode 100644 src/components/htm/HTMTokenBalancesTable.vue create mode 100644 src/components/htm/HTMUserCard.vue create mode 100644 src/components/htm/HTMUserHeader.vue create mode 100644 src/components/htm/HTMUserKeys.vue create mode 100644 src/components/htm/HTMUserMetadata.vue create mode 100644 src/components/ui/badge/Badge.vue create mode 100644 src/components/ui/badge/index.ts create mode 100644 src/pages/tokens/users/[id].vue create mode 100644 src/pages/tokens/users/index.vue diff --git a/src/components/htm/HTMTokenBalancesTable.vue b/src/components/htm/HTMTokenBalancesTable.vue new file mode 100644 index 0000000..3a5d03f --- /dev/null +++ b/src/components/htm/HTMTokenBalancesTable.vue @@ -0,0 +1,283 @@ + + + diff --git a/src/components/htm/HTMUserCard.vue b/src/components/htm/HTMUserCard.vue new file mode 100644 index 0000000..37f6e38 --- /dev/null +++ b/src/components/htm/HTMUserCard.vue @@ -0,0 +1,211 @@ + + + diff --git a/src/components/htm/HTMUserHeader.vue b/src/components/htm/HTMUserHeader.vue new file mode 100644 index 0000000..48cd706 --- /dev/null +++ b/src/components/htm/HTMUserHeader.vue @@ -0,0 +1,144 @@ + + + diff --git a/src/components/htm/HTMUserKeys.vue b/src/components/htm/HTMUserKeys.vue new file mode 100644 index 0000000..ee55b46 --- /dev/null +++ b/src/components/htm/HTMUserKeys.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/htm/HTMUserMetadata.vue b/src/components/htm/HTMUserMetadata.vue new file mode 100644 index 0000000..dcd3d26 --- /dev/null +++ b/src/components/htm/HTMUserMetadata.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/navigation/AppSidebar.vue b/src/components/navigation/AppSidebar.vue index 3a5ef08..86e1ad3 100644 --- a/src/components/navigation/AppSidebar.vue +++ b/src/components/navigation/AppSidebar.vue @@ -1,5 +1,5 @@ + + diff --git a/src/components/ui/badge/index.ts b/src/components/ui/badge/index.ts new file mode 100644 index 0000000..5ab6ef6 --- /dev/null +++ b/src/components/ui/badge/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex gap-1 items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type BadgeVariants = VariantProps diff --git a/src/pages/tokens/my-balance.vue b/src/pages/tokens/my-balance.vue index 18e7667..bda6797 100644 --- a/src/pages/tokens/my-balance.vue +++ b/src/pages/tokens/my-balance.vue @@ -15,6 +15,7 @@ import { toast } from 'vue-sonner'; import CollapsibleMemoInput from '@/components/CollapsibleMemoInput.vue'; import { TokenAmountInput } from '@/components/htm/amount'; +import HTMTokenBalancesTable from '@/components/htm/HTMTokenBalancesTable.vue'; import HTMView from '@/components/htm/HTMView.vue'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -22,7 +23,6 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; -import TextTooltip from '@/components/ui/texttooltip/TextTooltip.vue'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import AddToGoogleWallet from '@/components/wallet/AddToGoogleWallet.vue'; import { useTokensStore, type CTokenBalanceDisplay, type CTokenPairBalanceDefinition, type TokenStoreApiResponse } from '@/stores/tokens.store'; @@ -428,247 +428,13 @@ onMounted(() => { v-else-if="fetchedBalances && fetchedBalances.items.length > 0" class="space-y-4" > - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- Asset - - Balances - - Total - - Actions -
-
-
- - - {{ balance.liquid.symbol?.charAt(0).toUpperCase() || '' }} - -
-
-
- - {{ balance.liquid.name }} - - - - - - STAKED - -
-
- {{ balance.liquid.symbol }} -
-
- {{ balance.liquid.assetNum }} -
-
-
-
-
-
- - Liquid - - - {{ balance.liquid.displayBalance }} - -
- -
- - Staked - - - {{ balance.vesting.displayBalance }} - -
- - - -
- No balance yet -
-
-
-
- {{ balance.displayTotal }} -
-
-
- - - - - - - - - - - -
-
-
-
-
+
+import { onMounted, ref, computed } from 'vue'; +import { useRoute } from 'vue-router'; + +import HTMTokenBalancesTable from '@/components/htm/HTMTokenBalancesTable.vue'; +import HTMUserHeader from '@/components/htm/HTMUserHeader.vue'; +import HTMUserKeys from '@/components/htm/HTMUserKeys.vue'; +import HTMUserMetadata from '@/components/htm/HTMUserMetadata.vue'; +import HTMView from '@/components/htm/HTMView.vue'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useTokensStore, type CTokenUser, type CTokenPairBalanceDefinition } from '@/stores/tokens.store'; +import { toastError } from '@/utils/parse-error'; + +// Router +const route = useRoute(); + +// Stores +const tokensStore = useTokensStore(); + +// State +const user = ref(null); +const userBalances = ref([]); +const isLoading = ref(true); +const isLoadingBalances = ref(false); + +// Get operational key from route parameter +const operationalKey = computed(() => decodeURIComponent(route.params.id as string)); + +// Load user details +const loadUserDetails = async () => { + try { + user.value = await tokensStore.getUser(operationalKey.value); + } catch (error) { + toastError('Failed to load user details', error); + } +}; + +// Load user token balances +const loadUserBalances = async () => { + if (!user.value) return; + + try { + isLoadingBalances.value = true; + const balances = await tokensStore.getBalance(user.value.operationalKey, undefined, 1); + userBalances.value = balances.items.filter(b => b.total > 0n); + } catch { + // Silently fail - user might not have any balances + userBalances.value = []; + } finally { + isLoadingBalances.value = false; + } +}; + +// Initialize +onMounted(async () => { + isLoading.value = true; + await loadUserDetails(); + if (user.value) + await loadUserBalances(); + + isLoading.value = false; +}); + + + diff --git a/src/pages/tokens/users/index.vue b/src/pages/tokens/users/index.vue new file mode 100644 index 0000000..441e0bb --- /dev/null +++ b/src/pages/tokens/users/index.vue @@ -0,0 +1,285 @@ + + + diff --git a/src/stores/tokens.store.ts b/src/stores/tokens.store.ts index e401e45..d0a0408 100644 --- a/src/stores/tokens.store.ts +++ b/src/stores/tokens.store.ts @@ -2,10 +2,9 @@ import type { IWaxBaseInterface } from '@hiveio/wax'; import { defineStore } from 'pinia'; import { shallowRef } from 'vue'; -import { transformUserName } from '@/stores/user.store'; import { getWax } from '@/stores/wax.store'; import { isVesting } from '@/utils/nai-tokens'; -import { CtokensAppMetadataType, type CtokensAppAssetType, type CtokensAppBalance, type CtokensAppToken } from '@/utils/wallet/ctokens/api'; +import { CtokensAppMetadataType, type CtokensAppAssetType, type CtokensAppBalance, type CtokensAppToken, type CtokensAppUser } from '@/utils/wallet/ctokens/api'; import CTokensProvider from '@/utils/wallet/ctokens/signer'; export interface CTokenDisplayBase { @@ -24,13 +23,19 @@ export interface CTokenDisplayBase { } export interface CTokenUser { + managementKey: string; operationalKey: string; + hiveAccount?: string | null; metadata: Record; displayName: string; about: string; name: string; profileImage: string; website: string; + email?: string; + location?: string; + twitter?: string; + github?: string; } export interface CTokenUserRanked extends CTokenUser { @@ -82,19 +87,36 @@ const transformUserToDisplayFormat = ; user?: string; operational_key?: string; + management_key?: string; + hive_account?: string | null; }>(userData: T): CTokenUser => { const { metadata } = userData; - const operationalKey = userData.user || String(userData.operational_key); + const operationalKey = userData.user || String(userData.operational_key || ''); + const managementKey = String(userData.management_key || ''); + + // Generate display name + let displayName = String(metadata?.name || ''); + if (!displayName) { + // Use operational key as fallback, format it nicely + const keyPart = operationalKey.slice(3, 13); + displayName = `${keyPart.slice(0, 4)}...${keyPart.slice(-4)}`; + } return { + managementKey, operationalKey, + hiveAccount: userData.hive_account, metadata: metadata || {}, - displayName: String(metadata?.name) || transformUserName(operationalKey), + displayName, about: metadata?.about as string, name: metadata?.name as string, profileImage: metadata?.profile_image as string, - website: metadata?.website as string + website: metadata?.website as string, + email: metadata?.email as string, + location: metadata?.location as string, + twitter: metadata?.twitter as string, + github: metadata?.github as string }; }; @@ -114,6 +136,11 @@ const transformToApiResponseFormat = (undefined); export const formatAsset = (wax: IWaxBaseInterface, value: string | bigint, precision: number, name?: string): string => { + if (precision === 0) { + const formatted = wax.formatter.formatNumber(String(value), 0); + return name ? `${formatted} ${name}` : formatted; + } + const integer = String(value).slice(0, -precision) || '0'; const fraction = String(value).slice(-precision).padEnd(precision, '0'); @@ -421,6 +448,96 @@ export const useTokensStore = defineStore('tokens', { this.isLoading = false; } }, + async getUser (operationalKey: string): Promise { + this.isLoading = true; + + try { + const wax = await getWax(); + + const user = await wax.restApi.ctokensApi.users({ user: operationalKey }); + + if (!user?.management_key) + throw new Error('User not found'); + + return transformUserToDisplayFormat(user); + } finally { + this.isLoading = false; + } + }, + async loadUsers (page = 1): Promise> { + this.isLoading = true; + + try { + const wax = await getWax(); + + const usersResponse = await wax.restApi.ctokensApi.metadata.metadataType({ + metadataType: CtokensAppMetadataType.User, + key: '', + value: '', + page + }); + + const users = (usersResponse.items || []).map((user) => + transformUserToDisplayFormat(user as CtokensAppUser) + ); + + return transformToApiResponseFormat(users, usersResponse, page); + } finally { + this.isLoading = false; + } + }, + async searchUsers (query: string, page = 1): Promise> { + this.isLoading = true; + + try { + const wax = await getWax(); + + // Search by name or other metadata fields + const searchArray = [ + wax.restApi.ctokensApi.metadata.metadataType({ + metadataType: CtokensAppMetadataType.User, + key: 'name', + value: query, + page + }), + wax.restApi.ctokensApi.metadata.metadataType({ + metadataType: CtokensAppMetadataType.User, + key: 'about', + value: query, + page + }) + ]; + + const usersFound = await Promise.allSettled(searchArray); + + const usersRaw = new Map(); + + let maxPages = 0, totalItems = 0; + + for (const result of usersFound) { + if (result.status === 'fulfilled') { + for (const item of (result.value.items || []) as CtokensAppUser[]) { + if (item.operational_key) + usersRaw.set(item.operational_key, transformUserToDisplayFormat(item)); + + } + + maxPages = Math.max(maxPages, result.value.total_pages || 0); + totalItems += result.value.total_items || 0; + } + } + + const usersData = Array.from(usersRaw.values()); + + return transformToApiResponseFormat(usersData, { + items: usersData, + total_items: totalItems, + total_pages: maxPages + }, page); + } finally { + this.isLoading = false; + } + }, async reset (cTokensWallet?: CTokensProvider | undefined) { // Cleanup only if we don't want to use any other L2 wallet if (cTokensWallet === undefined) -- GitLab From 0d6182b8b0a2ac3003757486d7834ebaac801ab2 Mon Sep 17 00:00:00 2001 From: mtyszczak Date: Mon, 1 Dec 2025 15:21:32 +0100 Subject: [PATCH 02/29] Add Pagination component --- .../htm/tokens/TokenTopHoldersCard.vue | 5 +- src/components/ui/pagination/Pagination.vue | 184 ++++++++++++++++++ src/components/ui/pagination/index.ts | 1 + src/pages/tokens/list.vue | 46 ++--- src/pages/tokens/my-balance.vue | 20 +- src/pages/tokens/users/index.vue | 24 ++- 6 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 src/components/ui/pagination/Pagination.vue create mode 100644 src/components/ui/pagination/index.ts diff --git a/src/components/htm/tokens/TokenTopHoldersCard.vue b/src/components/htm/tokens/TokenTopHoldersCard.vue index 378c048..c9968a2 100644 --- a/src/components/htm/tokens/TokenTopHoldersCard.vue +++ b/src/components/htm/tokens/TokenTopHoldersCard.vue @@ -60,9 +60,10 @@ const props = defineProps<{ v-else-if="props.topHolders.length > 0" class="space-y-2" > -
@@ -118,7 +119,7 @@ const props = defineProps<{ {{ props.token.symbol || 'tokens' }}

-
+
+import { computed } from 'vue'; +import { mdiChevronLeft, mdiChevronRight, mdiChevronDoubleLeft, mdiChevronDoubleRight } from '@mdi/js'; +import { Button } from '@/components/ui/button'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + maxVisiblePages?: number; + loading?: boolean; +} + +interface PaginationEmits { + (e: 'update:currentPage', page: number): void; + (e: 'pageChange', page: number): void; +} + +const props = withDefaults(defineProps(), { + maxVisiblePages: 5, + loading: false +}); + +const emit = defineEmits(); + +// Calculate which page numbers to display +const visiblePages = computed(() => { + const { currentPage, totalPages, maxVisiblePages } = props; + + if (totalPages <= maxVisiblePages) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const halfVisible = Math.floor(maxVisiblePages / 2); + let start = Math.max(1, currentPage - halfVisible); + let end = Math.min(totalPages, start + maxVisiblePages - 1); + + // Adjust start if we're near the end + if (end - start < maxVisiblePages - 1) { + start = Math.max(1, end - maxVisiblePages + 1); + } + + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +}); + +const showFirstEllipsis = computed(() => { + const pages = visiblePages.value; + return pages.length > 0 && pages[0]! > 1; +}); + +const showLastEllipsis = computed(() => { + const pages = visiblePages.value; + return pages.length > 0 && pages[pages.length - 1]! < props.totalPages; +}); + +const goToPage = (page: number) => { + if (page < 1 || page > props.totalPages || page === props.currentPage || props.loading) { + return; + } + + emit('update:currentPage', page); + emit('pageChange', page); +}; + +const goToFirst = () => goToPage(1); +const goToPrevious = () => goToPage(props.currentPage - 1); +const goToNext = () => goToPage(props.currentPage + 1); +const goToLast = () => goToPage(props.totalPages); + + + diff --git a/src/components/ui/pagination/index.ts b/src/components/ui/pagination/index.ts new file mode 100644 index 0000000..b60aa92 --- /dev/null +++ b/src/components/ui/pagination/index.ts @@ -0,0 +1 @@ +export { default as Pagination } from './Pagination.vue'; diff --git a/src/pages/tokens/list.vue b/src/pages/tokens/list.vue index 6e549e4..be942b9 100644 --- a/src/pages/tokens/list.vue +++ b/src/pages/tokens/list.vue @@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Pagination } from '@/components/ui/pagination'; import { Skeleton } from '@/components/ui/skeleton'; import TextTooltip from '@/components/ui/texttooltip/TextTooltip.vue'; import { useSettingsStore } from '@/stores/settings.store'; @@ -106,12 +107,16 @@ watch(showOnlyMyTokens, () => { if (searchQuery.value.trim().length > 0) return void searchFn(searchQuery.value); - void loadTokens(tokensList.value.page); + void loadTokens(1); // Reset to page 1 when filtering }); -const loadMore = () => { +const handlePageChange = (page: number) => { if (tokensStore.isLoading) return; - void loadTokens(tokensList.value.page + 1); + + if (searchQuery.value.trim().length > 0) + void searchFn(searchQuery.value); + else + void loadTokens(page); }; const clearSearch = () => { @@ -319,35 +324,18 @@ onUnmounted(() => {
- -
- + +
+
- + { void loadAccountBalances(fetchedBalances.value!.page + 1); }; +const handlePageChange = (page: number) => { + if (isLoading.value) return; + void loadAccountBalances(page); +}; + const openTransferDialog = (balance: CTokenBalanceDisplay) => { if (balance.isStaked) { toast.error('No liquid balance available for transfer'); @@ -436,9 +442,19 @@ onMounted(() => { @unstake="openTransformDialog" /> - + +
+ +
+ +
- + +
+ +
+ +