@visualizevalue/mint-app-base 0.2.0 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visualizevalue/mint-app-base",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -10,21 +10,13 @@
10
10
  </template>
11
11
  </ProfileHeader>
12
12
 
13
- <section v-if="artistScope">
14
- <p>Collected art from <Account :address="artistScope" /> will be indexed on profiles soon...</p>
15
- <p v-if="isMyArtistScope">
16
- Manage Your collections <NuxtLink :to="{ name: 'id', params: { id: address } }">here</NuxtLink>.
17
- </p>
18
- <p v-else>
19
- You can mint your own art on
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 artistScope = useArtistScope()
38
- const isMyArtistScope = useIsMeCheck(artistScope)
29
+ const hasIndexer = useHasIndexer()
39
30
 
40
31
  const artist = ref(null)
41
32
 
@@ -151,7 +151,9 @@ export async function rpcFetchProfile (
151
151
  wagmi: Config,
152
152
  address: `0x${string}`,
153
153
  ): Promise<Partial<Artist>> {
154
- const client = getPublicClient(wagmi, { chainId: 1 }) as PublicClient
154
+ const client = getPublicClient(wagmi, { chainId: 1 })
155
+ if (!client) return {}
156
+
155
157
  const block = await client.getBlockNumber()
156
158
 
157
159
  let ens, avatar, description, url, email, twitter, github
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
+ }