@necrolab/dashboard 0.5.1 → 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,7 +7,12 @@
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" />
14
+ <meta name="darkreader-lock" />
15
+ <meta name="color-scheme" content="dark" />
11
16
  <title>Necro Lab - Dashboard</title>
12
17
  <!-- DNS prefetch for external resources -->
13
18
  <link rel="dns-prefetch" href="https://fonts.googleapis.com" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@necrolab/dashboard",
3
- "version": "0.5.1",
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
  }
Binary file
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 {
@@ -1,5 +1,5 @@
1
1
  /* ==========================================================================
2
- NECRO DASHBOARD - MAIN STYLES
2
+ NECRO DASHBOARD
3
3
  Modular SCSS architecture with @use imports
4
4
  ========================================================================== */
5
5
 
@@ -47,14 +47,14 @@ img {
47
47
 
48
48
  /* Global icon color consistency */
49
49
  svg {
50
- color: oklch(0.90 0 0) !important;
50
+ color: oklch(0.9 0 0) !important;
51
51
  }
52
52
 
53
53
  /* For stroke icons (Eye, etc) - stroke on svg element */
54
54
  svg[stroke] path,
55
55
  svg[stroke] circle,
56
56
  svg[stroke] line {
57
- stroke: oklch(0.90 0 0) !important;
57
+ stroke: oklch(0.9 0 0) !important;
58
58
  }
59
59
 
60
60
  /* For filled icons with explicit fill */
@@ -62,7 +62,7 @@ svg path[fill]:not([fill="none"]),
62
62
  svg circle[fill]:not([fill="none"]),
63
63
  svg rect[fill]:not([fill="none"]),
64
64
  svg polygon[fill]:not([fill="none"]) {
65
- fill: oklch(0.90 0 0) !important;
65
+ fill: oklch(0.9 0 0) !important;
66
66
  }
67
67
 
68
68
  /* For filled icons without explicit fill attribute */
@@ -70,7 +70,7 @@ svg:not([stroke]):not([fill="none"]) path:not([fill]),
70
70
  svg:not([stroke]):not([fill="none"]) circle:not([fill]),
71
71
  svg:not([stroke]):not([fill="none"]) rect:not([fill]),
72
72
  svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
73
- fill: oklch(0.90 0 0) !important;
73
+ fill: oklch(0.9 0 0) !important;
74
74
  }
75
75
 
76
76
  /* ==========================================================================
@@ -82,31 +82,32 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
82
82
  }
83
83
 
84
84
  .status-indicator {
85
- @apply w-2 h-2 rounded-full flex-shrink-0;
85
+ @apply h-2 w-2 flex-shrink-0 rounded-full;
86
86
  min-width: 4px;
87
87
  min-height: 4px;
88
88
  }
89
89
 
90
90
  .mobile-icons {
91
- @apply flex lg:hidden ml-auto items-center gap-x-2;
91
+ @apply ml-auto flex items-center gap-x-2 lg:hidden;
92
92
 
93
93
  button {
94
- @apply w-8 h-8 flex items-center justify-center rounded transition-all duration-150;
94
+ @apply flex h-8 w-8 items-center justify-center rounded transition-all duration-150;
95
95
  background-color: oklch(0.2046 0 0);
96
96
  border: 2px solid oklch(0.2809 0 0);
97
97
  color: oklch(0.82 0 0);
98
98
 
99
- &:hover, &:active {
99
+ &:hover,
100
+ &:active {
100
101
  border-color: oklch(0.72 0.15 145);
101
102
  outline: 1px solid oklch(0.72 0.15 145);
102
103
  outline-offset: 0;
103
- color: oklch(0.90 0 0);
104
+ color: oklch(0.9 0 0);
104
105
  }
105
106
  }
106
107
  }
107
108
 
108
109
  .loading-spinner {
109
- @apply animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full;
110
+ @apply h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent;
110
111
  }
111
112
 
112
113
  /* ==========================================================================
@@ -114,7 +115,7 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
114
115
  ========================================================================== */
115
116
 
116
117
  .label-override {
117
- @apply flex items-center text-xs mb-2;
118
+ @apply mb-2 flex items-center text-xs;
118
119
  color: oklch(0.65 0 0);
119
120
 
120
121
  svg {
@@ -128,10 +129,10 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
128
129
 
129
130
  .task-switches {
130
131
  h4 {
131
- color: oklch(0.90 0 0);
132
+ color: oklch(0.9 0 0);
132
133
  font-size: 0.8125rem;
133
134
  font-weight: 500;
134
- @apply text-center flex items-center gap-x-2 mx-auto mb-2;
135
+ @apply mx-auto mb-2 flex items-center gap-x-2 text-center;
135
136
  }
136
137
 
137
138
  .switch-wrapper {
@@ -141,7 +142,7 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
141
142
  svg {
142
143
  width: 15px !important;
143
144
  height: 15px !important;
144
- color: oklch(0.90 0 0) !important;
145
+ color: oklch(0.9 0 0) !important;
145
146
  margin-left: 0.25rem !important;
146
147
  }
147
148
  }
@@ -223,11 +224,11 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
223
224
  @screen md {
224
225
  .btn-primary,
225
226
  .btn-secondary {
226
- @apply text-sm px-3 py-1.5;
227
+ @apply px-3 py-1.5 text-sm;
227
228
  }
228
229
 
229
230
  .btn-action {
230
- @apply text-sm px-6 h-12;
231
+ @apply h-12 px-6 text-sm;
231
232
  min-height: 48px;
232
233
  }
233
234
 
@@ -241,7 +242,7 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
241
242
 
242
243
  @screen mobile-portrait {
243
244
  .btn-action {
244
- @apply text-base px-8 h-14;
245
+ @apply h-14 px-8 text-base;
245
246
  min-height: 56px;
246
247
  font-weight: 600;
247
248
  border-radius: 8px;
@@ -257,7 +258,7 @@ svg:not([stroke]):not([fill="none"]) polygon:not([fill]) {
257
258
 
258
259
  @screen mobile-landscape {
259
260
  .btn-action {
260
- @apply text-base px-8 h-14;
261
+ @apply h-14 px-8 text-base;
261
262
  min-height: 56px;
262
263
  font-weight: 600;
263
264
  }
@@ -1,31 +1,42 @@
1
1
  <template>
2
2
  <div class="form-section">
3
3
  <!-- Username -->
4
- <div class="input-wrapper mb-4">
5
- <label class="label-override">
6
- <span>Username</span>
7
- <ProfileIcon class="ml-2" />
8
- </label>
9
- <div class="input-default">
10
- <input v-model="user" placeholder="Username" />
4
+ <div class="input-container mb-3">
5
+ <div class="flex items-center gap-2">
6
+ <ProfileIcon class="w-4 h-4" />
7
+ <span class="text-light-300 font-medium text-sm">Username</span>
8
+ </div>
9
+ <div class="flex-1 flex items-center">
10
+ <input
11
+ type="text"
12
+ class="login-input"
13
+ v-model="user"
14
+ />
11
15
  </div>
12
16
  </div>
13
17
  <!-- Password -->
14
- <div class="input-wrapper">
15
- <label class="label-override">
16
- <span>Password</span>
17
- <KeyIcon class="ml-2" />
18
- </label>
19
- <div class="input-default">
20
- <input v-model="password" type="password" placeholder="Password" />
18
+ <div class="input-container mb-4">
19
+ <div class="flex items-center gap-2">
20
+ <KeyIcon class="w-4 h-4" />
21
+ <span class="text-light-300 font-medium text-sm">Password</span>
22
+ </div>
23
+ <div class="flex-1 flex items-center">
24
+ <input
25
+ type="password"
26
+ class="login-input"
27
+ v-model="password"
28
+ />
21
29
  </div>
22
30
  </div>
23
31
  <button
24
- class="bg-green-400 hover:bg-green-500 smooth-hover text-white font-medium px-4 py-2 rounded transition-all duration-150 w-full mt-4 h-10 flex items-center justify-center"
32
+ class="login-btn mt-6 mx-auto"
25
33
  @click="login()"
26
34
  :disabled="buttonDisabled"
27
35
  >
28
36
  <span v-if="!buttonDisabled">Login</span>
37
+ <svg v-if="!buttonDisabled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
38
+ <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M13.8 12H3"/>
39
+ </svg>
29
40
  <div v-else class="loading-spinner"></div>
30
41
  </button>
31
42
  </div>
@@ -56,43 +67,65 @@ async function login() {
56
67
  </script>
57
68
 
58
69
  <style lang="scss" scoped>
59
- .label-override {
60
- @apply flex items-center text-sm mb-2;
61
- color: #e1e1e4 !important;
70
+ .login-btn {
71
+ @apply flex items-center justify-center gap-2 rounded-lg transition-all duration-150;
72
+ background: oklch(0.72 0.15 145);
73
+ border: 2px solid oklch(0.72 0.15 145);
74
+ color: oklch(1 0 0);
75
+ height: 3rem;
76
+ width: 12rem;
77
+ font-size: 0.9375rem;
78
+ font-weight: 600;
79
+ letter-spacing: 0.05em;
80
+ text-transform: uppercase;
81
+
82
+ &:hover:not(:disabled) {
83
+ background: oklch(0.68 0.15 145);
84
+ border-color: oklch(0.68 0.15 145);
85
+ transform: translateY(-1px);
86
+ box-shadow: 0 4px 12px oklch(0.72 0.15 145 / 0.3);
87
+ }
88
+
89
+ &:active:not(:disabled) {
90
+ transform: translateY(0);
91
+ box-shadow: 0 2px 4px oklch(0.72 0.15 145 / 0.2);
92
+ }
62
93
 
63
- svg {
64
- width: 16px;
65
- height: 16px;
66
- path {
67
- fill: #e1e1e4 !important;
68
- }
94
+ &:disabled {
95
+ opacity: 0.7;
96
+ cursor: not-allowed;
69
97
  }
70
98
  }
71
99
 
72
- .input-default {
73
- @apply bg-dark-400 border border-dark-650 rounded-lg px-4 py-2 flex items-center;
74
- min-height: 40px;
100
+ .input-container {
101
+ @apply text-white bg-dark-500 px-3 rounded-lg border-2 border-dark-550 flex items-center justify-between h-11;
102
+ overflow: visible;
103
+ transition: border-color 0.15s ease;
75
104
 
76
- input {
77
- @apply bg-transparent border-none outline-none w-full text-white;
78
- &::placeholder {
79
- color: oklch(0.65 0 0);
80
- }
105
+ &:hover {
106
+ border-color: oklch(0.30 0 0);
107
+ }
108
+
109
+ &:focus-within {
110
+ border-color: oklch(0.72 0.15 145) !important;
111
+ outline: 1px solid oklch(0.72 0.15 145);
112
+ outline-offset: 0;
81
113
  }
82
114
  }
83
115
 
84
- @media (max-height: 500px) and (orientation: landscape) {
85
- .label-override {
86
- @apply text-xs mb-1;
87
-
88
- svg {
89
- width: 14px;
90
- height: 14px;
91
- }
116
+ .login-input {
117
+ @apply w-full h-full text-sm text-white bg-transparent border-0 outline-none px-2 py-1;
118
+
119
+ &:focus {
120
+ @apply outline-none border-0 shadow-none bg-transparent;
121
+ }
122
+
123
+ &:hover:not(:focus) {
124
+ background: transparent;
92
125
  }
93
126
 
94
- .input-default {
95
- min-height: 36px;
127
+ &::placeholder {
128
+ color: oklch(0.50 0 0);
96
129
  }
97
130
  }
98
131
  </style>
@@ -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>
@@ -37,7 +37,7 @@
37
37
 
38
38
  <script setup>
39
39
  import { ref, onMounted, onUnmounted } from "vue";
40
- import logoIcon from "/android-chrome-192x192.png";
40
+ import logoIcon from "/reconnect-logo.png";
41
41
 
42
42
  const dotIndex = ref(0);
43
43
  const progressWidth = ref(0);
@@ -119,7 +119,7 @@ const props = defineProps({
119
119
  left: 0;
120
120
  right: 0;
121
121
  bottom: 0;
122
- background: rgba(26, 27, 30, 0.98);
122
+ background: oklch(0.1822 0 0 / 0.98);
123
123
  backdrop-filter: blur(12px);
124
124
  -webkit-backdrop-filter: blur(12px);
125
125
  }
@@ -269,7 +269,7 @@ const props = defineProps({
269
269
  left: -100%;
270
270
  width: 100%;
271
271
  height: 100%;
272
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
272
+ background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.2), transparent);
273
273
  animation: shimmer 2s ease-in-out infinite;
274
274
  }
275
275
 
@@ -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
 
@@ -1,10 +1,9 @@
1
1
  <template>
2
2
  <div class="login-container" v-once>
3
3
  <div class="login-card">
4
- <div class="flex justify-center mb-8">
5
- <img src="@/assets/img/logo_trans.png" class="h-16 object-cover" alt="Logo: Necro" />
4
+ <div class="mb-10 flex justify-center">
5
+ <img src="@/assets/img/logo_trans.png" class="mb-3 h-16 object-cover" alt="Logo: NecroLab" />
6
6
  </div>
7
- <h2 class="text-l text-white text-center font-bold mb-6">Please login to proceed</h2>
8
7
 
9
8
  <LoginForm />
10
9
  </div>
@@ -15,7 +14,7 @@ import LoginForm from "@/components/Auth/LoginForm.vue";
15
14
  </script>
16
15
  <style lang="scss" scoped>
17
16
  .login-container {
18
- @apply flex flex-col items-center min-h-screen px-4;
17
+ @apply flex min-h-screen flex-col items-center px-4;
19
18
  margin-top: 3vh;
20
19
 
21
20
  // Mobile devices
@@ -33,7 +32,7 @@ import LoginForm from "@/components/Auth/LoginForm.vue";
33
32
  }
34
33
 
35
34
  .login-card {
36
- @apply bg-dark-400 border border-dark-650 rounded-lg shadow-xl;
35
+ @apply rounded-lg border border-dark-650 bg-dark-400 shadow-xl;
37
36
  backdrop-filter: blur(10px);
38
37
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
39
38
  width: 100%;
@@ -52,7 +51,7 @@ import LoginForm from "@/components/Auth/LoginForm.vue";
52
51
  margin: 0.25rem 0;
53
52
 
54
53
  h2 {
55
- @apply text-lg mb-2;
54
+ @apply mb-2 text-lg;
56
55
  }
57
56
 
58
57
  .flex.justify-center {
@@ -79,11 +78,11 @@ import LoginForm from "@/components/Auth/LoginForm.vue";
79
78
  // Title responsive sizing
80
79
  .login-card h2 {
81
80
  @media (max-width: 480px) {
82
- @apply text-lg mb-3;
81
+ @apply mb-3 text-lg;
83
82
  }
84
83
 
85
84
  @media (orientation: landscape) {
86
- @apply text-base mb-2;
85
+ @apply mb-2 text-base;
87
86
  }
88
87
  }
89
88
  </style>
@@ -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: {