@necrolab/dashboard 0.5.2 → 0.5.3

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/backend/api.js CHANGED
@@ -153,11 +153,17 @@ app.ws("/api/updates", async function (ws, req) {
153
153
  setTimeout(() => send({ event: "init-axs-accounts", accounts: Bot.AXS.Accounts }));
154
154
 
155
155
  ws.on("message", async (msg) => {
156
- if (msg === "ping") return ws.send("pong");
157
- const parsed = JSON.parse(msg);
158
- parsed.data.user = currentUser;
159
- const res = await endpoints.handleWebsocketMessage(parsed);
160
- if (res?.error) ws.send(JSON.stringify([{ event: "error", msg: res.error }]));
156
+ const msgStr = msg.toString();
157
+ if (msgStr === "ping") return ws.send("pong");
158
+
159
+ try {
160
+ const parsed = JSON.parse(msgStr);
161
+ parsed.data.user = currentUser;
162
+ const res = await endpoints.handleWebsocketMessage(parsed);
163
+ if (res?.error) ws.send(JSON.stringify([{ event: "error", msg: res.error }]));
164
+ } catch (e) {
165
+ logger.Error("WebSocket message parse error:", e.message, "Message:", msgStr);
166
+ }
161
167
  });
162
168
  }
163
169
  if (req.query.type === "console") {
@@ -194,14 +200,26 @@ app.post("/api/login", async (req, res) => {
194
200
  const pwd = req.body.password;
195
201
  const user = req.body.name;
196
202
  const authResult = auth.loginToAccount(user, pwd);
197
- if (authResult.token) return res.cookie("auth", authResult.token).send(authResult);
198
- else return res.send(authResult);
203
+ if (authResult.token) {
204
+ return res.cookie("auth", authResult.token, {
205
+ httpOnly: false,
206
+ sameSite: 'lax',
207
+ path: '/'
208
+ }).send(authResult);
209
+ } else {
210
+ return res.send(authResult);
211
+ }
199
212
  });
200
213
 
201
214
  app.post("/api/logout", async (req, res) => {
202
215
  const token = req.cookies.auth;
203
216
  const result = auth.invalidateAuthToken(token);
204
- return res.cookie("auth", "").send(result);
217
+ return res.cookie("auth", "", {
218
+ httpOnly: false,
219
+ sameSite: 'lax',
220
+ path: '/',
221
+ maxAge: 0
222
+ }).send(result);
205
223
  });
206
224
 
207
225
  // ===== File editing =====
package/index.html CHANGED
@@ -7,6 +7,9 @@
7
7
  name="viewport"
8
8
  content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
9
9
  />
10
+ <!-- Lock iPhone to portrait orientation only -->
11
+ <meta name="apple-mobile-web-app-capable" content="yes" />
12
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
10
13
  <meta name="description" content="Necro Lab - dashboard" />
11
14
  <meta name="darkreader-lock" />
12
15
  <meta name="color-scheme" content="dark" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@necrolab/dashboard",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "rm -rf dist && npx workbox-cli generateSW workbox-config.cjs && vite build",
@@ -5,13 +5,14 @@
5
5
  {
6
6
  "src": "/android-chrome-192x192.png?v=2",
7
7
  "type": "image/png",
8
- "sizes": "192x192"
8
+ "sizes": "192x192",
9
+ "purpose": "any"
9
10
  },
10
11
  {
11
- "src": "/android-chrome-192x192.png?v=2",
12
+ "src": "/android-chrome-512x512.png?v=2",
12
13
  "type": "image/png",
13
- "sizes": "192x192",
14
- "purpose": "any maskable"
14
+ "sizes": "512x512",
15
+ "purpose": "maskable"
15
16
  },
16
17
  {
17
18
  "src": "/android-chrome-512x512.png?v=2",
@@ -29,5 +30,6 @@
29
30
  "display": "standalone",
30
31
  "scope": "/",
31
32
  "theme_color": "#2e2f32",
32
- "description": "Necro Lab"
33
+ "description": "Necro Lab",
34
+ "orientation": "portrait"
33
35
  }
package/src/App.vue CHANGED
@@ -1,5 +1,15 @@
1
1
  <template>
2
2
  <div class="layout">
3
+ <!-- iPhone Landscape Lock Overlay -->
4
+ <div v-if="!isIpadOS() && isIOS()" class="iphone-landscape-lock">
5
+ <div class="rotate-message">
6
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="rotate-icon">
7
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
8
+ </svg>
9
+ <p class="rotate-text">Please rotate your device to portrait mode</p>
10
+ </div>
11
+ </div>
12
+
3
13
  <transition name="fade">
4
14
  <Splash v-if="isLoading" />
5
15
  </transition>
@@ -88,6 +98,70 @@ function isIpadOS() {
88
98
  return navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform);
89
99
  }
90
100
 
101
+ // Lock iPhone (not iPad) to portrait orientation only
102
+ function lockOrientationToPortrait() {
103
+ if (isIOS() && !isIpadOS()) {
104
+ // Try to lock orientation using Screen Orientation API
105
+ if (screen.orientation && screen.orientation.lock) {
106
+ screen.orientation.lock('portrait').catch(() => {
107
+ // Silently fail if not supported or in browser (vs PWA)
108
+ });
109
+ }
110
+
111
+ // Also prevent landscape handling
112
+ const preventLandscape = () => {
113
+ if (window.innerWidth > window.innerHeight) {
114
+ // Force dimensions to portrait if somehow in landscape
115
+ document.documentElement.style.setProperty('width', window.innerHeight + 'px');
116
+ document.documentElement.style.setProperty('height', window.innerWidth + 'px');
117
+ }
118
+ };
119
+
120
+ window.addEventListener('orientationchange', preventLandscape);
121
+ window.addEventListener('resize', preventLandscape);
122
+ }
123
+ }
124
+
125
+ // Run orientation lock on mount
126
+ lockOrientationToPortrait();
127
+
128
+ // Prevent pinch-to-zoom gestures
129
+ let lastTouchDistance = 0;
130
+
131
+ document.addEventListener('touchstart', (e) => {
132
+ if (e.touches.length > 1) {
133
+ e.preventDefault();
134
+ }
135
+ }, { passive: false });
136
+
137
+ document.addEventListener('touchmove', (e) => {
138
+ if (e.touches.length > 1) {
139
+ e.preventDefault();
140
+ }
141
+ }, { passive: false });
142
+
143
+ document.addEventListener('gesturestart', (e) => {
144
+ e.preventDefault();
145
+ }, { passive: false });
146
+
147
+ document.addEventListener('gesturechange', (e) => {
148
+ e.preventDefault();
149
+ }, { passive: false });
150
+
151
+ document.addEventListener('gestureend', (e) => {
152
+ e.preventDefault();
153
+ }, { passive: false });
154
+
155
+ // Prevent double-tap zoom
156
+ let lastTouchEnd = 0;
157
+ document.addEventListener('touchend', (e) => {
158
+ const now = Date.now();
159
+ if (now - lastTouchEnd <= 300) {
160
+ e.preventDefault();
161
+ }
162
+ lastTouchEnd = now;
163
+ }, { passive: false });
164
+
91
165
  // Handle orientation changes for iOS devices
92
166
  const handleOrientationChange = () => {
93
167
  if (isIOS()) {
@@ -653,7 +727,7 @@ watch(
653
727
  const layout = computed(() => router.currentRoute.value.meta.layout);
654
728
  </script>
655
729
  <style lang="scss">
656
- // Ultra bulletproof scroll prevention
730
+ // Ultra bulletproof scroll prevention and zoom prevention
657
731
  html,
658
732
  body {
659
733
  overflow: hidden !important;
@@ -661,6 +735,10 @@ body {
661
735
  width: 100% !important;
662
736
  height: 100% !important;
663
737
  touch-action: none !important;
738
+ // Prevent zoom on desktop
739
+ zoom: 1;
740
+ -moz-transform: scale(1);
741
+ -moz-transform-origin: 0 0;
664
742
  }
665
743
 
666
744
  #app {
@@ -671,6 +749,11 @@ body {
671
749
  touch-action: none !important;
672
750
  }
673
751
 
752
+ // Prevent any element from being zoomable
753
+ * {
754
+ touch-action: manipulation !important;
755
+ }
756
+
674
757
  .dropdown {
675
758
  position: relative;
676
759
  display: inline-block;
@@ -691,6 +774,52 @@ body {
691
774
  height: 100vh !important;
692
775
  }
693
776
 
777
+ // iPhone landscape lock overlay
778
+ .iphone-landscape-lock {
779
+ display: none;
780
+ position: fixed;
781
+ inset: 0;
782
+ background: oklch(0.1822 0 0);
783
+ z-index: 99999;
784
+ align-items: center;
785
+ justify-content: center;
786
+
787
+ // Only show on iPhone (not iPad) in landscape
788
+ @media (max-device-width: 430px) and (orientation: landscape) {
789
+ display: flex;
790
+ }
791
+
792
+ .rotate-message {
793
+ text-align: center;
794
+ padding: 2rem;
795
+
796
+ .rotate-icon {
797
+ width: 64px;
798
+ height: 64px;
799
+ margin: 0 auto 1rem;
800
+ color: oklch(0.72 0.15 145);
801
+ animation: rotate-pulse 2s ease-in-out infinite;
802
+ }
803
+
804
+ .rotate-text {
805
+ color: oklch(0.9 0 0);
806
+ font-size: 1.125rem;
807
+ font-weight: 500;
808
+ }
809
+ }
810
+ }
811
+
812
+ @keyframes rotate-pulse {
813
+ 0%, 100% {
814
+ transform: rotate(0deg) scale(1);
815
+ opacity: 1;
816
+ }
817
+ 50% {
818
+ transform: rotate(90deg) scale(1.1);
819
+ opacity: 0.8;
820
+ }
821
+ }
822
+
694
823
  .router-wrapper {
695
824
  @apply pt-5;
696
825
  transition: margin 0.25s;
@@ -4,22 +4,31 @@
4
4
 
5
5
  .Toastify__toast-theme--colored.Toastify__toast--default,
6
6
  .Toastify__toast-theme--light {
7
- @apply bg-dark-550 text-white;
7
+ background: oklch(0.2046 0 0);
8
+ border: 2px solid oklch(0.2809 0 0);
9
+ color: oklch(0.90 0 0);
8
10
  }
9
11
 
10
12
  .Toastify__toast-icon {
11
- background: white;
12
- border-radius: 100%;
13
+ background: transparent;
14
+ width: 20px;
15
+ height: 20px;
16
+
17
+ svg {
18
+ width: 20px;
19
+ height: 20px;
20
+ }
13
21
  }
14
22
 
15
23
  .Toastify__close-button.Toastify__close-button--light {
16
24
  margin-top: 1px;
17
25
  padding-left: 0.1rem;
18
- @apply border-2 border-white flex border-solid w-6 h-6 items-center justify-center rounded-full;
26
+ @apply flex border-solid w-6 h-6 items-center justify-center rounded-full;
27
+ border: 2px solid oklch(0.90 0 0);
19
28
  }
20
29
 
21
30
  .Toastify__close-button.Toastify__close-button--light svg {
22
- color: white;
31
+ color: oklch(0.90 0 0);
23
32
  }
24
33
 
25
34
  .Toastify__close-button.Toastify__close-button--light svg path {
@@ -91,7 +100,7 @@
91
100
  }
92
101
 
93
102
  .Toastify__toast--success .Toastify__progress-bar {
94
- @apply bg-green-400;
103
+ background: oklch(0.72 0.15 145);
95
104
  }
96
105
 
97
106
  .Toastify__progress-bar {
@@ -2,7 +2,10 @@
2
2
  <Row
3
3
  class="relative h-16 grid-cols-5 text-white md:grid-cols-7"
4
4
  @click="ui.setOpenContextMenu('')"
5
- @click.right.prevent="ui.setOpenContextMenu('')">
5
+ @click.right.prevent="ui.setOpenContextMenu('')"
6
+ @dblclick="handleDoubleClick"
7
+ @touchstart="handleTouchStart"
8
+ @touchend="handleTouchEnd">
6
9
  <div class="col-span-3 flex lg:col-span-2">
7
10
  <Checkbox
8
11
  class="ml-0 mr-4"
@@ -189,4 +192,40 @@ const edit = () => {
189
192
  ui.currentlyEditing = props.account;
190
193
  ui.toggleModal("create-account");
191
194
  };
195
+
196
+ // Double-click/tap selection
197
+ let lastTapTime = 0;
198
+ const DOUBLE_TAP_DELAY = 300; // ms
199
+
200
+ const handleDoubleClick = (event) => {
201
+ // Prevent if clicking on buttons or checkbox
202
+ if (event.target.closest('button') || event.target.closest('.checkbox')) {
203
+ return;
204
+ }
205
+ ui.toggleAccountSelected(props.account.id);
206
+ };
207
+
208
+ const handleTouchStart = (event) => {
209
+ // Store touch time for double-tap detection
210
+ const currentTime = Date.now();
211
+ const tapGap = currentTime - lastTapTime;
212
+
213
+ if (tapGap < DOUBLE_TAP_DELAY && tapGap > 0) {
214
+ // Double-tap detected
215
+ if (!event.target.closest('button') && !event.target.closest('.checkbox')) {
216
+ event.preventDefault(); // Prevent zoom
217
+ ui.toggleAccountSelected(props.account.id);
218
+ }
219
+ }
220
+
221
+ lastTapTime = currentTime;
222
+ };
223
+
224
+ const handleTouchEnd = (event) => {
225
+ // Prevent default to avoid potential zoom issues
226
+ // but only if not interacting with buttons/checkbox
227
+ if (event.target.closest('button') || event.target.closest('.checkbox')) {
228
+ return;
229
+ }
230
+ };
192
231
  </script>
@@ -2,7 +2,10 @@
2
2
  <Row
3
3
  class="relative grid-cols-7 text-white lg:grid-cols-8"
4
4
  @click="ui.setOpenContextMenu('')"
5
- @click.right.prevent="ui.setOpenContextMenu('')">
5
+ @click.right.prevent="ui.setOpenContextMenu('')"
6
+ @dblclick="handleDoubleClick"
7
+ @touchstart="handleTouchStart"
8
+ @touchend="handleTouchEnd">
6
9
  <div class="col-span-3 flex lg:col-span-2">
7
10
  <Checkbox
8
11
  class="ml-0 mr-4"
@@ -216,4 +219,40 @@ const edit = () => {
216
219
  ui.toggleModal("create-profile");
217
220
  };
218
221
  const deleteProfile = async () => await ui.deleteProfile(props.profile.id);
222
+
223
+ // Double-click/tap selection
224
+ let lastTapTime = 0;
225
+ const DOUBLE_TAP_DELAY = 300; // ms
226
+
227
+ const handleDoubleClick = (event) => {
228
+ // Prevent if clicking on buttons or checkbox
229
+ if (event.target.closest('button') || event.target.closest('.checkbox')) {
230
+ return;
231
+ }
232
+ ui.toggleProfileSelected(props.profile.id);
233
+ };
234
+
235
+ const handleTouchStart = (event) => {
236
+ // Store touch time for double-tap detection
237
+ const currentTime = Date.now();
238
+ const tapGap = currentTime - lastTapTime;
239
+
240
+ if (tapGap < DOUBLE_TAP_DELAY && tapGap > 0) {
241
+ // Double-tap detected
242
+ if (!event.target.closest('button') && !event.target.closest('.checkbox')) {
243
+ event.preventDefault(); // Prevent zoom
244
+ ui.toggleProfileSelected(props.profile.id);
245
+ }
246
+ }
247
+
248
+ lastTapTime = currentTime;
249
+ };
250
+
251
+ const handleTouchEnd = (event) => {
252
+ // Prevent default to avoid potential zoom issues
253
+ // but only if not interacting with buttons/checkbox
254
+ if (event.target.closest('button') || event.target.closest('.checkbox')) {
255
+ return;
256
+ }
257
+ };
219
258
  </script>
@@ -7,15 +7,22 @@
7
7
  .table-component {
8
8
  @apply flex-col bg-clip-padding rounded relative box-border border border-dark-600 bg-dark-500;
9
9
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
10
- overflow: auto !important;
10
+ overflow-x: auto !important;
11
+ overflow-y: auto !important;
11
12
  overscroll-behavior: auto !important;
12
- touch-action: auto !important;
13
+ // Prevent pinch-to-zoom while allowing scrolling
14
+ touch-action: pan-x pan-y !important;
13
15
  max-height: calc(100vh - 200px);
16
+ -webkit-overflow-scrolling: touch;
14
17
  }
15
18
 
16
19
  .table-component > .grid {
17
20
  @apply bg-dark-400;
18
21
  border-bottom: 1px solid oklch(0.26 0 0);
22
+ // Only enforce min-width on desktop, allow mobile to fit screen
23
+ @media (min-width: 768px) {
24
+ min-width: 640px;
25
+ }
19
26
  }
20
27
 
21
28
  /* iPhone landscape mode table optimization */
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <Row class="relative grid-cols-10 gap-2 text-white lg:grid-cols-12" @click="ui.setOpenContextMenu('')">
2
+ <Row
3
+ class="relative grid-cols-10 gap-2 text-white lg:grid-cols-12"
4
+ @click="ui.setOpenContextMenu('')"
5
+ @dblclick="handleDoubleClick"
6
+ @touchstart="handleTouchStart"
7
+ @touchend="handleTouchEnd">
3
8
  <div class="col-span-1 flex items-center justify-start lg:col-span-2">
4
9
  <Checkbox
5
10
  class="ml-2 mr-4 flex-shrink-0"
@@ -389,6 +394,42 @@ const props = defineProps({
389
394
  const contextMenuPosition = ref({});
390
395
  const contextMenuRef = ref(null);
391
396
 
397
+ // Double-click/tap selection
398
+ let lastTapTime = 0;
399
+ const DOUBLE_TAP_DELAY = 300; // ms
400
+
401
+ const handleDoubleClick = (event) => {
402
+ // Prevent if clicking on buttons or checkbox
403
+ if (event.target.closest('button') || event.target.closest('.checkbox')) {
404
+ return;
405
+ }
406
+ ui.toggleTaskSelected(props.task.taskId);
407
+ };
408
+
409
+ const handleTouchStart = (event) => {
410
+ // Store touch time for double-tap detection
411
+ const currentTime = Date.now();
412
+ const tapGap = currentTime - lastTapTime;
413
+
414
+ if (tapGap < DOUBLE_TAP_DELAY && tapGap > 0) {
415
+ // Double-tap detected
416
+ if (!event.target.closest('button') && !event.target.closest('.checkbox')) {
417
+ event.preventDefault(); // Prevent zoom
418
+ ui.toggleTaskSelected(props.task.taskId);
419
+ }
420
+ }
421
+
422
+ lastTapTime = currentTime;
423
+ };
424
+
425
+ const handleTouchEnd = (event) => {
426
+ // Prevent default to avoid potential zoom issues
427
+ // but only if not interacting with buttons/checkbox
428
+ if (event.target.closest('button') || event.target.closest('.checkbox')) {
429
+ return;
430
+ }
431
+ };
432
+
392
433
  // Handle right-click to position context menu
393
434
  const handleRightClick = (event) => {
394
435
  const menuWidth = 168; // w-42 = 10.5rem = 168px
@@ -14,17 +14,17 @@
14
14
  <UpIcon v-if="ui.sortData.sortBy === 'eventId' && ui.sortData.reversed" class="ml-1" />
15
15
  </div>
16
16
  </div>
17
- <div class="flex items-center justify-center col-span-2" v-once>
17
+ <div class="col-span-2 flex items-center justify-start" v-once>
18
18
  <TicketIcon class="mr-0 lg:mr-3" />
19
19
  <h4 class="hidden lg:flex">Tickets</h4>
20
20
  </div>
21
- <div class="flex items-center justify-center col-span-5 md:col-span-4 lg:col-span-5" @click="ui.toggleSort('status')">
21
+ <div class="col-span-5 md:col-span-4 lg:col-span-5 flex items-center justify-center" @click="ui.toggleSort('status')">
22
22
  <StatusIcon class="mr-0 lg:mr-3" />
23
23
  <h4 class="hidden lg:flex">Status</h4>
24
24
  <DownIcon v-if="ui.sortData.sortBy === 'status' && !ui.sortData.reversed" class="ml-1" />
25
25
  <UpIcon v-if="ui.sortData.sortBy === 'status' && ui.sortData.reversed" class="ml-1" />
26
26
  </div>
27
- <div class="flex items-center justify-center col-span-2 lg:col-span-3" v-once>
27
+ <div class="col-span-2 lg:col-span-3 flex items-center justify-center" v-once>
28
28
  <ClickIcon class="mr-0 lg:mr-3" />
29
29
  <h4 class="hidden lg:flex">Actions</h4>
30
30
  </div>
@@ -58,9 +58,6 @@
58
58
  </div>
59
59
  </div>
60
60
  </div>
61
-
62
- <!-- View Task Modal -->
63
- <ViewTask v-if="ui.activeModal === 'view-task' && ui.selectedTaskForView" :task="ui.selectedTaskForView" />
64
61
  </div>
65
62
  </template>
66
63
  <style lang="scss" scoped>
@@ -102,7 +99,6 @@ import { computed, ref, onMounted, onUnmounted } from "vue";
102
99
  import { Table, Header } from "@/components/Table";
103
100
  import { EventIcon, TicketIcon, StatusIcon, ClickIcon, DownIcon, UpIcon, TasksIcon } from "@/components/icons";
104
101
  import Task from "./Task.vue";
105
- import ViewTask from "./ViewTask.vue";
106
102
  import Checkbox from "@/components/ui/controls/atomic/Checkbox.vue";
107
103
  import { useUIStore } from "@/stores/ui";
108
104
 
@@ -192,7 +188,7 @@ const dynamicTableHeight = computed(() => {
192
188
  const filtersAndStatsHeight = windowWidth.value >= 768
193
189
  ? (ui.queueStats.show ? 50 : 45) // Desktop: single row
194
190
  : (ui.queueStats.show ? 130 : 90); // Mobile: stats row + stacked filters
195
- const utilitiesHeight = windowWidth.value <= 768 ? 90 : 160; // Utilities section height (ensure buttons fully visible)
191
+ const utilitiesHeight = windowWidth.value <= 768 ? 180 : 160; // Increase mobile utilities space to prevent cutoff
196
192
  const margins =
197
193
  windowWidth.value >= 1024 ? 20 : windowWidth.value <= 480 && windowHeight.value > windowWidth.value ? 8 : 12;
198
194
 
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="grid grid-cols-1 gap-3 lg:grid-cols-1" v-if="ui.currentModule == 'TM'">
2
+ <div class="utilities-wrapper grid grid-cols-1 gap-3 lg:grid-cols-1" v-if="ui.currentModule == 'TM'">
3
3
  <div class="lg:justify-self-end">
4
4
  <h4 class="hidden lg:block text-white opacity-40 uppercase font-medium mb-1">Utils</h4>
5
5
  <div class="flex gap-3 justify-between lg:justify-start">
@@ -23,6 +23,14 @@ import { useUIStore } from "@/stores/ui";
23
23
  const ui = useUIStore();
24
24
  </script>
25
25
  <style lang="scss" scoped>
26
+ .utilities-wrapper {
27
+ // Add extra margin on mobile to prevent buttons from being cut off
28
+ @media (max-width: 768px) {
29
+ margin-top: 1rem;
30
+ margin-bottom: 1.5rem;
31
+ }
32
+ }
33
+
26
34
  .utility-btn {
27
35
  height: 50px;
28
36
 
@@ -8,10 +8,10 @@
8
8
  <div class="section-card mb-4 mt-4">
9
9
  <h3 class="section-title">Task Overview</h3>
10
10
  <div class="grid grid-cols-1 gap-3">
11
- <div class="info-row copyable" @click="copy(task.taskId)">
11
+ <div class="info-row" @click="copy(task.taskId)">
12
12
  <span class="info-icon flex items-center justify-center text-base font-bold">#</span>
13
13
  <span class="info-label">Task ID</span>
14
- <span class="info-value">{{ task.taskId }}</span>
14
+ <span class="info-value copyable">{{ task.taskId }}</span>
15
15
  </div>
16
16
  <div class="info-row" @click="copy(task.eventId)">
17
17
  <StadiumIcon class="info-icon" />
@@ -66,6 +66,8 @@ onClickOutside(target, (event) => {
66
66
  backdrop-filter: blur(4px);
67
67
  overflow-y: auto;
68
68
  -webkit-overflow-scrolling: touch;
69
+ // Prevent zoom in modals
70
+ touch-action: pan-y !important;
69
71
  }
70
72
 
71
73
  .component-modal {
@@ -73,6 +75,8 @@ onClickOutside(target, (event) => {
73
75
  margin-bottom: 10rem;
74
76
  overflow-y: visible;
75
77
  @apply flex flex-col rounded-lg bg-dark-300 px-5 py-5;
78
+ // Prevent zoom in modal content
79
+ touch-action: pan-y !important;
76
80
 
77
81
  .modal-header {
78
82
  @apply flex font-bold text-white;
@@ -326,10 +326,10 @@ const toggleMenu = () => {
326
326
  const logout = async () => {
327
327
  ui.logger.Info("Logging out of session");
328
328
  const json = await sendLogout();
329
- if (json.error) return ui.showError(json.error);
330
- else {
331
- ui.setProfile({});
332
- router.push("/login");
333
- }
329
+ if (json.error) ui.showError(json.error);
330
+
331
+ // Always clear profile and redirect, even if logout request failed
332
+ ui.setProfile({});
333
+ router.push("/login");
334
334
  };
335
335
  </script>
@@ -20,7 +20,8 @@
20
20
  class="dropdown-item"
21
21
  :class="i !== 0 ? 'border-t border-dark-650' : ''"
22
22
  v-for="(f, i) in !allowDefault ? props.options : ['', ...props.options]"
23
- @click.stop="chose(f)">
23
+ @mousedown.prevent.stop="chose(f)"
24
+ @touchstart.prevent.stop="chose(f)">
24
25
  <span class="dropdown-item-text" :class="capitalize ? 'capitalize' : ''">
25
26
  {{ f ? f : props.default }}
26
27
  </span>
@@ -33,11 +34,10 @@
33
34
  </template>
34
35
 
35
36
  <script setup>
36
- import { ref, computed, watch } from "vue";
37
+ import { ref, computed, watch, onMounted, onUnmounted } from "vue";
37
38
  import { DownIcon, CheckmarkIcon } from "@/components/icons";
38
39
  import { useUIStore } from "@/stores/ui";
39
40
  import { useDropdownPosition } from "@/composables/useDropdownPosition";
40
- import { useClickOutside } from "@/composables/useClickOutside";
41
41
 
42
42
  const ui = useUIStore();
43
43
 
@@ -78,10 +78,27 @@ const { menuStyle, updatePosition } = useDropdownPosition(dropdownRef, {
78
78
  }
79
79
  });
80
80
 
81
- useClickOutside(dropdownRef, () => {
82
- if (opened.value) {
81
+ // Improved click outside that accounts for teleported menu
82
+ const handleClickOutside = (event) => {
83
+ if (!opened.value) return;
84
+
85
+ // Check if click is outside both the dropdown trigger and the menu
86
+ const clickedDropdown = dropdownRef.value?.contains(event.target);
87
+ const clickedMenu = event.target.closest('.dropdown-menu-portal');
88
+
89
+ if (!clickedDropdown && !clickedMenu) {
83
90
  ui.setCurrentDropdown("");
84
91
  }
92
+ };
93
+
94
+ onMounted(() => {
95
+ document.addEventListener('mousedown', handleClickOutside);
96
+ document.addEventListener('touchstart', handleClickOutside);
97
+ });
98
+
99
+ onUnmounted(() => {
100
+ document.removeEventListener('mousedown', handleClickOutside);
101
+ document.removeEventListener('touchstart', handleClickOutside);
85
102
  });
86
103
 
87
104
  const toggleOpened = () => {
@@ -177,6 +194,7 @@ const chose = (f) => {
177
194
  -webkit-overflow-scrolling: touch !important;
178
195
  scrollbar-width: none;
179
196
  -ms-overflow-style: none;
197
+ z-index: 1000;
180
198
  }
181
199
 
182
200
  .dropdown-menu-portal::-webkit-scrollbar {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div class="console-page">
3
3
  <h4 class="mb-2 flex items-center gap-2 pt-5 text-sm font-bold text-white lg:mt-1">
4
4
  Console
5
5
  <ConsoleIcon />
@@ -94,7 +94,7 @@
94
94
 
95
95
  <Smoothie
96
96
  :weight="0.2"
97
- class="console scrollable smooth-scroll overflow-y-auto overflow-x-hidden font-mono text-white"
97
+ class="console scrollable smooth-scroll overflow-y-auto overflow-x-auto font-mono text-white"
98
98
  style="min-height: 12rem !important"
99
99
  ref="$autoscroll"
100
100
  @wheel.stop
@@ -118,7 +118,7 @@
118
118
  v-bind:key="`log-${index}`"
119
119
  :style="{ '--index': index }"><code class="md:text-sm lg:text-base" v-html="line"></code></pre>
120
120
  </Smoothie>
121
- <div class="mt-3 flex justify-between md:hidden">
121
+ <div class="console-switches mt-4 mb-6 flex justify-between md:hidden">
122
122
  <button
123
123
  class="flex h-10 items-center justify-center gap-3 rounded border border-dark-650 bg-dark-400 px-2 shadow-sm">
124
124
  <h3 class="text-sm text-white">Hide Monitors</h3>
@@ -138,13 +138,44 @@
138
138
  </div>
139
139
  </template>
140
140
  <style lang="scss" scoped>
141
+ .console-page {
142
+ // Add generous bottom padding on mobile to prevent switch cutoff
143
+ @media (max-width: 768px) {
144
+ padding-bottom: 4rem;
145
+ margin-bottom: 2rem;
146
+ }
147
+
148
+ @media (max-width: 480px) and (orientation: portrait) {
149
+ padding-bottom: 6rem;
150
+ margin-bottom: 3rem;
151
+ }
152
+ }
153
+
154
+ .console-switches {
155
+ @media (max-width: 480px) and (orientation: portrait) {
156
+ margin-top: 1.5rem !important;
157
+ margin-bottom: 4rem !important;
158
+ }
159
+ }
160
+
141
161
  .console {
142
162
  @apply relative rounded border-2 border-dark-550 bg-dark-400 p-2 lg:p-5;
143
163
  height: calc(100vh - 18rem);
144
164
  scrollbar-width: thin;
145
165
  scrollbar-color: oklch(0.35 0 0) oklch(0.19 0 0);
146
166
 
147
- @media (min-width: 768px) {
167
+ // Use fixed height on mobile portrait to ensure switches are visible
168
+ @media (max-width: 768px) {
169
+ max-height: 60vh;
170
+ height: auto;
171
+ }
172
+
173
+ @media (max-width: 480px) and (orientation: portrait) {
174
+ max-height: 50vh;
175
+ height: auto;
176
+ }
177
+
178
+ @media (min-width: 769px) and (max-width: 1023px) {
148
179
  height: calc(100vh - 16rem);
149
180
  }
150
181
 
@@ -126,6 +126,9 @@
126
126
  <Utilities class="utilities-section" />
127
127
  </div>
128
128
 
129
+ <!-- View Task Modal -->
130
+ <ViewTask v-if="ui.activeModal === 'view-task' && ui.selectedTaskForView" :task="ui.selectedTaskForView" />
131
+
129
132
  <transition-group name="fade">
130
133
  <CreateTaskTM
131
134
  v-if="ui.currentModule == 'TM' && activeModal === 'create-task'"
@@ -234,6 +237,7 @@
234
237
  import { computed, onMounted, ref } from "vue";
235
238
  import { DesktopControls } from "@/components/Tasks/Controls";
236
239
  import TaskView from "@/components/Tasks/TaskView.vue";
240
+ import ViewTask from "@/components/Tasks/ViewTask.vue";
237
241
  import Utilities from "@/components/Tasks/Utilities.vue";
238
242
  import CreateTaskTM from "@/components/Tasks/CreateTaskTM.vue";
239
243
  import CreateTaskAXS from "@/components/Tasks/CreateTaskAXS.vue";
package/vite.config.js CHANGED
@@ -7,7 +7,7 @@ const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
7
7
 
8
8
  // https://vitejs.dev/config/
9
9
  export default defineConfig({
10
- cacheDir: './node_modules/.vite',
10
+ cacheDir: "./node_modules/.vite",
11
11
  build: {
12
12
  rollupOptions: {
13
13
  output: {