@necrolab/dashboard 0.5.33 → 0.5.35

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@necrolab/dashboard",
3
- "version": "0.5.33",
3
+ "version": "0.5.35",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "node scripts/build.mjs",
@@ -7,19 +7,25 @@
7
7
  @apply flex items-center justify-between;
8
8
  padding-top: 0.75rem;
9
9
  padding-bottom: 0.5rem;
10
- flex-wrap: nowrap;
10
+ flex-wrap: nowrap !important;
11
11
  gap: 0.5rem;
12
+ min-height: 0;
12
13
 
13
14
  .page-header-card {
14
15
  @apply flex items-center gap-2.5 rounded-lg;
15
16
  padding: 0.375rem 0.75rem;
16
17
  background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-end) 100%);
17
18
  border: 1px solid var(--color-border-light);
18
- flex: 0 1 auto;
19
+ flex: 0 0 auto;
19
20
  min-width: 0;
20
- max-width: 60%;
21
21
  white-space: nowrap;
22
- overflow: hidden;
22
+
23
+ /* Mobile - allow shrinking if needed */
24
+ @media (max-width: 767px) {
25
+ flex: 0 1 auto;
26
+ max-width: calc(100% - 180px);
27
+ gap: 0.5rem;
28
+ }
23
29
 
24
30
  svg, img {
25
31
  width: 17px;
@@ -38,17 +38,33 @@
38
38
  .Toastify__toast-container {
39
39
  pointer-events: none;
40
40
  --toastify-toast-bd-radius: 6px;
41
+
42
+ /* Mobile responsive - prevent overflow and edge-to-edge toasts */
43
+ @media (max-width: 767px) {
44
+ width: calc(100vw - 2rem) !important;
45
+ max-width: 360px;
46
+ right: 1rem !important;
47
+ left: auto !important;
48
+ padding: 0 !important;
49
+ top: calc(env(safe-area-inset-top) + 1rem) !important;
50
+ }
41
51
  }
42
52
 
43
53
  .Toastify__toast {
44
54
  min-height: 50px !important;
45
- height: 50px !important;
55
+ height: auto !important;
46
56
  pointer-events: auto !important;
47
57
  margin-bottom: 6px !important;
48
58
 
49
59
  transition: transform 0.18s cubic-bezier(0.25, 0.1, 0.25, 1), opacity 0.12s cubic-bezier(0.25, 0.1, 0.25, 1) !important;
50
60
  transform: translate3d(0, 0, 0);
51
61
 
62
+ /* Mobile responsive - ensure proper sizing */
63
+ @media (max-width: 767px) {
64
+ margin-bottom: 8px !important;
65
+ border-radius: 6px !important;
66
+ }
67
+
52
68
  &.Toastify__slide-enter-active {
53
69
  animation: slideInRight 0.22s cubic-bezier(0.25, 0.1, 0.25, 1);
54
70
  }
@@ -114,6 +114,7 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
114
114
 
115
115
  .mobile-icons {
116
116
  @apply ml-auto flex items-center gap-x-2 lg:hidden;
117
+ flex-shrink: 0;
117
118
 
118
119
  button {
119
120
  @apply flex h-8 w-8 items-center justify-center rounded transition-all duration-150 bg-dark-400;
@@ -1,73 +1,400 @@
1
1
  <template>
2
- <div class="flex-gap-1 items-center font-bold text-white lg:gap-3" v-if="ui.queueStats.show" :key="key">
2
+ <div
3
+ class="queue-stats"
4
+ :class="[{ 'queue-stats--full': visibleCards === 4 }, $attrs.class]"
5
+ :style="statsGridStyle"
6
+ v-if="ui.queueStats.show"
7
+ :key="key">
3
8
  <div
4
9
  v-if="ui.queueStats.total"
5
- class="stat-badge">
6
- <h2 class="stat-header">
10
+ class="stat-badge queue-stats__card queue-stats__card--metric">
11
+ <h2 class="queue-stats__header">
7
12
  <img width="14px" src="@/assets/img/wildcard.svg" />
8
- <span class="hidden md:inline">Total</span>
9
13
  </h2>
10
- <span class="stat-value">
14
+ <span class="queue-stats__value">
11
15
  {{ ui.queueStats.total }}
12
16
  </span>
13
17
  </div>
14
18
  <div
15
19
  v-if="ui.queueStats.queued"
16
- class="stat-badge">
17
- <h2 class="stat-header">
20
+ class="stat-badge queue-stats__card queue-stats__card--metric">
21
+ <h2 class="queue-stats__header">
18
22
  <SkiIcon />
19
- <span class="hidden md:inline">Queued</span>
20
23
  </h2>
21
- <span class="stat-value">
24
+ <span class="queue-stats__value">
22
25
  {{ ui.queueStats.queued }}
23
26
  </span>
24
27
  </div>
25
28
  <div
26
29
  v-if="ui.queueStats.sleeping"
27
- class="stat-badge">
28
- <h2 class="stat-header">
30
+ class="stat-badge queue-stats__card queue-stats__card--metric">
31
+ <h2 class="queue-stats__header">
29
32
  <TimerIcon />
30
- <span class="hidden md:inline">Sleeping</span>
31
33
  </h2>
32
- <span class="stat-value">
34
+ <span class="queue-stats__value">
33
35
  {{ ui.queueStats.sleeping }}
34
36
  </span>
35
37
  </div>
36
38
  <div
37
39
  v-if="ui.queueStats.nextQueuePasses.length > 0"
38
- class="mb-2 flex h-8 min-w-0 items-center justify-between gap-2 rounded-xl p-2 text-sm lg:mb-5 lg:gap-3 lg:p-3 bg-dark-400 border-2 border-dark-550">
39
- <h2 class="stat-header">
40
- <CartIcon />
41
- <span class="hidden sm:block">Next Passes</span>
42
- <span class="block sm:hidden">Pass</span>
40
+ class="stat-badge queue-stats__card queue-stats__card--passes"
41
+ :title="ui.queueStats.nextQueuePasses.join(', ')">
42
+ <h2 class="queue-stats__header">
43
+ <SandclockIcon />
43
44
  </h2>
44
- <span class="flex items-center truncate text-right text-xs font-bold text-light-300">
45
- {{ ui.queueStats.nextQueuePasses.slice(0, queuePassAmount).join(", ") }}
45
+ <span class="queue-stats__value queue-stats__value--passes">
46
+ <span class="queue-stats__pass-text">{{ queuePassPreviewText }}</span>
47
+ <button
48
+ v-if="hiddenPassCount > 0"
49
+ type="button"
50
+ class="queue-stats__more-btn"
51
+ @click.stop="openPassesOverlay">
52
+ +{{ hiddenPassCount }}
53
+ </button>
46
54
  </span>
47
55
  </div>
48
56
  </div>
57
+
58
+ <Teleport to="body">
59
+ <div
60
+ v-if="showPassesOverlay"
61
+ class="queue-pass-overlay"
62
+ @click.self="closePassesOverlay">
63
+ <div class="queue-pass-modal">
64
+ <div class="queue-pass-modal__header">
65
+ <h3>Next Queue Passes</h3>
66
+ <button
67
+ type="button"
68
+ class="queue-pass-modal__close"
69
+ @click="closePassesOverlay">
70
+ Close
71
+ </button>
72
+ </div>
73
+ <div class="queue-pass-modal__body">
74
+ <ul class="queue-pass-modal__list">
75
+ <li
76
+ v-for="(pass, index) in ui.queueStats.nextQueuePasses"
77
+ :key="`${pass}-${index}`"
78
+ class="queue-pass-modal__item">
79
+ <span>#{{ index + 1 }}</span>
80
+ <span>{{ pass }}</span>
81
+ </li>
82
+ </ul>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </Teleport>
49
87
  </template>
50
88
 
51
89
  <script setup>
52
- import { SkiIcon, TimerIcon, CartIcon } from "@/components/icons";
90
+ import { SkiIcon, TimerIcon, SandclockIcon } from "@/components/icons";
53
91
  import { useUIStore } from "@/stores/ui";
54
- import { ref, onUnmounted } from "vue";
92
+ import { ref, onUnmounted, computed, onMounted } from "vue";
93
+
94
+ defineOptions({
95
+ inheritAttrs: false
96
+ });
55
97
 
56
98
  const ui = useUIStore();
57
99
 
58
100
  const getQueuePassAmount = (width) => {
59
- if (width > 1024) return 8;
60
- if (width > 640) return 3;
101
+ if (width >= 1536) return 10;
102
+ if (width >= 1280) return 8;
103
+ if (width >= 1024) return 6;
104
+ if (width >= 768) return 4;
105
+ if (width >= 640) return 2;
61
106
  return 1;
62
107
  };
108
+ const getQueuePassCharBudget = (width) => {
109
+ if (width >= 1536) return 56;
110
+ if (width >= 1280) return 42;
111
+ if (width >= 1024) return 32;
112
+ if (width >= 768) return 24;
113
+ if (width >= 640) return 16;
114
+ return 10;
115
+ };
116
+ const buildQueuePassPreview = (passes, maxItems, maxChars) => {
117
+ const values = passes.slice(0, maxItems).map((value) => String(value));
118
+ if (!values.length) return "";
119
+
120
+ let currentLength = 0;
121
+ const output = [];
122
+
123
+ for (const value of values) {
124
+ const separator = output.length ? 2 : 0;
125
+ const nextLength = currentLength + separator + value.length;
126
+ if (nextLength > maxChars) break;
127
+ output.push(value);
128
+ currentLength = nextLength;
129
+ }
130
+
131
+ if (output.length === 0) output.push(values[0]);
132
+
133
+ return {
134
+ visible: output,
135
+ hiddenCount: Math.max(passes.length - output.length, 0)
136
+ };
137
+ };
63
138
 
64
139
  let key = ref(0);
140
+ const width = ref(window.innerWidth);
65
141
  let queuePassAmount = ref(getQueuePassAmount(window.innerWidth));
142
+ const queuePassCharBudget = ref(getQueuePassCharBudget(window.innerWidth));
143
+ const showPassesOverlay = ref(false);
144
+ const visibleCards = computed(
145
+ () =>
146
+ Number(Boolean(ui.queueStats.total)) +
147
+ Number(Boolean(ui.queueStats.queued)) +
148
+ Number(Boolean(ui.queueStats.sleeping)) +
149
+ Number(Boolean(ui.queueStats.nextQueuePasses.length))
150
+ );
151
+ const statsGridStyle = computed(() => ({
152
+ "--stats-card-count": `${Math.max(visibleCards.value, 1)}`
153
+ }));
154
+ const queuePassPreviewData = computed(() =>
155
+ buildQueuePassPreview(ui.queueStats.nextQueuePasses, queuePassAmount.value, queuePassCharBudget.value)
156
+ );
157
+ const queuePassPreviewText = computed(() => queuePassPreviewData.value.visible.join(", "));
158
+ const hiddenPassCount = computed(() => queuePassPreviewData.value.hiddenCount);
159
+
160
+ const openPassesOverlay = () => {
161
+ if (!hiddenPassCount.value) return;
162
+ showPassesOverlay.value = true;
163
+ };
66
164
 
67
- const handleResize = () => (queuePassAmount.value = getQueuePassAmount(window.innerWidth));
165
+ const closePassesOverlay = () => {
166
+ showPassesOverlay.value = false;
167
+ };
168
+
169
+ const handleResize = () => {
170
+ width.value = window.innerWidth;
171
+ queuePassAmount.value = getQueuePassAmount(width.value);
172
+ queuePassCharBudget.value = getQueuePassCharBudget(width.value);
173
+ };
68
174
  window.addEventListener("resize", handleResize);
69
175
 
176
+ const handleKeyDown = (event) => {
177
+ if (event.key === "Escape" && showPassesOverlay.value) {
178
+ closePassesOverlay();
179
+ }
180
+ };
181
+
182
+ onMounted(() => {
183
+ window.addEventListener("keydown", handleKeyDown);
184
+ });
185
+
70
186
  onUnmounted(() => {
71
187
  window.removeEventListener("resize", handleResize);
188
+ window.removeEventListener("keydown", handleKeyDown);
72
189
  });
73
190
  </script>
191
+
192
+ <style scoped>
193
+ .queue-stats {
194
+ display: grid;
195
+ width: 100%;
196
+ min-width: 0;
197
+ grid-template-columns: repeat(var(--stats-card-count), minmax(0, 1fr));
198
+ gap: 0.35rem;
199
+ }
200
+
201
+ .queue-stats__card {
202
+ @apply bg-dark-400 border-2 border-dark-550;
203
+ height: 2.5rem;
204
+ min-width: 0;
205
+ gap: 0.35rem;
206
+ border-radius: 0.55rem;
207
+ padding: 0 0.5rem;
208
+ }
209
+
210
+ .queue-stats__header {
211
+ min-width: 1rem;
212
+ flex: 0 0 auto;
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 0.25rem;
216
+ color: rgb(212 212 219 / 1);
217
+ font-weight: 600;
218
+ font-size: 0.7rem;
219
+ line-height: 1.1;
220
+ white-space: nowrap;
221
+ }
222
+
223
+ .queue-stats__value {
224
+ min-width: 0;
225
+ flex: 1 1 auto;
226
+ display: flex;
227
+ align-items: center;
228
+ justify-content: flex-end;
229
+ overflow: hidden;
230
+ text-align: right;
231
+ text-overflow: clip;
232
+ white-space: nowrap;
233
+ color: rgb(212 212 219 / 1);
234
+ font-weight: 700;
235
+ font-size: 0.7rem;
236
+ font-variant-numeric: tabular-nums;
237
+ line-height: 1.1;
238
+ margin-left: 0.25rem;
239
+ }
240
+
241
+ .queue-stats__card--metric .queue-stats__header {
242
+ min-width: 1rem;
243
+ }
244
+
245
+ .queue-stats__card--metric .queue-stats__value {
246
+ flex: 0 0 auto;
247
+ min-width: 3.4rem;
248
+ margin-left: 0.35rem;
249
+ }
250
+
251
+ .queue-stats__header :deep(svg),
252
+ .queue-stats__header img {
253
+ width: 12px;
254
+ height: 12px;
255
+ flex-shrink: 0;
256
+ }
257
+
258
+ .queue-stats__card--passes {
259
+ padding-right: 0.4rem;
260
+ gap: 0.3rem;
261
+ }
262
+
263
+ .queue-stats__card--passes .queue-stats__header {
264
+ min-width: 1.1rem;
265
+ }
266
+
267
+ .queue-stats__value--passes {
268
+ font-size: 0.67rem;
269
+ gap: 0.35rem;
270
+ }
271
+
272
+ .queue-stats__pass-text {
273
+ min-width: 0;
274
+ flex: 1 1 auto;
275
+ overflow: hidden;
276
+ white-space: nowrap;
277
+ text-overflow: ellipsis;
278
+ }
279
+
280
+ .queue-stats__more-btn {
281
+ @apply bg-dark-550 border border-dark-650 text-light-300;
282
+ flex-shrink: 0;
283
+ border-radius: 9999px;
284
+ padding: 0.1rem 0.4rem;
285
+ font-size: 0.62rem;
286
+ font-weight: 700;
287
+ line-height: 1.2;
288
+ }
289
+
290
+ .queue-pass-overlay {
291
+ position: fixed;
292
+ inset: 0;
293
+ z-index: 90;
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: center;
297
+ background: rgba(0, 0, 0, 0.45);
298
+ padding: 1rem;
299
+ }
300
+
301
+ .queue-pass-modal {
302
+ @apply bg-dark-400 border border-dark-550;
303
+ width: min(34rem, 95vw);
304
+ border-radius: 0.75rem;
305
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
306
+ }
307
+
308
+ .queue-pass-modal__header {
309
+ @apply border-b border-dark-550 text-light-200;
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: space-between;
313
+ gap: 0.5rem;
314
+ padding: 0.75rem 0.9rem;
315
+ }
316
+
317
+ .queue-pass-modal__header h3 {
318
+ font-size: 0.9rem;
319
+ font-weight: 700;
320
+ }
321
+
322
+ .queue-pass-modal__close {
323
+ @apply bg-dark-500 border border-dark-650 text-light-300;
324
+ border-radius: 0.45rem;
325
+ padding: 0.25rem 0.5rem;
326
+ font-size: 0.72rem;
327
+ font-weight: 600;
328
+ }
329
+
330
+ .queue-pass-modal__body {
331
+ max-height: min(60vh, 28rem);
332
+ overflow-y: auto;
333
+ padding: 0.55rem;
334
+ }
335
+
336
+ .queue-pass-modal__list {
337
+ display: grid;
338
+ gap: 0.35rem;
339
+ }
340
+
341
+ .queue-pass-modal__item {
342
+ @apply bg-dark-500 border border-dark-650 text-light-300;
343
+ display: flex;
344
+ align-items: center;
345
+ justify-content: space-between;
346
+ border-radius: 0.5rem;
347
+ padding: 0.35rem 0.55rem;
348
+ font-size: 0.78rem;
349
+ font-variant-numeric: tabular-nums;
350
+ }
351
+
352
+ @media (min-width: 768px) {
353
+ .queue-stats {
354
+ gap: 0.45rem;
355
+ }
356
+
357
+ .queue-stats__card {
358
+ padding: 0 0.7rem;
359
+ }
360
+
361
+ .queue-stats__header {
362
+ gap: 0.35rem;
363
+ font-size: 0.72rem;
364
+ }
365
+
366
+ .queue-stats__value {
367
+ font-size: 0.72rem;
368
+ }
369
+
370
+ .queue-stats__value--passes {
371
+ font-size: 0.7rem;
372
+ }
373
+
374
+ .queue-stats__card--metric .queue-stats__value {
375
+ min-width: 3.7rem;
376
+ }
377
+ }
378
+
379
+ @media (min-width: 960px) {
380
+ .queue-stats--full {
381
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.35fr);
382
+ }
383
+ }
384
+
385
+ @media (min-width: 1280px) {
386
+ .queue-stats--full {
387
+ grid-template-columns: repeat(5, minmax(0, 1fr));
388
+ }
389
+
390
+ .queue-stats--full .queue-stats__card--passes {
391
+ grid-column: span 2;
392
+ }
393
+ }
394
+
395
+ @media (min-width: 1280px) {
396
+ .queue-stats__value--passes {
397
+ font-size: 0.75rem;
398
+ }
399
+ }
400
+ </style>
@@ -85,8 +85,8 @@
85
85
  </div>
86
86
  </div>
87
87
  <div class="col-span-2 overflow-hidden lg:col-span-3 xl:col-span-2">
88
- <h4 class="lg:text-2xs text-center text-xs leading-tight text-light-300">
89
- <span v-if="!props.task.reservedTicketsList">-</span>
88
+ <h4 class="lg:text-2xs text-center text-xs leading-tight">
89
+ <span v-if="!props.task.reservedTicketsList" class="text-light-300">-</span>
90
90
  <div v-else class="overflow-hidden">
91
91
  <div
92
92
  v-for="(l, index) in props.task.reservedTicketsList.split('\n')"
@@ -99,7 +99,12 @@
99
99
  'font-bold text-green-400': isTotalPrice(
100
100
  l,
101
101
  index,
102
- props.task.reservedTicketsList.split(' ')
102
+ props.task.reservedTicketsList.split('\n')
103
+ ),
104
+ 'text-light-300': !isTotalPrice(
105
+ l,
106
+ index,
107
+ props.task.reservedTicketsList.split('\n')
103
108
  )
104
109
  }">
105
110
  {{ l.trim() }}
@@ -109,7 +114,8 @@
109
114
  <span
110
115
  class="mt-1 block text-xs font-bold"
111
116
  :class="{
112
- 'text-red-400': taskTimeLeft === '00:00' || taskTimeLeft === 'No Cartholds'
117
+ 'text-red-400': taskTimeLeft === '00:00' || taskTimeLeft === 'No Cartholds',
118
+ 'text-light-300': taskTimeLeft !== '00:00' && taskTimeLeft !== 'No Cartholds'
113
119
  }">
114
120
  {{ taskTimeLeft !== "00:00" ? taskTimeLeft : "Expired" }}
115
121
  </span>
@@ -1,5 +1,6 @@
1
1
  <template>
2
- <div class="table-component relative box-border flex flex-col rounded-lg bg-dark-500 bg-clip-padding overflow-x-hidden shadow-sm">
2
+ <div
3
+ class="table-component relative box-border flex flex-col overflow-x-hidden rounded-lg bg-dark-500 bg-clip-padding shadow-sm">
3
4
  <Header class="grid-cols-10 gap-2 text-center lg:grid-cols-12">
4
5
  <div class="col-span-1 flex items-center justify-start lg:col-span-2">
5
6
  <Checkbox
@@ -9,26 +10,26 @@
9
10
  :isHeader="true" />
10
11
  <div class="mx-auto hidden items-center lg:flex" @click="ui.toggleSort('eventId')">
11
12
  <EventIcon class="lg:mr-3" />
12
- <h4 class="hidden lg:flex text-white">Event</h4>
13
+ <h4 class="hidden text-white lg:flex">Event</h4>
13
14
  <DownIcon v-if="ui.sortData.sortBy === 'eventId' && !ui.sortData.reversed" class="ml-1" />
14
15
  <UpIcon v-if="ui.sortData.sortBy === 'eventId' && ui.sortData.reversed" class="ml-1" />
15
16
  </div>
16
17
  </div>
17
18
  <div class="col-span-2 flex items-center justify-center lg:col-span-3 xl:col-span-2" v-once>
18
19
  <TicketIcon class="mr-0 lg:mr-3" />
19
- <h4 class="hidden lg:flex text-white">Tickets</h4>
20
+ <h4 class="hidden text-white lg:flex">Tickets</h4>
20
21
  </div>
21
22
  <div
22
23
  class="col-span-6 flex items-center justify-center md:col-span-5 lg:col-span-4 xl:col-span-5"
23
24
  @click="ui.toggleSort('status')">
24
25
  <StatusIcon class="mr-0 lg:mr-3" />
25
- <h4 class="hidden lg:flex text-white">Status</h4>
26
+ <h4 class="hidden text-white lg:flex">Status</h4>
26
27
  <DownIcon v-if="ui.sortData.sortBy === 'status' && !ui.sortData.reversed" class="ml-1" />
27
28
  <UpIcon v-if="ui.sortData.sortBy === 'status' && ui.sortData.reversed" class="ml-1" />
28
29
  </div>
29
30
  <div class="col-span-1 flex items-center justify-end md:col-span-2 md:justify-center lg:col-span-3" v-once>
30
31
  <ClickIcon class="mr-0 lg:mr-3" />
31
- <h4 class="hidden lg:flex text-white">Actions</h4>
32
+ <h4 class="hidden text-white lg:flex">Actions</h4>
32
33
  </div>
33
34
  <div class="absolute right-5 top-3.5 hidden items-center xl:flex">
34
35
  <h4 class="text-center text-xs text-white">ID</h4>
@@ -36,7 +37,7 @@
36
37
  </Header>
37
38
  <DynamicScroller
38
39
  v-if="virtualTaskItems.length && useVirtualScroller"
39
- class="hidden-scrollbars touch-pan-y min-h-0 overflow-y-auto overflow-x-hidden scrollable"
40
+ class="hidden-scrollbars scrollable min-h-0 touch-pan-y overflow-y-auto overflow-x-hidden"
40
41
  :style="{ height: maxTableHeight, maxHeight: maxTableHeight }"
41
42
  :items="virtualTaskItems"
42
43
  :min-item-size="virtualMinItemSize"
@@ -56,7 +57,7 @@
56
57
  ]"
57
58
  :data-index="index">
58
59
  <div
59
- class="shrink-0 border-b border-dark-650 min-h-14.5 md:min-h-17.25 has-[.event-details]:min-h-18.75 mobile-portrait:min-h-12.5 transition-colors duration-150 ease-in-out hover:!bg-dark-550">
60
+ class="mobile-portrait:min-h-12.5 min-h-14.5 shrink-0 border-b border-dark-650 transition-colors duration-150 ease-in-out hover:!bg-dark-550 has-[.event-details]:min-h-18.75 md:min-h-17.25">
60
61
  <Task
61
62
  v-if="props.tasks[item.taskId]"
62
63
  :task="props.tasks[item.taskId]"
@@ -68,12 +69,12 @@
68
69
  </DynamicScroller>
69
70
  <div
70
71
  v-else-if="virtualTaskItems.length"
71
- class="hidden-scrollbars touch-pan-y min-h-0 overflow-y-auto overflow-x-hidden scrollable"
72
+ class="hidden-scrollbars scrollable min-h-0 touch-pan-y overflow-y-auto overflow-x-hidden"
72
73
  :style="{ maxHeight: maxTableHeight }">
73
74
  <div
74
75
  v-for="(item, index) in virtualTaskItems"
75
76
  :key="item.taskId"
76
- class="shrink-0 border-b border-dark-650 min-h-14.5 md:min-h-17.25 has-[.event-details]:min-h-18.75 mobile-portrait:min-h-12.5 transition-colors duration-150 ease-in-out hover:!bg-dark-550">
77
+ class="mobile-portrait:min-h-12.5 min-h-14.5 shrink-0 border-b border-dark-650 transition-colors duration-150 ease-in-out hover:!bg-dark-550 has-[.event-details]:min-h-18.75 md:min-h-17.25">
77
78
  <Task
78
79
  v-if="props.tasks[item.taskId]"
79
80
  :task="props.tasks[item.taskId]"
@@ -83,18 +84,15 @@
83
84
  </div>
84
85
  <div
85
86
  v-else
86
- class="empty-state flex flex-col items-center justify-center py-8 text-center bg-dark-400 text-light-500 text-sm font-medium"
87
+ class="empty-state flex flex-col items-center justify-center bg-dark-400 py-8 text-center text-sm font-medium text-light-500"
87
88
  :style="{ minHeight: emptyStateHeight }">
88
- <div
89
- v-if="
90
- !ui.queueStats.queued && !ui.queueStats.sleeping && ui.queueStats.nextQueuePasses.length === 0
91
- ">
92
- <TasksIcon class="mx-auto empty-state-icon" />
89
+ <div v-if="!ui.queueStats.queued && !ui.queueStats.sleeping && ui.queueStats.nextQueuePasses.length === 0">
90
+ <TasksIcon class="empty-state-icon mx-auto" />
93
91
  <p class="text-sm text-light-400">No tasks yet</p>
94
92
  <p class="mt-1 text-xs text-light-500">Create tasks to get started</p>
95
93
  </div>
96
94
  <div v-else>
97
- <TasksIcon class="mx-auto empty-state-icon" />
95
+ <TasksIcon class="empty-state-icon mx-auto" />
98
96
  <p class="text-sm text-light-400">No tasks match current filters</p>
99
97
  <p class="mt-1 text-xs text-light-500">Adjust filters to see tasks</p>
100
98
  </div>
@@ -118,7 +116,7 @@ const props = defineProps({
118
116
  },
119
117
  searchQuery: {
120
118
  type: String,
121
- default: ''
119
+ default: ""
122
120
  },
123
121
  preferEventName: {
124
122
  type: Boolean,
@@ -203,7 +201,11 @@ const filteredTaskIds = computed(() => {
203
201
  continue;
204
202
  }
205
203
 
206
- if (task.siteId !== ui.currentCountry.siteId && !siteIdEdgeCases[task.siteId]?.includes(ui.currentCountry.siteId)) continue;
204
+ if (
205
+ task.siteId !== ui.currentCountry.siteId &&
206
+ !siteIdEdgeCases[task.siteId]?.includes(ui.currentCountry.siteId)
207
+ )
208
+ continue;
207
209
  if (ui.currentEvent && task.eventId !== ui.currentEvent) continue;
208
210
  if (!shouldTaskShow(task)) continue;
209
211
  if (!shouldTaskMatchSearch(task, searchLower)) continue;
@@ -264,33 +266,45 @@ const handleVirtualWheel = (event) => {
264
266
 
265
267
  const maxTableHeight = computed(() => {
266
268
  // Detect PWA mode (standalone display)
267
- const isPWA = window.matchMedia('(display-mode: standalone)').matches;
269
+ const isPWA = window.matchMedia("(display-mode: standalone)").matches;
268
270
 
269
271
  // Calculate available space for table
270
272
  const headerHeight = windowWidth.value >= 1024 ? 80 : 64; // Navbar padding (lg:pt-20 vs pt-16)
271
273
  const titleHeight = 45; // Tasks title and mobile controls
272
274
  const controlsHeight = windowWidth.value >= 650 ? 55 : 0; // Desktop controls
273
- // On desktop: stats + filters all in one row (45px)
274
- // On mobile: stats row (40px) + filters stacked (90px) = 130px
275
- const filtersAndStatsHeight =
276
- windowWidth.value >= 768
277
- ? ui.queueStats.show
278
- ? 50
279
- : 45 // Desktop: single row
280
- : ui.queueStats.show
281
- ? 130
282
- : 90; // Mobile: stats row + stacked filters
275
+ // Keep this in sync with Tasks.vue controls layout:
276
+ // - >= 1280px: stats stays in same row as filters
277
+ // - 768px..1279px: stats takes its own wrapped row when visible
278
+ // - < 768px: mobile has dedicated stats row + controls/search rows
279
+ const isDesktopInlineStats = windowWidth.value >= 1280;
280
+ const isTabletWrappedStats = windowWidth.value >= 768 && windowWidth.value < 1280;
281
+
282
+ const filtersAndStatsHeight = (() => {
283
+ if (isDesktopInlineStats) {
284
+ return ui.queueStats.show ? 50 : 45;
285
+ }
286
+
287
+ if (isTabletWrappedStats) {
288
+ const filterRowHeight = 45;
289
+ const wrappedStatsRowHeight = ui.queueStats.show ? 54 : 0;
290
+ return filterRowHeight + wrappedStatsRowHeight;
291
+ }
292
+
293
+ return ui.queueStats.show ? 155 : 110;
294
+ })();
283
295
 
284
296
  // Reserve more space for UTILS on iPhone PWA to prevent overflow
285
- const utilitiesHeight = isPWA && windowWidth.value <= 768
286
- ? 260 // iPhone PWA: extra space for UTILS
287
- : windowWidth.value > 1024
288
- ? 200 // Desktop
289
- : windowWidth.value <= 768
290
- ? 220 // Mobile browser
291
- : 180; // Tablet
292
-
293
- const margins = windowWidth.value >= 1024 ? 30 : windowWidth.value <= 480 && windowHeight.value > windowWidth.value ? 8 : 12;
297
+ const utilitiesHeight =
298
+ isPWA && windowWidth.value <= 768
299
+ ? 150 // iPhone PWA: extra space for UTILS
300
+ : windowWidth.value > 1024
301
+ ? 200 // Desktop
302
+ : windowWidth.value <= 768
303
+ ? 150 // Mobile browser
304
+ : 210; // Tablet
305
+
306
+ const margins =
307
+ windowWidth.value >= 1024 ? 30 : windowWidth.value <= 480 && windowHeight.value > windowWidth.value ? 8 : 12;
294
308
 
295
309
  const totalUsedSpace =
296
310
  headerHeight + titleHeight + controlsHeight + filtersAndStatsHeight + utilitiesHeight + margins;
@@ -164,7 +164,6 @@ const getSectionNameMapping = () => {
164
164
  }
165
165
  });
166
166
 
167
- log("getSectionNameMapping:", sectionRealName);
168
167
  return sectionRealName;
169
168
  };
170
169
 
@@ -583,14 +582,11 @@ export default class FilterBuilder {
583
582
  // Use BOTH stroke and fill to cover entire section area, not just row lines
584
583
  this.cssClasses += `.svg-wrapper path.hover-highlight-stroke {stroke: ${colors.HIGHLIGHT} !important; fill: ${colors.HIGHLIGHT} !important; fill-opacity: 0.3 !important;}\n`;
585
584
  this.cssClasses += `.svg-wrapper path.hover-highlight-fill {fill: ${colors.HIGHLIGHT} !important;}\n`;
586
-
587
- log("Generated CSS:", this.cssClasses);
588
585
  }
589
586
 
590
587
  addLabelHandlers() {
591
588
  const labels = document.querySelectorAll("tspan");
592
589
  const gaSectionNameMapping = this.getSectionNameMapping();
593
- log("addLabelHandlers: gaSectionNameMapping", gaSectionNameMapping);
594
590
 
595
591
  for (let i = 0; i < labels.length; i++) {
596
592
  const label = labels[i];
package/src/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createApp } from "vue";
2
2
  import { createPinia } from "pinia";
3
3
  import VueVirtualScroller from "vue-virtual-scroller";
4
+ import Vue3Toastify from "vue3-toastify";
4
5
 
5
6
  import App from "./App.vue";
6
7
  import router from "./router";
@@ -18,4 +19,8 @@ app.config.performance = true;
18
19
  app.use(createPinia());
19
20
  app.use(router);
20
21
  app.use(VueVirtualScroller);
22
+ app.use(Vue3Toastify, {
23
+ newestOnTop: true,
24
+ position: "top-right"
25
+ });
21
26
  app.mount("#app");
@@ -3,6 +3,7 @@ import WebsocketHeartbeatJs from "websocket-heartbeat-js";
3
3
  import { decode } from "@msgpack/msgpack";
4
4
  import router from "@/router/index";
5
5
  import { sortTaskIds } from "@/libs/utils/array";
6
+ import { DEBUG } from "@/utils/debug";
6
7
 
7
8
  const TASK_UPDATE_EVENTS = new Set(["task-update", "task-setstatus", "task-setinfo"]);
8
9
  const QUEUE_STAT_FIELDS = new Set(["siteId", "eventId", "inQueue", "status", "queuePosition"]);
@@ -568,6 +569,7 @@ export class ConnectionHandler {
568
569
  if (msg?.data) msg.data.module = this.ui.currentModule;
569
570
  this.socket.send(JSON.stringify(msg));
570
571
  } catch {
572
+ if (DEBUG) return;
571
573
  this.ui.startSpinner("Reconnecting");
572
574
  }
573
575
  }
@@ -604,13 +606,13 @@ export class ConnectionHandler {
604
606
  };
605
607
 
606
608
  this.socket.onclose = () => {
607
- this.ui.startSpinner("Reconnecting");
609
+ if (!DEBUG) this.ui.startSpinner("Reconnecting");
608
610
  hasShownDisconnect = true;
609
611
  this.clearTaskUpdateQueue();
610
612
  };
611
613
 
612
614
  this.socket.onerror = () => {
613
- this.ui.startSpinner("Reconnecting");
615
+ if (!DEBUG) this.ui.startSpinner("Reconnecting");
614
616
  hasShownDisconnect = true;
615
617
  this.clearTaskUpdateQueue();
616
618
  };
package/src/stores/ui.js CHANGED
@@ -5,6 +5,10 @@ import { toast } from "vue3-toastify";
5
5
  import { createLogger } from "@/stores/logger";
6
6
  import { betterSort, sortTaskIds } from "@/libs/utils/array";
7
7
 
8
+ // Track active toast IDs for manual limit enforcement
9
+ const activeToasts = [];
10
+ const MAX_TOASTS = 2;
11
+
8
12
  const TOAST_CONFIG = {
9
13
  autoClose: 1400,
10
14
  pauseOnHover: false,
@@ -17,6 +21,27 @@ const TOAST_CONFIG = {
17
21
  newestOnTop: true
18
22
  };
19
23
 
24
+ // Helper to enforce toast limit
25
+ const showToastWithLimit = (type, message) => {
26
+ // If we're at the limit, remove the oldest toast
27
+ if (activeToasts.length >= MAX_TOASTS) {
28
+ const oldestId = activeToasts.shift();
29
+ toast.remove(oldestId);
30
+ }
31
+
32
+ // Show new toast and track its ID
33
+ const toastId = toast[type](message, TOAST_CONFIG);
34
+ activeToasts.push(toastId);
35
+
36
+ // Remove from tracking when it auto-closes
37
+ setTimeout(() => {
38
+ const index = activeToasts.indexOf(toastId);
39
+ if (index > -1) activeToasts.splice(index, 1);
40
+ }, TOAST_CONFIG.autoClose + 100);
41
+
42
+ return toastId;
43
+ };
44
+
20
45
  import mockTaskData from "@/stores/sampleData.js";
21
46
  import { useRouter } from "vue-router";
22
47
  import { DEBUG } from "@/utils/debug";
@@ -24,6 +49,16 @@ import { DEBUG } from "@/utils/debug";
24
49
  const ALL_SELECTED = 0;
25
50
  const SOME_SELECTED = 1;
26
51
  const NO_SELECTED = 2;
52
+ const DEV_QUEUE_STATS_STORAGE_KEY = "dev-queue-stats-mock";
53
+ const DEV_QUEUE_STATS_PRESETS = {
54
+ heavy: {
55
+ show: true,
56
+ total: 10000,
57
+ queued: 5000,
58
+ sleeping: 3000,
59
+ nextQueuePasses: [11, 18, 26, 35, 49, 57, 72, 88, 103, 121, 147, 169]
60
+ }
61
+ };
27
62
 
28
63
  export const useUIStore = defineStore("ui", () => {
29
64
  const logger = createLogger("UI");
@@ -98,6 +133,55 @@ export const useUIStore = defineStore("ui", () => {
98
133
  sleeping: 0,
99
134
  nextQueuePasses: []
100
135
  });
136
+ const devQueueStatsMock = ref(null);
137
+
138
+ const normalizeQueueStats = (input = {}) => {
139
+ const total = Number(input.total) || 0;
140
+ const queued = Number(input.queued) || 0;
141
+ const sleeping = Number(input.sleeping) || 0;
142
+ const nextQueuePasses = Array.isArray(input.nextQueuePasses)
143
+ ? input.nextQueuePasses.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0)
144
+ : [];
145
+ const show = Boolean(input.show ?? (queued > 0 || sleeping > 0 || nextQueuePasses.length > 0));
146
+
147
+ return {
148
+ show,
149
+ total,
150
+ queued,
151
+ sleeping,
152
+ nextQueuePasses: nextQueuePasses.sort((a, b) => a - b)
153
+ };
154
+ };
155
+
156
+ const applyQueueStats = (stats) => {
157
+ queueStats.value.show = stats.show;
158
+ queueStats.value.total = stats.total;
159
+ queueStats.value.queued = stats.queued;
160
+ queueStats.value.sleeping = stats.sleeping;
161
+ queueStats.value.nextQueuePasses = stats.nextQueuePasses;
162
+ };
163
+
164
+ const setDevQueueStatsMock = (stats) => {
165
+ if (!DEBUG) return;
166
+ if (!stats) {
167
+ devQueueStatsMock.value = null;
168
+ localStorage.removeItem(DEV_QUEUE_STATS_STORAGE_KEY);
169
+ refreshQueueStats();
170
+ return;
171
+ }
172
+
173
+ const normalized = normalizeQueueStats(stats);
174
+ devQueueStatsMock.value = normalized;
175
+ localStorage.setItem(DEV_QUEUE_STATS_STORAGE_KEY, JSON.stringify(normalized));
176
+ applyQueueStats(normalized);
177
+ };
178
+
179
+ const setDevQueueStatsPreset = (preset = "heavy") => {
180
+ if (!DEBUG) return;
181
+ const data = DEV_QUEUE_STATS_PRESETS[preset];
182
+ if (!data) return;
183
+ setDevQueueStatsMock(data);
184
+ };
101
185
 
102
186
  const router = useRouter();
103
187
  const openContextMenu = ref("");
@@ -205,7 +289,6 @@ export const useUIStore = defineStore("ui", () => {
205
289
  input.style.userSelect = "auto";
206
290
  input.style.touchAction = "pan-x pan-y";
207
291
  });
208
-
209
292
  };
210
293
 
211
294
  const enableScroll = () => {
@@ -242,10 +325,14 @@ export const useUIStore = defineStore("ui", () => {
242
325
 
243
326
  // Restore scroll position
244
327
  window.scrollTo(0, parseInt(scrollY || "0") * -1);
245
-
246
328
  };
247
329
 
248
330
  const refreshQueueStats = () => {
331
+ if (DEBUG && devQueueStatsMock.value) {
332
+ applyQueueStats(devQueueStatsMock.value);
333
+ return;
334
+ }
335
+
249
336
  const siteId = currentCountry.value.siteId;
250
337
  const eventId = currentEvent.value;
251
338
  const sleepingStatuses = new Set([
@@ -284,6 +371,23 @@ export const useUIStore = defineStore("ui", () => {
284
371
  queueStats.value.show = queued > 0 || sleeping > 0 || positions.length > 0;
285
372
  };
286
373
 
374
+ if (DEBUG) {
375
+ try {
376
+ const saved = localStorage.getItem(DEV_QUEUE_STATS_STORAGE_KEY);
377
+ if (saved) {
378
+ const parsed = JSON.parse(saved);
379
+ devQueueStatsMock.value = normalizeQueueStats(parsed);
380
+ applyQueueStats(devQueueStatsMock.value);
381
+ }
382
+ } catch {
383
+ localStorage.removeItem(DEV_QUEUE_STATS_STORAGE_KEY);
384
+ }
385
+
386
+ window.__setQueueStatsMock = (stats) => setDevQueueStatsMock(stats);
387
+ window.__setQueueStatsPreset = (preset = "heavy") => setDevQueueStatsPreset(preset);
388
+ window.__clearQueueStatsMock = () => setDevQueueStatsMock(null);
389
+ }
390
+
287
391
  const toggleModal = (name, clearValue = false) => {
288
392
  if (disabledButtons.value["add-tasks"] && name === "create-task") return;
289
393
  if (clearValue) currentlyEditing.value = {};
@@ -457,7 +561,6 @@ export const useUIStore = defineStore("ui", () => {
457
561
  mainCheckbox,
458
562
  toggleMainCheckbox,
459
563
 
460
-
461
564
  // single
462
565
  deleteTask: (taskId) => {
463
566
  delete tasks.value[taskId];
@@ -526,8 +629,8 @@ export const useUIStore = defineStore("ui", () => {
526
629
  massEditPresaleCode: (eventId, presaleCode) => connection.sendMassEditPresaleCode(eventId, presaleCode),
527
630
 
528
631
  // alerts
529
- showError: (err) => toast.error(err, TOAST_CONFIG),
530
- showSuccess: (msg) => toast.success(msg, TOAST_CONFIG),
632
+ showError: (err) => showToastWithLimit("error", err),
633
+ showSuccess: (msg) => showToastWithLimit("success", msg),
531
634
  // Country
532
635
  currentCountry,
533
636
  setCurrentCountry: (country, closeModal, newModule) => {
@@ -551,6 +654,8 @@ export const useUIStore = defineStore("ui", () => {
551
654
 
552
655
  disabledButtons,
553
656
  queueStats,
657
+ setDevQueueStatsMock,
658
+ setDevQueueStatsPreset,
554
659
 
555
660
  refreshQueueStats,
556
661
 
@@ -37,24 +37,11 @@
37
37
  @deleteAll="ui.deleteTasks()" />
38
38
  </div>
39
39
 
40
- <div class="mb-2 flex items-center justify-between gap-2 md:hidden">
41
- <Stats class="stats-component flex-1" />
42
- <div class="flex-gap-2 items-center">
43
- <div
44
- class="flex h-8 items-center justify-between rounded-md border border-dark-650 bg-dark-400 px-2 text-xs font-medium text-white">
45
- <p class="text-2xs">Name</p>
46
- <Switch class="scale-75" v-model="preferEventName" />
47
- </div>
48
- <PriceSortToggle
49
- class="h-8 min-w-20 max-w-28 flex-shrink-0"
50
- :options="['All', 'Checkout']"
51
- :darker="true"
52
- :current="ui.taskFilter"
53
- @change="(e) => ui.setTaskFilter(e)" />
54
- </div>
40
+ <div class="mb-2 md:hidden">
41
+ <Stats class="stats-component w-full" />
55
42
  </div>
56
43
 
57
- <div class="mb-1 hidden items-center gap-2 md:flex lg:mb-2">
44
+ <div class="mb-1 hidden flex-wrap items-center gap-2 md:flex xl:flex-nowrap lg:mb-2">
58
45
  <div v-if="uniqEventIds.length > 1" class="w-52 flex-shrink-0">
59
46
  <Dropdown
60
47
  :onClick="(f) => ui.setCurrentEvent(f)"
@@ -87,7 +74,9 @@
87
74
  :darker="true"
88
75
  :current="ui.taskFilter"
89
76
  @change="(e) => ui.setTaskFilter(e)" />
90
- <Stats class="stats-component ml-auto" />
77
+ <div class="stats-component min-w-0 w-full md:basis-full xl:ml-auto xl:basis-auto xl:flex-1 xl:max-w-2xl">
78
+ <Stats class="w-full" />
79
+ </div>
91
80
  </div>
92
81
 
93
82
  <div class="mb-1 flex flex-col gap-2 md:hidden">
@@ -101,16 +90,29 @@
101
90
  class="input-default event-dropdown w-full hover:bg-dark-400"
102
91
  rightAmount="right-2" />
103
92
  </div>
104
- <div class="w-full">
105
- <div class="input-default flex items-center">
106
- <input
107
- v-model="taskSearchQuery"
108
- type="text"
109
- placeholder="Search tasks..."
110
- aria-label="Search tasks"
111
- class="transparent-input" />
112
- <span v-if="taskSearchQuery" class="ml-2 text-xs text-light-500">{{ filteredTaskCount }}</span>
93
+ <div class="flex w-full items-center gap-2">
94
+ <div class="flex-1">
95
+ <div class="input-default flex items-center">
96
+ <input
97
+ v-model="taskSearchQuery"
98
+ type="text"
99
+ placeholder="Search tasks..."
100
+ aria-label="Search tasks"
101
+ class="transparent-input" />
102
+ <span v-if="taskSearchQuery" class="ml-2 text-xs text-light-500">{{ filteredTaskCount }}</span>
103
+ </div>
113
104
  </div>
105
+ <div
106
+ class="flex h-8 items-center justify-between rounded-md border border-dark-650 bg-dark-400 px-2 text-xs font-medium text-white flex-shrink-0">
107
+ <p class="text-2xs">Name</p>
108
+ <Switch class="scale-75" v-model="preferEventName" />
109
+ </div>
110
+ <PriceSortToggle
111
+ class="h-8 min-w-20 max-w-28 flex-shrink-0"
112
+ :options="['All', 'Checkout']"
113
+ :darker="true"
114
+ :current="ui.taskFilter"
115
+ @change="(e) => ui.setTaskFilter(e)" />
114
116
  </div>
115
117
  </div>
116
118