components/rmrk/Gallery/Holder/Holder.vue
<template>
<div class="block">
<NeoCollapse
:open="isOpen"
class="card"
:class="hideCollapse ? 'collapseHidden' : 'bordered'"
animation="slide"
aria-id="contentIdForHistory"
>
<template #trigger="props">
<div
class="card-header"
role="button"
aria-controls="contentIdForHistory"
>
<p class="card-header-title">
{{ collapseTitleOption || $t('holders') }}
</p>
<a class="card-header-icon">
<NeoIcon :icon="props.open ? 'chevron-up' : 'chevron-down'" />
</a>
</div>
</template>
<div class="flex justify-between box-container">
<NeoField
grouped
group-multiline
>
<div class="control">
<NeoCheckbox v-model="showDetailIcon">
NFT Details
</NeoCheckbox>
</div>
<div
v-for="(column, index) in columnsVisible"
:key="index"
class="control"
>
<NeoCheckbox v-model="column.display">
{{ column.title }}
</NeoCheckbox>
</div>
</NeoField>
</div>
<NeoTable
v-model:current-page="currentPage"
:data="showList"
class="mb-4"
hoverable
custom-row-key="Id"
:show-detail-icon="showDetailIcon"
:detail-key="groupKey"
custom-detail-row
detailed
paginated
:per-page="itemsPerPage"
aria-next-label="Next page"
aria-previous-label="Previous page"
aria-page-label="Page"
aria-current-label="Current page"
:default-sort="[defaultSortOption, 'desc']"
>
<NeoTableColumn
v-slot="props"
:visible="columnsVisible['Name'].display"
:field="groupKey"
cell-class="md:w-1/5"
:label="nameHeaderLabel"
>
<nuxt-link
v-if="groupKey === 'Holder' || groupKey === 'Flipper'"
:to="`/${urlPrefix}/u/${props.row[groupKey]}?tab=${
groupKey === 'Holder' ? 'holdings' : 'gains'
}`"
>
<Identity :address="props.row[groupKey]" />
</nuxt-link>
<nuxt-link
v-else
:to="`/${urlPrefix}/collection/${props.row.CollectionId}`"
>
<Identity
:address="props.row.Item.collection.issuer"
:custom-name-option="props.row.Item.collection.name"
/>
</nuxt-link>
</NeoTableColumn>
<NeoTableColumn
v-slot="props"
:visible="columnsVisible['Amount'].display"
numeric
field="Amount"
label="Amount"
sortable
>
{{ props.row.Amount }}
</NeoTableColumn>
<NeoTableColumn
v-slot="props"
:visible="columnsVisible['Bought'].display"
field="Bought"
label="Bought"
sortable
>
{{ props.row.BoughtFormatted }}
</NeoTableColumn>
<NeoTableColumn
v-slot="props"
:visible="columnsVisible['Sale'].display"
field="Sale"
:label="saleHeaderLabel"
sortable
>
{{ props.row.SaleFormatted }}
</NeoTableColumn>
<NeoTableColumn
v-if="displayPercentage"
v-slot="props"
:visible="columnsVisible['Percentage'].display"
field="Percentage"
label="Percentage"
sortable
>
<span :class="percentageTextClassName(props.row.Percentage)">
{{ toPercent(props.row.Percentage, '-') }}
</span>
</NeoTableColumn>
<NeoTableColumn
v-slot="props"
:visible="columnsVisible['Date'].display"
field="Timestamp"
:label="dateHeaderLabel"
sortable
>
<NeoTooltip
:label="props.row.Date"
position="left"
>
<BlockExplorerLink
:text="props.row.Time"
:block-id="props.row.Block"
/>
</NeoTooltip>
</NeoTableColumn>
<template #detail="props">
<tr
v-for="item in props.row.Items"
:key="item.Item.id"
>
<td v-if="showDetailIcon" />
<td
v-show="columnsVisible['Name'].display"
class="md:w-1/5"
>
<nuxt-link :to="`/${urlPrefix}/gallery/${item.Item.id}`">
{{ item.Item.name || item.Item.id }}
</nuxt-link>
</td>
<td
v-show="columnsVisible['Amount'].display"
class="text-right"
>
{{ item.Amount }}
</td>
<td v-show="columnsVisible['Bought'].display">
{{ item.BoughtFormatted }}
</td>
<td v-show="columnsVisible['Sale'].display">
{{ item.SaleFormatted }}
</td>
<td
v-if="displayPercentage"
v-show="columnsVisible['Percentage'].display"
:class="percentageTextClassName(item.Percentage)"
>
{{ toPercent(item.Percentage, '-') }}
</td>
<td v-show="columnsVisible['Date'].display">
<NeoTooltip
:label="item.Date"
position="left"
>
<BlockExplorerLink
:text="item.Time"
:block-id="item.Block"
/>
</NeoTooltip>
</td>
</tr>
</template>
</NeoTable>
</NeoCollapse>
</div>
</template>
<script lang="ts" setup>
import { Interaction } from '@kodadot1/minimark/v1'
import { formatDistanceToNow } from 'date-fns'
import {
NeoCheckbox,
NeoCollapse,
NeoField,
NeoIcon,
NeoTable,
NeoTableColumn,
NeoTooltip,
} from '@kodadot1/brick'
import { parsePriceForItem } from './helper'
import type { Interaction as EventInteraction } from '@/types'
import { toPercent } from '@/utils/filters'
import { parseDate } from '@/utils/datetime'
import { usePreferencesStore } from '@/stores/preferences'
import Identity from '@/components/identity/IdentityIndex.vue'
import BlockExplorerLink from '@/components/shared/BlockExplorerLink.vue'
export type NftHolderEvent = {
nft: {
id: string
name: string
collection: {
id: string
}
}
} & EventInteraction
type NFTItem = {
id: string
name: string
}
export type TableRow = {
Holder?: string
Flipper?: string
Bought?: number
BoughtFormatted?: string
Sale?: number
SaleFormatted?: string
Id?: string
SortKey?: number
Amount: number
Items?: TableRow[]
Item: NFTItem
} & BaseTableRow
type BaseTableRow = {
Date: string
Time: string
Timestamp: number
Block: string
CollectionId: string
Amount: number
}
const prop = withDefaults(
defineProps<{
events?: NftHolderEvent[]
tableRowsOption?: TableRow[]
hideCollapse?: boolean
groupKeyOption: string
nameHeaderLabel: string
dateHeaderLabel: string
saleHeaderLabel: string
collapseTitleOption: string
defaultSortOption: string
displayPercentage?: boolean
isFlipper?: boolean
}>(),
{
events: () => [],
tableRowsOption: () => [],
hideCollapse: false,
groupKeyOption: '',
nameHeaderLabel: 'Name',
dateHeaderLabel: 'Date',
saleHeaderLabel: 'Sale',
collapseTitleOption: '',
defaultSortOption: 'Amount',
displayPercentage: false,
isFlipper: false,
},
)
const route = useRoute()
const preferencesStore = usePreferencesStore()
const { decimals, unit } = useChain()
const { urlPrefix } = usePrefix()
const { replaceUrl } = useReplaceUrl()
const isOpen = ref(false)
const showDetailIcon = ref(true)
const currentPage = ref(parseInt(route.query?.page) || 1)
const customGroups = ref<TableRow[]>([])
const columnsVisible = ref({
Name: { title: 'Name', display: true },
Amount: { title: 'Amount', display: true },
Bought: { title: 'Bought', display: true },
Sale: { title: 'Sale', display: true },
Percentage: { title: 'Percentage', display: true },
Date: { title: 'Date', display: true },
})
const itemsPerPage = computed(() => preferencesStore.getHistoryItemsPerPage)
const showList = computed(() => customGroups.value)
const groupKey = computed(() => prop.groupKeyOption || 'Holder')
const percentageTextClassName = (percentage: number) => {
if (percentage > 0) {
return 'text-k-green'
}
else if (percentage < 0) {
return 'text-k-red'
}
return ''
}
const createTable = () => {
const NFTList = generateNFTList()
customGroups.value = generateCustomGroups(NFTList)
}
const createTableByTableRow = () => {
customGroups.value = generateCustomGroups(prop.tableRowsOption)
}
const generateNFTList = (): TableRow[] => {
const itemRowMap: Record<string, TableRow> = {}
for (const newEvent of prop.events) {
const date = new Date(newEvent['timestamp'])
const timestamp = date.getTime()
const dateStr = parseDate(date)
const formatTime = formatDistanceToNow(date, { addSuffix: true })
const block = String(newEvent['blockNumber'])
const collectionId = newEvent['nft']['collection']['id']
const commonInfo = {
Date: dateStr,
Time: formatTime,
Timestamp: timestamp,
Block: block,
CollectionId: collectionId,
Amount: 1,
}
const nftId = newEvent['nft'].id
if (newEvent['interaction'] === Interaction.MINTNFT) {
if (!itemRowMap[nftId]) {
itemRowMap[nftId] = {
Item: newEvent['nft'],
Holder: newEvent['caller'],
SortKey: timestamp,
Bought: 0,
Sale: 0,
...commonInfo,
}
}
}
else if (newEvent['interaction'] === Interaction.LIST) {
const listPrice = parseInt(newEvent['meta'])
if (itemRowMap[nftId]) {
if (!('Sale' in itemRowMap[nftId])) {
itemRowMap[nftId]['Sale'] = listPrice
}
}
else {
itemRowMap[nftId] = {
Item: newEvent['nft'],
Holder: newEvent['caller'],
Sale: listPrice,
...commonInfo,
}
}
}
else if (newEvent['interaction'] === Interaction.SEND) {
if (itemRowMap[nftId]) {
if (!('Bought' in itemRowMap[nftId])) {
itemRowMap[nftId]['Bought'] = 0
itemRowMap[nftId]['SortKey'] = timestamp
}
}
else {
itemRowMap[nftId] = {
Item: newEvent['nft'],
Holder: newEvent['meta'],
SortKey: timestamp,
Bought: 0,
Sale: 0,
...commonInfo,
}
}
}
else if (newEvent['interaction'] === Interaction.CONSUME) {
if (!itemRowMap[nftId]) {
itemRowMap[nftId] = {
Item: newEvent['nft'],
Holder: '-',
Bought: 0,
Sale: 0,
...commonInfo,
}
}
}
else if (newEvent['interaction'] === Interaction.BUY) {
const bought = parseInt(newEvent['meta'])
if (itemRowMap[nftId]) {
if (!('Bought' in itemRowMap[nftId])) {
itemRowMap[nftId]['Bought'] = bought
itemRowMap[nftId]['SortKey'] = timestamp
}
}
else {
itemRowMap[nftId] = {
Item: newEvent['nft'],
Holder: newEvent['caller'],
SortKey: timestamp,
Bought: bought,
Sale: 0,
...commonInfo,
}
}
}
}
return Object.values(itemRowMap)
}
const getGroupNameFromRow = (item: TableRow): string => {
let groupName
switch (groupKey.value) {
case 'Holder':
groupName = item['Holder']
break
case 'CollectionId':
groupName = item['Item']['collection']['id']
break
case 'Flipper':
groupName = item['Flipper']
break
}
return groupName || item['Holder']
}
const getCustomRowFilter = (): ((item: TableRow) => boolean) => {
switch (groupKey.value) {
case 'Holder':
return item => item.Holder !== '-'
case 'CollectionId':
if (prop.isFlipper) {
return item => item.Flipper === route.params.id
}
return item => item.Holder === route.params.id
default:
return () => true
}
}
const getCustomId = (item: TableRow): string => {
let customId
switch (groupKey.value) {
case 'Flipper':
customId = item['Flipper']
break
case 'Holder':
case 'CollectionId':
customId = item['CollectionId'] + item['Item']['id']
}
return customId || item['Timestamp']
}
const generateCustomGroups = (itemRowList: TableRow[]): TableRow[] => {
const customGroups: Record<string, TableRow> = {}
itemRowList.filter(getCustomRowFilter()).forEach((item: TableRow) => {
item = {
...item,
Bought: item.Bought ?? 0,
Sale: item.Sale ?? 0,
} as TableRow
const groupName = getGroupNameFromRow(item)
if (customGroups[groupName]) {
customGroups[groupName].Items?.push(item)
customGroups[groupName].Bought
= (customGroups[groupName]['Bought'] ?? 0) + (item['Bought'] ?? 0)
customGroups[groupName]['Sale']
= (customGroups[groupName]['Sale'] ?? 0) + (item['Sale'] ?? 0)
}
else {
customGroups[groupName] = {
...item,
Id: getCustomId(item),
Items: [item],
}
}
})
const customGroupsList = Object.values(customGroups)
customGroupsList.forEach((group) => {
parsePriceForItem(group, decimals.value, unit.value)
if (!group['Items']) {
return
}
const groupItems: TableRow[] = group['Items']
group['Amount'] = groupItems.length
groupItems.forEach((item) => {
parsePriceForItem(item, decimals.value, unit.value)
})
group['Items'] = groupItems.sort(
(a, b) => (b.SortKey ?? 0) - (a.SortKey ?? 0),
)
group['Percentage'] = group['Percentage'] / group['Amount']
})
return customGroupsList
}
watch(() => prop.events, createTable)
watch(() => prop.tableRowsOption, createTableByTableRow)
watch(currentPage, val => replaceUrl({ page: String(val) }))
</script>