4sp-dv-latest 1.0.3 → 1.0.4

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.
@@ -6,7 +6,7 @@
6
6
  <title>4SP - VERSION 5 CLIENT</title>
7
7
  <link rel="icon" type="image/png" href="https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo.png">
8
8
 
9
- <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.19/logged-in/">
9
+ <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.45/logged-in/">
10
10
  <script src="https://cdn.tailwindcss.com"></script>
11
11
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
12
12
 
@@ -66,7 +66,7 @@
66
66
 
67
67
  .navbar-logo { height: 40px; width: auto; transition: filter 0.3s ease; }
68
68
 
69
- /* --- GLIDE / SCROLL STYLES ADDED FROM NAVIGATION.JS --- */
69
+ /* --- GLIDE / SCROLL STYLES --- */
70
70
  .tab-wrapper {
71
71
  flex-grow: 1;
72
72
  display: flex;
@@ -75,7 +75,7 @@
75
75
  min-width: 0;
76
76
  margin: 0 1rem;
77
77
  justify-content: center;
78
- overflow: hidden; /* Important for masking */
78
+ overflow: hidden;
79
79
  }
80
80
 
81
81
  .tab-scroll-container {
@@ -86,9 +86,10 @@
86
86
  scrollbar-width: none;
87
87
  white-space: nowrap;
88
88
  max-width: 100%;
89
- scroll-behavior: smooth; /* Smooth scrolling for arrows */
90
- padding-left: 20px; /* Prevent cut-off by fade */
89
+ scroll-behavior: smooth;
90
+ padding-left: 20px;
91
91
  padding-right: 20px;
92
+ padding-block: 10px;
92
93
  }
93
94
  .tab-scroll-container::-webkit-scrollbar { display: none; }
94
95
 
@@ -97,37 +98,39 @@
97
98
  position: absolute;
98
99
  top: 0;
99
100
  height: 100%;
100
- width: 60px; /* Width of the fade area */
101
+ width: 60px;
101
102
  display: flex;
102
103
  align-items: center;
103
104
  justify-content: center;
104
- color: #ffffff;
105
+ color: var(--glide-btn-color, #ffffff);
105
106
  font-size: 1rem;
106
107
  cursor: pointer;
107
108
  opacity: 1;
108
- transition: opacity 0.3s, color 0.3s ease;
109
- z-index: 10;
109
+ transition: opacity 0.3s, color 0.3s ease, background-color 0.3s ease;
110
+ z-index: 55;
110
111
  pointer-events: auto;
111
112
  background: transparent;
112
113
  border: none;
113
114
  }
114
115
 
115
- /* Fading Gradients */
116
116
  #glide-left {
117
117
  left: 0;
118
- background: linear-gradient(to right, var(--navbar-bg, #000000) 30%, transparent);
118
+ background-color: var(--navbar-bg, #000000);
119
+ -webkit-mask-image: linear-gradient(to right, black 30%, transparent);
120
+ mask-image: linear-gradient(to right, black 30%, transparent);
119
121
  justify-content: flex-start;
120
122
  padding-left: 8px;
121
123
  }
122
124
  #glide-right {
123
125
  right: 0;
124
- background: linear-gradient(to left, var(--navbar-bg, #000000) 30%, transparent);
126
+ background-color: var(--navbar-bg, #000000);
127
+ -webkit-mask-image: linear-gradient(to left, black 30%, transparent);
128
+ mask-image: linear-gradient(to left, black 30%, transparent);
125
129
  justify-content: flex-end;
126
130
  padding-right: 8px;
127
131
  }
128
132
 
129
133
  .scroll-glide-button.hidden { opacity: 0 !important; pointer-events: none !important; }
130
- /* ----------------------------------------------------- */
131
134
 
132
135
  .nav-tab {
133
136
  padding: 0.5rem 1rem;
@@ -135,12 +138,15 @@
135
138
  font-size: 0.875rem; font-weight: 400;
136
139
  border-radius: 12px; text-decoration: none; display: flex; align-items: center; gap: 0.5rem;
137
140
  border: 1px solid transparent; transition: all 0.2s; cursor: pointer;
138
- flex-shrink: 0; /* Prevent tabs from squishing */
141
+ flex-shrink: 0;
142
+ position: relative;
139
143
  }
140
144
  .nav-tab:hover {
141
145
  color: var(--tab-hover-text, #ffffff);
142
146
  background-color: var(--tab-hover-bg, rgba(79, 70, 229, 0.05));
143
- border-color: var(--tab-hover-border, transparent);
147
+ border-color: var(--tab-active-border, #4f46e5);
148
+ transform: translateY(-1px);
149
+ z-index: 50;
144
150
  }
145
151
  .nav-tab.active {
146
152
  color: var(--tab-active-text, #4f46e5);
@@ -153,231 +159,393 @@
153
159
  background-color: var(--tab-active-hover-bg, rgba(79, 70, 229, 0.15));
154
160
  }
155
161
 
156
- .auth-controls-wrapper { display: flex; align-items: center; gap: 1rem; position: relative; }
157
-
158
- .icon-btn {
159
- width: 40px; height: 40px; border-radius: 50%; border: 1px solid #4b5563;
160
- display: flex; align-items: center; justify-content: center; color: #d1d5db;
161
- cursor: pointer; background: transparent; transition: background 0.2s; position: relative;
162
- }
163
- .icon-btn:hover { background-color: #374151; color: white; }
164
-
165
- .auth-menu {
166
- position: absolute; right: 0; top: 50px; width: 14rem;
167
- background: var(--menu-bg, #000);
168
- border: 1px solid var(--menu-border, #374151);
169
- border-radius: 0.75rem;
170
- padding: 0.5rem; display: none; flex-direction: column; gap: 0.25rem; z-index: 50;
171
- }
172
- .auth-menu.open { display: flex; }
173
-
174
- .auth-menu-item {
175
- display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem;
176
- color: var(--menu-text, #d1d5db);
177
- border-radius: 0.5rem; text-decoration: none; font-size: 0.875rem;
178
- transition: background 0.15s; cursor: pointer;
179
- }
180
- .auth-menu-item:hover {
181
- background-color: var(--menu-item-hover-bg, #374151);
182
- color: var(--menu-item-hover-text, white);
183
- }
184
- .auth-header {
185
- padding: 0.5rem;
186
- border-bottom: 1px solid var(--menu-divider, #374151);
187
- margin-bottom: 0.25rem;
188
- }
189
- .auth-username { color: var(--menu-username-text, white); font-weight: 400; font-size: 0.9rem; }
190
- .auth-email { color: var(--menu-email-text, #9ca3af); font-size: 0.75rem; }
191
-
192
- /* --- Settings Page Styles --- */
193
- #settings-overlay {
194
- position: fixed;
195
- bottom: 0;
196
- left: 0;
197
- right: 0;
198
- top: 64px; /* Below navbar */
199
- background: #040404;
200
- z-index: 50;
201
- display: flex;
202
- flex-direction: column;
203
- }
204
-
205
- #settings-container {
206
- display: flex;
207
- flex-grow: 1;
208
- padding: 20px;
209
- overflow: hidden; /* Contain scroll */
210
- }
162
+ .auth-controls-wrapper { display: flex; align-items: center; gap: 1rem; position: relative; }
163
+
164
+ .icon-btn {
165
+ width: 40px; height: 40px; border-radius: 50%; border: 1px solid #4b5563;
166
+ display: flex; align-items: center; justify-content: center; color: #d1d5db;
167
+ cursor: pointer; background: transparent; transition: background 0.2s; position: relative;
168
+ }
169
+ .icon-btn:hover {
170
+ background-color: #374151; color: white;
171
+ z-index: 50;
172
+ }
211
173
 
212
- #settings-sidebar {
213
- width: 250px;
214
- padding-right: 20px;
215
- flex-shrink: 0;
216
- display: flex;
217
- flex-direction: column;
218
- gap: 10px;
219
- overflow-y: auto;
220
- }
174
+ /* --- DISABLE ANIMATIONS FOR AUTH BTN --- */
175
+ #auth-btn, #auth-btn:hover, #auth-btn:active {
176
+ transform: none !important;
177
+ transition: none !important;
178
+ background-color: transparent !important;
179
+ z-index: auto !important;
180
+ }
221
181
 
222
- #settings-main-view {
223
- flex-grow: 1;
224
- padding: 20px;
225
- background-color: #0d0d0d;
226
- border: 1px solid #1a1a1a;
227
- border-radius: 0.75rem;
228
- overflow-y: auto;
229
- overflow-x: hidden;
230
- }
182
+ /* Auth Menu & Settings */
183
+ .auth-menu {
184
+ position: absolute;
185
+ right: 0;
186
+ top: 55px;
187
+ width: 16rem;
188
+ background: var(--menu-bg, #000);
189
+ border: 1px solid var(--menu-border, #333);
190
+ border-radius: 1.5rem;
191
+ padding: 0.75rem;
192
+ display: none;
193
+ flex-direction: column;
194
+ gap: 0.5rem;
195
+ z-index: 50;
196
+ box-shadow: 0 10px 30px rgba(0,0,0,0.6);
197
+ transform-origin: top right;
198
+ }
199
+
200
+ @keyframes menu-pop-in {
201
+ 0% { opacity: 0; transform: translateY(-10px) scale(0.95); }
202
+ 70% { transform: translateY(2px) scale(1.01); }
203
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
204
+ }
205
+
206
+ .auth-menu.open {
207
+ display: flex;
208
+ animation: menu-pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
209
+ }
210
+
211
+ .auth-menu-item {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: 0.75rem;
215
+ padding: 0.75rem 1rem;
216
+ color: var(--menu-text, #d1d5db);
217
+ background: #0a0a0a;
218
+ border: 1px solid #333;
219
+ border-radius: 1rem;
220
+ text-decoration: none;
221
+ font-size: 0.9rem;
222
+ font-weight: 500;
223
+ transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
224
+ cursor: pointer;
225
+ }
226
+
227
+ .auth-menu-item:hover {
228
+ background-color: #000;
229
+ border-color: #fff;
230
+ color: #fff;
231
+ transform: translateY(-2px) scale(1.02);
232
+ box-shadow: 0 5px 15px rgba(255,255,255,0.05);
233
+ }
231
234
 
232
- .btn-toolbar-style {
233
- background: #000000;
234
- border: 1px solid #333;
235
- border-radius: 0.75rem;
236
- color: #d1d5db;
237
- padding: 0.5rem 1rem;
238
- font-weight: 500;
239
- cursor: pointer;
240
- transition: all 0.2s;
241
- display: inline-flex;
242
- align-items: center;
243
- gap: 0.5rem;
244
- }
245
- .btn-toolbar-style:hover {
246
- background-color: #000000;
247
- border-color: #fff;
248
- color: #ffffff;
249
- }
250
-
251
- .btn-toolbar-style.btn-primary-override {
252
- justify-content: center;
253
- background-color: rgba(79, 70, 229, 0.1);
254
- border: 1px solid #4f46e5;
255
- color: #4f46e5;
256
- }
257
- .btn-toolbar-style.btn-primary-override:hover {
258
- background-color: rgba(79, 70, 229, 0.15);
259
- border-color: #6366f1;
260
- color: #6366f1;
261
- }
235
+ .auth-menu-item:active {
236
+ transform: translateY(0) scale(0.98);
237
+ }
262
238
 
263
- .btn-toolbar-style.btn-primary-override-danger {
264
- justify-content: center;
265
- background-color: rgba(220, 38, 38, 0.1);
266
- border: 1px solid #dc2626;
267
- color: #dc2626;
268
- }
269
- .btn-toolbar-style.btn-primary-override-danger:hover {
270
- background-color: rgba(220, 38, 38, 0.15);
271
- border-color: #ef4444;
272
- color: #ef4444;
273
- }
239
+ .auth-menu-item .fa-gear, .nav-tab .fa-gear {
240
+ display: inline-block;
241
+ transform-origin: center;
242
+ /* Quick transition for each "click" step */
243
+ transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
244
+ }
274
245
 
275
- .settings-tab {
276
- width: 100%;
277
- justify-content: flex-start;
278
- }
279
- .settings-tab.active {
280
- background-color: rgba(79, 70, 229, 0.1);
281
- border: 1px solid #4f46e5;
282
- color: #4f46e5;
283
- }
284
- .settings-tab.active:hover {
285
- background-color: rgba(79, 70, 229, 0.15);
286
- border-color: #6366f1;
287
- color: #6366f1;
288
- }
246
+ .auth-header {
247
+ padding: 0.5rem 1rem;
248
+ border-bottom: 1px solid var(--menu-divider, #333);
249
+ margin-bottom: 0.25rem;
250
+ }
251
+ .auth-username { color: var(--menu-username-text, white); font-weight: 400; font-size: 0.9rem; }
252
+ .auth-email { color: var(--menu-email-text, #9ca3af); font-size: 0.75rem; }
289
253
 
290
- .settings-box {
291
- border: 1px solid #333;
292
- border-radius: 1rem;
293
- background-color: #000000;
294
- padding: 1.5rem;
295
- margin-bottom: 1.5rem;
296
- }
297
-
298
- .input-text-style, .input-select-style {
299
- width: 100%; padding: 0.75rem; border: 1px solid #252525;
300
- background-color: #111111; border-radius: 0.5rem; color: #c0c0c0; transition: all 0.2s;
301
- }
302
- .input-text-style:focus, .input-select-style:focus {
303
- border-color: #505050; outline: none; box-shadow: 0 0 0 2px rgba(80, 80, 80, 0.5);
304
- }
254
+ #settings-overlay {
255
+ position: fixed; bottom: 0; left: 0; right: 0; top: 64px;
256
+ background: #040404; z-index: 50; display: flex; flex-direction: column;
257
+ }
258
+
259
+ #settings-container { display: flex; flex-grow: 1; padding: 20px; overflow: hidden; }
305
260
 
306
- /* --- Input Key Style (Panic Key) --- */
307
- .input-key-style {
308
- width: 4rem; /* Fixed width for a single key */
309
- padding: 0.75rem;
310
- border: 1px solid #252525;
311
- background-color: #111111;
312
- border-radius: 0.5rem;
313
- color: #c0c0c0;
314
- transition: all 0.2s;
315
- font-size: 1.1rem;
316
- text-align: center;
317
- font-weight: 500;
318
- text-transform: uppercase;
319
- caret-color: transparent;
320
- }
321
- .input-key-style:focus {
322
- border-color: #505050;
323
- outline: none;
324
- box-shadow: 0 0 0 2px rgba(80, 80, 80, 0.5);
325
- }
326
- .input-key-style::placeholder {
327
- font-size: 1rem;
328
- text-transform: none;
329
- }
330
- .general-message-area { min-height: 20px; margin-top: 1rem; font-weight: 400; }
331
- .success-message { color: #4ade80; }
332
- .error-message { color: #f87171; }
333
- .warning-message { color: #fbbf24; }
334
-
335
- /* --- Theme Picker Styles --- */
336
- #theme-picker-container {
337
- display: grid;
338
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
339
- gap: 1rem;
340
- width: 100%;
341
- }
342
- .theme-button {
343
- border: 2px solid var(--tab-active-border, #4f46e5);
344
- background-color: #111;
345
- border-radius: 0.75rem;
346
- padding: 0.75rem;
347
- text-align: center;
348
- cursor: pointer;
349
- transition: all 0.2s ease-out;
350
- opacity: 0.7;
351
- position: relative;
352
- }
353
- .theme-button:hover {
354
- opacity: 1;
355
- border-color: var(--tab-active-hover-border, #6366f1);
356
- transform: translateY(-2px);
357
- }
358
- .theme-button.active {
359
- opacity: 1;
360
- box-shadow: 0 0 10px rgba(79, 70, 229, 0.3);
361
- }
362
- .theme-button-name {
363
- font-weight: 500;
364
- color: #e0e0e0;
365
- }
261
+ #settings-sidebar {
262
+ width: 250px; padding-right: 20px; flex-shrink: 0;
263
+ display: flex; flex-direction: column; gap: 10px; overflow-y: auto;
264
+ }
366
265
 
367
- /* Cropper Styles */
368
- #cropperModal {
369
- display: none; position: fixed; z-index: 2050; left: 0; top: 0; width: 100%; height: 100%;
370
- background-color: rgba(0, 0, 0, 0.85); justify-content: center; align-items: center; flex-direction: column;
371
- }
372
- #cropperContent {
373
- background-color: #000000; padding: 0; border-radius: 1rem; border: 1px solid #333;
374
- display: flex; flex-direction: column; width: 90%; max-width: 600px; overflow: hidden;
375
- }
376
- #cropperCanvasContainer {
377
- position: relative; margin-bottom: 20px; overflow: hidden; max-height: 60vh;
378
- }
379
- #cropperCanvas { display: block; max-width: 100%; max-height: 60vh; border: 1px dashed rgba(255, 255, 255, 0.3); }
266
+ #settings-main-view {
267
+ flex-grow: 1; padding: 20px; background-color: #0d0d0d;
268
+ border: 1px solid #1a1a1a; border-radius: 0.75rem;
269
+ overflow-y: auto; overflow-x: hidden;
270
+ }
271
+
272
+ .btn-toolbar-style {
273
+ background: #000000; border: 1px solid #333; border-radius: 0.75rem;
274
+ color: #d1d5db; padding: 0.5rem 1rem; font-weight: 500; cursor: pointer;
275
+ transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.5rem;
276
+ position: relative;
277
+ }
278
+ .btn-toolbar-style:hover {
279
+ background-color: #000000; border-color: #fff; color: #ffffff;
280
+ z-index: 50;
281
+ }
282
+
283
+ .btn-toolbar-style.btn-primary-override {
284
+ justify-content: center; background-color: rgba(79, 70, 229, 0.1);
285
+ border: 1px solid #4f46e5; color: #4f46e5;
286
+ }
287
+ .btn-toolbar-style.btn-primary-override:hover {
288
+ background-color: rgba(79, 70, 229, 0.15); border-color: #6366f1; color: #6366f1;
289
+ }
290
+
291
+ .btn-toolbar-style.btn-primary-override-danger {
292
+ justify-content: center; background-color: rgba(220, 38, 38, 0.1);
293
+ border: 1px solid #dc2626; color: #dc2626;
294
+ }
295
+ .btn-toolbar-style.btn-primary-override-danger:hover {
296
+ background-color: rgba(220, 38, 38, 0.15); border-color: #ef4444; color: #ef4444;
297
+ }
298
+
299
+ .settings-tab { width: 100%; justify-content: flex-start; }
300
+ .settings-tab.active {
301
+ background-color: rgba(79, 70, 229, 0.1); border: 1px solid #4f46e5; color: #4f46e5;
302
+ }
303
+ .settings-tab.active:hover {
304
+ background-color: rgba(79, 70, 229, 0.15); border-color: #6366f1; color: #6366f1;
305
+ }
306
+
307
+ /* --- DISABLE ANIMATIONS FOR SETTINGS TABS --- */
308
+ .settings-tab, .settings-tab:hover, .settings-tab:active {
309
+ transition: none !important;
310
+ transform: none !important;
311
+ animation: none !important;
312
+ }
313
+
314
+ .settings-box {
315
+ border: 1px solid #333; border-radius: 1.5rem;
316
+ background-color: #000000; padding: 1.5rem; margin-bottom: 1.5rem;
317
+ }
318
+
319
+ .input-text-style, .input-select-style {
320
+ width: 100%; padding: 0.75rem; border: 1px solid #252525;
321
+ background-color: #111111; border-radius: 1rem; color: #c0c0c0; transition: all 0.2s;
322
+ }
323
+ .input-text-style:focus, .input-select-style:focus {
324
+ border-color: #505050; outline: none; box-shadow: 0 0 0 2px rgba(80, 80, 80, 0.5);
325
+ }
326
+
327
+ .input-key-style {
328
+ width: 4rem; padding: 0.75rem; border: 1px solid #252525;
329
+ background-color: #111111; border-radius: 1rem; color: #c0c0c0;
330
+ transition: all 0.2s; font-size: 1.1rem; text-align: center;
331
+ font-weight: 500; text-transform: uppercase; caret-color: transparent;
332
+ }
333
+ .input-key-style:focus {
334
+ border-color: #505050; outline: none; box-shadow: 0 0 0 2px rgba(80, 80, 80, 0.5);
335
+ }
336
+
337
+ /* Preference Toggles */
338
+ .icon-btn.off {
339
+ border-color: #ef4444;
340
+ color: #ef4444;
341
+ background-color: rgba(239, 68, 68, 0.05);
342
+ }
343
+ .icon-btn.off:hover {
344
+ background-color: rgba(239, 68, 68, 0.1);
345
+ border-color: #f87171;
346
+ }
347
+ .input-key-style::placeholder { font-size: 1rem; text-transform: none; }
348
+
349
+ .general-message-area { min-height: 20px; margin-top: 1rem; font-weight: 400; }
350
+
351
+ #theme-picker-container {
352
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
353
+ gap: 1rem; width: 100%;
354
+ }
355
+ .theme-button {
356
+ border: 2px solid var(--tab-active-border, #4f46e5);
357
+ background-color: #111; border-radius: 0.75rem; padding: 0.75rem;
358
+ text-align: center; cursor: pointer; transition: all 0.2s ease-out;
359
+ opacity: 0.7; position: relative;
360
+ }
361
+ .theme-button:hover {
362
+ opacity: 1; border-color: var(--tab-active-hover-border, #6366f1);
363
+ transform: translateY(-2px);
364
+ }
365
+ .theme-button.active { opacity: 1; box-shadow: 0 0 10px rgba(79, 70, 229, 0.3); }
366
+ .theme-button-name { font-weight: 500; color: #e0e0e0; }
367
+
368
+ /* Cropper */
369
+ #cropperModal {
370
+ display: none; position: fixed; z-index: 2050; left: 0; top: 0; width: 100%; height: 100%;
371
+ background-color: rgba(0, 0, 0, 0.85); justify-content: center; align-items: center; flex-direction: column;
372
+ }
373
+ #cropperContent {
374
+ background-color: #000000; padding: 0; border-radius: 1rem; border: 1px solid #333;
375
+ display: flex; flex-direction: column; width: 90%; max-width: 600px; overflow: hidden;
376
+ }
377
+ #cropperCanvasContainer {
378
+ position: relative; margin-bottom: 20px; overflow: hidden; max-height: 60vh;
379
+ }
380
+ #cropperCanvas { display: block; max-width: 100%; max-height: 60vh; border: 1px dashed rgba(255, 255, 255, 0.3); }
381
+
382
+ /* --- ANIMATION & THEME VARIABLES --- */
383
+ :root {
384
+ --bg-page: #040404; --bg-container: #000000; --border-color: #333;
385
+ --text-primary: #c0c0c0; --text-light-grey: #d1d5db; --accent-color: #6366f1;
386
+ --navbar-bg: #000000; --navbar-border: #1f2937;
387
+ --tab-text: #9ca3af; --tab-hover-text: #ffffff;
388
+ --tab-active-text: #4f46e5; --tab-active-bg: rgba(79, 70, 229, 0.1);
389
+ --tab-active-border: #4f46e5;
390
+ }
391
+
392
+ @keyframes shake {
393
+ 0% { transform: translateX(0); }
394
+ 25% { transform: translateX(-5px) rotate(-5deg); }
395
+ 50% { transform: translateX(5px) rotate(5deg); }
396
+ 75% { transform: translateX(-5px) rotate(-5deg); }
397
+ 100% { transform: translateX(0); }
398
+ }
399
+ .shake-anim { animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; }
400
+
401
+ /* Animation Control */
402
+ body.no-animations *,
403
+ body.no-animations *:hover,
404
+ body.no-animations *:active,
405
+ body.no-animations *:focus {
406
+ transition: none !important;
407
+ animation: none !important;
408
+ transform: none !important;
409
+ box-shadow: none !important;
410
+ }
411
+
412
+ body.no-animations .auth-menu-item .fa-gear,
413
+ body.no-animations .nav-tab .fa-gear {
414
+ transition: none !important;
415
+ transform: none !important;
416
+ }
417
+
418
+ /* Bubbly Spring Transition */
419
+ body:not(.no-animations) .btn-toolbar-style,
420
+ body:not(.no-animations) .btn-lock-screen,
421
+ body:not(.no-animations) .icon-btn,
422
+ body:not(.no-animations) .nav-tab,
423
+ body:not(.no-animations) .btn-card-action,
424
+ body:not(.no-animations) .input-text-style,
425
+ body:not(.no-animations) .input-lock-screen,
426
+ body:not(.no-animations) .input-key-style {
427
+ transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
428
+ }
429
+
430
+ body:not(.no-animations) .btn-toolbar-style:active,
431
+ body:not(.no-animations) .btn-lock-screen:active,
432
+ body:not(.no-animations) .icon-btn:active,
433
+ body:not(.no-animations) .nav-tab:active,
434
+ body:not(.no-animations) .btn-card-action:active {
435
+ transform: scale(0.98); transition: transform 0.05s ease-out !important;
436
+ }
437
+
438
+ /* Refined Buttons - Reduced Scale for less clipping/pop */
439
+ .btn-toolbar-style {
440
+ background: #0a0a0a; border: 1px solid #333; border-radius: 14px;
441
+ color: #d1d5db; padding: 0.75rem 1.25rem; font-weight: 500; cursor: pointer;
442
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
443
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6); position: relative;
444
+ }
445
+ .btn-toolbar-style:hover {
446
+ transform: scale(1.02) translateY(-1px);
447
+ box-shadow: 0 6px 20px rgba(255, 255, 255, 0.1);
448
+ border-color: #fff; color: #ffffff;
449
+ z-index: 50;
450
+ }
451
+
452
+ .btn-lock-screen {
453
+ padding: 0.75rem 1.25rem; font-size: 0.875rem; font-weight: 500; border-radius: 14px;
454
+ color: rgb(99 102 241); background-color: rgba(99, 102, 241, 0.1);
455
+ border: 1px solid rgb(99 102 241); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6);
456
+ position: relative;
457
+ }
458
+ .btn-lock-screen:hover {
459
+ transform: scale(1.02) translateY(-1px);
460
+ box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
461
+ background-color: rgba(99, 102, 241, 0.2);
462
+ z-index: 50;
463
+ }
464
+
465
+ .icon-btn {
466
+ width: 40px; height: 40px; border-radius: 14px;
467
+ border: 1px solid #4b5563; display: flex; align-items: center; justify-content: center; color: #d1d5db;
468
+ cursor: pointer; background: transparent; position: relative;
469
+ }
470
+ .icon-btn:hover {
471
+ background-color: #374151; color: white; transform: scale(1.05);
472
+ z-index: 50;
473
+ }
474
+
475
+ .btn-card-action {
476
+ display: inline-flex; align-items: center; justify-content: center;
477
+ width: 2rem; height: 2rem; border-radius: 14px;
478
+ background-color: transparent; border: 1px solid transparent;
479
+ color: #9ca3af; cursor: pointer; position: relative;
480
+ }
481
+ .btn-card-action:hover {
482
+ background-color: rgba(79, 70, 229, 0.1); color: #4f46e5; border-color: #4f46e5;
483
+ transform: scale(1.05); z-index: 50;
484
+ }
485
+
486
+ .input-text-style:focus, .input-select-style:focus { transform: scale(1.01); }
487
+ .input-lock-screen:focus { transform: scale(1.01); }
488
+ .input-key-style:focus { transform: scale(1.05); }
489
+
490
+ .nav-tab {
491
+ padding: 0.5rem 1rem; color: var(--tab-text);
492
+ font-size: 0.875rem; font-weight: 400; border-radius: 12px;
493
+ text-decoration: none; display: flex; align-items: center; gap: 0.5rem;
494
+ border: 1px solid transparent; cursor: pointer; background: transparent;
495
+ position: relative;
496
+ }
497
+ .nav-tab:hover {
498
+ color: var(--tab-hover-text); background-color: rgba(79, 70, 229, 0.05);
499
+ border-color: var(--tab-active-border, #4f46e5); transform: scale(1.02);
500
+ z-index: 50;
501
+ }
380
502
 
503
+ .eagler-dropdown {
504
+ position: relative; background-color: rgba(20, 20, 20, 0.98);
505
+ border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 14px;
506
+ padding: 0.5rem; min-width: 200px; display: inline-block;
507
+ }
508
+ .eagler-dropdown-link {
509
+ display: block; padding: 0.6rem 0.8rem; color: #d1d5db; text-decoration: none;
510
+ border-radius: 14px; font-size: 0.9rem;
511
+ transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
512
+ }
513
+ .eagler-dropdown-link:hover {
514
+ background-color: rgba(255, 255, 255, 0.1); color: white; transform: translateX(4px);
515
+ }
516
+
517
+ /* Notifications */
518
+ #notification-container {
519
+ position: fixed;
520
+ bottom: 2rem;
521
+ right: 2rem;
522
+ display: flex;
523
+ flex-direction: column;
524
+ gap: 0.75rem;
525
+ z-index: 9999;
526
+ pointer-events: none;
527
+ }
528
+ .notification-toast {
529
+ background-color: #0a0a0a; border: 1px solid #333; border-radius: 14px;
530
+ padding: 0.75rem 1.25rem; color: #fff; box-shadow: 0 4px 15px rgba(0,0,0,0.5);
531
+ display: flex; align-items: center; gap: 0.75rem; font-size: 0.9rem;
532
+ min-width: 200px; transform: translateX(120%);
533
+ transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease, background-color 0.2s;
534
+ opacity: 0; pointer-events: auto; cursor: default;
535
+ }
536
+ .notification-toast.show { transform: translateX(0); opacity: 1; }
537
+ .notification-toast.show:hover {
538
+ transform: scale(1.02) translateX(-5px); background-color: #151515;
539
+ border-color: #555; box-shadow: 0 8px 25px rgba(0,0,0,0.7);
540
+ }
541
+
542
+ /* UNIVERSAL LOADER CSS */
543
+ #universal-loader {
544
+ pointer-events: none; /* Allows clicks to pass through when hidden/opacity 0 */
545
+ }
546
+ #universal-loader.active {
547
+ pointer-events: auto;
548
+ }
381
549
  </style>
382
550
  </head>
383
551
  <body>
@@ -430,7 +598,7 @@
430
598
  <div class="auth-username" id="display-username">Client User</div>
431
599
  <div class="auth-email" id="display-email">local@client</div>
432
600
  </div>
433
- <a href="#" class="auth-menu-item" onclick="loadPage('settings.html'); return false;">
601
+ <a href="#" id="auth-settings-btn" class="auth-menu-item" onclick="loadPage('settings.html'); return false;">
434
602
  <i class="fa-solid fa-gear w-4"></i> Settings
435
603
  </a>
436
604
  <div class="auth-menu-item text-red-400 hover:text-red-300" id="logout-btn">
@@ -467,13 +635,29 @@
467
635
  <h3 class="text-3xl font-bold text-white mb-6">General Settings</h3>
468
636
 
469
637
  <div class="settings-box">
470
- <h3 class="text-xl font-bold text-white mb-2">Account Username</h3>
638
+ <h3 class="text-xl font-bold text-white mb-2">Account Details & Preferences</h3>
639
+
471
640
  <label class="block text-gray-400 text-sm mb-2 font-light">New Username</label>
472
- <div class="flex gap-2">
641
+ <div class="flex gap-2 mb-6">
473
642
  <input type="text" id="settings-username-input" class="input-text-style" placeholder="Enter username">
474
643
  <button id="save-username-btn" class="btn-toolbar-style btn-primary-override">Save</button>
475
644
  </div>
476
645
  <p id="username-msg" class="general-message-area text-sm mt-2"></p>
646
+
647
+ <div class="flex gap-8 items-center pt-4 border-t border-[#1a1a1a]">
648
+ <div class="flex flex-col items-center gap-2">
649
+ <button id="toggle-animations-btn" class="icon-btn" title="Toggle Navbar Animations">
650
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
651
+ </button>
652
+ <span class="text-xs text-gray-500">Animations</span>
653
+ </div>
654
+ <div class="flex flex-col items-center gap-2">
655
+ <button id="toggle-sounds-btn" class="icon-btn" title="Toggle UI Sounds">
656
+ <i class="fa-solid fa-volume-high"></i>
657
+ </button>
658
+ <span class="text-xs text-gray-500">Sounds</span>
659
+ </div>
660
+ </div>
477
661
  </div>
478
662
 
479
663
  <h3 class="text-xl font-bold text-white mb-2 mt-6">Organization</h3>
@@ -506,7 +690,7 @@
506
690
  <div class="settings-box mb-8 p-4">
507
691
  <div class="flex items-start gap-6">
508
692
  <div class="flex flex-col items-center gap-2">
509
- <div id="pfp-preview" class="w-24 h-24 rounded-full bg-gray-700 overflow-hidden border-2 border-[#333] flex items-center justify-center text-3xl font-bold text-white relative">
693
+ <div id="pfp-preview" class="w-24 h-24 rounded-[32px] bg-gray-700 overflow-hidden border-2 border-[#333] flex items-center justify-center text-3xl font-bold text-white relative">
510
694
  </div>
511
695
  <p class="text-xs text-gray-500">Preview</p>
512
696
  </div>
@@ -518,15 +702,29 @@
518
702
  </div>
519
703
 
520
704
  <div id="pfp-letter-options" class="block">
521
- <div class="grid grid-cols-6 gap-2 mb-4 max-w-xs">
522
- </div>
523
- <button id="save-letter-pfp-btn" class="btn-toolbar-style btn-primary-override">Set Letter Avatar</button>
705
+ <label class="block text-gray-400 text-xs mb-1 font-light">Custom Text (Max 3)</label>
706
+ <div class="flex gap-4 items-center mb-4">
707
+ <input type="text" id="pfp-custom-text" maxlength="3" class="input-text-style w-24 text-center uppercase" placeholder="A">
708
+ <p class="text-xs text-gray-500">Leave empty to use username initial.</p>
709
+ </div>
710
+
711
+ <label class="block text-gray-400 text-xs mb-2 font-light">Background Color</label>
712
+ <div class="flex flex-wrap gap-2 mb-6" id="pfp-color-grid">
713
+ <!-- Colors generated by JS -->
714
+ </div>
715
+
716
+ <button id="save-letter-pfp-btn" class="btn-toolbar-style btn-primary-override w-full justify-center">Set Letter Avatar</button>
524
717
  </div>
525
718
 
526
719
  <div id="pfp-upload-options" class="hidden">
527
- <input type="file" id="pfp-upload-input" accept="image/*" class="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[#222] file:text-white hover:file:bg-[#333] mb-4"/>
528
- <p class="text-xs text-gray-500 mb-4">Max size 2MB. Square images work best.</p>
529
- <button id="save-upload-pfp-btn" class="btn-toolbar-style btn-primary-override">Upload & Set</button>
720
+ <input type="file" id="pfp-upload-input" accept="image/*" class="hidden"/>
721
+
722
+ <button id="trigger-upload-btn" class="btn-toolbar-style mb-4 w-full justify-center">
723
+ <i class="fa-solid fa-cloud-arrow-up mr-2"></i> Choose Image...
724
+ </button>
725
+
726
+ <p class="text-xs text-gray-500 mb-4 text-center">Max size 2MB. Square images work best.</p>
727
+ <button id="save-upload-pfp-btn" class="btn-toolbar-style btn-primary-override w-full justify-center">Upload & Set</button>
530
728
  </div>
531
729
  </div>
532
730
  </div>
@@ -559,1235 +757,81 @@
559
757
  <input type="text" id="panicKey1" data-key-id="1" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
560
758
  <input type="url" id="panicUrl1" class="input-text-style" placeholder="e.g., https://google.com">
561
759
  </div>
562
-
563
- <div class="flex items-center gap-4 mb-3">
564
- <input type="text" id="panicKey2" data-key-id="2" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
565
- <input type="url" id="panicUrl2" class="input-text-style" placeholder="e.g., https://youtube.com/feed/subscriptions">
566
- </div>
567
-
568
- <div class="flex items-center gap-4 mb-3">
569
- <input type="text" id="panicKey3" data-key-id="3" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
570
- <input type="url" id="panicUrl3" class="input-text-style" placeholder="e.g., https://wikipedia.org">
571
- </div>
572
-
573
- <div class="flex justify-between items-center pt-4 border-t border-[#252525]">
574
- <p id="panicKeyMessage" class="general-message-area text-sm"></p>
575
- <button id="applyPanicKeyBtn" class="btn-toolbar-style btn-primary-override w-36" style="padding: 0.5rem 0.75rem;">
576
- <i class="fa-solid fa-check mr-1"></i> Apply Keys
577
- </button>
578
- </div>
579
- </div>
580
- </div>
581
-
582
- <div id="tab-about" class="settings-section hidden">
583
- <h3 class="text-3xl font-bold text-white mb-6">About 4SP</h3>
584
- <div class="settings-box p-6 flex flex-col items-center text-center">
585
- <img src="https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png" class="h-24 mb-4" alt="Logo">
586
- <h1 class="text-3xl font-bold text-white mb-2">(4SP) 4simpleproblems</h1>
587
- <p class="text-gray-400 mb-6 max-w-lg font-light">From a soundboard, to a full downloadable client of a platform.</p>
588
-
589
- <div class="inline-block bg-[#0a0a0a] border border-[#333] px-4 py-2 rounded-[14px] mb-8">
590
- <span class="text-gray-500 text-sm">Version:</span>
591
- <span class="text-indigo-400 font-mono font-bold ml-2">5.0.0 (DV)</span>
592
- </div>
593
-
594
-
595
- <div class="flex gap-4">
596
- <a href="https://github.com/4simpleproblems-v5" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-github fa-xl"></i></a>
597
- <a href="https://youtube.com/4simpleproblems" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-youtube fa-xl"></i></a>
598
- <a href="https://x.com/@4simpleproblems" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-discord fa-xl"></i></a>
599
- </div>
600
- </div>
601
- </div>
602
- </div>
603
- </div>
604
- </div>
605
-
606
- <div id="cropperModal">
607
- <div id="cropperContent">
608
- <div class="flex justify-between items-center p-6 border-b border-[#333] bg-black">
609
- <h3 class="text-2xl font-bold text-white">Adjust Profile Picture</h3>
610
- <button id="cancelCropBtn" class="btn-toolbar-style w-10 h-10 flex items-center justify-center p-0 rounded-xl">
611
- <i class="fa-solid fa-xmark fa-xl"></i>
612
- </button>
613
- </div>
614
- <div class="flex flex-col items-center p-6 bg-[#0a0a0a]">
615
- <p class="text-sm text-gray-400 mb-4">Drag to move. Scroll to resize.</p>
616
- <div id="cropperCanvasContainer" class="relative mb-6 border border-[#333] rounded-lg overflow-hidden w-full flex justify-center bg-black">
617
- <canvas id="cropperCanvas"></canvas>
618
- </div>
619
- <div class="flex justify-end w-full">
620
- <button id="submitCropBtn" class="btn-toolbar-style btn-primary-override px-6 py-2 rounded-xl">
621
- <i class="fa-solid fa-check mr-2"></i> Submit
622
- </button>
623
- </div>
624
- </div>
625
- </div>
626
- </div>
627
-
628
- <template id="games-page-template">
629
- <!DOCTYPE html>
630
- <html lang="en">
631
- <head>
632
- <title>4SP - GAMES</title>
633
- <meta charset="UTF-8" />
634
- <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
635
- <link rel="icon" type="image/x-icon" href="img/favicon.ico" id="faviconLink" />
636
-
637
- <link rel="preconnect" href="https://fonts.googleapis.com">
638
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
639
- <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
640
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
641
-
642
- <script src="https://cdn.tailwindcss.com"></script>
643
- <script src="https://dv-service-lfs.4simpleproblems.workers.dev/url-changer.js"></script>
644
- <script src="https://cdn.jsdelivr.net/npm/4sp-dv@latest/analytics.js"></script>
645
-
646
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-1D4F692C1Q"></script>
647
- <script>
648
- window.dataLayer = window.dataLayer || [];
649
- function gtag(){dataLayer.push(arguments);}
650
- gtag('js', new Date());
651
-
652
- gtag('config', 'G-1D4F692C1Q');
653
- </script>
654
- <script>
655
- tailwind.config = {
656
- darkMode: 'class',
657
- theme: {
658
- extend: {
659
- colors: {
660
- 'deep-black': '#040404',
661
- 'card-dark': '#111111',
662
- 'accent-indigo': '#4f46e5',
663
- 'brand-border': '#252525',
664
- 'strongdog-orange': '#FFA500',
665
- 'gn-math': '#FB2651',
666
- 'gameboy': '#9A2257',
667
- 'other-grey': '#AAAAAA',
668
- },
669
- fontFamily: {
670
- sans: ['Geist', 'sans-serif'],
671
- },
672
- borderRadius: {
673
- 'md': '0.375rem',
674
- 'lg': '0.5rem',
675
- 'xl': '0.75rem',
676
- '2xl': '1.25rem',
677
- '3xl': '1.5rem',
678
- },
679
- aspectRatio: {
680
- '3/2': '3 / 2',
681
- },
682
- }
683
- }
684
- }
685
- </script>
686
-
687
- <style>
688
- /* Base styles */
689
- :root {
690
- --menu-bg: #000000;
691
- --menu-border: #333;
692
- --menu-text: #d1d5db;
693
- --tab-hover-text: #ffffff;
694
- --tab-active-bg: rgba(79, 70, 229, 0.1);
695
- --tab-active-text: #4f46e5;
696
- --tab-active-border: #4f46e5;
697
- }
698
-
699
- body {
700
- font-family: 'Geist', 'sans-serif';
701
- font-weight: 300;
702
- background-color: #040404;
703
- padding-bottom: 100px;
704
- }
705
-
706
- h1, h2, h3, .font-bold, .font-semibold, strong {
707
- font-weight: 400 !important;
708
- }
709
-
710
- /* --- Toolbar Button Style (Global) --- */
711
- .btn-toolbar-style {
712
- background: var(--menu-bg);
713
- border: 1px solid var(--menu-border);
714
- border-radius: 0.75rem;
715
- color: var(--menu-text);
716
- padding: 0.5rem 1rem;
717
- font-weight: 500;
718
- cursor: pointer;
719
- transition: all 0.2s;
720
- display: inline-flex;
721
- align-items: center;
722
- justify-content: center;
723
- gap: 0.5rem;
724
- text-decoration: none;
725
- font-size: 1rem;
726
- }
727
- .btn-toolbar-style:hover {
728
- background-color: var(--menu-bg);
729
- border-color: #fff;
730
- color: var(--tab-hover-text);
731
- }
732
-
733
- /* --- Card Action Buttons --- */
734
- .btn-card-action {
735
- display: inline-flex;
736
- align-items: center;
737
- justify-content: center;
738
- width: 2.25rem;
739
- height: 2.25rem;
740
- border-radius: 0.5rem;
741
- background-color: transparent;
742
- border: 1px solid transparent;
743
- color: #9ca3af;
744
- cursor: pointer;
745
- transition: all 0.2s;
746
- font-size: 1rem;
747
- }
748
- .btn-card-action:hover {
749
- background-color: var(--tab-active-bg);
750
- color: var(--tab-active-text);
751
- border-color: var(--tab-active-border);
752
- }
753
-
754
- /* Favorite Button Logic */
755
- .btn-card-action.fav-action .fa-solid-star { display: none; }
756
- .btn-card-action.fav-action .fa-regular-star { display: inline-block; }
757
-
758
- .btn-card-action.fav-action.favorited .fa-solid-star { display: inline-block; color: #facc15; }
759
- .btn-card-action.fav-action.favorited .fa-regular-star { display: none; }
760
-
761
- .btn-card-action.fav-action:not(.favorited):hover .fa-regular-star { color: var(--tab-active-text); }
762
-
763
- /* Card Hover Effects */
764
- .zone-item {
765
- transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
766
- will-change: transform;
767
- }
768
- .zone-item:hover {
769
- transform: translateY(-4px);
770
- box-shadow: 0 8px 25px rgba(79, 70, 229, 0.2);
771
- }
772
-
773
- .other-zone-item {
774
- min-height: 250px;
775
- transition: box-shadow 0.2s ease-in-out;
776
- position: relative;
777
- z-index: 1;
778
- }
779
- .other-zone-item:hover {
780
- box-shadow: 0 8px 25px rgba(170, 170, 170, 0.2);
781
- z-index: 10;
782
- }
783
- .other-zone-item .image-overlay {
784
- background: linear-gradient(90deg, rgba(7,7,7,0.85) 0%, rgba(7,7,7,0) 40%, rgba(7,7,7,0) 60%, rgba(7,7,7,0.85) 100%);
785
- border-radius: 1.25rem;
786
- }
787
- .other-zone-item:hover .image-overlay {
788
- background: linear-gradient(90deg, rgba(7,7,7,0.7) 0%, rgba(7,7,7,0) 50%, rgba(7,7,7,0) 50%, rgba(7,7,7,0.7) 100%);
789
- }
790
-
791
- /* --- Animations --- */
792
- @keyframes fadeOut {
793
- from { opacity: 1; transform: scale(1); }
794
- to { opacity: 0; transform: scale(0.95); }
795
- }
796
- .fade-out {
797
- animation: fadeOut 0.3s ease-out forwards;
798
- pointer-events: none;
799
- }
800
-
801
- /* Search Bar & Results */
802
- .search-results-container {
803
- max-height: 300px;
804
- overflow-y: auto;
805
- scrollbar-width: thin;
806
- scrollbar-color: #333 transparent;
807
- }
808
- .search-item {
809
- display: flex;
810
- cursor: pointer;
811
- transition: background-color 0.2s;
812
- color: #e5e7eb;
813
- text-decoration: none;
814
- padding: 10px 15px;
815
- }
816
- .search-item:hover { background-color: rgba(255, 255, 255, 0.05); }
817
-
818
- @media (max-width: 768px) {
819
- #bottom-fixed-bar {
820
- left: 10px !important; right: 10px !important;
821
- bottom: 10px !important; width: auto !important;
822
- }
823
- }
824
-
825
- /* Polyfill for aspect-ratio */
826
- .aspect-w-3 { position: relative; padding-bottom: 66.666667%; }
827
- .aspect-h-2 { }
828
- .aspect-w-3 > * { position: absolute; height: 100%; width: 100%; top: 0; right: 0; bottom: 0; left: 0; }
829
-
830
- /* --- Game Viewer Modal --- */
831
- #zoneViewer { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.9); z-index: 5000; flex-direction: column; }
832
- #zoneViewer .zone-header {
833
- background-color: #111111;
834
- padding: 12px 20px;
835
- display: flex;
836
- justify-content: space-between;
837
- align-items: center;
838
- border-bottom: 1px solid #252525;
839
- box-shadow: 0 2px 5px rgba(0,0,0,0.1);
840
- flex-shrink: 0;
841
- }
842
- #zoneViewer .zone-header .zone-title h2 {
843
- margin: 0;
844
- font-size: 1.3em;
845
- color: #ffffff;
846
- font-family: 'Geist', sans-serif;
847
- }
848
- #zoneViewer .zone-header .zone-controls { display: flex; align-items: center; }
849
- #zoneViewer .zone-header .zone-controls button,
850
- #zoneViewer .zone-header .zone-controls a {
851
- margin-left: 8px;
852
- width: 40px;
853
- height: 40px;
854
- background-color: rgba(255, 255, 255, 0.1);
855
- backdrop-filter: blur(5px);
856
- -webkit-backdrop-filter: blur(5px);
857
- color: white;
858
- border: 1px solid rgba(255, 255, 255, 0.2);
859
- border-radius: 0.5rem;
860
- cursor: pointer;
861
- font-size: 1em;
862
- display: flex;
863
- align-items: center;
864
- justify-content: center;
865
- transition: background-color 0.2s ease-in-out;
866
- text-decoration: none;
867
- }
868
- #zoneViewer .zone-header .zone-controls button:hover,
869
- #zoneViewer .zone-header .zone-controls a:hover { background-color: rgba(255, 255, 255, 0.2); }
870
- #zoneViewer iframe { flex-grow: 1; border: none; background-color: #000; }
871
- .hidden { display: none !important; }
872
-
873
- /* --- Dropdown (Modified to popup ABOVE) --- */
874
- .eagler-dropdown {
875
- display: none;
876
- position: absolute;
877
- bottom: 120%; /* Pops up above the button */
878
- right: 0;
879
- margin-bottom: 5px;
880
- background-color: rgba(20, 20, 20, 0.98);
881
- backdrop-filter: blur(12px);
882
- -webkit-backdrop-filter: blur(12px);
883
- border: 1px solid rgba(255, 255, 255, 0.15);
884
- border-radius: 0.75rem;
885
- padding: 0.5rem;
886
- z-index: 1000;
887
- min-width: 200px;
888
- box-shadow: 0 10px 25px rgba(0,0,0,0.6);
889
- transform-origin: bottom right;
890
- }
891
- .eagler-dropdown.show { display: block; animation: fadeInUp 0.15s ease-out; }
892
-
893
- @keyframes fadeInUp {
894
- from { opacity: 0; transform: translateY(10px) scale(0.95); }
895
- to { opacity: 1; transform: translateY(0) scale(1); }
896
- }
897
-
898
- .eagler-dropdown-link {
899
- display: block;
900
- padding: 0.6rem 0.8rem;
901
- color: #d1d5db;
902
- text-decoration: none;
903
- border-radius: 0.5rem;
904
- transition: all 0.2s;
905
- font-weight: 400;
906
- white-space: nowrap;
907
- font-size: 0.9rem;
908
- }
909
- .eagler-dropdown-link:hover { background-color: rgba(255, 255, 255, 0.1); color: white; }
910
-
911
- #creditsModal { display: none; }
912
-
913
- /* Modal Scrollbar */
914
- .custom-scrollbar::-webkit-scrollbar { width: 8px; }
915
- .custom-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); border-radius: 4px; }
916
- .custom-scrollbar::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
917
- .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #555; }
918
- </style>
919
- </head>
920
-
921
- <body class="bg-deep-black text-white min-h-screen">
922
-
923
- <main class="mx-auto my-5 w-full max-w-screen-2xl px-4 relative">
924
- <button id="creditsBtn" class="absolute top-0 right-4 z-20 btn-toolbar-style">
925
- <i class="fa-solid fa-users mr-2"></i>Credits
926
- </button>
927
-
928
- <div class="mx-auto my-8 p-4 sm:p-6 w-full max-w-3xl relative">
929
- <p class="bg-card-dark text-white p-5 rounded-xl shadow-lg border border-brand-border text-sm sm:text-base text-center">
930
- <strong>Welcome to 4SP Games!</strong><br>
931
- This is a collection of games curated for the 4SP community. Use the search bar below to find your favorite.
932
- </p>
933
- </div>
934
-
935
- <div id="favoritesSection">
936
- <h2 id="favoritesHeader" class="text-center text-3xl font-bold mt-12 mb-6 text-white" style="display: none;">
937
- <strong>Favorites</strong>
938
- </h2>
939
- <div class="games-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-4" id="favoritesGameList" style="display: none;"></div>
940
- </div>
941
-
942
- <div id="category-viewer" class="w-full mt-12">
943
- <div id="category-slider-container" class="relative flex items-center justify-center max-w-sm mx-auto h-12">
944
- <button id="prev-category" class="absolute left-0 top-1/2 -translate-y-1/2 text-2xl text-gray-500 hover:text-white transition-all p-2 z-10 disabled:opacity-30 disabled:text-gray-700 disabled:hover:text-gray-700">
945
- <i class="fas fa-chevron-left"></i>
946
- </button>
947
- <div class="overflow-hidden w-64 text-center">
948
- <div id="category-slider" class="flex transition-transform duration-500 ease-in-out">
949
- </div>
950
- </div>
951
- <button id="next-category" class="absolute right-0 top-1/2 -translate-y-1/2 text-2xl text-gray-500 hover:text-white transition-all p-2 z-10 disabled:opacity-30 disabled:text-gray-700 disabled:hover:text-gray-700">
952
- <i class="fas fa-chevron-right"></i>
953
- </button>
954
- </div>
955
-
956
- <div id="games-grid-container" class="games-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-4 mt-6 transition-opacity duration-300 ease-in-out">
957
- </div>
958
- </div>
959
- </main>
960
-
961
- <div id="bottom-fixed-bar" class="fixed bottom-4 right-4 bg-black/80 backdrop-blur-xl rounded-3xl border border-brand-border shadow-2xl shadow-black/50 w-full max-w-md z-50 transition-all duration-300 flex flex-col">
962
- <div id="searchResults" class="search-results-container w-full" style="display: none;">
963
- </div>
964
-
965
- <div id="searchDivider" class="w-full h-[1px] bg-white/10" style="display: none;"></div>
966
-
967
- <div class="flex items-center w-full p-1">
968
- <div class="pl-4 pr-2 text-gray-400">
969
- <i class="fa-solid fa-magnifying-glass"></i>
970
- </div>
971
- <input type="text" id="searchInput" placeholder="Search for games…" autocomplete="off" class="w-full py-3 pr-4 bg-transparent text-white placeholder-gray-500 focus:outline-none focus:ring-0 text-base" />
972
- </div>
973
- </div>
974
-
975
- <div id="zoneViewer" aria-modal="true" role="dialog">
976
- <div class="zone-header">
977
- <div class="zone-title">
978
- <h2 id="zoneNameEl">Game Title</h2>
979
- </div>
980
- <div class="zone-controls">
981
- <button id="fullscreenBtnZone" title="Fullscreen"><i class="fas fa-expand"></i></button>
982
- <a id="downloadBtnZone" title="Download" class="hidden" href="#"><i class="fas fa-download"></i></a>
983
- <button id="closeBtnZone" title="Close"><i class="fas fa-times"></i></button>
984
- </div>
985
- </div>
986
- <iframe id="zoneFrame" title="Game Content" allowfullscreen sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"></iframe>
987
- </div>
988
-
989
- <div id="creditsModal" class="fixed top-0 left-0 w-full h-full bg-black/70 backdrop-blur-lg z-[6000] items-center justify-center p-4" style="display: none;">
990
- <div class="bg-card-dark rounded-xl border border-brand-border shadow-2xl w-full max-w-lg overflow-y-auto max-h-[90vh]">
991
- <div class="flex justify-between items-center p-4 border-b border-brand-border sticky top-0 bg-card-dark z-10">
992
- <h3 class="text-xl font-bold">Project Credits</h3>
993
- <button id="closeCreditsBtn" class="text-white hover:text-gray-400 p-2"><i class="fas fa-times"></i></button>
994
- </div>
995
- <div id="creditsContent" class="p-6 space-y-6">
996
- </div>
997
- </div>
998
- </div>
999
-
1000
- <div id="instruction-overlay" class="fixed inset-0 bg-black/80 z-[8000] transition-opacity duration-300 opacity-0 flex items-center justify-center p-4" style="display: none;">
1001
- <div class="bg-card-dark text-white rounded-3xl border border-gray-700 shadow-2xl backdrop-blur-md w-full max-w-2xl max-h-[90vh] flex flex-col">
1002
- <div class="p-6 sm:p-8 overflow-y-auto custom-scrollbar">
1003
- <h3 class="text-2xl font-bold mb-4 text-center">Welcome to 4SP Games</h3>
1004
- <p class="text-sm text-gray-400 mb-6 text-center">Please read the following information before playing.</p>
1005
-
1006
- <ul class="space-y-4 text-gray-300 text-sm sm:text-base leading-relaxed">
1007
- <li class="flex items-start">
1008
- <i class="fas fa-compass mt-1 mr-3 text-accent-indigo"></i>
1009
- <span><strong>Navigation:</strong> Use the arrows next to the category title (currently "<strong>StrongdogXP</strong>") to switch between different game collections like <strong>GN-Math</strong> and <strong>Others</strong>.</span>
1010
- </li>
1011
- <li class="flex items-start">
1012
- <i class="fas fa-file-signature mt-1 mr-3 text-accent-indigo"></i>
1013
- <span><strong>Rebranding:</strong> "4SP Game Hub" is now shortened to "<strong>4SP Games</strong>".</span>
1014
- </li>
1015
- <li class="flex items-start">
1016
- <i class="fas fa-bug mt-1 mr-3 text-strongdog-orange"></i>
1017
- <span><strong>Stability Disclaimer:</strong> Some games may be unstable, glitchy, or fail to load. This is often due to browser restrictions or the age of the game files.</span>
1018
- </li>
1019
- <li class="flex items-start">
1020
- <i class="fas fa-user-gear mt-1 mr-3 text-gn-math"></i>
1021
- <span><strong>Management:</strong> I do not personally manage or host the files for these game collections. I cannot directly fix broken game files or save data issues.</span>
1022
- </li>
1023
- <li class="flex items-start">
1024
- <i class="fas fa-server mt-1 mr-3 text-gn-math"></i>
1025
- <span><strong>GN-Math & CDN:</strong> The <strong>GN-Math</strong> category is particularly hard to modify because it uses a <em>CDN (Content Delivery Network)</em>. This means the game files are hosted on remote servers around the world to load faster, rather than being stored here. I cannot edit those files.</span>
1026
- </li>
1027
- </ul>
1028
- </div>
1029
-
1030
- <div class="p-6 border-t border-gray-800 bg-black/20 rounded-b-3xl flex flex-col sm:flex-row items-center justify-between gap-4">
1031
- <span class="text-xs text-gray-500">Type "I understand" to continue.</span>
1032
- <input type="text" id="instruction-input" placeholder="Type 'I understand'" class="w-full sm:w-auto px-6 py-3 border border-transparent text-base font-normal rounded-xl text-white bg-purple-500/10 ring-1 ring-purple-500/50 focus:ring-purple-500/80 focus:bg-purple-500/20 focus:outline-none placeholder-gray-400 transition-all duration-200">
1033
- </div>
1034
- </div>
1035
- </div>
1036
-
1037
- <script>
1038
- // --- Configuration & Globals ---
1039
-
1040
- const GN_ZONES_URL = "https://cdn.jsdelivr.net/gh/gn-math/assets@main/zones.json";
1041
- const GN_COVER_URL_BASE = "https://cdn.jsdelivr.net/gh/gn-math/covers@main";
1042
-
1043
- // FIXED: Use raw.githack.com for HTML to prevent "showing code" issue
1044
- const GN_HTML_URL_BASE = "https://raw.githack.com/gn-math/html/main";
1045
-
1046
- const WORKER_ROOT = "https://dv-service-lfs.4simpleproblems.workers.dev/";
1047
- const GAMES_BASE_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/";
1048
- const CARDS_DATA_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/cards-data.js";
1049
-
1050
- const FAVORITES_KEY = 'gameHubFavorites_v3';
1051
-
1052
- let allGames = [];
1053
- let searchableGames = [];
1054
- let categories = [];
1055
- let currentCategoryIndex = 0;
1056
- const categoryColors = {
1057
- 'StrongdogXP': 'text-strongdog-orange',
1058
- 'GN-Math': 'text-gn-math',
1059
- 'Gameboy Games': 'text-gameboy',
1060
- 'Others': 'text-other-grey',
1061
- };
1062
-
1063
- const CREDITS_DATA = {
1064
- 'StrongdogXP': { credit: "Core game collection provided by Josh P.", githubUrl: "https://github.com/jman1593/" },
1065
- 'GN-Math': { credit: "Primary game source and infrastructure provided by the GN-Math Community.", githubUrl: "https://github.com/gn-math/" },
1066
- 'Gameboy Games': { credit: "Emulator and ROM files from various public sources." },
1067
- 'Others': { credit: "Various community developers and open-source projects (e.g., DOOM, Eaglercraft, Carnage3D, JS-DOS)." }
1068
- };
1069
-
1070
- const favoritesHeader = document.getElementById("favoritesHeader");
1071
- const favoritesGameList = document.getElementById("favoritesGameList");
1072
- const searchInput = document.getElementById("searchInput");
1073
- const searchResults = document.getElementById("searchResults");
1074
- const searchDivider = document.getElementById("searchDivider");
1075
- const categorySlider = document.getElementById('category-slider');
1076
- const prevCategoryBtn = document.getElementById('prev-category');
1077
- const nextCategoryBtn = document.getElementById('next-category');
1078
- const gamesGridContainer = document.getElementById('games-grid-container');
1079
-
1080
- const creditsModal = document.getElementById('creditsModal');
1081
- const creditsContent = document.getElementById('creditsContent');
1082
- const creditsBtn = document.getElementById('creditsBtn');
1083
- const closeCreditsBtn = document.getElementById('closeCreditsBtn');
1084
-
1085
- const INSTRUCTION_KEY = '4sp-games-instruction-extended-seen';
1086
- const instructionOverlay = document.getElementById('instruction-overlay');
1087
- const instructionInput = document.getElementById('instruction-input');
1088
-
1089
- // --- Intersection Observer for Image Virtualization ---
1090
- const imageObserver = new IntersectionObserver((entries, observer) => {
1091
- entries.forEach(entry => {
1092
- const img = entry.target;
1093
- if (entry.isIntersecting) {
1094
- if (img.dataset.src) {
1095
- img.src = img.dataset.src;
1096
- }
1097
- } else {
1098
- if(img.src && !img.src.includes('placehold.co') && !img.dataset.src) {
1099
- img.dataset.src = img.src;
1100
- }
1101
- if(img.dataset.src) {
1102
- img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1103
- }
1104
- }
1105
- });
1106
- }, {
1107
- rootMargin: "300px 0px",
1108
- threshold: 0.01
1109
- });
1110
-
1111
- function observeImages(container) {
1112
- const images = container.querySelectorAll('img[data-src]');
1113
- images.forEach(img => imageObserver.observe(img));
1114
- }
1115
-
1116
- // --- URL Management ---
1117
- function updateURL(category, gameId = null) {
1118
- let hash = `#${encodeURIComponent(category.replace(/\s+/g, '-'))}`;
1119
- if (gameId) hash += `?id=${gameId}`;
1120
- try {
1121
- history.replaceState(null, null, hash);
1122
- } catch(e) {
1123
- console.warn('History API not supported in this environment');
1124
- }
1125
- }
1126
-
1127
- function parseURL() {
1128
- const hash = window.location.hash.substring(1);
1129
- if (!hash) return { category: null, gameId: null };
1130
- const parts = hash.split('?');
1131
- let rawCategory = parts[0];
1132
- let category = decodeURIComponent(rawCategory).replace(/-/g, ' ');
1133
- let gameId = null;
1134
- if (parts[1]) {
1135
- const params = new URLSearchParams(parts[1]);
1136
- gameId = params.get('id');
1137
- }
1138
- return { category: rawCategory, gameId };
1139
- }
1140
-
1141
- function setupInstructionOverlay() {
1142
- if (localStorage.getItem(INSTRUCTION_KEY) !== 'seen') {
1143
- instructionOverlay.style.display = 'flex';
1144
- document.body.style.overflow = 'hidden';
1145
- setTimeout(() => {
1146
- instructionOverlay.classList.remove('opacity-0');
1147
- instructionOverlay.classList.add('opacity-100');
1148
- }, 10);
1149
- instructionInput.addEventListener('input', (e) => {
1150
- if (e.target.value.trim().toLowerCase() === 'i understand') {
1151
- instructionOverlay.classList.remove('opacity-100');
1152
- instructionOverlay.classList.add('opacity-0');
1153
- setTimeout(() => {
1154
- instructionOverlay.style.display = 'none';
1155
- document.body.style.overflow = '';
1156
- localStorage.setItem(INSTRUCTION_KEY, 'seen');
1157
- }, 300);
1158
- }
1159
- });
1160
- }
1161
- }
1162
-
1163
- // --- Favorites Management (Live Updates) ---
1164
- const getFavorites = () => JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
1165
- const saveFavorites = (favs) => localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs));
1166
-
1167
- function toggleFavorite(gameId) {
1168
- const strGameId = String(gameId);
1169
- let favorites = getFavorites().map(String);
1170
- const isFavorited = favorites.includes(strGameId);
1171
-
1172
- if (isFavorited) {
1173
- favorites = favorites.filter(id => id !== strGameId);
1174
- saveFavorites(favorites);
1175
- updateAllFavoriteButtons();
1176
- const favCard = favoritesGameList.querySelector(`.zone-item[data-game-id='${strGameId}'], .other-zone-item[data-game-id='${strGameId}']`);
1177
- if (favCard) {
1178
- favCard.classList.add('fade-out');
1179
- setTimeout(() => {
1180
- favCard.remove();
1181
- if (favoritesGameList.children.length === 0) {
1182
- favoritesHeader.style.display = 'none';
1183
- favoritesGameList.style.display = 'none';
1184
- }
1185
- }, 300);
1186
- }
1187
- } else {
1188
- favorites.push(strGameId);
1189
- saveFavorites(favorites);
1190
- updateAllFavoriteButtons();
1191
- let gameData = allGames.find(g => String(g.id) === strGameId);
1192
- if (!gameData) {
1193
- allGames.some(g => {
1194
- if (g.versions) {
1195
- const ver = g.versions.find(v => String(v.favoriteId) === strGameId);
1196
- if (ver) {
1197
- gameData = { ...g, id: ver.favoriteId, name: `${g.name} - ${ver.name}`, url: ver.url, versions: undefined };
1198
- return true;
1199
- }
1200
- }
1201
- return false;
1202
- });
1203
- }
1204
- if (gameData) {
1205
- favoritesHeader.style.display = "block";
1206
- favoritesGameList.style.display = "grid";
1207
- favoritesGameList.classList.add('grid', 'grid-cols-2', 'sm:grid-cols-3', 'md:grid-cols-5', 'lg:grid-cols-7', 'gap-4');
1208
- const newCard = createGameCard(gameData, true);
1209
- favoritesGameList.appendChild(newCard);
1210
- }
1211
- }
1212
- }
1213
-
1214
- function updateAllFavoriteButtons() {
1215
- const favorites = getFavorites().map(String);
1216
- document.querySelectorAll('.btn-card-action.fav-action').forEach(btn => {
1217
- const card = btn.closest('.zone-item, .other-zone-item, .search-item');
1218
- if (!card) return;
1219
- const cardGameId = String(card.dataset.gameId);
1220
- if (btn.classList.contains('version-favorite-btn')) {
1221
- const isAnyVersionFavorited = favorites.some(favId => favId.startsWith(cardGameId + '_'));
1222
- btn.classList.toggle('favorited', isAnyVersionFavorited);
1223
- } else {
1224
- const isFavorited = favorites.includes(cardGameId);
1225
- btn.classList.toggle('favorited', isFavorited);
1226
- btn.title = isFavorited ? 'Remove from Favorites' : 'Add to Favorites';
1227
- }
1228
- });
1229
- }
1230
-
1231
- // --- URL Helpers (Strongdog) ---
1232
- // Modified to support injected URLs using WORKER_ROOT + directory logic
1233
- function getAdjustedUrls(imgSrc, page) {
1234
- if (page && page > 1) {
1235
- // ../strongdog2/img/... -> exists outside GAMES dir
1236
- return { adjustedImgSrc: `${WORKER_ROOT}strongdog${page}/img/${imgSrc}` };
1237
- }
1238
- // ./img -> assumed inside GAMES dir or wherever relative
1239
- return { adjustedImgSrc: `${GAMES_BASE_URL}img/${imgSrc}` };
1240
- }
1241
-
1242
- function sd_getBaseURLForPage(page) {
1243
- // Updated to point to WORKER_ROOT for numbered pages, GAMES_BASE_URL for default
1244
- if (page > 1) return `${WORKER_ROOT}strongdog${page}/`;
1245
- return `${WORKER_ROOT}STRONGDOG/`; // Assumes default strongdog is at root/STRONGDOG? Or inside GAMES?
1246
- // Re-reading logic: "stuff like ../DOOM/ will stay in GAMES directory but strongdog# directories exit"
1247
- // Let's assume default STRONGDOG is adjacent to strongdog2 etc. at root level based on prior naming conventions
1248
- }
1249
-
1250
- async function sd_getEmbedPath(adjustedHref, originalHref, page) {
1251
- let cleanHref = adjustedHref.replace(/index\.html$/, "").replace(/base\.html$/, "").replace(/\.html$/, "");
1252
- if (!cleanHref.endsWith("/")) cleanHref += "/";
1253
- const pathsToTry = [cleanHref + "game/index.html", cleanHref + "game/base.html", cleanHref + "gamereal/index.html", cleanHref + "gamereal/base.html", cleanHref + "index.html", cleanHref + "base.html", ];
1254
- try {
1255
- const response = await fetch(adjustedHref);
1256
- if (response.ok) {
1257
- const text = await response.text();
1258
- const match = text.match(/embedGame\((['"])(.*?)\1,\s*(['"])(.*?)\3\)/);
1259
- if (match) {
1260
- const resolvedPath = new URL(match[2], adjustedHref).href;
1261
- if (await sd_fileExists(resolvedPath)) return resolvedPath;
1262
- }
1263
- }
1264
- } catch (error) {}
1265
- for (const path of pathsToTry) {
1266
- if (await sd_fileExists(path)) return path;
1267
- }
1268
- return adjustedHref;
1269
- }
1270
- async function sd_fileExists(url) {
1271
- try { const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch { return false; }
1272
- }
1273
-
1274
- // --- URL Resolution Helper for "Others" ---
1275
- function resolveGameUrl(relativePath) {
1276
- // Logic:
1277
- // If path starts with ../ -> Go to WORKER_ROOT + path (stripping ../)
1278
- // If path starts with ../GAMES/ -> It will effectively be WORKER_ROOT/GAMES/ (which matches GAMES_BASE_URL)
1279
- // This allows us to access root folders like ../GTA-JSDOS/ while keeping ../GAMES/sm64 intact.
1280
-
1281
- if (relativePath.startsWith('../')) {
1282
- const pathPart = relativePath.substring(3); // Remove '../'
1283
- return `${WORKER_ROOT}${pathPart}`;
1284
- }
1285
-
1286
- // If it doesn't start with ../, assume it's relative to GAMES base
1287
- return `${GAMES_BASE_URL}${relativePath.replace(/^\.\//, '')}`;
1288
- }
1289
-
1290
- // --- Game Data (Others) ---
1291
- // We keep the original relative paths here so the resolver logic above can handle them dynamically
1292
- const othersGames = [
1293
- { id: "other-eaglercraft", name: "Eaglercraft", imgSrc: "./images/eaglercraft.png", description: "The classic survival and building game with authentic block-based physics and multiplayer support, accessible directly in the browser.", category: "Others", versions: [ { name: "Release 1.12.2", url: "../EAGLERCRAFT/eaglercraft-1.12.2.html", favoriteId: "other-eaglercraft_1.12.2" }, { name: "Release 1.12.2 WASM", url: "../EAGLERCRAFT/eaglercraft-1.12.2-wasm.html", favoriteId: "other-eaglercraft_wasm", wasm: true } ] },
1294
- { id: "other-gta", name: "Grand Theft Auto", imgSrc: "./images/gta.png", description: "The original top-down classic. Cause chaos, complete missions, or just go for a drive in three iconic cities.", category: "Others", versions: [ { name: "Carnage3D", url: "../CARNAGE3D/web/carnage3D.html", favoriteId: "other-gta_carnage3d" }, { name: "Grand Theft Auto (JS-DOS)", url: "../GTA-JSDOS/index.html", favoriteId: "other-gta_jsdos" } ] },
1295
- { id: "other-simcity", name: "Sim City", imgSrc: "./images/simcity2000.png", description: "The original city-building classics. Design, build, and manage the city of your dreams in either the original Sim City or the detailed Sim City 2000.", category: "Others", versions: [ { name: "Sim City", url: "../SIM-CITY/index.html", favoriteId: "other-simcity_1" }, { name: "Sim City 2000", url: "../SIM-CITY-2/index.html", favoriteId: "other-simcity_2000" } ] },
1296
- { id: "other-doom", name: "DOOM", imgSrc: "./images/doom.png", description: "The legendary first-person shooter that pioneered the genre, focusing on blasting through hordes of demons in a timeless, adrenaline-fueled classic.", category: "Others", url: "../DOOM/index.html" },
1297
- { id: "other-sm64", name: "Super Mario 64", imgSrc: "./images/sm64.png", description: "The iconic 3D platformer that revolutionized gaming. Run, jump, and triple-jump your way through vast worlds to collect stars and save the princess.", category: "Others", url: "../GAMES/sm64/index.html" }
1298
- ];
1299
-
1300
- // --- Card Creation ---
1301
-
1302
- function createOthersGameCard(game) {
1303
- const favorites = getFavorites().map(String);
1304
- const isAnyFavorite = game.versions ? favorites.some(favId => game.versions.some(v => String(v.favoriteId) === favId)) : favorites.includes(String(game.id));
1305
- const card = document.createElement("div");
1306
- card.className = 'other-zone-item bg-card-dark rounded-2xl border border-brand-border col-span-full shadow-lg';
1307
- card.dataset.gameId = game.id;
1308
-
1309
- // Resolve Image URL
1310
- const resolvedImgSrc = resolveGameUrl(game.imgSrc);
1311
-
1312
- let gameButtonsHtml = '';
1313
- let favoriteButtonHtml = '';
1314
-
1315
- if (game.versions && game.versions.length > 0) {
1316
- const availableVersions = game.versions;
1317
- if (availableVersions.length === 1) {
1318
- const vUrl = resolveGameUrl(availableVersions[0].url);
1319
- gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${vUrl}" data-version-name="${availableVersions[0].name}" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>`;
1320
- } else {
1321
- const dropdownLinks = availableVersions.map(v => {
1322
- const vUrl = resolveGameUrl(v.url);
1323
- return `<a href="#" class="eagler-dropdown-link" data-url="${vUrl}" data-version-name="${v.name}">${v.name}</a>`
1324
- }).join('');
1325
- gameButtonsHtml = `<div class="relative"><button class="btn-card-action version-btn" title="Select Version"><i class="fa-solid fa-chevron-up transition-colors"></i></button><div class="eagler-dropdown">${dropdownLinks}</div></div>`;
1326
- }
1327
- favoriteButtonHtml = `<div class="relative"><button class="btn-card-action fav-action version-favorite-btn ${isAnyFavorite ? 'favorited' : ''}" title="Favorite a version"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button><div class="eagler-dropdown">${game.versions.map(v => `<a href="#" class="eagler-dropdown-link favorite-link" data-favorite-id="${v.favoriteId}">${v.name}</a>`).join('')}</div></div>`;
1328
- } else {
1329
- const gUrl = resolveGameUrl(game.url);
1330
- gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${gUrl}" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>`;
1331
- favoriteButtonHtml = `<button class="btn-card-action fav-action ${isAnyFavorite ? 'favorited' : ''}" title="${isAnyFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button>`;
1332
- }
1333
-
1334
- card.innerHTML = `
1335
- <div class="relative w-full h-full cursor-pointer group" style="min-height: 250px;">
1336
- <img data-src="${resolvedImgSrc}" alt="${game.name}" loading="lazy" decoding="async" class="w-full h-full object-cover absolute inset-0 rounded-2xl">
1337
- <div class="image-overlay absolute inset-0 transition-colors duration-200"></div>
1338
- <div class="absolute inset-0 p-4 sm:p-6 flex flex-col justify-between rounded-2xl">
1339
- <div class="flex items-start justify-between w-full">
1340
- <h3 class="text-4xl font-bold text-white truncate drop-shadow-lg" style="max-width: 80%;" title="${game.name}">${game.name}</h3>
1341
- <div class="flex items-center space-x-3 bg-black/80 backdrop-blur-md rounded-xl px-2 py-1">
1342
- ${gameButtonsHtml}
1343
- ${favoriteButtonHtml}
1344
- </div>
1345
- </div>
1346
- <p class="text-lg text-white font-medium drop-shadow-lg" style="max-width: 50%;">${game.description}</p>
1347
- </div>
1348
- </div>
1349
- `;
1350
-
1351
- if (!game.versions) {
1352
- card.querySelector('.group').addEventListener('click', () => {
1353
- const gUrl = resolveGameUrl(game.url);
1354
- openZone({...game, url: gUrl});
1355
- });
1356
- }
1357
- card.querySelectorAll('.play-action').forEach(btn => btn.addEventListener('click', (e) => { e.stopPropagation(); openZone({ ...game, name: btn.dataset.versionName ? `${game.name} - ${btn.dataset.versionName}` : game.name, url: btn.dataset.url }); }));
1358
-
1359
- const versionBtn = card.querySelector('.version-btn');
1360
- if (versionBtn) { versionBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); versionBtn.nextElementSibling.classList.toggle('show'); }); }
1361
-
1362
- card.querySelectorAll('.eagler-dropdown-link:not(.favorite-link)').forEach(link => link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openZone({ ...game, name: link.dataset.versionName ? `${game.name} - ${link.dataset.versionName}` : game.name, url: link.dataset.url }); link.closest('.eagler-dropdown').classList.remove('show'); }));
1363
-
1364
- const favBtn = card.querySelector('.btn-card-action.fav-action');
1365
- if (favBtn.classList.contains('version-favorite-btn')) {
1366
- favBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); favBtn.nextElementSibling.classList.toggle('show'); });
1367
- card.querySelectorAll('.favorite-link').forEach(link => link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFavorite(link.dataset.favoriteId); link.closest('.eagler-dropdown').classList.remove('show'); }));
1368
- } else {
1369
- favBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
1370
- }
1371
- imageObserver.observe(card.querySelector('img'));
1372
- return card;
1373
- }
1374
-
1375
- function createGameCard(game, forceSmallCard = false) {
1376
- if (game.category === 'Others' && !forceSmallCard) return createOthersGameCard(game);
1377
- const isStrongdog = game.category === 'StrongdogXP';
1378
-
1379
- // Resolve Image Src
1380
- let imgSrc = game.imgSrc;
1381
- if (isStrongdog) {
1382
- imgSrc = getAdjustedUrls(game.imgSrc, game.page).adjustedImgSrc;
1383
- } else if (game.category === 'Others' || game.category === 'Gameboy Games') {
1384
- imgSrc = resolveGameUrl(game.imgSrc);
1385
- }
1386
-
1387
- const isFavorite = getFavorites().map(String).includes(String(game.id));
1388
-
1389
- const card = document.createElement("div");
1390
- card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
1391
- card.dataset.gameId = game.id;
1392
- card.innerHTML = `
1393
- <div class="relative w-full cursor-pointer group">
1394
- <div class="aspect-w-3 aspect-h-2"><img data-src="${imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
1395
- <h3 class="absolute top-2 right-2 z-10 max-w-[80%] bg-black/60 backdrop-blur-md rounded-xl px-3 py-1.5 text-white truncate text-sm font-semibold shadow-lg" title="${game.name}">${game.name}</h3>
1396
- <div class="absolute bottom-2 right-2 bg-black/80 backdrop-blur-md rounded-xl px-2 py-1 flex items-center space-x-2 shadow-lg">
1397
- <button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>
1398
- <button class="btn-card-action fav-action ${isFavorite ? 'favorited' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button>
1399
- </div>
1400
- </div>
1401
- `;
1402
-
1403
- const handlePlay = async (e) => {
1404
- e.stopPropagation();
1405
- if (isStrongdog) {
1406
- const baseURL = sd_getBaseURLForPage(game.page);
1407
- let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
1408
- const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
1409
- openZone({ name: game.name, url: embedPath, category: game.category, id: game.id });
1410
- } else {
1411
- // For GN-Math, url is absolute (handled in init). For Others/Gameboy, we might need to resolve
1412
- let gUrl = game.url;
1413
- if (game.category === 'Others' || game.category === 'Gameboy Games') {
1414
- gUrl = resolveGameUrl(game.url);
1415
- }
1416
- openZone({ ...game, url: gUrl });
1417
- }
1418
- };
1419
-
1420
- card.querySelector('.group').addEventListener('click', handlePlay);
1421
- card.querySelector('.play-action').addEventListener('click', handlePlay);
1422
- card.querySelector('.fav-action').addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
1423
- imageObserver.observe(card.querySelector('img'));
1424
- return card;
1425
- }
1426
-
1427
- // --- Game Viewer Logic ---
1428
- function openZone(game) {
1429
- if (!game || !game.url) return;
1430
- updateURL(categories[currentCategoryIndex], game.id || null);
1431
-
1432
- const zoneViewer = document.getElementById('zoneViewer');
1433
- const zoneFrame = document.getElementById('zoneFrame');
1434
- const zoneNameEl = document.getElementById('zoneNameEl');
1435
- const downloadBtn = document.getElementById('downloadBtnZone');
1436
-
1437
- // Setup Download Button (Specific to Eaglercraft)
1438
- if (game.baseGameId === 'other-eaglercraft' || game.id === 'other-eaglercraft') {
1439
- downloadBtn.href = game.url;
1440
- downloadBtn.download = game.name.replace(/ /g, '_') + '.html';
1441
- downloadBtn.removeAttribute('target');
1442
- downloadBtn.classList.remove('hidden');
1443
- } else {
1444
- downloadBtn.classList.add('hidden');
1445
- downloadBtn.href = '#';
1446
- downloadBtn.removeAttribute('download');
1447
- }
1448
-
1449
- zoneNameEl.textContent = game.name;
1450
-
1451
- // RESET FRAME BEFORE LOAD
1452
- zoneFrame.src = 'about:blank';
1453
-
1454
- // SECURITY: Set sandbox permissions
1455
- const sandboxRules = 'allow-scripts allow-same-origin allow-forms allow-pointer-lock';
1456
- zoneFrame.setAttribute('sandbox', sandboxRules);
1457
- zoneFrame.setAttribute('allow', 'fullscreen; pointer-lock; autoplay; clipboard-write');
1458
-
1459
- // --- REVISED LOGIC FOR LOADING GAMES ---
1460
- // With raw.githack.com, we can treat GN-Math games as standard URL games again
1461
- // because Githack serves correct Content-Type: text/html headers.
1462
- const isStandardURLGame = game.category === 'StrongdogXP' || game.category === 'Others' || game.category === 'GN-Math';
1463
-
1464
- if (isStandardURLGame) {
1465
- // Load URL directly for standard games
1466
- zoneFrame.src = game.url;
1467
- zoneViewer.style.display = "flex";
1468
- } else {
1469
- // Fallback for other potential categories (same logic as before: fetch & write)
1470
- fetch(`${game.url}?t=${Date.now()}`)
1471
- .then(response => {
1472
- if (!response.ok) throw new Error(`HTTP error ${response.status}`);
1473
- return response.text();
1474
- })
1475
- .then(html => {
1476
- const doc = zoneFrame.contentWindow.document;
1477
- doc.open();
1478
- doc.write(html);
1479
- doc.close();
1480
- zoneViewer.style.display = "flex";
1481
- })
1482
- .catch(error => {
1483
- console.error(`Failed to load game "${game.name}": ${error.message}`);
1484
- zoneFrame.src = game.url;
1485
- zoneViewer.style.display = "flex";
1486
- });
1487
- }
1488
- }
1489
-
1490
- function closeZoneViewer() {
1491
- updateURL(categories[currentCategoryIndex]);
1492
- const downloadBtn = document.getElementById('downloadBtnZone');
1493
- downloadBtn.classList.add('hidden');
1494
- downloadBtn.href = '#';
1495
- downloadBtn.removeAttribute('download');
1496
- downloadBtn.removeAttribute('target');
1497
- const zoneViewer = document.getElementById('zoneViewer');
1498
- const zoneFrame = document.getElementById('zoneFrame');
1499
- zoneViewer.style.display = "none";
1500
- if (zoneFrame) {
1501
- zoneFrame.src = 'about:blank';
1502
- }
1503
- }
1504
-
1505
- // --- Render Lists ---
1506
- function renderGames(gamesToRender, targetElement, forceSmallCard = false) {
1507
- targetElement.innerHTML = '';
1508
- targetElement.className = (categories[currentCategoryIndex] === 'Others' && !forceSmallCard) ? 'grid grid-cols-1 gap-6 mt-6' : 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-4 mt-6';
1509
- gamesToRender.forEach(game => targetElement.appendChild(createGameCard(game, forceSmallCard)));
1510
- observeImages(targetElement);
1511
- }
1512
-
1513
- function renderFavorites() {
1514
- const favoriteIds = getFavorites().map(String);
1515
- if (favoriteIds.length > 0) {
1516
- favoritesHeader.style.display = "block";
1517
- favoritesGameList.style.display = "grid";
1518
- const favoriteGames = [];
1519
- allGames.forEach(game => {
1520
- if (favoriteIds.includes(String(game.id)) && !game.versions) { favoriteGames.push(game); }
1521
- else if (game.versions) {
1522
- game.versions.forEach(v => {
1523
- if (favoriteIds.includes(String(v.favoriteId))) {
1524
- // Need to resolve URL here for favorites too!
1525
- let vUrl = v.url;
1526
- if (game.category === 'Others' || game.category === 'Gameboy Games') {
1527
- vUrl = resolveGameUrl(v.url);
1528
- } else if (game.category === 'StrongdogXP') {
1529
- // Strongdog favorites usually just rely on the ID to regenerate logic, but for safety in this object:
1530
- // Logic remains handled inside createGameCard via ID lookup generally, but let's leave as is
1531
- // because strongdog logic inside createGameCard regenerates paths based on ID/Page props anyway.
1532
- }
1533
-
1534
- favoriteGames.push({ ...game, id: v.favoriteId, name: `${game.name} - ${v.name}`, url: vUrl, versions: undefined });
1535
- }
1536
- });
1537
- }
1538
- });
1539
- renderGames(favoriteGames.sort((a, b) => a.name.localeCompare(b.name)), favoritesGameList, true);
1540
- } else {
1541
- favoritesHeader.style.display = "none";
1542
- favoritesGameList.style.display = "none";
1543
- }
1544
- }
1545
-
1546
- // --- Category Nav ---
1547
- function updateArrowVisibility() {
1548
- prevCategoryBtn.disabled = currentCategoryIndex === 0;
1549
- nextCategoryBtn.disabled = currentCategoryIndex === categories.length - 1;
1550
- }
1551
-
1552
- function switchCategory(newIndex) {
1553
- currentCategoryIndex = newIndex;
1554
- categorySlider.style.transform = `translateX(-${currentCategoryIndex * (100 / categories.length)}%)`;
1555
- renderGamesForCurrentCategory();
1556
- updateArrowVisibility();
1557
- updateURL(categories[currentCategoryIndex]);
1558
- }
1559
-
1560
- async function renderGamesForCurrentCategory() {
1561
- const categoryName = categories[currentCategoryIndex];
1562
- gamesGridContainer.classList.add('opacity-0');
1563
- await new Promise(r => setTimeout(r, 150));
1564
- renderGames(allGames.filter(g => g.category === categoryName), gamesGridContainer, false);
1565
- gamesGridContainer.classList.remove('opacity-0');
1566
- }
1567
-
1568
- // --- Search ---
1569
- function debounce(func, delay) {
1570
- let timeout;
1571
- return function(...args) {
1572
- clearTimeout(timeout);
1573
- timeout = setTimeout(() => func.apply(this, args), delay);
1574
- };
1575
- }
1576
-
1577
- const handleSearch = debounce(() => {
1578
- const text = searchInput.value.toLowerCase().trim();
1579
- const filteredDropdown = text ? searchableGames.filter(g => g.name.toLowerCase().includes(text)).slice(0, 5) : [];
1580
-
1581
- searchResults.innerHTML = "";
1582
-
1583
- if (filteredDropdown.length === 0 || !text) {
1584
- searchResults.style.display = "none";
1585
- searchDivider.style.display = "none";
1586
- return;
1587
- }
1588
-
1589
- const favorites = getFavorites().map(String);
1590
- filteredDropdown.forEach(game => {
1591
- const isStrongdog = game.category === 'StrongdogXP';
1592
- let imgSrc;
1593
- let finalGameData = { ...game }; // clone
1594
-
1595
- if (isStrongdog) {
1596
- const { adjustedImgSrc } = getAdjustedUrls(game.imgSrc, game.page);
1597
- imgSrc = adjustedImgSrc;
1598
- } else if (game.category === 'Others' || game.category === 'Gameboy Games') {
1599
- imgSrc = resolveGameUrl(game.imgSrc);
1600
- finalGameData.url = resolveGameUrl(game.url);
1601
- } else {
1602
- imgSrc = game.imgSrc;
1603
- }
1604
-
1605
- const linkEl = document.createElement("a");
1606
- linkEl.href = '#';
1607
- linkEl.className = 'search-item flex justify-between items-center';
1608
- linkEl.dataset.gameId = game.id;
1609
- let isFavorite = favorites.includes(String(game.id));
1610
-
1611
- linkEl.innerHTML = `
1612
- <div class="flex items-center truncate min-w-0">
1613
- <img src="${imgSrc}" alt="${game.name}" class="w-10 h-10 rounded-lg object-cover flex-shrink-0">
1614
- <span class="ml-3 font-medium truncate text-gray-200" title="${game.name}">${game.name}</span>
1615
- </div>
1616
- <div class="flex items-center flex-shrink-0">
1617
- <div class="bg-black/80 backdrop-blur-md rounded-xl px-2 py-1 flex items-center space-x-2">
1618
- <button class="btn-card-action play-action" title="Play Game">
1619
- <i class="fa-solid fa-play transition-colors"></i>
1620
- </button>
1621
- <button class="btn-card-action fav-action ${isFavorite ? 'favorited' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
1622
- <i class="fa-solid fa-star fa-solid-star transition-colors"></i>
1623
- <i class="fa-regular fa-star fa-regular-star transition-colors"></i>
1624
- </button>
1625
- </div>
1626
- </div>
1627
- `;
1628
-
1629
- const favBtn = linkEl.querySelector('.fav-action');
1630
- favBtn.addEventListener('click', (e) => {
1631
- e.preventDefault(); e.stopPropagation();
1632
- toggleFavorite(game.id);
1633
- });
1634
-
1635
- linkEl.addEventListener('click', async (e) => {
1636
- e.preventDefault();
1637
- if (isStrongdog) {
1638
- const baseURL = sd_getBaseURLForPage(game.page);
1639
- let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
1640
- const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
1641
- const strongdogGameData = { name: game.name, url: embedPath, category: game.category, id: game.id };
1642
- openZone(strongdogGameData);
1643
- } else {
1644
- openZone(finalGameData);
1645
- }
1646
- searchResults.style.display = "none";
1647
- searchDivider.style.display = "none";
1648
- });
1649
- searchResults.appendChild(linkEl);
1650
- });
1651
- searchResults.style.display = "block";
1652
- searchDivider.style.display = "block";
1653
- }, 250);
1654
-
1655
- // --- FETCHING DATA MANUALLY (NO MODULES) ---
1656
- // This function fetches the JS file as text, transforms it to set a global variable, and evals it.
1657
- // This bypasses module loading restrictions on file:// protocols.
1658
- async function fetchGameData() {
1659
- try {
1660
- // Fetch the file
1661
- const response = await fetch(CARDS_DATA_URL);
1662
- if (!response.ok) throw new Error('Failed to fetch game data');
1663
- let scriptText = await response.text();
1664
-
1665
- // Transform 'export default' to 'window.loadedGameData ='
1666
- // This assumes the file structure is simple "export default [...]"
1667
- if (scriptText.includes('export default')) {
1668
- scriptText = scriptText.replace('export default', 'window.loadedGameData =');
1669
- }
1670
-
1671
- // Execute the script
1672
- // We use new Function instead of eval for slight isolation, though still unsafe for untrusted code
1673
- // (But we trust this source).
1674
- // However, new Function creates a local scope. We need global assignment.
1675
- // Standard eval or appending a script tag is better.
1676
- const scriptEl = document.createElement('script');
1677
- scriptEl.textContent = scriptText;
1678
- document.body.appendChild(scriptEl);
1679
-
1680
- // Wait a tick for execution
1681
- await new Promise(resolve => setTimeout(resolve, 0));
1682
-
1683
- if (window.loadedGameData) {
1684
- return window.loadedGameData;
1685
- } else {
1686
- console.warn("Script loaded but window.loadedGameData is undefined");
1687
- return [];
1688
- }
1689
- } catch (e) {
1690
- console.error("Error manual loading games:", e);
1691
- return [];
1692
- }
1693
- }
1694
-
1695
- // --- Init ---
1696
- async function initializeApp() {
1697
- // Load Game Data Manually
1698
- let gamesData = [];
1699
- try {
1700
- gamesData = await fetchGameData();
1701
- } catch (e) {
1702
- console.error("Could not load external games data", e);
1703
- }
1704
-
1705
- const strongdogGames = gamesData.map(g => ({ ...g, category: 'StrongdogXP' }));
1706
-
1707
- try {
1708
- const res = await fetch(GN_ZONES_URL);
1709
- const gnMathRaw = await res.json();
1710
- const gnMathGames = gnMathRaw.map(g => ({ id: `gn-${g.id}`, name: g.name, imgSrc: g.cover.replace("{COVER_URL}", GN_COVER_URL_BASE), category: 'GN-Math', url: g.url.replace("{HTML_URL}", GN_HTML_URL_BASE) }));
1711
- allGames = [...strongdogGames, ...gnMathGames, ...othersGames];
1712
- } catch (error) {
1713
- console.warn("Failed to load GN-Math, falling back to others.", error);
1714
- allGames = [...strongdogGames, ...othersGames];
1715
- }
1716
-
1717
- allGames = allGames.filter(g => !['bitlife', 'soundboard'].some(term => g.name.toLowerCase().includes(term)));
1718
-
1719
- searchableGames = [];
1720
- allGames.forEach(game => {
1721
- if (game.versions && game.versions.length > 0) {
1722
- game.versions.forEach(version => {
1723
- searchableGames.push({
1724
- ...game,
1725
- id: version.favoriteId,
1726
- name: `${game.name} - ${version.name}`,
1727
- url: version.url,
1728
- wasm: version.wasm,
1729
- versions: undefined,
1730
- baseGameId: game.id
1731
- });
1732
- });
1733
- } else {
1734
- searchableGames.push(game);
1735
- }
1736
- });
1737
-
1738
- categories = ['StrongdogXP', 'GN-Math', 'Gameboy Games', 'Others'].filter(cat => allGames.some(g => g.category === cat));
1739
-
1740
- const { category: urlCat } = parseURL();
1741
- if (urlCat) currentCategoryIndex = Math.max(0, categories.findIndex(c => c.replace(/\s+/g, '-').toLowerCase() === urlCat.toLowerCase()));
1742
-
1743
- categorySlider.style.width = `${categories.length * 100}%`;
1744
- categorySlider.innerHTML = categories.map(cat => `<div class="w-64 flex-shrink-0"><h2 class="text-3xl font-bold ${categoryColors[cat] || 'text-white'}"><strong>${cat}</strong></h2></div>`).join('');
1745
-
1746
- switchCategory(currentCategoryIndex);
1747
- renderFavorites();
1748
- setupInstructionOverlay();
1749
-
1750
- searchInput.placeholder = `Search all games...`;
1751
- searchInput.addEventListener("input", handleSearch);
1752
- searchInput.addEventListener("focus", handleSearch);
1753
-
1754
- document.addEventListener("click", ev => {
1755
- if (!document.getElementById('bottom-fixed-bar').contains(ev.target)) {
1756
- searchResults.style.display = "none";
1757
- searchDivider.style.display = "none";
1758
- }
1759
- if (!ev.target.closest('.version-btn') && !ev.target.closest('.version-favorite-btn') && !ev.target.closest('.eagler-dropdown')) {
1760
- document.querySelectorAll('.eagler-dropdown.show').forEach(d => {
1761
- d.classList.remove('show');
1762
- });
1763
- }
1764
- });
760
+
761
+ <div class="flex items-center gap-4 mb-3">
762
+ <input type="text" id="panicKey2" data-key-id="2" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
763
+ <input type="url" id="panicUrl2" class="input-text-style" placeholder="e.g., https://youtube.com/feed/subscriptions">
764
+ </div>
765
+
766
+ <div class="flex items-center gap-4 mb-3">
767
+ <input type="text" id="panicKey3" data-key-id="3" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
768
+ <input type="url" id="panicUrl3" class="input-text-style" placeholder="e.g., https://wikipedia.org">
769
+ </div>
770
+
771
+ <div class="flex justify-between items-center pt-4 border-t border-[#252525]">
772
+ <p id="panicKeyMessage" class="general-message-area text-sm"></p>
773
+ <button id="applyPanicKeyBtn" class="btn-toolbar-style btn-primary-override w-36" style="padding: 0.5rem 0.75rem;">
774
+ <i class="fa-solid fa-check mr-1"></i> Apply Keys
775
+ </button>
776
+ </div>
777
+ </div>
778
+ </div>
1765
779
 
1766
- prevCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex - 1));
1767
- nextCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex + 1));
1768
- document.getElementById('closeBtnZone').addEventListener('click', closeZoneViewer);
1769
-
1770
- // --- FULLSCREEN BUTTON LISTENER ---
1771
- document.getElementById('fullscreenBtnZone').addEventListener('click', () => {
1772
- const zoneFrame = document.getElementById('zoneFrame');
1773
- if (zoneFrame && zoneFrame.requestFullscreen) {
1774
- zoneFrame.requestFullscreen();
1775
- } else if (zoneFrame && zoneFrame.webkitRequestFullscreen) {
1776
- zoneFrame.webkitRequestFullscreen();
1777
- } else if (zoneFrame && zoneFrame.msRequestFullscreen) {
1778
- zoneFrame.msRequestFullscreen();
1779
- }
1780
- });
780
+ <div id="tab-about" class="settings-section hidden">
781
+ <h3 class="text-3xl font-bold text-white mb-6">About 4SP</h3>
782
+ <div class="settings-box p-6 flex flex-col items-center text-center">
783
+ <img src="https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png" class="h-24 mb-4" alt="Logo">
784
+ <h1 class="text-3xl font-bold text-white mb-2">(4SP) 4simpleproblems</h1>
785
+ <p class="text-gray-400 mb-6 max-w-lg font-light">From a soundboard, to a full downloadable client of a platform.</p>
786
+
787
+ <div class="inline-block bg-[#0a0a0a] border border-[#333] px-4 py-2 rounded-[14px] mb-8">
788
+ <span class="text-gray-500 text-sm">Version:</span>
789
+ <span class="text-indigo-400 font-mono font-bold ml-2">5.0.0 (DV)</span>
790
+ </div>
1781
791
 
1782
- creditsBtn.addEventListener('click', () => { creditsModal.style.display = 'flex'; creditsContent.innerHTML = categories.map(cat => CREDITS_DATA[cat] ? `<div class="p-4 border border-brand-border rounded-lg"><h4 class="font-bold mb-1">${cat}</h4><p class="text-sm text-gray-400">${CREDITS_DATA[cat].credit}</p></div>` : '').join(''); });
1783
- closeCreditsBtn.addEventListener('click', () => creditsModal.style.display = 'none');
1784
- }
792
+
793
+ <div class="flex gap-4">
794
+ <a href="https://github.com/4simpleproblems-v5" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-github fa-xl"></i></a>
795
+ <a href="https://youtube.com/4simpleproblems" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-youtube fa-xl"></i></a>
796
+ <a href="https://x.com/@4simpleproblems" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-discord fa-xl"></i></a>
797
+ </div>
798
+ </div>
799
+ </div>
800
+ </div>
801
+ </div>
802
+ </div>
803
+
804
+ <div id="cropperModal">
805
+ <div id="cropperContent">
806
+ <div class="flex justify-between items-center p-6 border-b border-[#333] bg-black">
807
+ <h3 class="text-2xl font-bold text-white">Adjust Profile Picture</h3>
808
+ <button id="cancelCropBtn" class="btn-toolbar-style w-10 h-10 flex items-center justify-center p-0 rounded-xl">
809
+ <i class="fa-solid fa-xmark fa-xl"></i>
810
+ </button>
811
+ </div>
812
+ <div class="flex flex-col items-center p-6 bg-[#0a0a0a]">
813
+ <p class="text-sm text-gray-400 mb-4">Drag to move. Scroll to resize.</p>
814
+ <div id="cropperCanvasContainer" class="relative mb-6 border border-[#333] rounded-lg overflow-hidden w-full flex justify-center bg-black">
815
+ <canvas id="cropperCanvas"></canvas>
816
+ </div>
817
+ <div class="flex justify-end w-full">
818
+ <button id="submitCropBtn" class="btn-toolbar-style btn-primary-override px-6 py-2 rounded-xl">
819
+ <i class="fa-solid fa-check mr-2"></i> Submit
820
+ </button>
821
+ </div>
822
+ </div>
823
+ </div>
824
+ </div>
1785
825
 
1786
- document.addEventListener('DOMContentLoaded', initializeApp);
1787
- </script>
1788
- </body>
1789
- </html>
1790
- </template>
826
+ <div id="universal-loader" class="fixed inset-0 bg-[#000000] z-[9999] opacity-0 flex flex-col items-end justify-end p-12 transition-opacity duration-200 hidden">
827
+ <img src="https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png" class="absolute top-6 right-6 h-12 w-auto opacity-50" alt="Logo">
828
+ <div class="flex flex-col items-end gap-2">
829
+ <h2 id="loader-title" class="text-white text-3xl font-light italic font-[Geist]">Loading...</h2>
830
+ <div class="w-64 h-1 bg-[#333] rounded-full overflow-hidden">
831
+ <div id="loader-bar" class="h-full bg-white w-0 transition-all duration-1000 ease-out"></div>
832
+ </div>
833
+ </div>
834
+ </div>
1791
835
 
1792
836
  <script type="module">
1793
837
  import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js";
@@ -1828,7 +872,16 @@
1828
872
  const displayUsername = document.getElementById('display-username');
1829
873
  const displayEmail = document.getElementById('display-email');
1830
874
 
1831
- const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.19/logged-in/';
875
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.45/logged-in/';
876
+
877
+ // Preload Logos
878
+ const preloadImgs = [
879
+ 'https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo-dark.png',
880
+ 'https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo-christmas.png',
881
+ 'https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png'
882
+ ];
883
+ preloadImgs.forEach(src => new Image().src = src);
884
+
1832
885
  const STORAGE_KEY = 'local_access_code';
1833
886
  const USER_DATA_KEY = 'local_user_data';
1834
887
  const LAST_PAGE_KEY = 'local_last_page';
@@ -1854,7 +907,9 @@
1854
907
  function initAdminKeybinds(db, auth, user) {
1855
908
  console.log("[Admin Keybinds] Initializing for", user.uid);
1856
909
  let explicitEnabled = true;
910
+ let thirdPartyEnabled = true; // Track third party state
1857
911
  let lastEnablePressTime = 0;
912
+ let lastThirdPartyEnablePressTime = 0; // Separate timer for third party
1858
913
  let configUnsubscribe = null;
1859
914
 
1860
915
  // Visual Feedback Helper
@@ -1926,9 +981,14 @@
1926
981
  explicitEnabled = data.explicitEnabled;
1927
982
  console.log(`[Admin Keybinds] Explicit Enabled: ${explicitEnabled}`);
1928
983
  }
984
+ if (data.thirdPartyEnabled !== undefined) {
985
+ thirdPartyEnabled = data.thirdPartyEnabled;
986
+ console.log(`[Admin Keybinds] Third Party Enabled: ${thirdPartyEnabled}`);
987
+ }
1929
988
  } else {
1930
989
  console.log("[Admin Keybinds] Config doc missing. Defaulting to TRUE.");
1931
990
  explicitEnabled = true;
991
+ thirdPartyEnabled = true;
1932
992
  }
1933
993
  }, (error) => console.error("Config Listen Error:", error));
1934
994
  }
@@ -1936,45 +996,87 @@
1936
996
  // Init Listener
1937
997
  subscribeToConfig();
1938
998
 
1939
- // Key Listener
1940
- document.addEventListener('keydown', async (e) => {
1941
- // CHANGED: Trigger is Ctrl + Shift + E as requested
1942
- if (e.ctrlKey && e.shiftKey && (e.key.toLowerCase() === 'e' || e.code === 'KeyE')) {
1943
- e.preventDefault();
1944
- console.log("[Admin Keybinds] Ctrl+Shift+E detected.");
1945
-
1946
- if (explicitEnabled) {
1947
- // Logic for disabling (Immediate)
1948
- console.log("[Admin Keybinds] Disabling explicit sounds...");
999
+ // EXPOSE GLOBAL TRIGGERS
1000
+ window.triggerAdminKeybind = async () => {
1001
+ console.log("[Admin Keybinds] Triggered via global function.");
1002
+ if (explicitEnabled) {
1003
+ // Logic for disabling (Immediate)
1004
+ console.log("[Admin Keybinds] Disabling explicit sounds...");
1005
+ try {
1006
+ await setDoc(doc(db, 'config', 'soundboard'), { explicitEnabled: false }, { merge: true });
1007
+ showAdminToast("Explicit Sounds: DISABLED", "red");
1008
+ lastEnablePressTime = 0;
1009
+ } catch (err) {
1010
+ console.error("[Admin Keybinds] Failed to disable:", err);
1011
+ showAdminToast("Error: Check Console", "red");
1012
+ }
1013
+ } else {
1014
+ // Logic for enabling (Double Press Safety)
1015
+ const now = Date.now();
1016
+ if (now - lastEnablePressTime < 1500) {
1017
+ console.log("[Admin Keybinds] Enabling explicit sounds...");
1949
1018
  try {
1950
- await setDoc(doc(db, 'config', 'soundboard'), { explicitEnabled: false }, { merge: true });
1951
- showAdminToast("Explicit Sounds: DISABLED", "red");
1019
+ await setDoc(doc(db, 'config', 'soundboard'), { explicitEnabled: true }, { merge: true });
1020
+ showAdminToast("Explicit Sounds: ENABLED", "green");
1952
1021
  lastEnablePressTime = 0;
1953
1022
  } catch (err) {
1954
- console.error("[Admin Keybinds] Failed to disable:", err);
1023
+ console.error("[Admin Keybinds] Failed to enable:", err);
1955
1024
  showAdminToast("Error: Check Console", "red");
1956
1025
  }
1957
1026
  } else {
1958
- // Logic for enabling (Double Press Safety)
1959
- const now = Date.now();
1960
- if (now - lastEnablePressTime < 1500) {
1961
- console.log("[Admin Keybinds] Enabling explicit sounds...");
1962
- try {
1963
- await setDoc(doc(db, 'config', 'soundboard'), { explicitEnabled: true }, { merge: true });
1964
- showAdminToast("Explicit Sounds: ENABLED", "green");
1965
- lastEnablePressTime = 0;
1966
- } catch (err) {
1967
- console.error("[Admin Keybinds] Failed to enable:", err);
1968
- showAdminToast("Error: Check Console", "red");
1969
- }
1970
- } else {
1971
- console.log("[Admin Keybinds] Waiting for confirm...");
1972
- showAdminToast("Press Ctrl+Shift+E again to ENABLE", "blue");
1973
- lastEnablePressTime = now;
1027
+ console.log("[Admin Keybinds] Waiting for confirm...");
1028
+ showAdminToast("Press Ctrl+Shift+E again to ENABLE", "blue");
1029
+ lastEnablePressTime = now;
1030
+ }
1031
+ }
1032
+ };
1033
+
1034
+ window.triggerThirdPartyKeybind = async () => {
1035
+ console.log("[Admin Keybinds] Triggered Third Party toggle.");
1036
+ if (thirdPartyEnabled) {
1037
+ // Disable (Immediate)
1038
+ try {
1039
+ await setDoc(doc(db, 'config', 'soundboard'), { thirdPartyEnabled: false }, { merge: true });
1040
+ showAdminToast("Third Party Sounds: HIDDEN", "red");
1041
+ lastThirdPartyEnablePressTime = 0;
1042
+ } catch (err) {
1043
+ console.error("[Admin Keybinds] Failed to disable:", err);
1044
+ showAdminToast("Error: Check Console", "red");
1045
+ }
1046
+ } else {
1047
+ // Enable (Double Press Safety)
1048
+ const now = Date.now();
1049
+ if (now - lastThirdPartyEnablePressTime < 1500) {
1050
+ try {
1051
+ await setDoc(doc(db, 'config', 'soundboard'), { thirdPartyEnabled: true }, { merge: true });
1052
+ showAdminToast("Third Party Sounds: SHOWN", "green");
1053
+ lastThirdPartyEnablePressTime = 0;
1054
+ } catch (err) {
1055
+ console.error("[Admin Keybinds] Failed to enable:", err);
1056
+ showAdminToast("Error: Check Console", "red");
1974
1057
  }
1058
+ } else {
1059
+ showAdminToast("Press Ctrl+Shift+F again to SHOW", "blue");
1060
+ lastThirdPartyEnablePressTime = now;
1975
1061
  }
1976
1062
  }
1977
- });
1063
+ };
1064
+
1065
+ // Key Listener (Parent Window)
1066
+ window.addEventListener('keydown', async (e) => {
1067
+ // Trigger is Ctrl + Shift + E
1068
+ if (e.ctrlKey && e.shiftKey && (e.key.toLowerCase() === 'e' || e.code === 'KeyE')) {
1069
+ e.preventDefault();
1070
+ window.triggerAdminKeybind();
1071
+ }
1072
+ // Trigger is Ctrl + Shift + F
1073
+ if (e.ctrlKey && e.shiftKey && (e.key.toLowerCase() === 'f' || e.code === 'KeyF')) {
1074
+ e.preventDefault();
1075
+ e.stopImmediatePropagation();
1076
+ e.stopPropagation();
1077
+ window.triggerThirdPartyKeybind();
1078
+ }
1079
+ }, { capture: true });
1978
1080
  }
1979
1081
 
1980
1082
  // --- ANALYTICS LOGIC (Integrated) ---
@@ -2061,10 +1163,11 @@
2061
1163
 
2062
1164
  let currentPath = activePageUrl.replace(BASE_URL, '');
2063
1165
 
2064
- Object.values(PAGE_DATA).forEach(page => {
1166
+ Object.entries(PAGE_DATA).forEach(([key, page]) => {
2065
1167
  const isAuth = page.url === currentPath;
2066
1168
  const tab = document.createElement('a');
2067
1169
  tab.className = `nav-tab ${isAuth ? 'active' : ''}`;
1170
+ if (key === 'settings') tab.id = 'nav-settings-tab';
2068
1171
  tab.innerHTML = `<i class="${page.icon}"></i> ${page.name}`;
2069
1172
  tab.onclick = () => loadPage(page.url);
2070
1173
  tabsContainerEl.appendChild(tab);
@@ -2114,7 +1217,26 @@
2114
1217
  }
2115
1218
 
2116
1219
  // Initial check
2117
- setTimeout(updateScrollGilders, 50);
1220
+ setTimeout(() => {
1221
+ updateScrollGilders();
1222
+
1223
+ // Ensure Active Tab Visibility (Scroll if under gradient)
1224
+ const activeTab = tabsContainerEl.querySelector('.nav-tab.active');
1225
+ if (activeTab) {
1226
+ const containerRect = tabsContainerEl.getBoundingClientRect();
1227
+ const tabRect = activeTab.getBoundingClientRect();
1228
+ const gradientWidth = 70; // Gradient is ~60px + padding
1229
+
1230
+ // Check Left Edge
1231
+ if (tabRect.left < containerRect.left + gradientWidth) {
1232
+ tabsContainerEl.scrollBy({ left: tabRect.left - containerRect.left - gradientWidth - 20, behavior: 'smooth' });
1233
+ }
1234
+ // Check Right Edge
1235
+ else if (tabRect.right > containerRect.right - gradientWidth) {
1236
+ tabsContainerEl.scrollBy({ left: tabRect.right - containerRect.right + gradientWidth + 20, behavior: 'smooth' });
1237
+ }
1238
+ }
1239
+ }, 50);
2118
1240
  }
2119
1241
 
2120
1242
  // Auth Menu
@@ -2160,6 +1282,8 @@
2160
1282
  pfpType: data.pfpType,
2161
1283
  customPfp: data.customPfp,
2162
1284
  pfpLetterBg: data.pfpLetterBg,
1285
+ letterAvatarText: data.letterAvatarText,
1286
+ letterAvatarTextColor: data.letterAvatarTextColor, // New Field
2163
1287
  navbarTheme: data.navbarTheme // Sync theme if updated remotely
2164
1288
  };
2165
1289
 
@@ -2225,12 +1349,16 @@
2225
1349
  let pfpHtml = '';
2226
1350
 
2227
1351
  if (pfpType === 'custom' && userData.customPfp) {
2228
- pfpHtml = `<img src="${userData.customPfp}" class="w-full h-full object-cover rounded-full" alt="Profile">`;
1352
+ pfpHtml = `<img src="${userData.customPfp}" class="w-full h-full object-cover rounded-[14px]" alt="Profile">`;
2229
1353
  } else {
2230
1354
  // Letter Avatar
2231
- const letter = (userData.username || 'U').charAt(0).toUpperCase();
1355
+ const initial = (userData.username || 'U').charAt(0).toUpperCase();
1356
+ const letter = userData.letterAvatarText || initial; // Use custom text or fallback
2232
1357
  const bgColor = userData.pfpLetterBg || '#3B82F6'; // Default blue
2233
- pfpHtml = `<div class="w-full h-full rounded-full flex items-center justify-center text-white font-bold text-lg" style="background-color: ${bgColor};">${letter}</div>`;
1358
+ // Adjust font size if length > 1
1359
+ const fontSizeClass = letter.length > 2 ? 'text-xs' : (letter.length > 1 ? 'text-sm' : 'text-lg');
1360
+
1361
+ pfpHtml = `<div class="w-full h-full rounded-[14px] flex items-center justify-center text-white font-bold ${fontSizeClass}" style="background-color: ${bgColor};">${letter}</div>`;
2234
1362
  }
2235
1363
 
2236
1364
  authBtn.innerHTML = pfpHtml;
@@ -2400,6 +1528,36 @@
2400
1528
  const settingsOverlay = document.getElementById('settings-overlay');
2401
1529
  if (settingsOverlay) settingsOverlay.classList.add('hidden');
2402
1530
 
1531
+ // --- UNIVERSAL LOADER LOGIC ---
1532
+ // Normalize page name to find title
1533
+ let cleanPageName = pageName;
1534
+ if (cleanPageName.startsWith('./')) cleanPageName = cleanPageName.substring(2);
1535
+ if (cleanPageName.startsWith(BASE_URL)) cleanPageName = cleanPageName.substring(BASE_URL.length);
1536
+ // remove query params for lookup
1537
+ let lookupName = cleanPageName.split('?')[0].replace('.html', '');
1538
+
1539
+ const loader = document.getElementById('universal-loader');
1540
+ const loaderTitle = document.getElementById('loader-title');
1541
+ const loaderBar = document.getElementById('loader-bar');
1542
+
1543
+ if (loader && loaderTitle && loaderBar) {
1544
+ // Determine Title
1545
+ let pageTitle = "Loading...";
1546
+ const pageEntry = Object.values(PAGE_DATA).find(p => p.url === cleanPageName || p.url.includes(lookupName));
1547
+ if (pageEntry) pageTitle = pageEntry.name;
1548
+
1549
+ loaderTitle.textContent = pageTitle;
1550
+
1551
+ // Show Loader
1552
+ loader.classList.remove('hidden');
1553
+ // Trigger reflow/anim
1554
+ requestAnimationFrame(() => {
1555
+ loader.classList.remove('opacity-0');
1556
+ loader.classList.add('active');
1557
+ loaderBar.style.width = "70%"; // Start animation
1558
+ });
1559
+ }
1560
+
2403
1561
  // Normalize path
2404
1562
  if (pageName.startsWith('./')) pageName = pageName.substring(2);
2405
1563
  if (pageName.startsWith('../')) pageName = pageName.substring(3);
@@ -2418,67 +1576,7 @@
2418
1576
  localStorage.setItem(LAST_PAGE_KEY, pageName);
2419
1577
  renderNavbar(pageName);
2420
1578
 
2421
- // --- SPECIAL HANDLING FOR GAMES.HTML ---
2422
- if (pageName === 'games.html') {
2423
- const template = document.getElementById('games-page-template');
2424
- if (template) {
2425
- // Create new iframe
2426
- const oldFrame = document.getElementById('app-frame');
2427
- const newFrame = document.createElement('iframe');
2428
- newFrame.id = 'app-frame';
2429
-
2430
- if (oldFrame) {
2431
- newFrame.style.cssText = oldFrame.style.cssText;
2432
- if (oldFrame.parentNode) oldFrame.parentNode.replaceChild(newFrame, oldFrame);
2433
- } else {
2434
- newFrame.style.width = '100%';
2435
- newFrame.style.height = '100%';
2436
- newFrame.style.border = 'none';
2437
- newFrame.style.display = 'block';
2438
- newFrame.style.flexGrow = '1';
2439
- document.body.appendChild(newFrame);
2440
- }
2441
- appFrame = newFrame;
2442
-
2443
- const doc = newFrame.contentWindow.document;
2444
- doc.open();
2445
-
2446
- // We still need the base tag for certain resources, but imports inside template use absolute
2447
- const baseTag = '<base href="' + BASE_URL + '">';
2448
-
2449
- // Client bridge script
2450
- const patchScript = `
2451
- <script>
2452
- window._LOCAL_MODE = true;
2453
- window._CURRENT_PAGE_NAME = "${pageName}";
2454
-
2455
- window.currentUser = {
2456
- uid: '${currentUser ? currentUser.uid : "client-user"}',
2457
- displayName: '${currentUser ? currentUser.username : "Client User"}',
2458
- email: '${currentUser ? currentUser.email : "local@client"}',
2459
- photoURL: null,
2460
- providerData: []
2461
- };
2462
-
2463
- window.openSettings = function() { window.parent.openSettings(); };
2464
-
2465
- // Forward keydown to parent for panic key
2466
- window.addEventListener('keydown', function(e) {
2467
- if(window.parent.handlePanic) window.parent.handlePanic(e);
2468
- });
2469
- <\/script>
2470
- `;
2471
-
2472
- // Inject template content
2473
- let templateContent = template.innerHTML;
2474
- let modifiedHtml = templateContent.replace('<head>', '<head>' + baseTag + patchScript);
2475
- doc.write(modifiedHtml);
2476
- doc.close();
2477
- return;
2478
- }
2479
- }
2480
-
2481
- // Normal fetch flow for other pages
1579
+ // Normal fetch flow for ALL pages
2482
1580
  const url = BASE_URL + pageName;
2483
1581
 
2484
1582
  fetch(url)
@@ -2503,6 +1601,21 @@
2503
1601
  }
2504
1602
 
2505
1603
  appFrame = newFrame;
1604
+
1605
+ // Loader completion logic
1606
+ newFrame.onload = () => {
1607
+ if (loaderBar) loaderBar.style.width = "100%";
1608
+ setTimeout(() => {
1609
+ if (loader) {
1610
+ loader.classList.add('opacity-0');
1611
+ loader.classList.remove('active');
1612
+ setTimeout(() => {
1613
+ loader.classList.add('hidden');
1614
+ if(loaderBar) loaderBar.style.width = "0%";
1615
+ }, 200);
1616
+ }
1617
+ }, 200);
1618
+ };
2506
1619
 
2507
1620
  const doc = newFrame.contentWindow.document;
2508
1621
  doc.open();
@@ -2527,9 +1640,48 @@
2527
1640
  window.parent.openSettings();
2528
1641
  };
2529
1642
 
2530
- // Forward keydown to parent for panic key
1643
+ function notifyParentTyping() {
1644
+ if (window.parent && window.parent.playTypeSound) {
1645
+ window.parent.playTypeSound();
1646
+ }
1647
+ }
1648
+
2531
1649
  window.addEventListener('keydown', function(e) {
1650
+ // Forward Panic
2532
1651
  if(window.parent.handlePanic) window.parent.handlePanic(e);
1652
+
1653
+ // Forward Admin Keybind (Ctrl + Shift + E) - Explicit Check
1654
+ if (e.ctrlKey && e.shiftKey && (e.key.toLowerCase() === 'e' || e.code === 'KeyE')) {
1655
+ e.preventDefault();
1656
+ if (window.parent.triggerAdminKeybind) window.parent.triggerAdminKeybind();
1657
+ }
1658
+
1659
+ // Forward Admin Keybind (Ctrl + Shift + F) - Third Party Check
1660
+ if (e.ctrlKey && e.shiftKey && (e.key.toLowerCase() === 'f' || e.code === 'KeyF')) {
1661
+ e.preventDefault();
1662
+ e.stopImmediatePropagation();
1663
+ e.stopPropagation();
1664
+ if (window.parent.triggerThirdPartyKeybind) window.parent.triggerThirdPartyKeybind();
1665
+ }
1666
+
1667
+ // Typing Sound (Standard Inputs)
1668
+ const target = e.target;
1669
+ const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
1670
+ const isContentEditable = target.isContentEditable;
1671
+
1672
+ // Ignore non-character keys to avoid spamming sound on navigation
1673
+ const validKey = e.key.length === 1 || e.key === 'Backspace' || e.key === 'Enter';
1674
+
1675
+ if ((isInput || isContentEditable) && validKey && !e.repeat) {
1676
+ notifyParentTyping();
1677
+ }
1678
+ }, { capture: true });
1679
+
1680
+ // Fallback 'input' listener for weird edge cases
1681
+ window.addEventListener('input', function(e) {
1682
+ // Simple fallback if keydown missed it (e.g. virtual keyboard)
1683
+ // We rely on parent debouncing
1684
+ notifyParentTyping();
2533
1685
  });
2534
1686
 
2535
1687
  document.addEventListener('click', e => {
@@ -2557,6 +1709,8 @@
2557
1709
  .catch(err => {
2558
1710
  console.error(err);
2559
1711
  alert("Failed to load page: " + pageName);
1712
+ // Hide loader on error
1713
+ if(loader) loader.classList.add('hidden');
2560
1714
  });
2561
1715
  };
2562
1716
 
@@ -2587,6 +1741,9 @@
2587
1741
  if (sec.id === `tab-${tabId}`) sec.classList.remove('hidden');
2588
1742
  else sec.classList.add('hidden');
2589
1743
  });
1744
+
1745
+ // Save State
1746
+ localStorage.setItem('last_settings_tab', tabId);
2590
1747
  });
2591
1748
  });
2592
1749
 
@@ -2656,13 +1813,30 @@
2656
1813
 
2657
1814
  // --- 2. Personalization ---
2658
1815
  // Letter Avatar Setup
2659
- const letterColors = ['EF4444', 'F97316', 'F59E0B', '84CC16', '10B981', '06B6D4', '3B82F6', '6366F1', '8B5CF6', 'EC4899', '6B7280'];
1816
+ // Removed last color (Grey) to make 10 colors (5x2 grid)
1817
+ const letterColors = [
1818
+ 'EF4444', // Red
1819
+ 'F97316', 'FDBA74', // Orange
1820
+ 'EAB308', 'FDE047', // Yellow
1821
+ '22C55E', '86EFAC', // Green
1822
+ '06B6D4', '67E8F9', // Cyan
1823
+ '3B82F6', '93C5FD', // Blue
1824
+ '6366F1', 'A5B4FC', // Indigo
1825
+ 'A855F7', 'D8B4FE', // Purple
1826
+ 'EC4899', 'F9A8D4', // Pink
1827
+ '6B7280', '000000' // Grey & Black
1828
+ ];
2660
1829
  const pfpModeBtns = document.querySelectorAll('.pfp-mode-btn');
2661
1830
  const pfpLetterOptions = document.getElementById('pfp-letter-options');
2662
1831
  const pfpUploadOptions = document.getElementById('pfp-upload-options');
2663
1832
  const pfpPreview = document.getElementById('pfp-preview');
2664
1833
  const saveLetterPfpBtn = document.getElementById('save-letter-pfp-btn');
2665
1834
 
1835
+ // New Inputs
1836
+ const pfpCustomText = document.getElementById('pfp-custom-text');
1837
+ const pfpCustomColor = document.getElementById('pfp-custom-color');
1838
+ const triggerUploadBtn = document.getElementById('trigger-upload-btn');
1839
+
2666
1840
  let pfpState = { mode: 'letter', letterColor: '#3B82F6' };
2667
1841
 
2668
1842
  pfpModeBtns.forEach(btn => {
@@ -2680,17 +1854,24 @@
2680
1854
  } else {
2681
1855
  pfpLetterOptions.classList.add('hidden');
2682
1856
  pfpUploadOptions.classList.remove('hidden');
1857
+ updatePfpPreview(); // Ensure preview switches if needed
2683
1858
  }
2684
1859
  });
2685
1860
  });
2686
1861
 
2687
1862
  // Render Color Grid
2688
- const gridContainer = pfpLetterOptions.querySelector('.grid');
2689
- gridContainer.innerHTML = ''; // Clear prev to avoid dups on re-run
1863
+ const gridContainer = document.getElementById('pfp-color-grid');
1864
+ gridContainer.innerHTML = '';
1865
+
2690
1866
  letterColors.forEach(color => {
2691
1867
  const div = document.createElement('div');
2692
- div.className = 'w-8 h-8 rounded-full cursor-pointer hover:scale-110 transition border-2 border-transparent';
1868
+ div.className = 'w-10 h-10 rounded-lg cursor-pointer hover:scale-110 transition border-2 border-transparent';
2693
1869
  div.style.backgroundColor = '#' + color;
1870
+
1871
+ if (color === '000000') {
1872
+ div.style.borderColor = '#ffffff';
1873
+ }
1874
+
2694
1875
  div.onclick = () => {
2695
1876
  pfpState.letterColor = '#' + color;
2696
1877
  updatePfpPreview();
@@ -2698,25 +1879,69 @@
2698
1879
  gridContainer.appendChild(div);
2699
1880
  });
2700
1881
 
1882
+ // Listeners for new inputs
1883
+ pfpCustomText.addEventListener('input', () => updatePfpPreview());
1884
+
1885
+ // Removed old pfpCustomColor logic
1886
+
1887
+ if (triggerUploadBtn) {
1888
+ triggerUploadBtn.addEventListener('click', () => {
1889
+ document.getElementById('pfp-upload-input').click();
1890
+ });
1891
+ }
1892
+
2701
1893
  function updatePfpPreview() {
2702
1894
  if (pfpState.mode === 'letter') {
2703
- const initial = currentUser ? (currentUser.username || "U").charAt(0).toUpperCase() : "U";
1895
+ const usernameChar = currentUser ? (currentUser.username || "U").charAt(0).toUpperCase() : "U";
1896
+ // Prefer input value, fallback to saved text, fallback to username char
1897
+ let displayText = pfpCustomText.value.trim().toUpperCase();
1898
+
1899
+ if (!displayText) displayText = usernameChar;
1900
+
2704
1901
  pfpPreview.style.backgroundImage = 'none';
2705
1902
  pfpPreview.style.backgroundColor = pfpState.letterColor;
2706
- pfpPreview.innerText = initial;
1903
+ pfpPreview.style.color = '#FFFFFF';
1904
+ pfpPreview.innerText = displayText;
1905
+ // Dynamic font size
1906
+ if (displayText.length > 2) pfpPreview.style.fontSize = '1.5rem';
1907
+ else if (displayText.length > 1) pfpPreview.style.fontSize = '2rem';
1908
+ else pfpPreview.style.fontSize = '3rem';
1909
+
1910
+ } else if (pfpState.mode === 'upload') {
1911
+ // ... (Existing logic)
1912
+ if (currentUser && currentUser.customPfp && currentUser.pfpType === 'custom') {
1913
+ pfpPreview.style.backgroundImage = `url('${currentUser.customPfp}')`;
1914
+ pfpPreview.style.backgroundColor = "transparent";
1915
+ pfpPreview.style.backgroundSize = "cover";
1916
+ pfpPreview.innerText = "";
1917
+ } else {
1918
+ const initial = currentUser ? (currentUser.username || "U").charAt(0).toUpperCase() : "U";
1919
+ pfpPreview.style.backgroundImage = 'none';
1920
+ pfpPreview.style.backgroundColor = '#333';
1921
+ pfpPreview.style.color = '#FFFFFF';
1922
+ pfpPreview.innerText = initial;
1923
+ pfpPreview.style.fontSize = '3rem';
1924
+ }
2707
1925
  }
2708
1926
  }
2709
1927
 
2710
1928
  saveLetterPfpBtn.addEventListener('click', async () => {
2711
1929
  if (!currentUser) return;
1930
+
1931
+ const textVal = pfpCustomText.value.trim().toUpperCase();
1932
+ if (/[<>/\\\\]/.test(textVal)) {
1933
+ alert("Invalid characters in avatar text.");
1934
+ return;
1935
+ }
1936
+
2712
1937
  saveLetterPfpBtn.innerText = "Saving...";
2713
1938
  try {
2714
1939
  await updateDoc(doc(db, "users", currentUser.uid), {
2715
1940
  pfpType: 'letter',
2716
1941
  pfpLetterBg: pfpState.letterColor,
2717
- letterAvatarColor: pfpState.letterColor // Legacy support
1942
+ letterAvatarColor: pfpState.letterColor,
1943
+ letterAvatarText: textVal
2718
1944
  });
2719
- // Local update handled by snapshot
2720
1945
  saveLetterPfpBtn.innerText = "Saved!";
2721
1946
  setTimeout(() => saveLetterPfpBtn.innerText = "Set Letter Avatar", 2000);
2722
1947
  } catch(e) {
@@ -2991,15 +2216,23 @@
2991
2216
 
2992
2217
  // --- Logo Path Modification ---
2993
2218
  let logoSrc;
2994
- if (lightThemeNames.includes(theme.name)) {
2219
+ const isLightTheme = lightThemeNames.includes(theme.name);
2220
+
2221
+ if (isLightTheme) {
2995
2222
  logoSrc = 'https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo-dark.png';
2223
+ root.style.setProperty('--menu-username-text', '#000000');
2224
+ root.style.setProperty('--menu-email-text', '#444444');
2996
2225
  } else if (theme.name === 'Christmas') {
2997
2226
  logoSrc = 'https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo-christmas.png';
2227
+ root.style.setProperty('--menu-username-text', 'white');
2228
+ root.style.setProperty('--menu-email-text', '#9ca3af');
2998
2229
  } else {
2999
2230
  logoSrc = 'https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png';
2231
+ root.style.setProperty('--menu-username-text', 'white');
2232
+ root.style.setProperty('--menu-email-text', '#9ca3af');
3000
2233
  }
3001
2234
 
3002
- // Apply CSS Variables
2235
+ // Apply CSS Variables from theme object (overrides defaults if present)
3003
2236
  for (const [key, value] of Object.entries(theme)) {
3004
2237
  if (key !== 'logo-src' && key !== 'name') {
3005
2238
  root.style.setProperty(`--${key}`, value);
@@ -3009,29 +2242,44 @@
3009
2242
  // Apply Logo & Tinting
3010
2243
  const logoImg = document.getElementById('navbar-logo');
3011
2244
  if (logoImg) {
3012
- // Update Source (Use absolute path comparison or specific logic)
3013
- // We just set it. Browser handles cache.
3014
- // Note: The original navigation.js checked src endsWith to avoid reload flicker, but simple assignment is okay.
3015
2245
  if (!logoImg.src.includes(logoSrc)) {
3016
2246
  logoImg.src = logoSrc;
3017
2247
  }
3018
2248
 
3019
- // Apply Tinting
3020
2249
  const noFilterThemes = ['Dark', 'Light', 'Christmas'];
3021
- if (noFilterThemes.includes(theme.name)) {
2250
+ const isNoFilter = noFilterThemes.includes(theme.name);
2251
+
2252
+ // Check if mode is changing (Tinted <-> Standard)
2253
+ // We check existing transform state
2254
+ const wasNoFilter = logoImg.style.transform === '' || logoImg.style.transform === 'none';
2255
+ const modeChanged = isNoFilter !== wasNoFilter;
2256
+
2257
+ if (modeChanged) {
2258
+ logoImg.style.transition = 'none';
2259
+ }
2260
+
2261
+ if (isNoFilter) {
3022
2262
  logoImg.style.filter = '';
3023
2263
  logoImg.style.transform = '';
3024
2264
  } else {
3025
2265
  const tintColor = theme['tab-active-text'] || '#ffffff';
3026
- // The original navigation.js used this trick for SVG coloring via filter
3027
2266
  logoImg.style.filter = `drop-shadow(100px 0 0 ${tintColor})`;
3028
2267
  logoImg.style.transform = 'translateX(-100px)';
3029
2268
  }
2269
+
2270
+ if (modeChanged) {
2271
+ // Force Reflow
2272
+ void logoImg.offsetWidth;
2273
+ logoImg.style.transition = ''; // Restore
2274
+ }
3030
2275
  }
3031
2276
 
3032
2277
  // Save to local storage for persistence on reload
3033
2278
  localStorage.setItem('user-navbar-theme', JSON.stringify(theme));
3034
2279
 
2280
+ // Apply Glide Button Color Sync
2281
+ root.style.setProperty('--glide-btn-color', 'var(--tab-text)');
2282
+
3035
2283
  // Also update user doc if logged in
3036
2284
  if (currentUser) {
3037
2285
  updateDoc(doc(db, "users", currentUser.uid), { navbarTheme: theme }).catch(console.error);
@@ -3258,16 +2506,302 @@
3258
2506
  function loadSettingsData() {
3259
2507
  if (currentUser) {
3260
2508
  settingsUsernameInput.value = currentUser.username || "";
2509
+
2510
+ // PFP State Init
3261
2511
  pfpState.letterColor = currentUser.pfpLetterBg || '#3B82F6';
2512
+ pfpState.mode = currentUser.pfpType === 'custom' ? 'upload' : 'letter';
2513
+
2514
+ // Populate Inputs
2515
+ pfpCustomText.value = currentUser.letterAvatarText || "";
2516
+
2517
+ // Update UI Buttons to reflect current mode
2518
+ pfpModeBtns.forEach(btn => {
2519
+ if (btn.dataset.mode === pfpState.mode) {
2520
+ btn.classList.add('active', 'bg-[#222]');
2521
+ if (pfpState.mode === 'letter') {
2522
+ pfpLetterOptions.classList.remove('hidden');
2523
+ pfpUploadOptions.classList.add('hidden');
2524
+ } else {
2525
+ pfpLetterOptions.classList.add('hidden');
2526
+ pfpUploadOptions.classList.remove('hidden');
2527
+ }
2528
+ } else {
2529
+ btn.classList.remove('active', 'bg-[#222]');
2530
+ }
2531
+ });
2532
+
3262
2533
  updatePfpPreview();
3263
2534
  }
2535
+
2536
+ // Restore Tab
2537
+ const lastTab = localStorage.getItem('last_settings_tab');
2538
+ if (lastTab) {
2539
+ const tabBtn = document.querySelector(`.settings-tab[data-tab="${lastTab}"]`);
2540
+ if(tabBtn) tabBtn.click();
2541
+ }
2542
+
3264
2543
  loadThemes();
3265
- loadPanicKeys(); // Refresh inputs when settings open
2544
+ loadPanicKeys();
2545
+
2546
+ // --- App Preference Toggles Init ---
2547
+ const animBtn = document.getElementById('toggle-animations-btn');
2548
+ const soundBtn = document.getElementById('toggle-sounds-btn');
2549
+
2550
+ const updateToggleUI = () => {
2551
+ const isEnabled = window.areAnimationsEnabled;
2552
+ animBtn.classList.toggle('off', !isEnabled);
2553
+ animBtn.innerHTML = isEnabled ? '<i class="fa-solid fa-wand-magic-sparkles"></i>' : '<i class="fa-solid fa-wand-magic"></i>';
2554
+
2555
+ // Apply to body
2556
+ if (isEnabled) document.body.classList.remove('no-animations');
2557
+ else document.body.classList.add('no-animations');
2558
+
2559
+ soundBtn.classList.toggle('off', window.isMuted);
2560
+ soundBtn.innerHTML = window.isMuted ? '<i class="fa-solid fa-volume-xmark"></i>' : '<i class="fa-solid fa-volume-high"></i>';
2561
+ };
2562
+
2563
+ if (animBtn) {
2564
+ animBtn.onclick = () => {
2565
+ window.areAnimationsEnabled = !window.areAnimationsEnabled;
2566
+ localStorage.setItem('app_animations_enabled', window.areAnimationsEnabled);
2567
+ updateToggleUI();
2568
+ };
2569
+ }
2570
+
2571
+ if (soundBtn) {
2572
+ soundBtn.onclick = () => {
2573
+ window.isMuted = !window.isMuted;
2574
+ localStorage.setItem('app_is_muted', window.isMuted);
2575
+ updateToggleUI();
2576
+ };
2577
+ }
2578
+
2579
+ updateToggleUI();
3266
2580
  }
3267
2581
 
3268
2582
  checkAutoLogin();
3269
2583
  loadPanicKeys(); // Initial load for global listener
3270
2584
 
3271
2585
  </script>
2586
+
2587
+ <div id="notification-container"></div>
2588
+ <script>
2589
+ // --- Sound & Notification Logic ---
2590
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
2591
+
2592
+ // Sync with global state
2593
+ Object.defineProperty(window, 'isMuted', {
2594
+ get: () => localStorage.getItem('app_is_muted') === 'true',
2595
+ set: (v) => localStorage.setItem('app_is_muted', v)
2596
+ });
2597
+
2598
+ Object.defineProperty(window, 'areAnimationsEnabled', {
2599
+ get: () => localStorage.getItem('app_animations_enabled') !== 'false',
2600
+ set: (v) => localStorage.setItem('app_animations_enabled', v)
2601
+ });
2602
+
2603
+ // Initial Class Set
2604
+ if (!window.areAnimationsEnabled) document.body.classList.add('no-animations');
2605
+
2606
+ // EXPOSE PlayTypeSound GLOBALLY
2607
+ // Debounce to prevent rapid-fire stuttering if called too fast
2608
+ let typeSoundTimeout = null;
2609
+ window.playTypeSound = function() {
2610
+ if (window.isMuted) return;
2611
+ if (audioCtx.state === 'suspended') audioCtx.resume();
2612
+
2613
+ // Small debounce
2614
+ if (typeSoundTimeout) return;
2615
+ typeSoundTimeout = setTimeout(() => { typeSoundTimeout = null; }, 50);
2616
+
2617
+ const bufferSize = audioCtx.sampleRate * 0.02;
2618
+ const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
2619
+ const data = buffer.getChannelData(0);
2620
+ for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
2621
+
2622
+ const noise = audioCtx.createBufferSource();
2623
+ noise.buffer = buffer;
2624
+ const noiseFilter = audioCtx.createBiquadFilter();
2625
+ noiseFilter.type = 'bandpass';
2626
+ noiseFilter.frequency.value = 3000 + (Math.random() * 1000);
2627
+ noiseFilter.Q.value = 1;
2628
+ const noiseGain = audioCtx.createGain();
2629
+ noiseGain.gain.setValueAtTime(0.04, audioCtx.currentTime);
2630
+ noiseGain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.01);
2631
+ noise.connect(noiseFilter);
2632
+ noiseFilter.connect(noiseGain);
2633
+ noiseGain.connect(audioCtx.destination);
2634
+
2635
+ const osc = audioCtx.createOscillator();
2636
+ const gain = audioCtx.createGain();
2637
+ osc.type = 'sine';
2638
+ osc.frequency.setValueAtTime(150 + (Math.random() * 50), audioCtx.currentTime);
2639
+ gain.gain.setValueAtTime(0.03, audioCtx.currentTime);
2640
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.02);
2641
+ osc.connect(gain);
2642
+ gain.connect(audioCtx.destination);
2643
+
2644
+ noise.start();
2645
+ osc.start();
2646
+ noise.stop(audioCtx.currentTime + 0.02);
2647
+ osc.stop(audioCtx.currentTime + 0.02);
2648
+ };
2649
+
2650
+ function playClickSound() {
2651
+ if (window.isMuted) return;
2652
+ if (audioCtx.state === 'suspended') audioCtx.resume();
2653
+
2654
+ const osc = audioCtx.createOscillator();
2655
+ const gainNode = audioCtx.createGain();
2656
+ osc.connect(gainNode);
2657
+ gainNode.connect(audioCtx.destination);
2658
+ osc.type = 'sine';
2659
+ osc.frequency.setValueAtTime(300, audioCtx.currentTime);
2660
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
2661
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.015);
2662
+ osc.start();
2663
+ osc.stop(audioCtx.currentTime + 0.015);
2664
+ }
2665
+
2666
+ // --- App Preferences State ---
2667
+ window.isMuted = localStorage.getItem('app_is_muted') === 'true';
2668
+ window.areAnimationsEnabled = localStorage.getItem('app_animations_enabled') !== 'false'; // Default true
2669
+
2670
+ window.playMechClick = function(isReverse = false) {
2671
+ if (window.isMuted || !window.areAnimationsEnabled) return;
2672
+ if (audioCtx.state === 'suspended') audioCtx.resume();
2673
+ const now = audioCtx.currentTime;
2674
+
2675
+ const noiseGainVal = isReverse ? 0.015 : 0.02;
2676
+ const pitchFreq = isReverse ? 100 : 150;
2677
+
2678
+ // High-frequency noise burst
2679
+ const bufferSize = audioCtx.sampleRate * 0.01;
2680
+ const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
2681
+ const data = buffer.getChannelData(0);
2682
+ for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
2683
+
2684
+ const noise = audioCtx.createBufferSource();
2685
+ noise.buffer = buffer;
2686
+ const filter = audioCtx.createBiquadFilter();
2687
+ filter.type = 'highpass';
2688
+ filter.frequency.value = isReverse ? 3000 : 5000;
2689
+ const gain = audioCtx.createGain();
2690
+ gain.gain.setValueAtTime(noiseGainVal, now);
2691
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.01);
2692
+
2693
+ noise.connect(filter);
2694
+ filter.connect(gain);
2695
+ gain.connect(audioCtx.destination);
2696
+ noise.start();
2697
+ noise.stop(now + 0.01);
2698
+
2699
+ // Low-frequency "thud"
2700
+ const osc = audioCtx.createOscillator();
2701
+ const oscGain = audioCtx.createGain();
2702
+ osc.type = 'triangle';
2703
+ osc.frequency.setValueAtTime(pitchFreq, now);
2704
+ oscGain.gain.setValueAtTime(0.01, now);
2705
+ oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
2706
+ osc.connect(oscGain);
2707
+ oscGain.connect(audioCtx.destination);
2708
+ osc.start();
2709
+ osc.stop(now + 0.02);
2710
+ };
2711
+
2712
+ // Gear Clicking Sequence
2713
+ let gearHoverStartTime = 0;
2714
+ let currentGearRotation = 0;
2715
+ let currentAnimatingGear = null;
2716
+ const gearClickDelays = [0, 120, 240, 360]; // Adjusted for 0.5s total
2717
+
2718
+ const handleGearMouseOver = (e) => {
2719
+ const target = e.target.closest('#auth-settings-btn, #nav-settings-tab');
2720
+ if (target && currentAnimatingGear !== target) {
2721
+ currentAnimatingGear = target;
2722
+ const icon = target.querySelector('.fa-gear');
2723
+ if (!icon) return;
2724
+
2725
+ gearHoverStartTime = Date.now();
2726
+ gearClickDelays.forEach((delay, index) => {
2727
+ setTimeout(() => {
2728
+ if (target.matches(':hover')) {
2729
+ if (window.areAnimationsEnabled) {
2730
+ currentGearRotation = (index + 1) * 45;
2731
+ icon.style.transform = `rotate(${currentGearRotation}deg)`;
2732
+ }
2733
+ window.playMechClick(false);
2734
+ }
2735
+ }, delay);
2736
+ });
2737
+ }
2738
+ };
2739
+
2740
+ const handleGearMouseOut = (e) => {
2741
+ const target = e.target.closest('#auth-settings-btn, #nav-settings-tab');
2742
+ if (target && !target.contains(e.relatedTarget)) {
2743
+ currentAnimatingGear = null;
2744
+ const icon = target.querySelector('.fa-gear');
2745
+ if (!icon) return;
2746
+
2747
+ const elapsed = Date.now() - gearHoverStartTime;
2748
+ const clickCount = gearClickDelays.filter(d => d <= elapsed).length;
2749
+
2750
+ for (let i = 0; i < clickCount; i++) {
2751
+ setTimeout(() => {
2752
+ if (!target.matches(':hover')) {
2753
+ if (window.areAnimationsEnabled) {
2754
+ currentGearRotation -= 45;
2755
+ icon.style.transform = `rotate(${currentGearRotation}deg)`;
2756
+ }
2757
+ window.playMechClick(true);
2758
+ }
2759
+ }, gearClickDelays[i]);
2760
+ }
2761
+ }
2762
+ };
2763
+
2764
+ document.addEventListener('mouseover', handleGearMouseOver);
2765
+ document.addEventListener('mouseout', handleGearMouseOut);
2766
+
2767
+ const notificationContainer = document.getElementById('notification-container');
2768
+ function showNotification(message, iconClass = 'fa-solid fa-info-circle', type = 'info') {
2769
+ if (!notificationContainer) return;
2770
+ while (notificationContainer.children.length >= 3) {
2771
+ notificationContainer.removeChild(notificationContainer.firstChild);
2772
+ }
2773
+ const toast = document.createElement('div');
2774
+ toast.className = 'notification-toast';
2775
+ toast.innerHTML = `<i class="${iconClass} notification-icon ${type}"></i><span>${message}</span>`;
2776
+ notificationContainer.appendChild(toast);
2777
+ requestAnimationFrame(() => {
2778
+ toast.classList.add('show');
2779
+ playClickSound();
2780
+ });
2781
+ setTimeout(() => {
2782
+ toast.classList.remove('show');
2783
+ setTimeout(() => { if (toast.parentElement) toast.remove(); }, 300);
2784
+ }, 3000);
2785
+ }
2786
+
2787
+ // Event Listeners
2788
+ document.addEventListener('click', (e) => {
2789
+ const target = e.target.closest('button, .nav-tab, .zone-item, .btn-card-action, a, .icon-btn');
2790
+ if (target) playClickSound();
2791
+ });
2792
+
2793
+ document.addEventListener('keydown', (e) => {
2794
+ const target = e.target.closest('input, textarea') || e.target.isContentEditable;
2795
+ if (target && !e.repeat) window.playTypeSound();
2796
+ });
2797
+
2798
+ // Copy/Paste Feedback
2799
+ document.addEventListener('copy', () => {
2800
+ if (window.getSelection().toString().length > 0) showNotification('Copied to clipboard', 'fa-solid fa-copy', 'success');
2801
+ });
2802
+ document.addEventListener('paste', (e) => {
2803
+ if (e.target.closest('input, textarea')) showNotification('Pasted from clipboard', 'fa-solid fa-paste', 'info');
2804
+ });
2805
+ </script>
3272
2806
  </body>
3273
2807
  </html>