@mundogamernetwork/shared-ui 1.16.22 → 1.16.23

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.
@@ -1,460 +0,0 @@
1
- <script setup lang="ts">
2
- import {
3
- fetchPublicWall,
4
- getWallPixel,
5
- requestWallPixelShareRender,
6
- pollWallPixelShareRender,
7
- } from '../../../../services/indieWallService'
8
-
9
- const { t, locale } = useI18n()
10
- const route = useRoute()
11
- const localePath = useLocalePath()
12
- const wallSlug = route.params.slug as string
13
- const pixelId = route.params.pixelId as string
14
-
15
- // SSR-safe fetch: the wall must be resolved first (its numeric id, not the
16
- // slug, is what the pixel/share-render endpoints key off — see
17
- // IndieWallController::showPixel / IndieWallPixelShareController, which
18
- // compare indie_wall_id directly and do not resolve a slug like show()/
19
- // supporters() do).
20
- const { data, error } = await useAsyncData(`mural-pixel-${wallSlug}-${pixelId}`, async () => {
21
- const wallRes = await fetchPublicWall(wallSlug)
22
- const wall = wallRes.data?.data || wallRes.data
23
- if (!wall?.id) return null
24
- const pixelRes = await getWallPixel(wall.id, pixelId)
25
- const pixel = pixelRes.data?.data || pixelRes.data
26
- if (!pixel?.id) return null
27
- return { wall, pixel }
28
- })
29
-
30
- if (error.value || !data.value?.wall || !data.value?.pixel) {
31
- throw createError({ statusCode: 404, statusMessage: 'Pixel not found' })
32
- }
33
-
34
- const wall = ref<any>(data.value.wall)
35
- const pixel = ref<any>(data.value.pixel)
36
-
37
- // ── Display helpers (duplicated from pages/mural/[slug]/index.vue rather
38
- // than shared, matching this repo's existing pattern in
39
- // components/indie-wall/MediaKitWallBlock.vue, which duplicates the same
40
- // small pure functions instead of extracting a composable). ─────────────────
41
- function supporterDisplayName(s: any): string {
42
- if (!s) return t('tv.dashboard.indie_wall.anonymous')
43
- if (s.is_anonymous) return t('tv.dashboard.indie_wall.anonymous')
44
- return s.user?.nickname
45
- || s.user?.username
46
- || s.user?.full_name
47
- || s.user?.name
48
- || s.guest_name
49
- || t('tv.dashboard.indie_wall.anonymous')
50
- }
51
-
52
- function formatMoney(amount: number, currency?: string | null) {
53
- try {
54
- return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
55
- } catch {
56
- return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
57
- }
58
- }
59
-
60
- const displayName = computed(() => supporterDisplayName(pixel.value))
61
-
62
- // ── SEO ───────────────────────────────────────────────────────────────────
63
- const requestUrl = useRequestURL()
64
-
65
- const seoTitle = computed(() => t('tv.dashboard.indie_wall.share_pixel_title', {
66
- name: displayName.value,
67
- wall: wall.value?.name,
68
- }))
69
- const seoDescription = computed(() =>
70
- pixel.value?.supporter_message
71
- || t('tv.dashboard.indie_wall.share_pixel_default_desc', { wall: wall.value?.name }),
72
- )
73
- // share_image_url (auto-generated branded reveal) takes priority, then the
74
- // pixel's own uploaded image, then the wall's cover/logo as a last resort.
75
- // indie_wall_pixels.image_url is often a base64 data: URI (the column was
76
- // widened to `text` specifically for that) — a social crawler can't fetch a
77
- // data: URI for og:image/twitter:image, so it's skipped here in favor of the
78
- // wall's own image; the inline <img>/download link elsewhere can still use
79
- // the raw value since those render in an actual browser.
80
- const isShareableImageUrl = (url: unknown): url is string => typeof url === 'string' && /^https?:\/\//i.test(url)
81
- const seoImage = computed(() => {
82
- if (pixel.value?.share_image_url) return pixel.value.share_image_url
83
- if (isShareableImageUrl(pixel.value?.image_url)) return pixel.value.image_url
84
- return wall.value?.cover_image_url || wall.value?.logo_url || ''
85
- })
86
-
87
- // Raw useHead meta-array form — same proven pattern as
88
- // pages/mural/[slug]/index.vue (see the comment there: useSeoMeta silently
89
- // dropped og:*/twitter:*/description overrides in this app).
90
- useHead(() => ({
91
- title: seoTitle.value,
92
- link: [{ rel: 'canonical', href: requestUrl.href }],
93
- meta: [
94
- { name: 'description', content: seoDescription.value },
95
- { property: 'og:title', content: seoTitle.value },
96
- { property: 'og:description', content: seoDescription.value },
97
- ...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
98
- { property: 'og:type', content: 'website' },
99
- { property: 'og:url', content: requestUrl.href },
100
- { property: 'og:site_name', content: 'Mundo Gamer Wall' },
101
- { name: 'twitter:card', content: 'summary_large_image' },
102
- { name: 'twitter:title', content: seoTitle.value },
103
- { name: 'twitter:description', content: seoDescription.value },
104
- ...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
105
- ],
106
- }))
107
-
108
- // ── Share: copy / native share the page link ────────────────────────────────
109
- const shareText = computed(() => t('tv.dashboard.indie_wall.share_pixel_text', {
110
- name: displayName.value,
111
- wall: wall.value?.name,
112
- }))
113
- const shareTwitterUrl = computed(() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText.value)}&url=${encodeURIComponent(requestUrl.href)}`)
114
- const shareWhatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(`${shareText.value} ${requestUrl.href}`)}`)
115
- const shareFacebookUrl = computed(() => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(requestUrl.href)}`)
116
-
117
- const linkCopied = ref(false)
118
- async function shareOrCopyLink() {
119
- if (typeof navigator !== 'undefined' && (navigator as any).share) {
120
- try {
121
- await (navigator as any).share({ title: seoTitle.value, text: shareText.value, url: requestUrl.href })
122
- return
123
- } catch {
124
- // user cancelled the native sheet, or it's unsupported for this
125
- // context — fall back to clipboard copy below.
126
- }
127
- }
128
- try {
129
- await navigator.clipboard.writeText(requestUrl.href)
130
- linkCopied.value = true
131
- setTimeout(() => { linkCopied.value = false }, 2000)
132
- } catch { /* clipboard unavailable */ }
133
- }
134
-
135
- // ── Download the pixel's own image ──────────────────────────────────────────
136
- const pixelDownloadImage = computed(() => pixel.value?.share_image_url || pixel.value?.image_url || '')
137
-
138
- // ── Generate-on-demand branded reveal video ─────────────────────────────────
139
- const renderState = ref<'idle' | 'rendering' | 'done' | 'failed'>('idle')
140
- const renderAssetUrl = ref<string | null>(null)
141
- let renderPollTimer: ReturnType<typeof setInterval> | null = null
142
-
143
- function stopRenderPolling() {
144
- if (renderPollTimer) {
145
- clearInterval(renderPollTimer)
146
- renderPollTimer = null
147
- }
148
- }
149
-
150
- function pollRenderJob(jobId: string) {
151
- stopRenderPolling()
152
- renderPollTimer = setInterval(async () => {
153
- try {
154
- const res = await pollWallPixelShareRender(wall.value.id, pixel.value.id, jobId)
155
- const status = res.data?.status
156
- if (status === 'done') {
157
- renderAssetUrl.value = res.data?.assetUrl || null
158
- renderState.value = renderAssetUrl.value ? 'done' : 'failed'
159
- stopRenderPolling()
160
- } else if (status === 'failed') {
161
- renderState.value = 'failed'
162
- stopRenderPolling()
163
- }
164
- // any other status (e.g. 'rendering') keeps the loop going
165
- } catch {
166
- // transient network hiccup on a single poll — keep trying
167
- }
168
- }, 3000)
169
- }
170
-
171
- async function generateReveal() {
172
- if (!wall.value?.id || !pixel.value?.id || renderState.value === 'rendering') return
173
- renderState.value = 'rendering'
174
- renderAssetUrl.value = null
175
- try {
176
- const res = await requestWallPixelShareRender(wall.value.id, pixel.value.id, 'video')
177
- const jobId = res.data?.jobId
178
- if (!jobId) throw new Error('missing_job_id')
179
- pollRenderJob(jobId)
180
- } catch {
181
- renderState.value = 'failed'
182
- }
183
- }
184
-
185
- const renderAssetIsVideo = computed(() =>
186
- !!renderAssetUrl.value && /\.(mp4|webm|mov)(\?|$)/i.test(renderAssetUrl.value),
187
- )
188
-
189
- onUnmounted(() => stopRenderPolling())
190
-
191
- // ── Back to wall ─────────────────────────────────────────────────────────────
192
- const backToWallUrl = computed(() => localePath(`/mural/${wallSlug}`))
193
- </script>
194
-
195
- <template>
196
- <div class="pixel-page">
197
- <NuxtLink :to="backToWallUrl" class="back-link">
198
- ← {{ $t('tv.dashboard.indie_wall.back_to_wall') }}
199
- </NuxtLink>
200
-
201
- <div class="pixel-card">
202
- <div class="pixel-hero" :style="{ background: pixel.background_color || '#272930' }">
203
- <img v-if="pixel.share_image_url || pixel.image_url" :src="pixel.share_image_url || pixel.image_url" alt="" />
204
- <span v-else class="pixel-initial">{{ (displayName || '?')[0].toUpperCase() }}</span>
205
- </div>
206
-
207
- <div class="pixel-body">
208
- <p class="pixel-name">{{ displayName }}</p>
209
- <p v-if="pixel.title && pixel.title !== displayName" class="pixel-title">{{ pixel.title }}</p>
210
- <p v-if="pixel.supporter_message" class="pixel-msg">"{{ pixel.supporter_message }}"</p>
211
-
212
- <div class="pixel-amount-row">
213
- <span class="pixel-amount-label">{{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
214
- <span class="pixel-amount-value">{{ formatMoney(Number(pixel.amount_paid), wall?.currency) }}</span>
215
- </div>
216
-
217
- <div class="pixel-chips">
218
- <span class="pixel-chip">{{ pixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
219
- <span class="pixel-chip">{{ $t('tv.dashboard.indie_wall.mural_position') }} ({{ pixel.x }}, {{ pixel.y }})</span>
220
- </div>
221
-
222
- <a v-if="pixel.link" :href="pixel.link" target="_blank" rel="noopener" class="pixel-link">
223
- {{ pixel.link }}
224
- </a>
225
-
226
- <p class="pixel-wall-context">
227
- {{ $t('tv.dashboard.indie_wall.mural_by', { name: wall.owner_nickname || wall.owner_name || wall.name }) }}
228
- </p>
229
-
230
- <!-- ── Share ─────────────────────────────────────────────────── -->
231
- <div class="pixel-share">
232
- <p class="pixel-share-title">{{ $t('tv.dashboard.indie_wall.share_pixel_cta') }}</p>
233
-
234
- <div class="pixel-share-row">
235
- <a :href="shareTwitterUrl" target="_blank" rel="noopener" class="pixel-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
236
- <a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="pixel-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
237
- <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M9.2 6.8c-.2-.45-.42-.46-.62-.47h-.53c-.18 0-.48.07-.73.34-.25.27-.96.93-.96 2.28s.98 2.65 1.12 2.83c.14.18 1.9 3.03 4.7 4.14 2.32.92 2.8.74 3.3.7.5-.05 1.62-.66 1.85-1.3.23-.63.23-1.17.16-1.29-.07-.11-.25-.18-.53-.32-.27-.14-1.62-.8-1.87-.89-.25-.09-.43-.14-.62.14-.18.27-.7.89-.86 1.07-.16.18-.32.2-.59.07-.27-.14-1.15-.42-2.19-1.35-.81-.72-1.36-1.62-1.52-1.89-.16-.27-.02-.42.12-.55.13-.13.27-.33.4-.5.13-.16.18-.27.27-.45.09-.18.05-.34-.02-.48-.07-.14-.6-1.5-.85-2.05Z"/></svg>
238
- </a>
239
- <a :href="shareFacebookUrl" target="_blank" rel="noopener" class="pixel-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_facebook')">f</a>
240
- <button type="button" class="pixel-share-btn" @click="shareOrCopyLink" :aria-label="$t('tv.dashboard.indie_wall.share_copy_link')">
241
- {{ linkCopied ? '✓' : '🔗' }}
242
- </button>
243
- </div>
244
- <p v-if="linkCopied" class="pixel-share-copied">{{ $t('tv.dashboard.indie_wall.share_link_copied') }}</p>
245
-
246
- <a
247
- v-if="pixelDownloadImage"
248
- :href="pixelDownloadImage"
249
- download
250
- target="_blank"
251
- rel="noopener"
252
- class="pixel-share-action"
253
- >
254
- {{ $t('tv.dashboard.indie_wall.share_download_image') }}
255
- </a>
256
-
257
- <button
258
- type="button"
259
- class="pixel-share-action"
260
- :disabled="renderState === 'rendering'"
261
- @click="generateReveal"
262
- >
263
- {{ renderState === 'rendering'
264
- ? $t('tv.dashboard.indie_wall.share_generating')
265
- : $t('tv.dashboard.indie_wall.share_generate_video') }}
266
- </button>
267
-
268
- <p v-if="renderState === 'failed'" class="pixel-share-error">
269
- {{ $t('tv.dashboard.indie_wall.share_generate_failed') }}
270
- </p>
271
-
272
- <div v-if="renderState === 'done' && renderAssetUrl" class="pixel-reveal-result">
273
- <p class="pixel-reveal-title">{{ $t('tv.dashboard.indie_wall.share_video_ready') }}</p>
274
- <video v-if="renderAssetIsVideo" :src="renderAssetUrl" controls class="pixel-reveal-media" />
275
- <img v-else :src="renderAssetUrl" alt="" class="pixel-reveal-media" />
276
- <a :href="renderAssetUrl" download target="_blank" rel="noopener" class="pixel-share-action">
277
- {{ $t('tv.dashboard.indie_wall.share_download_image') }}
278
- </a>
279
- </div>
280
- </div>
281
- </div>
282
- </div>
283
- </div>
284
- </template>
285
-
286
- <style lang="scss" scoped>
287
- .pixel-page {
288
- max-width: 480px;
289
- margin: 0 auto;
290
- padding: 32px 16px 100px;
291
- }
292
-
293
- .back-link {
294
- display: inline-block;
295
- color: var(--secondary-info-fg, #aaa);
296
- font-size: 0.82rem;
297
- text-decoration: none;
298
- margin-bottom: 20px;
299
- &:hover { color: var(--title-fg, #fff); }
300
- }
301
-
302
- .pixel-card {
303
- background: #13141A;
304
- border: 1px solid rgba(107, 21, 172, 0.35);
305
- overflow: hidden;
306
- box-shadow: 0 16px 48px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(107, 21, 172, 0.1);
307
- }
308
-
309
- .pixel-hero {
310
- width: 100%;
311
- height: 260px;
312
- overflow: hidden;
313
- display: flex;
314
- align-items: center;
315
- justify-content: center;
316
- background: #0d0f13;
317
- img { max-width: 100%; max-height: 100%; object-fit: contain; }
318
- }
319
- .pixel-initial {
320
- font-size: 5rem;
321
- font-weight: 700;
322
- color: rgba(255, 255, 255, 0.5);
323
- line-height: 1;
324
- }
325
-
326
- .pixel-body { padding: 24px; }
327
- .pixel-name {
328
- font-size: 1.3rem;
329
- font-weight: 700;
330
- color: var(--title-fg, #fff);
331
- margin: 0 0 4px;
332
- }
333
- .pixel-title {
334
- color: var(--secondary-info-fg, #aaa);
335
- font-size: 0.85rem;
336
- line-height: 1.35;
337
- margin: 0 0 10px;
338
- }
339
- .pixel-msg {
340
- color: var(--secondary-info-fg, #aaa);
341
- font-size: 0.92rem;
342
- font-style: italic;
343
- line-height: 1.5;
344
- margin: 0 0 16px;
345
- }
346
- .pixel-amount-row {
347
- display: flex;
348
- align-items: center;
349
- justify-content: space-between;
350
- background: rgba(107, 21, 172, 0.12);
351
- border: 1px solid rgba(107, 21, 172, 0.25);
352
- padding: 10px 14px;
353
- margin-bottom: 12px;
354
- }
355
- .pixel-amount-label {
356
- font-size: 12px;
357
- color: var(--secondary-info-fg, #aaa);
358
- text-transform: uppercase;
359
- letter-spacing: 0.04em;
360
- }
361
- .pixel-amount-value {
362
- font-weight: 700;
363
- font-size: 1.15rem;
364
- color: var(--chip-text, #D297FF);
365
- }
366
- .pixel-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
367
- .pixel-chip {
368
- font-size: 11px;
369
- color: var(--secondary-info-fg, #aaa);
370
- background: rgba(255, 255, 255, 0.05);
371
- border: 1px solid rgba(255, 255, 255, 0.08);
372
- padding: 3px 8px;
373
- }
374
- .pixel-link {
375
- display: flex;
376
- align-items: center;
377
- gap: 6px;
378
- color: var(--chip-text, #D297FF);
379
- font-size: 0.8rem;
380
- word-break: break-all;
381
- text-decoration: none;
382
- margin-bottom: 8px;
383
- &:hover { text-decoration: underline; }
384
- }
385
- .pixel-wall-context {
386
- color: var(--secondary-info-fg, #888);
387
- font-size: 0.78rem;
388
- margin: 0;
389
- }
390
-
391
- .pixel-share {
392
- margin-top: 20px;
393
- padding-top: 20px;
394
- border-top: 1px solid #272930;
395
- }
396
- .pixel-share-title {
397
- margin: 0 0 10px;
398
- font-size: 0.85rem;
399
- font-weight: 600;
400
- color: var(--title-fg, #fff);
401
- }
402
- .pixel-share-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
403
- .pixel-share-btn {
404
- width: 36px;
405
- height: 36px;
406
- flex-shrink: 0;
407
- display: flex;
408
- align-items: center;
409
- justify-content: center;
410
- background: rgba(255, 255, 255, 0.06);
411
- border: 1px solid #272930;
412
- color: var(--title-fg, #fff);
413
- font-size: 0.9rem;
414
- font-weight: 700;
415
- cursor: pointer;
416
- text-decoration: none;
417
- transition: border-color 0.15s;
418
- &:hover { border-color: var(--chip-text, #D297FF); }
419
- }
420
- .pixel-share-copied {
421
- color: var(--chip-text, #D297FF);
422
- font-size: 0.78rem;
423
- margin: 0 0 10px;
424
- }
425
- .pixel-share-action {
426
- display: block;
427
- width: 100%;
428
- text-align: center;
429
- background: none;
430
- border: 1px solid var(--chip-text, #D297FF);
431
- color: var(--chip-text, #D297FF);
432
- font-weight: 600;
433
- font-size: 0.85rem;
434
- padding: 10px 14px;
435
- margin-top: 8px;
436
- cursor: pointer;
437
- text-decoration: none;
438
- transition: background 0.15s, color 0.15s, opacity 0.15s;
439
- &:hover:not(:disabled) { background: var(--chip-text, #D297FF); color: #13161C; }
440
- &:disabled { opacity: 0.5; cursor: not-allowed; }
441
- }
442
- .pixel-share-error {
443
- color: #ff6b6b;
444
- font-size: 0.78rem;
445
- margin: 8px 0 0;
446
- }
447
- .pixel-reveal-result { margin-top: 16px; }
448
- .pixel-reveal-title {
449
- margin: 0 0 8px;
450
- font-size: 0.82rem;
451
- font-weight: 600;
452
- color: var(--title-fg, #fff);
453
- }
454
- .pixel-reveal-media {
455
- width: 100%;
456
- display: block;
457
- background: #0d0f13;
458
- border: 1px solid #272930;
459
- }
460
- </style>
@@ -1,12 +0,0 @@
1
- <script setup lang="ts">
2
- const route = useRoute()
3
- const localePath = useLocalePath()
4
- const slug = route.params.slug as string
5
- const pixelId = route.params.pixelId as string
6
- const qs = Object.keys(route.query).length
7
- ? '?' + new URLSearchParams(route.query as Record<string, string>).toString()
8
- : ''
9
- navigateTo(localePath(`/mural/${slug}/pixel/${pixelId}`) + qs, { replace: true })
10
- </script>
11
-
12
- <template><div /></template>