@visualizevalue/mint-app-base 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article class="collected-item-card">
|
|
3
|
+
<Image
|
|
4
|
+
v-if="artifact.image"
|
|
5
|
+
:src="artifact.image"
|
|
6
|
+
:alt="artifact.name"
|
|
7
|
+
:aspect-ratio="1"
|
|
8
|
+
/>
|
|
9
|
+
<ImageVoid v-else />
|
|
10
|
+
<CardLink
|
|
11
|
+
:to="{
|
|
12
|
+
name: 'id-collection-tokenId',
|
|
13
|
+
params: {
|
|
14
|
+
id: artifact.collection.owner,
|
|
15
|
+
collection: artifact.collection.address,
|
|
16
|
+
tokenId: `${artifact.id}`,
|
|
17
|
+
},
|
|
18
|
+
}"
|
|
19
|
+
>{{ $t('token.view') }} {{ artifact.name }}</CardLink>
|
|
20
|
+
</article>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import type { OwnershipArtifact } from '~/utils/types'
|
|
25
|
+
|
|
26
|
+
defineProps<{
|
|
27
|
+
artifact: OwnershipArtifact
|
|
28
|
+
}>()
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<style scoped>
|
|
32
|
+
.collected-item-card {
|
|
33
|
+
position: relative;
|
|
34
|
+
|
|
35
|
+
:deep(a) {
|
|
36
|
+
z-index: 2;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section v-if="ownerships.length" class="collected-items">
|
|
3
|
+
<slot name="before" :ownerships="ownerships" />
|
|
4
|
+
|
|
5
|
+
<div class="collected-items-grid">
|
|
6
|
+
<CollectedItemCard
|
|
7
|
+
v-for="ownership in ownerships"
|
|
8
|
+
:key="`${ownership.artifact.collection.address}-${ownership.artifact.id}`"
|
|
9
|
+
:artifact="ownership.artifact"
|
|
10
|
+
/>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div ref="loadMoreTrigger" class="load-more-trigger">
|
|
14
|
+
<Loading v-if="loadingMore" :txt="$t('collected.loading')" />
|
|
15
|
+
</div>
|
|
16
|
+
</section>
|
|
17
|
+
<section v-else-if="! loading" class="collected-items-empty">
|
|
18
|
+
<slot name="before" :ownerships="[]" />
|
|
19
|
+
<template v-if="isMe">
|
|
20
|
+
<p>{{ $t('collected.empty') }}</p>
|
|
21
|
+
</template>
|
|
22
|
+
<template v-else>
|
|
23
|
+
<p>{{ $t('collected.empty_other') }}</p>
|
|
24
|
+
</template>
|
|
25
|
+
</section>
|
|
26
|
+
<section v-else>
|
|
27
|
+
<slot name="before" :ownerships="[]" />
|
|
28
|
+
<Loading :txt="$t('collected.loading')" />
|
|
29
|
+
</section>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
import { useIntersectionObserver } from '@vueuse/core'
|
|
34
|
+
import type { Ownership } from '~/utils/types'
|
|
35
|
+
|
|
36
|
+
interface PageInfo {
|
|
37
|
+
hasNextPage: boolean
|
|
38
|
+
endCursor: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface OwnershipsResponse {
|
|
42
|
+
data: {
|
|
43
|
+
ownerships: {
|
|
44
|
+
items: Ownership[]
|
|
45
|
+
pageInfo: PageInfo
|
|
46
|
+
totalCount: number
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
errors?: Array<{ message: string }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const props = defineProps<{
|
|
53
|
+
id: string
|
|
54
|
+
}>()
|
|
55
|
+
|
|
56
|
+
const id = computed(() => props.id)
|
|
57
|
+
const isMe = useIsMeCheck(id)
|
|
58
|
+
const endpoints = useIndexerEndpoints()
|
|
59
|
+
const artistScope = useArtistScope()
|
|
60
|
+
const store = useOnchainStore()
|
|
61
|
+
|
|
62
|
+
const scopedCollections = computed(() => {
|
|
63
|
+
if (!artistScope) return null
|
|
64
|
+
const collections = store.forArtist(artistScope as `0x${string}`)
|
|
65
|
+
return collections.map(c => c.address)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const ownerships = ref<Ownership[]>([])
|
|
69
|
+
const hasNextPage = ref(false)
|
|
70
|
+
const endCursor = ref<string | null>(null)
|
|
71
|
+
const loading = ref(true)
|
|
72
|
+
const loadingMore = ref(false)
|
|
73
|
+
const loadMoreTrigger = ref<HTMLElement | null>(null)
|
|
74
|
+
|
|
75
|
+
const buildQuery = (cursor?: string | null) => {
|
|
76
|
+
const filters = [`account: "${props.id}"`, `balance_gt: "0"`]
|
|
77
|
+
|
|
78
|
+
if (scopedCollections.value && scopedCollections.value.length > 0) {
|
|
79
|
+
const addresses = scopedCollections.value.map(a => `"${a}"`).join(', ')
|
|
80
|
+
filters.push(`collection_in: [${addresses}]`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return `
|
|
84
|
+
query Ownerships {
|
|
85
|
+
ownerships(
|
|
86
|
+
where: { ${filters.join(', ')} }
|
|
87
|
+
limit: 24
|
|
88
|
+
orderBy: "created_at"
|
|
89
|
+
orderDirection: "desc"
|
|
90
|
+
${cursor ? `after: "${cursor}"` : ''}
|
|
91
|
+
) {
|
|
92
|
+
items {
|
|
93
|
+
artifact {
|
|
94
|
+
id
|
|
95
|
+
name
|
|
96
|
+
image
|
|
97
|
+
collection {
|
|
98
|
+
address
|
|
99
|
+
owner
|
|
100
|
+
name
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
balance
|
|
104
|
+
created_at
|
|
105
|
+
updated_at
|
|
106
|
+
}
|
|
107
|
+
pageInfo {
|
|
108
|
+
hasNextPage
|
|
109
|
+
endCursor
|
|
110
|
+
}
|
|
111
|
+
totalCount
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fetchOwnerships = async (cursor?: string | null): Promise<OwnershipsResponse['data']['ownerships']> => {
|
|
118
|
+
let lastError: Error | undefined
|
|
119
|
+
for (const endpoint of endpoints.value) {
|
|
120
|
+
try {
|
|
121
|
+
const response = await $fetch<OwnershipsResponse>(endpoint, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: { query: buildQuery(cursor) },
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (response.errors) {
|
|
128
|
+
throw new Error(response.errors[0].message)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return response.data.ownerships
|
|
132
|
+
} catch (e) {
|
|
133
|
+
lastError = e instanceof Error ? e : new Error(String(e))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw lastError ?? new Error('All indexer endpoints failed')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const loadInitial = async () => {
|
|
140
|
+
loading.value = true
|
|
141
|
+
try {
|
|
142
|
+
const result = await fetchOwnerships()
|
|
143
|
+
ownerships.value = result.items
|
|
144
|
+
hasNextPage.value = result.pageInfo.hasNextPage
|
|
145
|
+
endCursor.value = result.pageInfo.endCursor
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error('Failed to load ownerships:', e)
|
|
148
|
+
} finally {
|
|
149
|
+
loading.value = false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const loadMore = async () => {
|
|
154
|
+
if (!hasNextPage.value || loadingMore.value) return
|
|
155
|
+
|
|
156
|
+
loadingMore.value = true
|
|
157
|
+
try {
|
|
158
|
+
const result = await fetchOwnerships(endCursor.value)
|
|
159
|
+
ownerships.value = [...ownerships.value, ...result.items]
|
|
160
|
+
hasNextPage.value = result.pageInfo.hasNextPage
|
|
161
|
+
endCursor.value = result.pageInfo.endCursor
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.error('Failed to load more ownerships:', e)
|
|
164
|
+
} finally {
|
|
165
|
+
loadingMore.value = false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
useIntersectionObserver(
|
|
170
|
+
loadMoreTrigger,
|
|
171
|
+
([{ isIntersecting }]) => {
|
|
172
|
+
if (isIntersecting && hasNextPage.value && !loadingMore.value) {
|
|
173
|
+
loadMore()
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
rootMargin: '100px',
|
|
178
|
+
threshold: 0.1,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// In single-artist mode, wait for scoped collections to resolve before fetching.
|
|
183
|
+
// Otherwise, fetch immediately.
|
|
184
|
+
if (artistScope) {
|
|
185
|
+
const unwatch = watch(scopedCollections, (collections) => {
|
|
186
|
+
if (collections && collections.length > 0) {
|
|
187
|
+
loadInitial()
|
|
188
|
+
nextTick(() => unwatch())
|
|
189
|
+
}
|
|
190
|
+
}, { immediate: true })
|
|
191
|
+
} else {
|
|
192
|
+
onMounted(() => loadInitial())
|
|
193
|
+
}
|
|
194
|
+
</script>
|
|
195
|
+
|
|
196
|
+
<style scoped>
|
|
197
|
+
.collected-items {
|
|
198
|
+
display: grid;
|
|
199
|
+
gap: var(--spacer-lg);
|
|
200
|
+
padding: var(--spacer-lg) var(--spacer);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.collected-items-grid {
|
|
204
|
+
display: grid;
|
|
205
|
+
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
|
206
|
+
gap: var(--spacer);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.collected-items-empty {
|
|
210
|
+
display: grid;
|
|
211
|
+
gap: var(--spacer);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.load-more-trigger {
|
|
215
|
+
min-height: 1px;
|
|
216
|
+
}
|
|
217
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const useIndexerEndpoints = () => {
|
|
2
|
+
const config = useRuntimeConfig()
|
|
3
|
+
return computed(() => {
|
|
4
|
+
const endpoints = config.public.indexerEndpoints
|
|
5
|
+
return endpoints ? String(endpoints).split(/\s+/).filter(Boolean) : []
|
|
6
|
+
})
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const useHasIndexer = () => {
|
|
10
|
+
const endpoints = useIndexerEndpoints()
|
|
11
|
+
return computed(() => endpoints.value.length > 0)
|
|
12
|
+
}
|
package/locales/en.json
CHANGED
|
@@ -100,6 +100,12 @@
|
|
|
100
100
|
"view_on_etherscan": "View on Etherscan",
|
|
101
101
|
"cancel": "Cancel"
|
|
102
102
|
},
|
|
103
|
+
"collected": {
|
|
104
|
+
"title": "Collected",
|
|
105
|
+
"loading": "Loading collected items...",
|
|
106
|
+
"empty": "No collected items yet.",
|
|
107
|
+
"empty_other": "This account hasn't collected any items yet."
|
|
108
|
+
},
|
|
103
109
|
"create": {
|
|
104
110
|
"title": "Create New",
|
|
105
111
|
"alert_new": {
|
package/package.json
CHANGED
|
@@ -10,21 +10,13 @@
|
|
|
10
10
|
</template>
|
|
11
11
|
</ProfileHeader>
|
|
12
12
|
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<NuxtLink :to="config.public.platformUrl" target="_blank">
|
|
21
|
-
{{ getMainDomain(config.public.platformUrl) }}
|
|
22
|
-
</NuxtLink>.
|
|
23
|
-
</p>
|
|
24
|
-
</section>
|
|
25
|
-
<section v-else>
|
|
26
|
-
<p>Collected art and curation options for your profile will come soon...</p>
|
|
27
|
-
</section>
|
|
13
|
+
<CollectedItems v-if="hasIndexer" :id="address" :key="`collected-${address}`">
|
|
14
|
+
<template #before>
|
|
15
|
+
<HeaderSection>
|
|
16
|
+
<h1>{{ $t('collected.title') }}</h1>
|
|
17
|
+
</HeaderSection>
|
|
18
|
+
</template>
|
|
19
|
+
</CollectedItems>
|
|
28
20
|
</PageFrame>
|
|
29
21
|
</template>
|
|
30
22
|
|
|
@@ -34,8 +26,7 @@ const route = useRoute()
|
|
|
34
26
|
const address = computed(() => route.params.address)
|
|
35
27
|
const store = useOnchainStore()
|
|
36
28
|
const isMe = useIsMeCheck(address.value)
|
|
37
|
-
const
|
|
38
|
-
const isMyArtistScope = useIsMeCheck(artistScope)
|
|
29
|
+
const hasIndexer = useHasIndexer()
|
|
39
30
|
|
|
40
31
|
const artist = ref(null)
|
|
41
32
|
|
package/utils/types.ts
CHANGED
|
@@ -72,3 +72,21 @@ export interface Renderer {
|
|
|
72
72
|
component?: string
|
|
73
73
|
version: bigint
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
export interface OwnershipArtifact {
|
|
77
|
+
id: string
|
|
78
|
+
name: string
|
|
79
|
+
image: string
|
|
80
|
+
collection: {
|
|
81
|
+
address: string
|
|
82
|
+
owner: string
|
|
83
|
+
name: string
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Ownership {
|
|
88
|
+
artifact: OwnershipArtifact
|
|
89
|
+
balance: string
|
|
90
|
+
created_at: string
|
|
91
|
+
updated_at: string
|
|
92
|
+
}
|