@mundogamernetwork/shared-ui 1.0.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.
Files changed (87) hide show
  1. package/README.md +283 -0
  2. package/components/PressKit/AssetGallery.vue +349 -0
  3. package/components/PressKit/Awards.vue +100 -0
  4. package/components/PressKit/Credits.vue +78 -0
  5. package/components/PressKit/FactSheet.vue +204 -0
  6. package/components/PressKit/Hero.vue +143 -0
  7. package/components/PressKit/Quotes.vue +80 -0
  8. package/components/PressKit/VideoPlayer.vue +134 -0
  9. package/components/checkout/MgCartItemList.vue +214 -0
  10. package/components/checkout/MgCartSummary.vue +204 -0
  11. package/components/checkout/MgCheckoutSidebar.vue +230 -0
  12. package/components/checkout/MgGuestEmailForm.vue +97 -0
  13. package/components/checkout/MgPaymentMethodSelector.vue +162 -0
  14. package/components/checkout/MgPixQRCode.vue +222 -0
  15. package/components/indie-wall/IndieWallLeaderboard.vue +208 -0
  16. package/components/indie-wall/MuralCanvas.vue +481 -0
  17. package/components/indie-wall/StepBlock.vue +314 -0
  18. package/components/indie-wall/StepCustomize.vue +530 -0
  19. package/components/indie-wall/StepGoal.vue +169 -0
  20. package/components/indie-wall/StepPackage.vue +145 -0
  21. package/components/indie-wall/StepPay.vue +209 -0
  22. package/components/indie-wall/SupportStepper.vue +372 -0
  23. package/components/invoices/MgInvoiceDownload.vue +50 -0
  24. package/components/pricing/MgBillingToggle.vue +74 -0
  25. package/components/pricing/MgPricingCard.vue +245 -0
  26. package/components/ui/Header/MgMessageCard.vue +147 -0
  27. package/components/ui/Header/MgMessageModal.vue +414 -0
  28. package/components/ui/Header/MgNotificationCard.vue +200 -0
  29. package/components/ui/Header/MgNotificationsModal.vue +125 -0
  30. package/components/ui/MgAnnouncementBanner.vue +147 -0
  31. package/components/ui/MgBanners.vue +23 -0
  32. package/components/ui/MgHeaderComponent.vue +283 -0
  33. package/components/ui/MgHeaderUIConfig.vue +225 -0
  34. package/components/ui/MgHeaderUIUser.vue +301 -0
  35. package/components/ui/MgLoginModal.vue +156 -0
  36. package/components/ui/MgPromotionBanner.vue +185 -0
  37. package/composables/useLogout.ts +42 -0
  38. package/composables/useMgCheckout.ts +287 -0
  39. package/composables/useMgUserNotifications.ts +122 -0
  40. package/composables/usePaymentMethods.ts +75 -0
  41. package/composables/useSubscription.ts +163 -0
  42. package/middleware/auth.global.ts +40 -0
  43. package/nuxt.config.ts +31 -0
  44. package/package.json +40 -0
  45. package/pages/[slug]/index.vue +112 -0
  46. package/pages/about.vue +133 -0
  47. package/pages/blog.vue +430 -0
  48. package/pages/careers.vue +329 -0
  49. package/pages/contact.vue +339 -0
  50. package/pages/faq.vue +317 -0
  51. package/pages/health-check.vue +20 -0
  52. package/pages/icons.vue +58 -0
  53. package/pages/magazine/[slug].vue +209 -0
  54. package/pages/magazine/index.vue +267 -0
  55. package/pages/media-kit/[slug].vue +625 -0
  56. package/pages/mural/[slug].vue +1058 -0
  57. package/pages/partners.vue +290 -0
  58. package/pages/press.vue +237 -0
  59. package/pages/presskit/[slug].vue +191 -0
  60. package/pages/roadmap.vue +355 -0
  61. package/pages/status.vue +199 -0
  62. package/pages/team.vue +266 -0
  63. package/pages/wall/[slug].vue +11 -0
  64. package/plugins/auth.client.ts +17 -0
  65. package/plugins/echo.client.ts +132 -0
  66. package/services/authService.ts +95 -0
  67. package/services/chatService.ts +53 -0
  68. package/services/contactService.ts +35 -0
  69. package/services/documentService.ts +16 -0
  70. package/services/httpService.ts +95 -0
  71. package/services/indieWallService.ts +174 -0
  72. package/services/institutionalService.ts +248 -0
  73. package/services/mediaKitService.ts +51 -0
  74. package/services/notificationsService.ts +20 -0
  75. package/services/pressKitService.ts +55 -0
  76. package/stores/announcement.ts +129 -0
  77. package/stores/auth.ts +86 -0
  78. package/stores/chat.ts +150 -0
  79. package/stores/contact.ts +28 -0
  80. package/stores/document.ts +27 -0
  81. package/stores/index.ts +34 -0
  82. package/stores/institutional.ts +231 -0
  83. package/stores/login.ts +27 -0
  84. package/stores/notifications.ts +133 -0
  85. package/stores/promotion.ts +154 -0
  86. package/types/index.ts +135 -0
  87. package/utils/serialize.ts +29 -0
@@ -0,0 +1,208 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ supporters: any[]
4
+ }>()
5
+
6
+ const { t } = useI18n()
7
+ const showAll = ref(false)
8
+
9
+ interface RankEntry {
10
+ key: string
11
+ name: string
12
+ avatarUrl: string | null
13
+ totalPixels: number
14
+ purchases: number
15
+ position: number
16
+ }
17
+
18
+ const ranked = computed<RankEntry[]>(() => {
19
+ const map = new Map<string, RankEntry>()
20
+
21
+ for (const s of props.supporters) {
22
+ if (s.status === false) continue
23
+
24
+ const userId = s.user?.id
25
+ const email = s.guest_email
26
+ const key = userId ? `u:${userId}` : email ? `e:${email}` : null
27
+ if (!key) continue
28
+
29
+ const name = s.is_anonymous
30
+ ? t('tv.dashboard.indie_wall.anonymous')
31
+ : (s.title || s.user?.name || s.guest_name || 'Guest')
32
+
33
+ if (map.has(key)) {
34
+ const entry = map.get(key)!
35
+ entry.totalPixels += s.pixel_count || 1
36
+ entry.purchases += 1
37
+ } else {
38
+ map.set(key, {
39
+ key,
40
+ name,
41
+ avatarUrl: s.user?.avatar_url || null,
42
+ totalPixels: s.pixel_count || 1,
43
+ purchases: 1,
44
+ position: 0,
45
+ })
46
+ }
47
+ }
48
+
49
+ return Array.from(map.values())
50
+ .sort((a, b) => b.totalPixels - a.totalPixels)
51
+ .map((entry, i) => ({ ...entry, position: i + 1 }))
52
+ })
53
+
54
+ const SHOW_INITIAL = 10
55
+ const visible = computed(() => showAll.value ? ranked.value : ranked.value.slice(0, SHOW_INITIAL))
56
+
57
+ function initials(name: string): string {
58
+ return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
59
+ }
60
+ </script>
61
+
62
+ <template>
63
+ <div v-if="ranked.length" class="iw-rank">
64
+ <div class="iw-rank-header">
65
+ <span class="iw-rank-title">{{ $t('tv.dashboard.indie_wall.leaderboard_title') }}</span>
66
+ <span class="iw-rank-title-span">{{ $t('tv.dashboard.indie_wall.leaderboard_title_span') }}</span>
67
+ </div>
68
+
69
+ <div class="iw-rank-list">
70
+ <div
71
+ v-for="entry in visible"
72
+ :key="entry.key"
73
+ class="iw-rank-row"
74
+ :class="{ 'iw-rank-first': entry.position === 1 }"
75
+ >
76
+ <div class="iw-rank-pos" :class="`iw-pos-${Math.min(entry.position, 4)}`">
77
+ {{ entry.position }}
78
+ </div>
79
+ <div class="iw-rank-avi">
80
+ <img v-if="entry.avatarUrl" :src="entry.avatarUrl" :alt="entry.name" />
81
+ <span v-else class="iw-rank-init">{{ initials(entry.name) }}</span>
82
+ </div>
83
+ <div class="iw-rank-info">
84
+ <span class="iw-rank-name">{{ entry.name }}</span>
85
+ <span class="iw-rank-meta">
86
+ {{ entry.totalPixels }} {{ $t('tv.dashboard.indie_wall.leaderboard_pixels') }}
87
+ &nbsp;|&nbsp;
88
+ {{ entry.purchases }}
89
+ {{ entry.purchases === 1 ? $t('tv.dashboard.indie_wall.leaderboard_purchases') : $t('tv.dashboard.indie_wall.leaderboard_purchases_plural') }}
90
+ </span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <button v-if="ranked.length > SHOW_INITIAL" class="iw-rank-toggle" type="button" @click="showAll = !showAll">
96
+ {{ showAll ? $t('tv.dashboard.indie_wall.leaderboard_show_less') : $t('tv.dashboard.indie_wall.leaderboard_show_more') }}
97
+ </button>
98
+ </div>
99
+ </template>
100
+
101
+ <style scoped lang="scss">
102
+ .iw-rank {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 16px;
106
+ }
107
+
108
+ .iw-rank-header {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 6px;
112
+ border-left: 3px solid var(--chip-text, #D297FF);
113
+ padding-left: 12px;
114
+ }
115
+ .iw-rank-title {
116
+ font-size: 1.1rem;
117
+ font-weight: 600;
118
+ color: var(--title-fg, #fff);
119
+ }
120
+ .iw-rank-title-span {
121
+ color: var(--chip-text, #D297FF);
122
+ font-size: 1.1rem;
123
+ font-weight: 600;
124
+ }
125
+
126
+ .iw-rank-list {
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 6px;
130
+ }
131
+
132
+ .iw-rank-row {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 12px;
136
+ background: #191B20;
137
+ border: 1px solid #1e2028;
138
+ padding: 10px 14px;
139
+ transition: border-color 0.15s;
140
+
141
+ &.iw-rank-first {
142
+ border-color: var(--chip-text, #D297FF);
143
+ background: rgba(210, 151, 255, 0.05);
144
+ }
145
+ }
146
+
147
+ .iw-rank-pos {
148
+ min-width: 28px;
149
+ text-align: center;
150
+ font-size: 0.82rem;
151
+ font-weight: 700;
152
+ color: var(--secondary-info-fg, #666);
153
+
154
+ &.iw-pos-1 { color: var(--chip-text, #D297FF); font-size: 1rem; }
155
+ &.iw-pos-2 { color: #aaa; }
156
+ &.iw-pos-3 { color: #888; }
157
+ }
158
+
159
+ .iw-rank-avi {
160
+ width: 36px;
161
+ height: 36px;
162
+ flex-shrink: 0;
163
+ overflow: hidden;
164
+ background: #272930;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+
169
+ img { width: 100%; height: 100%; object-fit: cover; }
170
+ }
171
+
172
+ .iw-rank-init {
173
+ font-size: 0.75rem;
174
+ font-weight: 700;
175
+ color: var(--chip-text, #D297FF);
176
+ letter-spacing: 0.05em;
177
+ }
178
+
179
+ .iw-rank-info {
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 2px;
183
+ min-width: 0;
184
+ }
185
+ .iw-rank-name {
186
+ font-size: 0.88rem;
187
+ font-weight: 600;
188
+ color: var(--title-fg, #fff);
189
+ white-space: nowrap;
190
+ overflow: hidden;
191
+ text-overflow: ellipsis;
192
+ }
193
+ .iw-rank-meta {
194
+ font-size: 0.72rem;
195
+ color: var(--secondary-info-fg, #888);
196
+ }
197
+
198
+ .iw-rank-toggle {
199
+ background: none;
200
+ border: none;
201
+ color: var(--secondary-info-fg, #888);
202
+ font-size: 0.78rem;
203
+ cursor: pointer;
204
+ padding: 0;
205
+ text-align: left;
206
+ &:hover { color: var(--title-fg, #fff); }
207
+ }
208
+ </style>
@@ -0,0 +1,481 @@
1
+ <script setup lang="ts">
2
+ interface WallShape {
3
+ width: number
4
+ height: number
5
+ }
6
+
7
+ interface Supporter {
8
+ id?: number | string
9
+ x: number
10
+ y: number
11
+ width?: number
12
+ height?: number
13
+ background_color?: string
14
+ image_url?: string
15
+ [k: string]: any
16
+ }
17
+
18
+ interface SelectionRect {
19
+ x: number
20
+ y: number
21
+ w: number
22
+ h: number
23
+ }
24
+
25
+ const props = withDefaults(defineProps<{
26
+ wall: WallShape | null
27
+ supporters: Supporter[]
28
+ selection?: SelectionRect | null
29
+ interactive?: boolean
30
+ height?: number
31
+ preselect?: { x: number; y: number } | null
32
+ }>(), {
33
+ selection: null,
34
+ interactive: true,
35
+ height: 480,
36
+ preselect: null,
37
+ })
38
+
39
+ const emit = defineEmits<{
40
+ (e: 'tap-empty', coord: { x: number; y: number }): void
41
+ (e: 'tap-supporter', supporter: Supporter): void
42
+ (e: 'hover-supporter', payload: { supporter: Supporter | null; clientX: number; clientY: number }): void
43
+ }>()
44
+
45
+ const wrapperRef = ref<HTMLElement | null>(null)
46
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
47
+ let ctx: CanvasRenderingContext2D | null = null
48
+ const canvasReady = ref(false)
49
+
50
+ const scale = ref(1)
51
+ const translateX = ref(0)
52
+ const translateY = ref(0)
53
+ const minScale = ref(0.5)
54
+
55
+ const GRID_THRESHOLD = 4
56
+ const DRAG_THRESHOLD = 5
57
+
58
+ const interaction = reactive({
59
+ isPanning: false,
60
+ hasDragged: false,
61
+ isPinching: false,
62
+ startX: 0,
63
+ startY: 0,
64
+ panStartX: 0,
65
+ panStartY: 0,
66
+ initialPinchDistance: 0,
67
+ })
68
+
69
+ const occupationMap = new Map<string, Supporter>()
70
+ const imageCache = new Map<string, HTMLImageElement>()
71
+ const imageLoadingSet = new Set<string>()
72
+ let rafId: number | null = null
73
+
74
+ function scheduleRedraw() {
75
+ if (rafId !== null) return
76
+ rafId = requestAnimationFrame(() => {
77
+ rafId = null
78
+ drawCanvas()
79
+ })
80
+ }
81
+
82
+ function rebuildOccupationMap() {
83
+ occupationMap.clear()
84
+ for (const s of props.supporters) {
85
+ const pw = s.width || 1
86
+ const ph = s.height || 1
87
+ for (let row = s.y; row < s.y + ph; row++) {
88
+ for (let col = s.x; col < s.x + pw; col++) {
89
+ occupationMap.set(`${col},${row}`, s)
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ function cacheImage(src: string) {
96
+ if (imageCache.has(src) || imageLoadingSet.has(src)) return
97
+ imageLoadingSet.add(src)
98
+ const img = new Image()
99
+ img.onload = () => {
100
+ imageCache.set(src, img)
101
+ imageLoadingSet.delete(src)
102
+ if (canvasReady.value) scheduleRedraw()
103
+ }
104
+ img.onerror = () => imageLoadingSet.delete(src)
105
+ img.src = src
106
+ }
107
+
108
+ function constrainPan(targetScale: number, tx: number, ty: number) {
109
+ if (!wrapperRef.value || !props.wall) return { x: tx, y: ty }
110
+ const cW = wrapperRef.value.clientWidth
111
+ const cH = wrapperRef.value.clientHeight
112
+ const sw = props.wall.width * targetScale
113
+ const sh = props.wall.height * targetScale
114
+ return {
115
+ x: sw <= cW ? (cW - sw) / 2 : Math.max(cW - sw, Math.min(tx, 0)),
116
+ y: sh <= cH ? (cH - sh) / 2 : Math.max(cH - sh, Math.min(ty, 0)),
117
+ }
118
+ }
119
+
120
+ function drawCanvas() {
121
+ if (!ctx || !canvasRef.value || !props.wall) return
122
+ const canvas = canvasRef.value
123
+ const c = ctx
124
+ c.save()
125
+ c.clearRect(0, 0, canvas.width, canvas.height)
126
+ c.translate(translateX.value, translateY.value)
127
+ c.scale(scale.value, scale.value)
128
+
129
+ const W = props.wall.width
130
+ const H = props.wall.height
131
+
132
+ c.fillStyle = '#0E0F13'
133
+ c.fillRect(0, 0, W, H)
134
+
135
+ if (scale.value >= GRID_THRESHOLD) {
136
+ c.strokeStyle = 'rgba(255,255,255,0.07)'
137
+ c.lineWidth = 1 / scale.value
138
+ const vx = -translateX.value / scale.value
139
+ const vy = -translateY.value / scale.value
140
+ const vw = canvas.width / scale.value
141
+ const vh = canvas.height / scale.value
142
+ const sx = Math.max(0, Math.floor(vx))
143
+ const ex = Math.min(W, Math.ceil(vx + vw))
144
+ const sy = Math.max(0, Math.floor(vy))
145
+ const ey = Math.min(H, Math.ceil(vy + vh))
146
+ c.beginPath()
147
+ for (let x = sx; x <= ex; x++) { c.moveTo(x, sy); c.lineTo(x, ey) }
148
+ for (let y = sy; y <= ey; y++) { c.moveTo(sx, y); c.lineTo(ex, y) }
149
+ c.stroke()
150
+ }
151
+
152
+ // Deduplicate by top-left cell — keep highest id (latest pixel) per position.
153
+ const dedupMap = new Map<string, typeof props.supporters[0]>()
154
+ for (const s of props.supporters) {
155
+ const key = `${s.x},${s.y}`
156
+ const existing = dedupMap.get(key)
157
+ if (!existing || Number(s.id) > Number(existing.id)) dedupMap.set(key, s)
158
+ }
159
+
160
+ for (const s of dedupMap.values()) {
161
+ const pw = s.width || 1
162
+ const ph = s.height || 1
163
+ if (s.image_url && imageCache.has(s.image_url)) {
164
+ c.imageSmoothingEnabled = false
165
+ c.drawImage(imageCache.get(s.image_url)!, s.x, s.y, pw, ph)
166
+ } else {
167
+ c.fillStyle = s.background_color || '#FDB215'
168
+ c.fillRect(s.x, s.y, pw, ph)
169
+ if (s.image_url) cacheImage(s.image_url)
170
+ }
171
+ }
172
+
173
+ if (props.selection) {
174
+ const r = props.selection
175
+ c.fillStyle = 'rgba(253,178,21,0.28)'
176
+ c.strokeStyle = '#FDB215'
177
+ c.lineWidth = 1.5 / scale.value
178
+ c.fillRect(r.x, r.y, r.w, r.h)
179
+ c.strokeRect(r.x, r.y, r.w, r.h)
180
+ }
181
+
182
+ c.restore()
183
+ }
184
+
185
+ function getWorldCoords(clientX: number, clientY: number) {
186
+ if (!canvasRef.value) return { x: 0, y: 0 }
187
+ const r = canvasRef.value.getBoundingClientRect()
188
+ return {
189
+ x: Math.floor((clientX - r.left - translateX.value) / scale.value),
190
+ y: Math.floor((clientY - r.top - translateY.value) / scale.value),
191
+ }
192
+ }
193
+
194
+ function handleTap(clientX: number, clientY: number) {
195
+ if (!props.interactive || !props.wall) return
196
+ const { x, y } = getWorldCoords(clientX, clientY)
197
+ if (x < 0 || y < 0 || x >= props.wall.width || y >= props.wall.height) return
198
+ const occupied = occupationMap.get(`${x},${y}`)
199
+ if (occupied) {
200
+ emit('tap-supporter', occupied)
201
+ return
202
+ }
203
+ emit('tap-empty', { x, y })
204
+ }
205
+
206
+ function handleMouseDown(e: MouseEvent) {
207
+ if (e.button !== 0) return
208
+ e.preventDefault()
209
+ interaction.isPanning = true
210
+ interaction.hasDragged = false
211
+ interaction.startX = e.clientX
212
+ interaction.startY = e.clientY
213
+ interaction.panStartX = translateX.value
214
+ interaction.panStartY = translateY.value
215
+ }
216
+
217
+ function handleMouseMove(e: MouseEvent) {
218
+ if (interaction.isPanning) {
219
+ const dx = Math.abs(e.clientX - interaction.startX)
220
+ const dy = Math.abs(e.clientY - interaction.startY)
221
+ if (!interaction.hasDragged && (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD)) {
222
+ interaction.hasDragged = true
223
+ }
224
+ if (interaction.hasDragged) {
225
+ const c = constrainPan(scale.value,
226
+ interaction.panStartX + (e.clientX - interaction.startX),
227
+ interaction.panStartY + (e.clientY - interaction.startY))
228
+ translateX.value = c.x
229
+ translateY.value = c.y
230
+ }
231
+ return
232
+ }
233
+ // Hover detection when not panning
234
+ const { x, y } = getWorldCoords(e.clientX, e.clientY)
235
+ const supp = occupationMap.get(`${x},${y}`) || null
236
+ emit('hover-supporter', { supporter: supp, clientX: e.clientX, clientY: e.clientY })
237
+ }
238
+
239
+ function handleMouseUp(e: MouseEvent) {
240
+ if (!interaction.isPanning) return
241
+ if (!interaction.hasDragged) handleTap(e.clientX, e.clientY)
242
+ interaction.isPanning = false
243
+ interaction.hasDragged = false
244
+ }
245
+
246
+ function handleMouseLeave() {
247
+ interaction.isPanning = false
248
+ interaction.hasDragged = false
249
+ emit('hover-supporter', { supporter: null, clientX: 0, clientY: 0 })
250
+ }
251
+
252
+ function handleWheel(e: WheelEvent) {
253
+ e.preventDefault()
254
+ const r = canvasRef.value!.getBoundingClientRect()
255
+ applyZoom(e.deltaY < 0 ? 1.2 : 1 / 1.2, { x: e.clientX - r.left, y: e.clientY - r.top })
256
+ }
257
+
258
+ function handleTouchStart(e: TouchEvent) {
259
+ e.preventDefault()
260
+ if (e.touches.length === 2) {
261
+ interaction.isPinching = true
262
+ interaction.hasDragged = false
263
+ interaction.initialPinchDistance = Math.hypot(
264
+ e.touches[0].clientX - e.touches[1].clientX,
265
+ e.touches[0].clientY - e.touches[1].clientY,
266
+ )
267
+ } else {
268
+ interaction.isPanning = true
269
+ interaction.hasDragged = false
270
+ interaction.startX = e.touches[0].clientX
271
+ interaction.startY = e.touches[0].clientY
272
+ interaction.panStartX = translateX.value
273
+ interaction.panStartY = translateY.value
274
+ }
275
+ }
276
+
277
+ function handleTouchMove(e: TouchEvent) {
278
+ e.preventDefault()
279
+ if (interaction.isPinching && e.touches.length === 2) {
280
+ const d = Math.hypot(
281
+ e.touches[0].clientX - e.touches[1].clientX,
282
+ e.touches[0].clientY - e.touches[1].clientY,
283
+ )
284
+ if (!interaction.initialPinchDistance) return
285
+ const r = canvasRef.value!.getBoundingClientRect()
286
+ const center = {
287
+ x: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
288
+ y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top,
289
+ }
290
+ applyZoom(d / interaction.initialPinchDistance, center)
291
+ interaction.initialPinchDistance = d
292
+ } else if (interaction.isPanning && e.touches.length === 1) {
293
+ const dx = Math.abs(e.touches[0].clientX - interaction.startX)
294
+ const dy = Math.abs(e.touches[0].clientY - interaction.startY)
295
+ if (!interaction.hasDragged && (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD)) {
296
+ interaction.hasDragged = true
297
+ }
298
+ if (interaction.hasDragged) {
299
+ const c = constrainPan(scale.value,
300
+ interaction.panStartX + (e.touches[0].clientX - interaction.startX),
301
+ interaction.panStartY + (e.touches[0].clientY - interaction.startY))
302
+ translateX.value = c.x
303
+ translateY.value = c.y
304
+ }
305
+ }
306
+ }
307
+
308
+ function handleTouchEnd(e: TouchEvent) {
309
+ if (!interaction.hasDragged && !interaction.isPinching && e.changedTouches.length > 0) {
310
+ handleTap(interaction.startX, interaction.startY)
311
+ }
312
+ interaction.isPanning = false
313
+ interaction.isPinching = false
314
+ interaction.hasDragged = false
315
+ }
316
+
317
+ function applyZoom(factor: number, center: { x: number; y: number }) {
318
+ const newScale = Math.max(minScale.value, Math.min(scale.value * factor, 50))
319
+ if (newScale === scale.value) return
320
+ const worldX = (center.x - translateX.value) / scale.value
321
+ const worldY = (center.y - translateY.value) / scale.value
322
+ const c = constrainPan(newScale, center.x - worldX * newScale, center.y - worldY * newScale)
323
+ scale.value = newScale
324
+ translateX.value = c.x
325
+ translateY.value = c.y
326
+ }
327
+
328
+ function zoomIn() {
329
+ if (!wrapperRef.value) return
330
+ applyZoom(1.2, { x: wrapperRef.value.clientWidth / 2, y: wrapperRef.value.clientHeight / 2 })
331
+ }
332
+
333
+ function zoomOut() {
334
+ if (!wrapperRef.value) return
335
+ applyZoom(1 / 1.2, { x: wrapperRef.value.clientWidth / 2, y: wrapperRef.value.clientHeight / 2 })
336
+ }
337
+
338
+ function resetView() {
339
+ if (!wrapperRef.value || !props.wall) return
340
+ scale.value = minScale.value
341
+ const c = constrainPan(scale.value, 0, 0)
342
+ translateX.value = c.x
343
+ translateY.value = c.y
344
+ scheduleRedraw()
345
+ }
346
+
347
+ function initCanvas() {
348
+ if (!wrapperRef.value || !canvasRef.value || !props.wall) return
349
+ const cw = wrapperRef.value.clientWidth
350
+ const ch = props.height
351
+ canvasRef.value.width = cw
352
+ canvasRef.value.height = ch
353
+ ctx = canvasRef.value.getContext('2d')
354
+ minScale.value = Math.min(cw / props.wall.width, ch / props.wall.height)
355
+ scale.value = minScale.value
356
+ const c = constrainPan(scale.value, 0, 0)
357
+ translateX.value = c.x
358
+ translateY.value = c.y
359
+ canvasReady.value = true
360
+ scheduleRedraw()
361
+
362
+ if (props.preselect) {
363
+ const { x, y } = props.preselect
364
+ if (props.wall && x >= 0 && y >= 0 && x < props.wall.width && y < props.wall.height) {
365
+ emit('tap-empty', { x, y })
366
+ }
367
+ }
368
+ }
369
+
370
+ watch(() => props.wall, async (newWall) => {
371
+ if (!newWall) return
372
+ await nextTick()
373
+ await nextTick()
374
+ initCanvas()
375
+ })
376
+
377
+ watch([
378
+ () => props.supporters,
379
+ () => props.selection,
380
+ scale,
381
+ translateX,
382
+ translateY,
383
+ canvasReady,
384
+ ], () => {
385
+ rebuildOccupationMap()
386
+ if (canvasReady.value) scheduleRedraw()
387
+ }, { deep: true })
388
+
389
+ onMounted(() => {
390
+ rebuildOccupationMap()
391
+ if (props.wall) {
392
+ nextTick(() => initCanvas())
393
+ }
394
+ window.addEventListener('resize', initCanvas)
395
+ })
396
+
397
+ onUnmounted(() => {
398
+ window.removeEventListener('resize', initCanvas)
399
+ if (rafId !== null) cancelAnimationFrame(rafId)
400
+ })
401
+
402
+ defineExpose({ zoomIn, zoomOut, resetView })
403
+ </script>
404
+
405
+ <template>
406
+ <div class="mc-frame" :class="{ 'mc-non-interactive': !interactive }">
407
+ <div v-if="interactive" class="mc-toolbar">
408
+ <button class="mc-zoom" type="button" @click="zoomIn" aria-label="Zoom in">+</button>
409
+ <button class="mc-zoom" type="button" @click="resetView" aria-label="Reset">⊙</button>
410
+ <button class="mc-zoom" type="button" @click="zoomOut" aria-label="Zoom out">−</button>
411
+ </div>
412
+ <div ref="wrapperRef" class="mc-wrapper" :style="{ height: height + 'px' }">
413
+ <canvas
414
+ ref="canvasRef"
415
+ :style="{ cursor: interactive ? (interaction.isPanning ? 'grabbing' : 'crosshair') : 'default' }"
416
+ @wheel.prevent="interactive && handleWheel($event)"
417
+ @mousedown="interactive && handleMouseDown($event)"
418
+ @mousemove="interactive && handleMouseMove($event)"
419
+ @mouseup="interactive && handleMouseUp($event)"
420
+ @mouseleave="interactive && handleMouseLeave()"
421
+ @touchstart.prevent="interactive && handleTouchStart($event)"
422
+ @touchmove.prevent="interactive && handleTouchMove($event)"
423
+ @touchend="interactive && handleTouchEnd($event)"
424
+ @touchcancel="interactive && handleTouchEnd($event)"
425
+ />
426
+ </div>
427
+ </div>
428
+ </template>
429
+
430
+ <style scoped lang="scss">
431
+ .mc-frame {
432
+ display: flex;
433
+ flex-direction: column;
434
+ gap: 8px;
435
+ width: 100%;
436
+ }
437
+
438
+ .mc-toolbar {
439
+ display: flex;
440
+ gap: 2px;
441
+ }
442
+
443
+ .mc-zoom {
444
+ width: 32px;
445
+ height: 32px;
446
+ background: #1e2028;
447
+ border: 1px solid #272930;
448
+ color: var(--secondary-info-fg, #aaa);
449
+ font-size: 1rem;
450
+ display: flex;
451
+ align-items: center;
452
+ justify-content: center;
453
+ cursor: pointer;
454
+ line-height: 1;
455
+ padding: 0;
456
+
457
+ &:hover {
458
+ background: #272930;
459
+ color: var(--title-fg, #fff);
460
+ }
461
+ }
462
+
463
+ .mc-wrapper {
464
+ user-select: none;
465
+ overflow: hidden;
466
+ position: relative;
467
+ border: 1px solid #1e2028;
468
+ background: #0E0F13;
469
+ width: 100%;
470
+ }
471
+
472
+ canvas {
473
+ display: block;
474
+ width: 100%;
475
+ height: 100%;
476
+ }
477
+
478
+ .mc-non-interactive canvas {
479
+ pointer-events: none;
480
+ }
481
+ </style>