Skip to content

Commit

Permalink
Tokens tab (#408)
Browse files Browse the repository at this point in the history
* Tokens tab

* Fix code smells

* Custom dipdup

* Add thumbnail

* Loading thumbnail

* Deduplication code

* Deduplication code - 2
aopoltorzhicky authored Jun 30, 2022
1 parent 2b267a0 commit ba7d6ac
Showing 9 changed files with 292 additions and 553 deletions.
3 changes: 2 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
NODE_ENV=development
VUE_APP_API_URI=https://sharm.better-call.dev/v1
VUE_APP_API_URI=https://sharm.better-call.dev/v1
IPFS_NODE=https://ipfs.io
94 changes: 94 additions & 0 deletions src/api/token_metadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const axios = require('axios').default;

export class RequestFailedError extends Error { }

function createAxios(baseURL, timeout = 10000) {
return axios.create({
baseURL: baseURL,
timeout: timeout,
responseType: 'json'
})
}

function get(api, query) {
return api.post('/v1/graphql', query)
.then(res => {
if (res.status !== 200 || res.data.errors) {
throw new RequestFailedError(JSON.stringify(res));
}
return res.data.data;
})
}

function buildQuery(query, address, network, limit, offset) {
return {
query: query,
variables: {
address: address,
network: network,
limit: limit,
offset: offset
},
operationName: "GetTokenMetadata"
}
}

export class TokenMetadataApi {
constructor(baseURL) {
this.api = createAxios(baseURL);
}

get(network, address, limit=10, offset=0) {
let query = buildQuery(`query GetTokenMetadata($address: String, $network: String, $limit: Int, $offset: Int) {
token_metadata(
where: {
contract: {_eq: $address},
network: {_eq: $network}}
limit: $limit
offset: $offset
order_by: {update_id: desc}
) {
network
contract
error
link
metadata
token_id
}
}`, address, network, limit, offset)

return get(this.api, query)
.then(res => {
return res.token_metadata;
})
}
}

export class DipDupTokenMetadataApi {
constructor(baseURL) {
this.api = createAxios(baseURL);
}

get(network, address, limit=10, offset=0) {
let query = buildQuery(`query GetTokenMetadata($address: String, $network: String, $limit: Int, $offset: Int) {
dipdup_token_metadata(
where: {
contract: {_eq: $address},
network: {_eq: $network}}
limit: $limit
offset: $offset
order_by: {update_id: desc}
) {
network
contract
metadata
token_id
}
}`, address, network, limit, offset)

return get(this.api, query)
.then(res => {
return res.dipdup_token_metadata;
})
}
}
6 changes: 5 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import { BrowserTracing } from "@sentry/tracing";

import { shortcut, formatDatetime, formatDate, plural, urlExtractBase58, checkAddress, round } from "@/utils/tz.js";
import { BetterCallApi } from "@/api/bcd.js";
import { TokenMetadataApi } from "@/api/token_metadata.js";
import { NodeRPC } from "@/api/rpc.js";
import { Bookmarks } from "@/utils/bookmarks.js";
import { MetadataAPI } from "@/api/metadata.js";
@@ -109,11 +110,14 @@ Vue.filter('snakeToCamel', (str) => {
let config = {
API_URI: process.env.VUE_APP_API_URI || `${window.location.protocol}//${window.location.host}/v1`,
HOME_PAGE: 'home',
TOKEN_METADATA_API: process.env.TOKEN_METADATA_API || "https://metadata.dipdup.net",
IPFS_NODE: process.env.IPFS_NODE || "https://ipfs.io",
METADATA_API_URI: process.env.METADATA_API_URI || "https://metadata.dipdup.net"
}

let api = new BetterCallApi(config.API_URI);
let bookmarks = new Bookmarks();
let tokenMetadata = new TokenMetadataApi(config.TOKEN_METADATA_API);
let metadataAPI = new MetadataAPI(config.METADATA_API_URI);

const isDark = localStorage.getItem('dark') ? JSON.parse(localStorage.getItem('dark')) : true;
@@ -138,7 +142,7 @@ api.getConfig().then(response => {

Vue.mixin({
data() {
return { config, api, rpc, helpers, bookmarks, metadataAPI }
return { config, api, rpc, helpers, bookmarks, metadataAPI, tokenMetadata }
}
});

1 change: 0 additions & 1 deletion src/views/contract/Contract.vue
Original file line number Diff line number Diff line change
@@ -75,7 +75,6 @@
:address="address"
:network="network"
:contract="contract"
:tokensTotal="tokensTotal"
:tokenBalancesTotal="tokenBalancesTotal"
:metadata="metadata"
:same-contracts="sameContracts"
4 changes: 1 addition & 3 deletions src/views/contract/MenuToolbar.vue
Original file line number Diff line number Diff line change
@@ -20,12 +20,10 @@
</v-tab>
<v-tab
:to="pushTo({ name: 'tokens' })"
:title="tokensTotal"
replace
v-if="isContract && tokensTotal > 0"
v-if="isContract"
>
<v-icon left small>mdi-circle-multiple-outline</v-icon>Tokens
<span class="ml-1">({{ tokensTotal | numberToCompactSIFormat(1) }})</span>
</v-tab>
<v-tab
:to="pushTo({ name: 'transfers' })"
92 changes: 0 additions & 92 deletions src/views/contract/TokensTab/TokenHolders.vue

This file was deleted.

252 changes: 0 additions & 252 deletions src/views/contract/TokensTab/TokenMetadata.vue

This file was deleted.

166 changes: 0 additions & 166 deletions src/views/contract/TokensTab/TokensList.vue

This file was deleted.

227 changes: 190 additions & 37 deletions src/views/contract/TokensTab/TokensTab.vue
Original file line number Diff line number Diff line change
@@ -1,57 +1,210 @@
<template>
<v-container class="canvas fill-canvas pa-8 ma-0" fluid>
<v-row no-gutters>
<v-col cols="8" class="pa-2">
<div
v-if="selectedToken"
>
<TokenMetadata :token="selectedToken"/>
<TokenHolders class="mt-3" :token="selectedToken" />
</div>
<v-skeleton-loader
v-else
:loading="typeof selectedToken !== 'number'"
type="image"
>
</v-skeleton-loader>
</v-col>
<v-col cols="4" class="pa-2">
<TokensList
:network="network"
:address="address"
:tokensTotal="tokensTotal"
@selectedToken="updateSelectedToken"
/>
</v-col>
</v-row>
<EmptyState
v-if="tokens.length === 0 && !loading"
icon="mdi-code-brackets"
title="Nothing found"
/>
<v-expansion-panels v-show="tokens.length > 0" multiple hover flat class="bb-1">
<v-expansion-panel v-for="(token) in tokens"
:key="token.token_id"
class="bl-1 br-1 bt-1 token-panel"
active-class="token-active-panel">
<v-expansion-panel-header class="py-0 px-4" :class="token.metadata === null && token.error !== null ? 'item-header-failed' : 'item-header-applied' " ripple :title="title(token)">
<template>
<v-list-item class="fill-height pa-0">
<v-list-item-content>
<v-list-item-title>{{ title(token) }}</v-list-item-title>
<v-list-item-subtitle class="font-weight-light hash text--secondary">
Token ID: {{ token.token_id }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content class="token-content py-4">
<v-list-item class="pl-0" v-if="token.link">
<v-list-item-content>
<v-list-item-subtitle class="overline">Token metadata link</v-list-item-subtitle>
<v-list-item-title class="d-flex align-center">
<span>{{ token.link }}</span>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
small
v-on="on"
icon
class="mr-2"
@click="
() => {
$clipboard(token.link);
showClipboardOK();
}
"
>
<v-icon small class="text--secondary">mdi-content-copy</v-icon>
</v-btn>
</template>
Copy token link
</v-tooltip>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-alert v-if="token.error && token.metadata === null" type="error" prominent text class="ma-0 align-center">
<div class="overline">Resolving token metadata error</div>
<div class="text--primary"> {{ token.error }}</div>
</v-alert>
<v-row v-if="token.metadata">
<v-col :cols="token.metadata.thumbnailUri ? 10 : 12">
<vue-json-pretty

class="raw-json-viewer py-3"
:data="token.metadata"
:highlightMouseoverNode="true"
:customValueFormatter="formatValue"
></vue-json-pretty>
</v-col>
<v-col cols="2" v-if="token.metadata.thumbnailUri" class="d-flex flex-column align-center justify-start">
<span class="overline mt-10" v-if="!token.loaded">Loading thumbnail...</span>
<div class="d-flex flex-column align-center justify-start">
<v-img @load="loadedImage(token)" :src="getIPFS(token.metadata.thumbnailUri)" sizes="200" alt="Thumbnail" contain eager max-height="200">
</v-img>
<div class="mt-4" v-if="token.loaded">
<p class="overline">Image from thumbnail URL</p>
</div>
</div>
</v-col>
</v-row>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-skeleton-loader
v-show="loading"
:loading="loading"
type="list-item-two-line, list-item-two-line, list-item-two-line"
>
</v-skeleton-loader>
<span
v-intersect="onDownloadPage"
v-if="!loading && !downloaded"
></span>
</v-container>
</template>

<script>
import TokenMetadata from "@/views/contract/TokensTab/TokenMetadata";
import TokenHolders from "@/views/contract/TokensTab/TokenHolders";
import TokensList from "@/views/contract/TokensTab/TokensList";
import { mapActions } from "vuex";
import EmptyState from "@/components/Cards/EmptyState.vue";
import VueJsonPretty from "vue-json-pretty";
import { DipDupTokenMetadataApi } from "@/api/token_metadata.js";
export default {
name: "ContractTokensTab",
props: {
network: String,
address: String,
tokensTotal: Number
address: String
},
components: {
TokensList,
TokenMetadata,
TokenHolders
EmptyState,
VueJsonPretty
},
data: () => ({
holders: {},
selectedToken: null
tokens: [],
loading: false,
downloaded: false,
ubisoft: {},
domains: {},
}),
created() {
this.ubisoft = new DipDupTokenMetadataApi('https://quartz.dipdup.net');
this.domains = new DipDupTokenMetadataApi('https://domains.dipdup.net/');
},
methods: {
async updateSelectedToken(newVal) {
this.selectedToken = newVal;
...mapActions(["showError", "hideError"]),
title(token) {
if (token.metadata !== null) {
return token.metadata.name;
}
return `${token.token_id}`;
},
getIPFS(url) {
if (!url) {
return '';
}
return `${this.config.IPFS_NODE}/ipfs/${url.replace('ipfs://', '')}`
},
loadedImage(token) {
token.loaded = true;
},
async onDownloadPage(_entries, _observer, isIntersecting) {
if (isIntersecting) {
await this.getTokens();
}
},
async getTokens() {
if (this.loading || this.downloaded) return;
this.loading = true;
let api = this.tokenMetadata;
if (this.address === 'KT1TnVQhjxeNvLutGvzwZvYtC7vKRpwPWhc6') {
api = this.ubisoft;
} else if (this.address === 'KT1GBZmSxmnKJXGMdMLbugPfLyUPmuLSMwKS') {
api = this.domains;
}
await api.get( this.network, this.address, 20, this.tokens.length)
.then(res => {
if (!res) {
this.downloaded = true; // prevent endless polling
} else {
this.downloaded = res.length === 0;
res.forEach(x => x.loaded = false);
this.tokens.push(...res);
}
})
.catch((err) => {
this.showError(err);
this.downloaded = true;
})
.finally(() => {
this.loading = false;
});
},
formatValue(_data, _key, _path, defaultFormatResult) {
return defaultFormatResult
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
},
}
},
watch: {
address: "getTokens",
},
};
</script>

<style scoped>
.token-content > .v-expansion-panel-content__wrap {
padding: 0;
}
.token-panel {
background-color: var(--v-data-base) !important;
}
.token-active-panel > .v-expansion-panel-header {
opacity: 0.8;
background-color: var(--v-border-base) !important;
}
.token-active-panel,
.token-panel.v-expansion-panel--next-active {
border-bottom: 1px solid var(--v-border-base);
}
</style>

<style lang="scss">
@import '../../../styles/vue-json-pretty.css';
</style>

0 comments on commit ba7d6ac

Please sign in to comment.