4sp-dv-latest 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3314 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>4SP - VERSION 5 CLIENT</title>
7
+ <link rel="icon" type="image/png" href="https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo.png">
8
+
9
+ <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.15/logged-in/">
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
12
+
13
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
14
+ <style>
15
+ body {
16
+ background-color: #040404;
17
+ color: #c0c0c0;
18
+ font-family: 'Geist', sans-serif;
19
+ height: 100vh;
20
+ margin: 0;
21
+ display: flex;
22
+ flex-direction: column;
23
+ font-weight: 300;
24
+ }
25
+
26
+ h1, h2, h3, .font-bold, .font-semibold, strong, b, .tracking-widest {
27
+ font-weight: 400 !important;
28
+ }
29
+
30
+ #app-frame { width: 100%; height: 100%; border: none; display: none; flex-grow: 1; }
31
+ .hidden { display: none !important; }
32
+
33
+ /* --- Navbar Styles --- */
34
+ #navbar-container {
35
+ background: var(--navbar-bg, #000000);
36
+ border-bottom: 1px solid var(--navbar-border, #1f2937);
37
+ height: 64px;
38
+ width: 100%;
39
+ display: none; /* Hidden until unlocked */
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ padding: 0 1rem;
43
+ box-sizing: border-box;
44
+ flex-shrink: 0;
45
+ z-index: 60;
46
+ transition: background-color 0.3s ease, border-color 0.3s ease;
47
+ position: relative;
48
+ }
49
+
50
+ /* --- FIXED OVERLAY LOGIC --- */
51
+ body.nav-overlay-mode #navbar-container {
52
+ position: fixed;
53
+ top: 0;
54
+ left: 0;
55
+ right: 0;
56
+ background: var(--navbar-bg, rgba(0, 0, 0, 0.6));
57
+ backdrop-filter: blur(12px);
58
+ -webkit-backdrop-filter: blur(12px);
59
+ border-bottom: 1px solid var(--navbar-border, rgba(255, 255, 255, 0.08));
60
+ }
61
+
62
+ body.nav-overlay-mode #app-frame {
63
+ height: 100vh;
64
+ margin-top: 0;
65
+ }
66
+
67
+ .navbar-logo { height: 40px; width: auto; transition: filter 0.3s ease; }
68
+
69
+ /* --- GLIDE / SCROLL STYLES ADDED FROM NAVIGATION.JS --- */
70
+ .tab-wrapper {
71
+ flex-grow: 1;
72
+ display: flex;
73
+ align-items: center;
74
+ position: relative;
75
+ min-width: 0;
76
+ margin: 0 1rem;
77
+ justify-content: center;
78
+ overflow: hidden; /* Important for masking */
79
+ }
80
+
81
+ .tab-scroll-container {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 0.5rem;
85
+ overflow-x: auto;
86
+ scrollbar-width: none;
87
+ white-space: nowrap;
88
+ max-width: 100%;
89
+ scroll-behavior: smooth; /* Smooth scrolling for arrows */
90
+ padding-left: 20px; /* Prevent cut-off by fade */
91
+ padding-right: 20px;
92
+ }
93
+ .tab-scroll-container::-webkit-scrollbar { display: none; }
94
+
95
+ /* Glide Buttons */
96
+ .scroll-glide-button {
97
+ position: absolute;
98
+ top: 0;
99
+ height: 100%;
100
+ width: 60px; /* Width of the fade area */
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ color: #ffffff;
105
+ font-size: 1rem;
106
+ cursor: pointer;
107
+ opacity: 1;
108
+ transition: opacity 0.3s, color 0.3s ease;
109
+ z-index: 10;
110
+ pointer-events: auto;
111
+ background: transparent;
112
+ border: none;
113
+ }
114
+
115
+ /* Fading Gradients */
116
+ #glide-left {
117
+ left: 0;
118
+ background: linear-gradient(to right, var(--navbar-bg, #000000) 30%, transparent);
119
+ justify-content: flex-start;
120
+ padding-left: 8px;
121
+ }
122
+ #glide-right {
123
+ right: 0;
124
+ background: linear-gradient(to left, var(--navbar-bg, #000000) 30%, transparent);
125
+ justify-content: flex-end;
126
+ padding-right: 8px;
127
+ }
128
+
129
+ .scroll-glide-button.hidden { opacity: 0 !important; pointer-events: none !important; }
130
+ /* ----------------------------------------------------- */
131
+
132
+ .nav-tab {
133
+ padding: 0.5rem 1rem;
134
+ color: var(--tab-text, #9ca3af);
135
+ font-size: 0.875rem; font-weight: 400;
136
+ border-radius: 12px; text-decoration: none; display: flex; align-items: center; gap: 0.5rem;
137
+ border: 1px solid transparent; transition: all 0.2s; cursor: pointer;
138
+ flex-shrink: 0; /* Prevent tabs from squishing */
139
+ }
140
+ .nav-tab:hover {
141
+ color: var(--tab-hover-text, #ffffff);
142
+ background-color: var(--tab-hover-bg, rgba(79, 70, 229, 0.05));
143
+ border-color: var(--tab-hover-border, transparent);
144
+ }
145
+ .nav-tab.active {
146
+ color: var(--tab-active-text, #4f46e5);
147
+ border-color: var(--tab-active-border, #4f46e5);
148
+ background-color: var(--tab-active-bg, rgba(79, 70, 229, 0.1));
149
+ }
150
+ .nav-tab.active:hover {
151
+ color: var(--tab-active-hover-text, #6366f1);
152
+ border-color: var(--tab-active-hover-border, #6366f1);
153
+ background-color: var(--tab-active-hover-bg, rgba(79, 70, 229, 0.15));
154
+ }
155
+
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, .pin-context-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
+ .pin-context-menu { right: auto; left: 0; width: 12rem; }
173
+ .auth-menu.open, .pin-context-menu.open { display: flex; }
174
+
175
+ .auth-menu-item {
176
+ display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem;
177
+ color: var(--menu-text, #d1d5db);
178
+ border-radius: 0.5rem; text-decoration: none; font-size: 0.875rem;
179
+ transition: background 0.15s; cursor: pointer;
180
+ }
181
+ .auth-menu-item:hover {
182
+ background-color: var(--menu-item-hover-bg, #374151);
183
+ color: var(--menu-item-hover-text, white);
184
+ }
185
+ .auth-header {
186
+ padding: 0.5rem;
187
+ border-bottom: 1px solid var(--menu-divider, #374151);
188
+ margin-bottom: 0.25rem;
189
+ }
190
+ .auth-username { color: var(--menu-username-text, white); font-weight: 400; font-size: 0.9rem; }
191
+ .auth-email { color: var(--menu-email-text, #9ca3af); font-size: 0.75rem; }
192
+
193
+ /* --- Settings Page Styles --- */
194
+ #settings-overlay {
195
+ position: fixed;
196
+ bottom: 0;
197
+ left: 0;
198
+ right: 0;
199
+ top: 64px; /* Below navbar */
200
+ background: #040404;
201
+ z-index: 50;
202
+ display: flex;
203
+ flex-direction: column;
204
+ }
205
+
206
+ #settings-container {
207
+ display: flex;
208
+ flex-grow: 1;
209
+ padding: 20px;
210
+ overflow: hidden; /* Contain scroll */
211
+ }
212
+
213
+ #settings-sidebar {
214
+ width: 250px;
215
+ padding-right: 20px;
216
+ flex-shrink: 0;
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: 10px;
220
+ overflow-y: auto;
221
+ }
222
+
223
+ #settings-main-view {
224
+ flex-grow: 1;
225
+ padding: 20px;
226
+ background-color: #0d0d0d;
227
+ border: 1px solid #1a1a1a;
228
+ border-radius: 0.75rem;
229
+ overflow-y: auto;
230
+ overflow-x: hidden;
231
+ }
232
+
233
+ .btn-toolbar-style {
234
+ background: #000000;
235
+ border: 1px solid #333;
236
+ border-radius: 0.75rem;
237
+ color: #d1d5db;
238
+ padding: 0.5rem 1rem;
239
+ font-weight: 500;
240
+ cursor: pointer;
241
+ transition: all 0.2s;
242
+ display: inline-flex;
243
+ align-items: center;
244
+ gap: 0.5rem;
245
+ }
246
+ .btn-toolbar-style:hover {
247
+ background-color: #000000;
248
+ border-color: #fff;
249
+ color: #ffffff;
250
+ }
251
+
252
+ .btn-toolbar-style.btn-primary-override {
253
+ justify-content: center;
254
+ background-color: rgba(79, 70, 229, 0.1);
255
+ border: 1px solid #4f46e5;
256
+ color: #4f46e5;
257
+ }
258
+ .btn-toolbar-style.btn-primary-override:hover {
259
+ background-color: rgba(79, 70, 229, 0.15);
260
+ border-color: #6366f1;
261
+ color: #6366f1;
262
+ }
263
+
264
+ .settings-tab {
265
+ width: 100%;
266
+ justify-content: flex-start;
267
+ }
268
+ .settings-tab.active {
269
+ background-color: rgba(79, 70, 229, 0.1);
270
+ border: 1px solid #4f46e5;
271
+ color: #4f46e5;
272
+ }
273
+ .settings-tab.active:hover {
274
+ background-color: rgba(79, 70, 229, 0.15);
275
+ border-color: #6366f1;
276
+ color: #6366f1;
277
+ }
278
+
279
+ .settings-box {
280
+ border: 1px solid #333;
281
+ border-radius: 1rem;
282
+ background-color: #000000;
283
+ padding: 1.5rem;
284
+ margin-bottom: 1.5rem;
285
+ }
286
+
287
+ .input-text-style, .input-select-style {
288
+ width: 100%; padding: 0.75rem; border: 1px solid #252525;
289
+ background-color: #111111; border-radius: 0.5rem; color: #c0c0c0; transition: all 0.2s;
290
+ }
291
+ .input-text-style:focus, .input-select-style:focus {
292
+ border-color: #505050; outline: none; box-shadow: 0 0 0 2px rgba(80, 80, 80, 0.5);
293
+ }
294
+
295
+ /* --- Input Key Style (Panic Key) --- */
296
+ .input-key-style {
297
+ width: 4rem; /* Fixed width for a single key */
298
+ padding: 0.75rem;
299
+ border: 1px solid #252525;
300
+ background-color: #111111;
301
+ border-radius: 0.5rem;
302
+ color: #c0c0c0;
303
+ transition: all 0.2s;
304
+ font-size: 1.1rem;
305
+ text-align: center;
306
+ font-weight: 500;
307
+ text-transform: uppercase;
308
+ caret-color: transparent;
309
+ }
310
+ .input-key-style:focus {
311
+ border-color: #505050;
312
+ outline: none;
313
+ box-shadow: 0 0 0 2px rgba(80, 80, 80, 0.5);
314
+ }
315
+ .input-key-style::placeholder {
316
+ font-size: 1rem;
317
+ text-transform: none;
318
+ }
319
+ .general-message-area { min-height: 20px; margin-top: 1rem; font-weight: 400; }
320
+ .success-message { color: #4ade80; }
321
+ .error-message { color: #f87171; }
322
+ .warning-message { color: #fbbf24; }
323
+
324
+ /* --- Theme Picker Styles --- */
325
+ #theme-picker-container {
326
+ display: grid;
327
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
328
+ gap: 1rem;
329
+ width: 100%;
330
+ }
331
+ .theme-button {
332
+ border: 2px solid var(--tab-active-border, #4f46e5);
333
+ background-color: #111;
334
+ border-radius: 0.75rem;
335
+ padding: 0.75rem;
336
+ text-align: center;
337
+ cursor: pointer;
338
+ transition: all 0.2s ease-out;
339
+ opacity: 0.7;
340
+ position: relative;
341
+ }
342
+ .theme-button:hover {
343
+ opacity: 1;
344
+ border-color: var(--tab-active-hover-border, #6366f1);
345
+ transform: translateY(-2px);
346
+ }
347
+ .theme-button.active {
348
+ opacity: 1;
349
+ box-shadow: 0 0 10px rgba(79, 70, 229, 0.3);
350
+ }
351
+ .theme-button-name {
352
+ font-weight: 500;
353
+ color: #e0e0e0;
354
+ }
355
+
356
+ /* Cropper Styles */
357
+ #cropperModal {
358
+ display: none; position: fixed; z-index: 2050; left: 0; top: 0; width: 100%; height: 100%;
359
+ background-color: rgba(0, 0, 0, 0.85); justify-content: center; align-items: center; flex-direction: column;
360
+ }
361
+ #cropperContent {
362
+ background-color: #000000; padding: 0; border-radius: 1rem; border: 1px solid #333;
363
+ display: flex; flex-direction: column; width: 90%; max-width: 600px; overflow: hidden;
364
+ }
365
+ #cropperCanvasContainer {
366
+ position: relative; margin-bottom: 20px; overflow: hidden; max-height: 60vh;
367
+ }
368
+ #cropperCanvas { display: block; max-width: 100%; max-height: 60vh; border: 1px dashed rgba(255, 255, 255, 0.3); }
369
+
370
+ </style>
371
+ </head>
372
+ <body>
373
+ <div id="lock-screen" class="w-full h-full flex flex-col items-center justify-center p-4">
374
+ <div class="max-w-5xl w-full bg-[#040404] flex flex-col md:flex-row border border-[#252525] rounded-3xl overflow-hidden shadow-2xl">
375
+ <div class="flex-1 p-8 flex flex-col justify-center items-center border-b md:border-b-0 md:border-r border-[#252525]">
376
+ <h2 class="text-3xl text-[#c0c0c0] mb-6 text-center w-full font-light tracking-tighter">4SP Version 5 Client (DV)</h2>
377
+ <p class="text-[#505050] text-center mb-8 text-sm">Enter your 12-character access code.</p>
378
+
379
+ <input type="text" id="codeInput" class="w-full max-w-xs bg-[#111] border border-[#252525] text-white rounded-xl p-3 text-center tracking-[0.2em] mb-4 outline-none focus:border-indigo-500 transition" placeholder="XXXX-XXXX-XXXX">
380
+
381
+ <button id="unlockBtn" class="w-full max-w-xs py-2 px-4 text-sm font-medium rounded-xl text-indigo-500 bg-indigo-500/10 border border-indigo-500 hover:bg-indigo-500/20 transition mb-4">
382
+ Access
383
+ </button>
384
+
385
+ <p id="lockMessage" class="text-red-400 text-xs mt-2 min-h-[20px] text-center"></p>
386
+ <p id="loading-text" class="text-gray-500 text-xs mt-2 hidden text-center">Verifying...</p>
387
+ </div>
388
+
389
+ <div class="flex-1 p-8 flex flex-col justify-center items-center bg-[#040404]">
390
+ <h2 class="text-3xl text-[#c0c0c0] mb-6 text-center w-full font-light tracking-tighter">How it works</h2>
391
+ <p class="text-lg text-[#505050] mb-8 text-center max-w-md font-light leading-relaxed">
392
+ This client provides secure, local access to the 4SP suite. Generate a unique code from your web dashboard to log in properly.
393
+ </p>
394
+
395
+ <a href="https://4sp-organization.github.io/connection.html" target="_blank" class="w-full max-w-xs py-2 px-4 text-sm font-medium rounded-xl text-cyan-500 bg-cyan-500/10 border border-cyan-500 hover:bg-cyan-500/20 transition text-center flex items-center justify-center">
396
+ Get Your Code
397
+ </a>
398
+ </div>
399
+ </div>
400
+ </div>
401
+
402
+ <div id="navbar-container">
403
+ <img src="https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png" id="navbar-logo" class="navbar-logo" alt="Logo">
404
+
405
+ <div class="tab-wrapper">
406
+ <button id="glide-left" class="scroll-glide-button hidden"><i class="fa-solid fa-chevron-left"></i></button>
407
+ <div class="tab-scroll-container" id="tabs-container">
408
+ </div>
409
+ <button id="glide-right" class="scroll-glide-button hidden"><i class="fa-solid fa-chevron-right"></i></button>
410
+ </div>
411
+
412
+ <div class="auth-controls-wrapper">
413
+ <div class="relative" id="pin-wrapper">
414
+ <button id="pin-btn" class="icon-btn" title="Pin this page">
415
+ <i class="fa-solid fa-thumbtack"></i>
416
+ </button>
417
+ <div id="pin-menu" class="pin-context-menu">
418
+ <div class="auth-menu-item" id="repin-btn">
419
+ <i class="fa-solid fa-thumbtack w-4"></i> Repin
420
+ </div>
421
+ <div class="auth-menu-item text-red-400 hover:text-red-300" id="remove-pin-btn">
422
+ <i class="fa-solid fa-xmark w-4"></i> Remove Pin
423
+ </div>
424
+ <div class="auth-menu-item text-red-400 hover:text-red-300" id="hide-pin-btn">
425
+ <i class="fa-solid fa-eye-slash w-4"></i> Hide Button
426
+ </div>
427
+ </div>
428
+ </div>
429
+
430
+ <div class="relative">
431
+ <button id="auth-btn" class="icon-btn">
432
+ <i class="fa-solid fa-user"></i>
433
+ </button>
434
+ <div id="auth-menu" class="auth-menu">
435
+ <div class="auth-header">
436
+ <div class="auth-username" id="display-username">Client User</div>
437
+ <div class="auth-email" id="display-email">local@client</div>
438
+ </div>
439
+ <a href="#" class="auth-menu-item" onclick="loadPage('settings.html'); return false;">
440
+ <i class="fa-solid fa-gear w-4"></i> Settings
441
+ </a>
442
+ <div class="auth-menu-item hidden" id="show-pin-menu-item">
443
+ <i class="fa-solid fa-eye w-4"></i> Show Pin Button
444
+ </div>
445
+ <div class="auth-menu-item text-red-400 hover:text-red-300" id="logout-btn">
446
+ <i class="fa-solid fa-right-from-bracket w-4"></i> Disconnect
447
+ </div>
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ <div id="settings-overlay" class="hidden">
454
+ <header class="flex justify-between items-center p-4 border-b border-[#1a1a1a]">
455
+ <h1 class="text-2xl font-bold text-white">4SP Settings</h1>
456
+ </header>
457
+
458
+ <div id="settings-container">
459
+ <nav id="settings-sidebar">
460
+ <button class="btn-toolbar-style settings-tab active" data-tab="general">
461
+ <i class="fa-solid fa-gear w-5"></i> <span>General</span>
462
+ </button>
463
+ <button class="btn-toolbar-style settings-tab" data-tab="personalization">
464
+ <i class="fa-solid fa-palette w-5"></i> <span>Personalization</span>
465
+ </button>
466
+ <button class="btn-toolbar-style settings-tab" data-tab="privacy">
467
+ <i class="fa-solid fa-shield-halved w-5"></i> <span>Privacy & Security</span>
468
+ </button>
469
+ <button class="btn-toolbar-style settings-tab" data-tab="about">
470
+ <i class="fa-solid fa-circle-info w-5"></i> <span>About 4SP</span>
471
+ </button>
472
+ </nav>
473
+
474
+ <div id="settings-main-view">
475
+ <div id="tab-general" class="settings-section">
476
+ <h3 class="text-3xl font-bold text-white mb-6">General Settings</h3>
477
+ <div class="settings-box">
478
+ <h3 class="text-xl font-bold text-white mb-2">Account Username</h3>
479
+ <label class="block text-gray-400 text-sm mb-2 font-light">New Username</label>
480
+ <div class="flex gap-2">
481
+ <input type="text" id="settings-username-input" class="input-text-style" placeholder="Enter username">
482
+ <button id="save-username-btn" class="btn-toolbar-style btn-primary-override">Save</button>
483
+ </div>
484
+ <p id="username-msg" class="general-message-area text-sm mt-2"></p>
485
+ </div>
486
+ </div>
487
+
488
+ <div id="tab-personalization" class="settings-section hidden">
489
+ <h3 class="text-3xl font-bold text-white mb-6">Personalization</h3>
490
+
491
+ <h3 class="text-xl font-bold text-white mb-2">Profile Picture</h3>
492
+ <div class="settings-box mb-8 p-4">
493
+ <div class="flex items-start gap-6">
494
+ <div class="flex flex-col items-center gap-2">
495
+ <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">
496
+ </div>
497
+ <p class="text-xs text-gray-500">Preview</p>
498
+ </div>
499
+
500
+ <div class="flex-grow">
501
+ <div class="flex gap-4 mb-4 border-b border-[#333] pb-4">
502
+ <button class="pfp-mode-btn active btn-toolbar-style" data-mode="letter">Letter Avatar</button>
503
+ <button class="pfp-mode-btn btn-toolbar-style" data-mode="upload">Upload Image</button>
504
+ </div>
505
+
506
+ <div id="pfp-letter-options" class="block">
507
+ <div class="grid grid-cols-6 gap-2 mb-4 max-w-xs">
508
+ </div>
509
+ <button id="save-letter-pfp-btn" class="btn-toolbar-style btn-primary-override">Set Letter Avatar</button>
510
+ </div>
511
+
512
+ <div id="pfp-upload-options" class="hidden">
513
+ <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"/>
514
+ <p class="text-xs text-gray-500 mb-4">Max size 2MB. Square images work best.</p>
515
+ <button id="save-upload-pfp-btn" class="btn-toolbar-style btn-primary-override">Upload & Set</button>
516
+ </div>
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <h3 class="text-xl font-bold text-white mb-2">Navigation Bar Theme</h3>
522
+ <div class="settings-box p-4">
523
+ <div id="theme-picker-container">
524
+ </div>
525
+ </div>
526
+ </div>
527
+
528
+ <div id="tab-privacy" class="settings-section hidden">
529
+ <h3 class="text-3xl font-bold text-white mb-6">Privacy & Security</h3>
530
+
531
+ <h3 class="text-xl font-bold text-white mb-2">Panic Key Settings</h3>
532
+ <div class="settings-box p-4">
533
+ <p class="text-sm font-light text-gray-400 mb-4">
534
+ Configure up to 3 panic keys. Pressing the specified key (without Shift, Ctrl, or Alt) on any page will redirect you to the URL you set.
535
+ <br>
536
+ <span class="text-yellow-400">Valid keys:</span> a-z, 0-9, and &#96; - = [ ] \ ; ' , . /
537
+ </p>
538
+
539
+ <div class="flex items-center gap-4 px-2 mb-2">
540
+ <label class="block text-gray-400 text-sm font-light" style="width: 4rem; text-align: center;">Key</label>
541
+ <label class="block text-gray-400 text-sm font-light flex-grow">Redirect URL</label>
542
+ </div>
543
+
544
+ <div class="flex items-center gap-4 mb-3">
545
+ <input type="text" id="panicKey1" data-key-id="1" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
546
+ <input type="url" id="panicUrl1" class="input-text-style" placeholder="e.g., https://google.com">
547
+ </div>
548
+
549
+ <div class="flex items-center gap-4 mb-3">
550
+ <input type="text" id="panicKey2" data-key-id="2" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
551
+ <input type="url" id="panicUrl2" class="input-text-style" placeholder="e.g., https://youtube.com/feed/subscriptions">
552
+ </div>
553
+
554
+ <div class="flex items-center gap-4 mb-3">
555
+ <input type="text" id="panicKey3" data-key-id="3" class="input-key-style panic-key-input" placeholder="-" maxlength="1">
556
+ <input type="url" id="panicUrl3" class="input-text-style" placeholder="e.g., https://wikipedia.org">
557
+ </div>
558
+
559
+ <div class="flex justify-between items-center pt-4 border-t border-[#252525]">
560
+ <p id="panicKeyMessage" class="general-message-area text-sm"></p>
561
+ <button id="applyPanicKeyBtn" class="btn-toolbar-style btn-primary-override w-36" style="padding: 0.5rem 0.75rem;">
562
+ <i class="fa-solid fa-check mr-1"></i> Apply Keys
563
+ </button>
564
+ </div>
565
+ </div>
566
+ </div>
567
+
568
+ <div id="tab-about" class="settings-section hidden">
569
+ <h3 class="text-3xl font-bold text-white mb-6">About 4SP</h3>
570
+ <div class="settings-box p-6 flex flex-col items-center text-center">
571
+ <img src="https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png" class="h-24 mb-4" alt="Logo">
572
+ <h1 class="text-3xl font-bold text-white mb-2">4SimpleProblems</h1>
573
+ <p class="text-gray-400 mb-6 max-w-lg font-light">A comprehensive student toolkit and entertainment platform.</p>
574
+
575
+ <div class="inline-block bg-[#0a0a0a] border border-[#333] px-4 py-2 rounded-full mb-8">
576
+ <span class="text-gray-500 text-sm">Version</span>
577
+ <span class="text-indigo-400 font-mono font-bold ml-2">5.0.0 DV</span>
578
+ </div>
579
+
580
+ <div class="flex gap-4">
581
+ <a href="#" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-github fa-xl"></i></a>
582
+ <a href="#" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-youtube fa-xl"></i></a>
583
+ <a href="#" class="text-gray-400 hover:text-white transition"><i class="fa-brands fa-discord fa-xl"></i></a>
584
+ </div>
585
+ </div>
586
+ </div>
587
+ </div>
588
+ </div>
589
+ </div>
590
+
591
+ <div id="cropperModal">
592
+ <div id="cropperContent">
593
+ <div class="flex justify-between items-center p-6 border-b border-[#333] bg-black">
594
+ <h3 class="text-2xl font-bold text-white">Adjust Profile Picture</h3>
595
+ <button id="cancelCropBtn" class="btn-toolbar-style w-10 h-10 flex items-center justify-center p-0 rounded-xl">
596
+ <i class="fa-solid fa-xmark fa-xl"></i>
597
+ </button>
598
+ </div>
599
+ <div class="flex flex-col items-center p-6 bg-[#0a0a0a]">
600
+ <p class="text-sm text-gray-400 mb-4">Drag to move. Scroll to resize.</p>
601
+ <div id="cropperCanvasContainer" class="relative mb-6 border border-[#333] rounded-lg overflow-hidden w-full flex justify-center bg-black">
602
+ <canvas id="cropperCanvas"></canvas>
603
+ </div>
604
+ <div class="flex justify-end w-full">
605
+ <button id="submitCropBtn" class="btn-toolbar-style btn-primary-override px-6 py-2 rounded-xl">
606
+ <i class="fa-solid fa-check mr-2"></i> Submit
607
+ </button>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ </div>
612
+
613
+ <template id="games-page-template">
614
+ <!DOCTYPE html>
615
+ <html lang="en">
616
+ <head>
617
+ <title>4SP - GAMES</title>
618
+ <meta charset="UTF-8" />
619
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
620
+ <link rel="icon" type="image/x-icon" href="img/favicon.ico" id="faviconLink" />
621
+
622
+ <link rel="preconnect" href="https://fonts.googleapis.com">
623
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
624
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
625
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
626
+
627
+ <script src="https://cdn.tailwindcss.com"></script>
628
+ <script src="https://dv-service-lfs.4simpleproblems.workers.dev/url-changer.js"></script>
629
+ <script src="https://cdn.jsdelivr.net/npm/4sp-dv@latest/analytics.js"></script>
630
+
631
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-1D4F692C1Q"></script>
632
+ <script>
633
+ window.dataLayer = window.dataLayer || [];
634
+ function gtag(){dataLayer.push(arguments);}
635
+ gtag('js', new Date());
636
+
637
+ gtag('config', 'G-1D4F692C1Q');
638
+ </script>
639
+ <script>
640
+ tailwind.config = {
641
+ darkMode: 'class',
642
+ theme: {
643
+ extend: {
644
+ colors: {
645
+ 'deep-black': '#040404',
646
+ 'card-dark': '#111111',
647
+ 'accent-indigo': '#4f46e5',
648
+ 'brand-border': '#252525',
649
+ 'strongdog-orange': '#FFA500',
650
+ 'gn-math': '#FB2651',
651
+ 'gameboy': '#9A2257',
652
+ 'other-grey': '#AAAAAA',
653
+ },
654
+ fontFamily: {
655
+ sans: ['Geist', 'sans-serif'],
656
+ },
657
+ borderRadius: {
658
+ 'md': '0.375rem',
659
+ 'lg': '0.5rem',
660
+ 'xl': '0.75rem',
661
+ '2xl': '1.25rem',
662
+ '3xl': '1.5rem',
663
+ },
664
+ aspectRatio: {
665
+ '3/2': '3 / 2',
666
+ },
667
+ }
668
+ }
669
+ }
670
+ </script>
671
+
672
+ <style>
673
+ /* Base styles */
674
+ :root {
675
+ --menu-bg: #000000;
676
+ --menu-border: #333;
677
+ --menu-text: #d1d5db;
678
+ --tab-hover-text: #ffffff;
679
+ --tab-active-bg: rgba(79, 70, 229, 0.1);
680
+ --tab-active-text: #4f46e5;
681
+ --tab-active-border: #4f46e5;
682
+ }
683
+
684
+ body {
685
+ font-family: 'Geist', 'sans-serif';
686
+ font-weight: 300;
687
+ background-color: #040404;
688
+ padding-bottom: 100px;
689
+ }
690
+
691
+ h1, h2, h3, .font-bold, .font-semibold, strong {
692
+ font-weight: 400 !important;
693
+ }
694
+
695
+ /* --- Toolbar Button Style (Global) --- */
696
+ .btn-toolbar-style {
697
+ background: var(--menu-bg);
698
+ border: 1px solid var(--menu-border);
699
+ border-radius: 0.75rem;
700
+ color: var(--menu-text);
701
+ padding: 0.5rem 1rem;
702
+ font-weight: 500;
703
+ cursor: pointer;
704
+ transition: all 0.2s;
705
+ display: inline-flex;
706
+ align-items: center;
707
+ justify-content: center;
708
+ gap: 0.5rem;
709
+ text-decoration: none;
710
+ font-size: 1rem;
711
+ }
712
+ .btn-toolbar-style:hover {
713
+ background-color: var(--menu-bg);
714
+ border-color: #fff;
715
+ color: var(--tab-hover-text);
716
+ }
717
+
718
+ /* --- Card Action Buttons --- */
719
+ .btn-card-action {
720
+ display: inline-flex;
721
+ align-items: center;
722
+ justify-content: center;
723
+ width: 2.25rem;
724
+ height: 2.25rem;
725
+ border-radius: 0.5rem;
726
+ background-color: transparent;
727
+ border: 1px solid transparent;
728
+ color: #9ca3af;
729
+ cursor: pointer;
730
+ transition: all 0.2s;
731
+ font-size: 1rem;
732
+ }
733
+ .btn-card-action:hover {
734
+ background-color: var(--tab-active-bg);
735
+ color: var(--tab-active-text);
736
+ border-color: var(--tab-active-border);
737
+ }
738
+
739
+ /* Favorite Button Logic */
740
+ .btn-card-action.fav-action .fa-solid-star { display: none; }
741
+ .btn-card-action.fav-action .fa-regular-star { display: inline-block; }
742
+
743
+ .btn-card-action.fav-action.favorited .fa-solid-star { display: inline-block; color: #facc15; }
744
+ .btn-card-action.fav-action.favorited .fa-regular-star { display: none; }
745
+
746
+ .btn-card-action.fav-action:not(.favorited):hover .fa-regular-star { color: var(--tab-active-text); }
747
+
748
+ /* Card Hover Effects */
749
+ .zone-item {
750
+ transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
751
+ will-change: transform;
752
+ }
753
+ .zone-item:hover {
754
+ transform: translateY(-4px);
755
+ box-shadow: 0 8px 25px rgba(79, 70, 229, 0.2);
756
+ }
757
+
758
+ .other-zone-item {
759
+ min-height: 250px;
760
+ transition: box-shadow 0.2s ease-in-out;
761
+ position: relative;
762
+ z-index: 1;
763
+ }
764
+ .other-zone-item:hover {
765
+ box-shadow: 0 8px 25px rgba(170, 170, 170, 0.2);
766
+ z-index: 10;
767
+ }
768
+ .other-zone-item .image-overlay {
769
+ 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%);
770
+ border-radius: 1.25rem;
771
+ }
772
+ .other-zone-item:hover .image-overlay {
773
+ 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%);
774
+ }
775
+
776
+ /* --- Animations --- */
777
+ @keyframes fadeOut {
778
+ from { opacity: 1; transform: scale(1); }
779
+ to { opacity: 0; transform: scale(0.95); }
780
+ }
781
+ .fade-out {
782
+ animation: fadeOut 0.3s ease-out forwards;
783
+ pointer-events: none;
784
+ }
785
+
786
+ /* Search Bar & Results */
787
+ .search-results-container {
788
+ max-height: 300px;
789
+ overflow-y: auto;
790
+ scrollbar-width: thin;
791
+ scrollbar-color: #333 transparent;
792
+ }
793
+ .search-item {
794
+ display: flex;
795
+ cursor: pointer;
796
+ transition: background-color 0.2s;
797
+ color: #e5e7eb;
798
+ text-decoration: none;
799
+ padding: 10px 15px;
800
+ }
801
+ .search-item:hover { background-color: rgba(255, 255, 255, 0.05); }
802
+
803
+ @media (max-width: 768px) {
804
+ #bottom-fixed-bar {
805
+ left: 10px !important; right: 10px !important;
806
+ bottom: 10px !important; width: auto !important;
807
+ }
808
+ }
809
+
810
+ /* Polyfill for aspect-ratio */
811
+ .aspect-w-3 { position: relative; padding-bottom: 66.666667%; }
812
+ .aspect-h-2 { }
813
+ .aspect-w-3 > * { position: absolute; height: 100%; width: 100%; top: 0; right: 0; bottom: 0; left: 0; }
814
+
815
+ /* --- Game Viewer Modal --- */
816
+ #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; }
817
+ #zoneViewer .zone-header {
818
+ background-color: #111111;
819
+ padding: 12px 20px;
820
+ display: flex;
821
+ justify-content: space-between;
822
+ align-items: center;
823
+ border-bottom: 1px solid #252525;
824
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
825
+ flex-shrink: 0;
826
+ }
827
+ #zoneViewer .zone-header .zone-title h2 {
828
+ margin: 0;
829
+ font-size: 1.3em;
830
+ color: #ffffff;
831
+ font-family: 'Geist', sans-serif;
832
+ }
833
+ #zoneViewer .zone-header .zone-controls { display: flex; align-items: center; }
834
+ #zoneViewer .zone-header .zone-controls button,
835
+ #zoneViewer .zone-header .zone-controls a {
836
+ margin-left: 8px;
837
+ width: 40px;
838
+ height: 40px;
839
+ background-color: rgba(255, 255, 255, 0.1);
840
+ backdrop-filter: blur(5px);
841
+ -webkit-backdrop-filter: blur(5px);
842
+ color: white;
843
+ border: 1px solid rgba(255, 255, 255, 0.2);
844
+ border-radius: 0.5rem;
845
+ cursor: pointer;
846
+ font-size: 1em;
847
+ display: flex;
848
+ align-items: center;
849
+ justify-content: center;
850
+ transition: background-color 0.2s ease-in-out;
851
+ text-decoration: none;
852
+ }
853
+ #zoneViewer .zone-header .zone-controls button:hover,
854
+ #zoneViewer .zone-header .zone-controls a:hover { background-color: rgba(255, 255, 255, 0.2); }
855
+ #zoneViewer iframe { flex-grow: 1; border: none; background-color: #000; }
856
+ .hidden { display: none !important; }
857
+
858
+ /* --- Dropdown (Modified to popup ABOVE) --- */
859
+ .eagler-dropdown {
860
+ display: none;
861
+ position: absolute;
862
+ bottom: 120%; /* Pops up above the button */
863
+ right: 0;
864
+ margin-bottom: 5px;
865
+ background-color: rgba(20, 20, 20, 0.98);
866
+ backdrop-filter: blur(12px);
867
+ -webkit-backdrop-filter: blur(12px);
868
+ border: 1px solid rgba(255, 255, 255, 0.15);
869
+ border-radius: 0.75rem;
870
+ padding: 0.5rem;
871
+ z-index: 1000;
872
+ min-width: 200px;
873
+ box-shadow: 0 10px 25px rgba(0,0,0,0.6);
874
+ transform-origin: bottom right;
875
+ }
876
+ .eagler-dropdown.show { display: block; animation: fadeInUp 0.15s ease-out; }
877
+
878
+ @keyframes fadeInUp {
879
+ from { opacity: 0; transform: translateY(10px) scale(0.95); }
880
+ to { opacity: 1; transform: translateY(0) scale(1); }
881
+ }
882
+
883
+ .eagler-dropdown-link {
884
+ display: block;
885
+ padding: 0.6rem 0.8rem;
886
+ color: #d1d5db;
887
+ text-decoration: none;
888
+ border-radius: 0.5rem;
889
+ transition: all 0.2s;
890
+ font-weight: 400;
891
+ white-space: nowrap;
892
+ font-size: 0.9rem;
893
+ }
894
+ .eagler-dropdown-link:hover { background-color: rgba(255, 255, 255, 0.1); color: white; }
895
+
896
+ #creditsModal { display: none; }
897
+
898
+ /* Modal Scrollbar */
899
+ .custom-scrollbar::-webkit-scrollbar { width: 8px; }
900
+ .custom-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); border-radius: 4px; }
901
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
902
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #555; }
903
+ </style>
904
+ </head>
905
+
906
+ <body class="bg-deep-black text-white min-h-screen">
907
+
908
+ <main class="mx-auto my-5 w-full max-w-screen-2xl px-4 relative">
909
+ <button id="creditsBtn" class="absolute top-0 right-4 z-20 btn-toolbar-style">
910
+ <i class="fa-solid fa-users mr-2"></i>Credits
911
+ </button>
912
+
913
+ <div class="mx-auto my-8 p-4 sm:p-6 w-full max-w-3xl relative">
914
+ <p class="bg-card-dark text-white p-5 rounded-xl shadow-lg border border-brand-border text-sm sm:text-base text-center">
915
+ <strong>Welcome to 4SP Games!</strong><br>
916
+ This is a collection of games curated for the 4SP community. Use the search bar below to find your favorite.
917
+ </p>
918
+ </div>
919
+
920
+ <div id="favoritesSection">
921
+ <h2 id="favoritesHeader" class="text-center text-3xl font-bold mt-12 mb-6 text-white" style="display: none;">
922
+ <strong>Favorites</strong>
923
+ </h2>
924
+ <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>
925
+ </div>
926
+
927
+ <div id="category-viewer" class="w-full mt-12">
928
+ <div id="category-slider-container" class="relative flex items-center justify-center max-w-sm mx-auto h-12">
929
+ <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">
930
+ <i class="fas fa-chevron-left"></i>
931
+ </button>
932
+ <div class="overflow-hidden w-64 text-center">
933
+ <div id="category-slider" class="flex transition-transform duration-500 ease-in-out">
934
+ </div>
935
+ </div>
936
+ <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">
937
+ <i class="fas fa-chevron-right"></i>
938
+ </button>
939
+ </div>
940
+
941
+ <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">
942
+ </div>
943
+ </div>
944
+ </main>
945
+
946
+ <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">
947
+ <div id="searchResults" class="search-results-container w-full" style="display: none;">
948
+ </div>
949
+
950
+ <div id="searchDivider" class="w-full h-[1px] bg-white/10" style="display: none;"></div>
951
+
952
+ <div class="flex items-center w-full p-1">
953
+ <div class="pl-4 pr-2 text-gray-400">
954
+ <i class="fa-solid fa-magnifying-glass"></i>
955
+ </div>
956
+ <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" />
957
+ </div>
958
+ </div>
959
+
960
+ <div id="zoneViewer" aria-modal="true" role="dialog">
961
+ <div class="zone-header">
962
+ <div class="zone-title">
963
+ <h2 id="zoneNameEl">Game Title</h2>
964
+ </div>
965
+ <div class="zone-controls">
966
+ <button id="fullscreenBtnZone" title="Fullscreen"><i class="fas fa-expand"></i></button>
967
+ <a id="downloadBtnZone" title="Download" class="hidden" href="#"><i class="fas fa-download"></i></a>
968
+ <button id="closeBtnZone" title="Close"><i class="fas fa-times"></i></button>
969
+ </div>
970
+ </div>
971
+ <iframe id="zoneFrame" title="Game Content" allowfullscreen sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"></iframe>
972
+ </div>
973
+
974
+ <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;">
975
+ <div class="bg-card-dark rounded-xl border border-brand-border shadow-2xl w-full max-w-lg overflow-y-auto max-h-[90vh]">
976
+ <div class="flex justify-between items-center p-4 border-b border-brand-border sticky top-0 bg-card-dark z-10">
977
+ <h3 class="text-xl font-bold">Project Credits</h3>
978
+ <button id="closeCreditsBtn" class="text-white hover:text-gray-400 p-2"><i class="fas fa-times"></i></button>
979
+ </div>
980
+ <div id="creditsContent" class="p-6 space-y-6">
981
+ </div>
982
+ </div>
983
+ </div>
984
+
985
+ <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;">
986
+ <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">
987
+ <div class="p-6 sm:p-8 overflow-y-auto custom-scrollbar">
988
+ <h3 class="text-2xl font-bold mb-4 text-center">Welcome to 4SP Games</h3>
989
+ <p class="text-sm text-gray-400 mb-6 text-center">Please read the following information before playing.</p>
990
+
991
+ <ul class="space-y-4 text-gray-300 text-sm sm:text-base leading-relaxed">
992
+ <li class="flex items-start">
993
+ <i class="fas fa-compass mt-1 mr-3 text-accent-indigo"></i>
994
+ <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>
995
+ </li>
996
+ <li class="flex items-start">
997
+ <i class="fas fa-file-signature mt-1 mr-3 text-accent-indigo"></i>
998
+ <span><strong>Rebranding:</strong> "4SP Game Hub" is now shortened to "<strong>4SP Games</strong>".</span>
999
+ </li>
1000
+ <li class="flex items-start">
1001
+ <i class="fas fa-bug mt-1 mr-3 text-strongdog-orange"></i>
1002
+ <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>
1003
+ </li>
1004
+ <li class="flex items-start">
1005
+ <i class="fas fa-user-gear mt-1 mr-3 text-gn-math"></i>
1006
+ <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>
1007
+ </li>
1008
+ <li class="flex items-start">
1009
+ <i class="fas fa-server mt-1 mr-3 text-gn-math"></i>
1010
+ <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>
1011
+ </li>
1012
+ </ul>
1013
+ </div>
1014
+
1015
+ <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">
1016
+ <span class="text-xs text-gray-500">Type "I understand" to continue.</span>
1017
+ <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">
1018
+ </div>
1019
+ </div>
1020
+ </div>
1021
+
1022
+ <script>
1023
+ // --- Configuration & Globals ---
1024
+
1025
+ const GN_ZONES_URL = "https://cdn.jsdelivr.net/gh/gn-math/assets@main/zones.json";
1026
+ const GN_COVER_URL_BASE = "https://cdn.jsdelivr.net/gh/gn-math/covers@main";
1027
+
1028
+ // FIXED: Use raw.githack.com for HTML to prevent "showing code" issue
1029
+ const GN_HTML_URL_BASE = "https://raw.githack.com/gn-math/html/main";
1030
+
1031
+ const WORKER_ROOT = "https://dv-service-lfs.4simpleproblems.workers.dev/";
1032
+ const GAMES_BASE_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/";
1033
+ const CARDS_DATA_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/cards-data.js";
1034
+
1035
+ const FAVORITES_KEY = 'gameHubFavorites_v3';
1036
+
1037
+ let allGames = [];
1038
+ let searchableGames = [];
1039
+ let categories = [];
1040
+ let currentCategoryIndex = 0;
1041
+ const categoryColors = {
1042
+ 'StrongdogXP': 'text-strongdog-orange',
1043
+ 'GN-Math': 'text-gn-math',
1044
+ 'Gameboy Games': 'text-gameboy',
1045
+ 'Others': 'text-other-grey',
1046
+ };
1047
+
1048
+ const CREDITS_DATA = {
1049
+ 'StrongdogXP': { credit: "Core game collection provided by Josh P.", githubUrl: "https://github.com/jman1593/" },
1050
+ 'GN-Math': { credit: "Primary game source and infrastructure provided by the GN-Math Community.", githubUrl: "https://github.com/gn-math/" },
1051
+ 'Gameboy Games': { credit: "Emulator and ROM files from various public sources." },
1052
+ 'Others': { credit: "Various community developers and open-source projects (e.g., DOOM, Eaglercraft, Carnage3D, JS-DOS)." }
1053
+ };
1054
+
1055
+ const favoritesHeader = document.getElementById("favoritesHeader");
1056
+ const favoritesGameList = document.getElementById("favoritesGameList");
1057
+ const searchInput = document.getElementById("searchInput");
1058
+ const searchResults = document.getElementById("searchResults");
1059
+ const searchDivider = document.getElementById("searchDivider");
1060
+ const categorySlider = document.getElementById('category-slider');
1061
+ const prevCategoryBtn = document.getElementById('prev-category');
1062
+ const nextCategoryBtn = document.getElementById('next-category');
1063
+ const gamesGridContainer = document.getElementById('games-grid-container');
1064
+
1065
+ const creditsModal = document.getElementById('creditsModal');
1066
+ const creditsContent = document.getElementById('creditsContent');
1067
+ const creditsBtn = document.getElementById('creditsBtn');
1068
+ const closeCreditsBtn = document.getElementById('closeCreditsBtn');
1069
+
1070
+ const INSTRUCTION_KEY = '4sp-games-instruction-extended-seen';
1071
+ const instructionOverlay = document.getElementById('instruction-overlay');
1072
+ const instructionInput = document.getElementById('instruction-input');
1073
+
1074
+ // --- Intersection Observer for Image Virtualization ---
1075
+ const imageObserver = new IntersectionObserver((entries, observer) => {
1076
+ entries.forEach(entry => {
1077
+ const img = entry.target;
1078
+ if (entry.isIntersecting) {
1079
+ if (img.dataset.src) {
1080
+ img.src = img.dataset.src;
1081
+ }
1082
+ } else {
1083
+ if(img.src && !img.src.includes('placehold.co') && !img.dataset.src) {
1084
+ img.dataset.src = img.src;
1085
+ }
1086
+ if(img.dataset.src) {
1087
+ img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1088
+ }
1089
+ }
1090
+ });
1091
+ }, {
1092
+ rootMargin: "300px 0px",
1093
+ threshold: 0.01
1094
+ });
1095
+
1096
+ function observeImages(container) {
1097
+ const images = container.querySelectorAll('img[data-src]');
1098
+ images.forEach(img => imageObserver.observe(img));
1099
+ }
1100
+
1101
+ // --- URL Management ---
1102
+ function updateURL(category, gameId = null) {
1103
+ let hash = `#${encodeURIComponent(category.replace(/\s+/g, '-'))}`;
1104
+ if (gameId) hash += `?id=${gameId}`;
1105
+ try {
1106
+ history.replaceState(null, null, hash);
1107
+ } catch(e) {
1108
+ console.warn('History API not supported in this environment');
1109
+ }
1110
+ }
1111
+
1112
+ function parseURL() {
1113
+ const hash = window.location.hash.substring(1);
1114
+ if (!hash) return { category: null, gameId: null };
1115
+ const parts = hash.split('?');
1116
+ let rawCategory = parts[0];
1117
+ let category = decodeURIComponent(rawCategory).replace(/-/g, ' ');
1118
+ let gameId = null;
1119
+ if (parts[1]) {
1120
+ const params = new URLSearchParams(parts[1]);
1121
+ gameId = params.get('id');
1122
+ }
1123
+ return { category: rawCategory, gameId };
1124
+ }
1125
+
1126
+ function setupInstructionOverlay() {
1127
+ if (localStorage.getItem(INSTRUCTION_KEY) !== 'seen') {
1128
+ instructionOverlay.style.display = 'flex';
1129
+ document.body.style.overflow = 'hidden';
1130
+ setTimeout(() => {
1131
+ instructionOverlay.classList.remove('opacity-0');
1132
+ instructionOverlay.classList.add('opacity-100');
1133
+ }, 10);
1134
+ instructionInput.addEventListener('input', (e) => {
1135
+ if (e.target.value.trim().toLowerCase() === 'i understand') {
1136
+ instructionOverlay.classList.remove('opacity-100');
1137
+ instructionOverlay.classList.add('opacity-0');
1138
+ setTimeout(() => {
1139
+ instructionOverlay.style.display = 'none';
1140
+ document.body.style.overflow = '';
1141
+ localStorage.setItem(INSTRUCTION_KEY, 'seen');
1142
+ }, 300);
1143
+ }
1144
+ });
1145
+ }
1146
+ }
1147
+
1148
+ // --- Favorites Management (Live Updates) ---
1149
+ const getFavorites = () => JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
1150
+ const saveFavorites = (favs) => localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs));
1151
+
1152
+ function toggleFavorite(gameId) {
1153
+ const strGameId = String(gameId);
1154
+ let favorites = getFavorites().map(String);
1155
+ const isFavorited = favorites.includes(strGameId);
1156
+
1157
+ if (isFavorited) {
1158
+ favorites = favorites.filter(id => id !== strGameId);
1159
+ saveFavorites(favorites);
1160
+ updateAllFavoriteButtons();
1161
+ const favCard = favoritesGameList.querySelector(`.zone-item[data-game-id='${strGameId}'], .other-zone-item[data-game-id='${strGameId}']`);
1162
+ if (favCard) {
1163
+ favCard.classList.add('fade-out');
1164
+ setTimeout(() => {
1165
+ favCard.remove();
1166
+ if (favoritesGameList.children.length === 0) {
1167
+ favoritesHeader.style.display = 'none';
1168
+ favoritesGameList.style.display = 'none';
1169
+ }
1170
+ }, 300);
1171
+ }
1172
+ } else {
1173
+ favorites.push(strGameId);
1174
+ saveFavorites(favorites);
1175
+ updateAllFavoriteButtons();
1176
+ let gameData = allGames.find(g => String(g.id) === strGameId);
1177
+ if (!gameData) {
1178
+ allGames.some(g => {
1179
+ if (g.versions) {
1180
+ const ver = g.versions.find(v => String(v.favoriteId) === strGameId);
1181
+ if (ver) {
1182
+ gameData = { ...g, id: ver.favoriteId, name: `${g.name} - ${ver.name}`, url: ver.url, versions: undefined };
1183
+ return true;
1184
+ }
1185
+ }
1186
+ return false;
1187
+ });
1188
+ }
1189
+ if (gameData) {
1190
+ favoritesHeader.style.display = "block";
1191
+ favoritesGameList.style.display = "grid";
1192
+ favoritesGameList.classList.add('grid', 'grid-cols-2', 'sm:grid-cols-3', 'md:grid-cols-5', 'lg:grid-cols-7', 'gap-4');
1193
+ const newCard = createGameCard(gameData, true);
1194
+ favoritesGameList.appendChild(newCard);
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ function updateAllFavoriteButtons() {
1200
+ const favorites = getFavorites().map(String);
1201
+ document.querySelectorAll('.btn-card-action.fav-action').forEach(btn => {
1202
+ const card = btn.closest('.zone-item, .other-zone-item, .search-item');
1203
+ if (!card) return;
1204
+ const cardGameId = String(card.dataset.gameId);
1205
+ if (btn.classList.contains('version-favorite-btn')) {
1206
+ const isAnyVersionFavorited = favorites.some(favId => favId.startsWith(cardGameId + '_'));
1207
+ btn.classList.toggle('favorited', isAnyVersionFavorited);
1208
+ } else {
1209
+ const isFavorited = favorites.includes(cardGameId);
1210
+ btn.classList.toggle('favorited', isFavorited);
1211
+ btn.title = isFavorited ? 'Remove from Favorites' : 'Add to Favorites';
1212
+ }
1213
+ });
1214
+ }
1215
+
1216
+ // --- URL Helpers (Strongdog) ---
1217
+ // Modified to support injected URLs using WORKER_ROOT + directory logic
1218
+ function getAdjustedUrls(imgSrc, page) {
1219
+ if (page && page > 1) {
1220
+ // ../strongdog2/img/... -> exists outside GAMES dir
1221
+ return { adjustedImgSrc: `${WORKER_ROOT}strongdog${page}/img/${imgSrc}` };
1222
+ }
1223
+ // ./img -> assumed inside GAMES dir or wherever relative
1224
+ return { adjustedImgSrc: `${GAMES_BASE_URL}img/${imgSrc}` };
1225
+ }
1226
+
1227
+ function sd_getBaseURLForPage(page) {
1228
+ // Updated to point to WORKER_ROOT for numbered pages, GAMES_BASE_URL for default
1229
+ if (page > 1) return `${WORKER_ROOT}strongdog${page}/`;
1230
+ return `${WORKER_ROOT}STRONGDOG/`; // Assumes default strongdog is at root/STRONGDOG? Or inside GAMES?
1231
+ // Re-reading logic: "stuff like ../DOOM/ will stay in GAMES directory but strongdog# directories exit"
1232
+ // Let's assume default STRONGDOG is adjacent to strongdog2 etc. at root level based on prior naming conventions
1233
+ }
1234
+
1235
+ async function sd_getEmbedPath(adjustedHref, originalHref, page) {
1236
+ let cleanHref = adjustedHref.replace(/index\.html$/, "").replace(/base\.html$/, "").replace(/\.html$/, "");
1237
+ if (!cleanHref.endsWith("/")) cleanHref += "/";
1238
+ const pathsToTry = [cleanHref + "game/index.html", cleanHref + "game/base.html", cleanHref + "gamereal/index.html", cleanHref + "gamereal/base.html", cleanHref + "index.html", cleanHref + "base.html", ];
1239
+ try {
1240
+ const response = await fetch(adjustedHref);
1241
+ if (response.ok) {
1242
+ const text = await response.text();
1243
+ const match = text.match(/embedGame\((['"])(.*?)\1,\s*(['"])(.*?)\3\)/);
1244
+ if (match) {
1245
+ const resolvedPath = new URL(match[2], adjustedHref).href;
1246
+ if (await sd_fileExists(resolvedPath)) return resolvedPath;
1247
+ }
1248
+ }
1249
+ } catch (error) {}
1250
+ for (const path of pathsToTry) {
1251
+ if (await sd_fileExists(path)) return path;
1252
+ }
1253
+ return adjustedHref;
1254
+ }
1255
+ async function sd_fileExists(url) {
1256
+ try { const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch { return false; }
1257
+ }
1258
+
1259
+ // --- URL Resolution Helper for "Others" ---
1260
+ function resolveGameUrl(relativePath) {
1261
+ // Logic:
1262
+ // If path starts with ../ -> Go to WORKER_ROOT + path (stripping ../)
1263
+ // If path starts with ../GAMES/ -> It will effectively be WORKER_ROOT/GAMES/ (which matches GAMES_BASE_URL)
1264
+ // This allows us to access root folders like ../GTA-JSDOS/ while keeping ../GAMES/sm64 intact.
1265
+
1266
+ if (relativePath.startsWith('../')) {
1267
+ const pathPart = relativePath.substring(3); // Remove '../'
1268
+ return `${WORKER_ROOT}${pathPart}`;
1269
+ }
1270
+
1271
+ // If it doesn't start with ../, assume it's relative to GAMES base
1272
+ return `${GAMES_BASE_URL}${relativePath.replace(/^\.\//, '')}`;
1273
+ }
1274
+
1275
+ // --- Game Data (Others) ---
1276
+ // We keep the original relative paths here so the resolver logic above can handle them dynamically
1277
+ const othersGames = [
1278
+ { 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 } ] },
1279
+ { 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" } ] },
1280
+ { 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" } ] },
1281
+ { 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" },
1282
+ { 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" }
1283
+ ];
1284
+
1285
+ // --- Card Creation ---
1286
+
1287
+ function createOthersGameCard(game) {
1288
+ const favorites = getFavorites().map(String);
1289
+ const isAnyFavorite = game.versions ? favorites.some(favId => game.versions.some(v => String(v.favoriteId) === favId)) : favorites.includes(String(game.id));
1290
+ const card = document.createElement("div");
1291
+ card.className = 'other-zone-item bg-card-dark rounded-2xl border border-brand-border col-span-full shadow-lg';
1292
+ card.dataset.gameId = game.id;
1293
+
1294
+ // Resolve Image URL
1295
+ const resolvedImgSrc = resolveGameUrl(game.imgSrc);
1296
+
1297
+ let gameButtonsHtml = '';
1298
+ let favoriteButtonHtml = '';
1299
+
1300
+ if (game.versions && game.versions.length > 0) {
1301
+ const availableVersions = game.versions;
1302
+ if (availableVersions.length === 1) {
1303
+ const vUrl = resolveGameUrl(availableVersions[0].url);
1304
+ 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>`;
1305
+ } else {
1306
+ const dropdownLinks = availableVersions.map(v => {
1307
+ const vUrl = resolveGameUrl(v.url);
1308
+ return `<a href="#" class="eagler-dropdown-link" data-url="${vUrl}" data-version-name="${v.name}">${v.name}</a>`
1309
+ }).join('');
1310
+ 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>`;
1311
+ }
1312
+ 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>`;
1313
+ } else {
1314
+ const gUrl = resolveGameUrl(game.url);
1315
+ gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${gUrl}" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>`;
1316
+ 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>`;
1317
+ }
1318
+
1319
+ card.innerHTML = `
1320
+ <div class="relative w-full h-full cursor-pointer group" style="min-height: 250px;">
1321
+ <img data-src="${resolvedImgSrc}" alt="${game.name}" loading="lazy" decoding="async" class="w-full h-full object-cover absolute inset-0 rounded-2xl">
1322
+ <div class="image-overlay absolute inset-0 transition-colors duration-200"></div>
1323
+ <div class="absolute inset-0 p-4 sm:p-6 flex flex-col justify-between rounded-2xl">
1324
+ <div class="flex items-start justify-between w-full">
1325
+ <h3 class="text-4xl font-bold text-white truncate drop-shadow-lg" style="max-width: 80%;" title="${game.name}">${game.name}</h3>
1326
+ <div class="flex items-center space-x-3 bg-black/80 backdrop-blur-md rounded-xl px-2 py-1">
1327
+ ${gameButtonsHtml}
1328
+ ${favoriteButtonHtml}
1329
+ </div>
1330
+ </div>
1331
+ <p class="text-lg text-white font-medium drop-shadow-lg" style="max-width: 50%;">${game.description}</p>
1332
+ </div>
1333
+ </div>
1334
+ `;
1335
+
1336
+ if (!game.versions) {
1337
+ card.querySelector('.group').addEventListener('click', () => {
1338
+ const gUrl = resolveGameUrl(game.url);
1339
+ openZone({...game, url: gUrl});
1340
+ });
1341
+ }
1342
+ 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 }); }));
1343
+
1344
+ const versionBtn = card.querySelector('.version-btn');
1345
+ if (versionBtn) { versionBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); versionBtn.nextElementSibling.classList.toggle('show'); }); }
1346
+
1347
+ 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'); }));
1348
+
1349
+ const favBtn = card.querySelector('.btn-card-action.fav-action');
1350
+ if (favBtn.classList.contains('version-favorite-btn')) {
1351
+ favBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); favBtn.nextElementSibling.classList.toggle('show'); });
1352
+ 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'); }));
1353
+ } else {
1354
+ favBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
1355
+ }
1356
+ imageObserver.observe(card.querySelector('img'));
1357
+ return card;
1358
+ }
1359
+
1360
+ function createGameCard(game, forceSmallCard = false) {
1361
+ if (game.category === 'Others' && !forceSmallCard) return createOthersGameCard(game);
1362
+ const isStrongdog = game.category === 'StrongdogXP';
1363
+
1364
+ // Resolve Image Src
1365
+ let imgSrc = game.imgSrc;
1366
+ if (isStrongdog) {
1367
+ imgSrc = getAdjustedUrls(game.imgSrc, game.page).adjustedImgSrc;
1368
+ } else if (game.category === 'Others' || game.category === 'Gameboy Games') {
1369
+ imgSrc = resolveGameUrl(game.imgSrc);
1370
+ }
1371
+
1372
+ const isFavorite = getFavorites().map(String).includes(String(game.id));
1373
+
1374
+ const card = document.createElement("div");
1375
+ card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
1376
+ card.dataset.gameId = game.id;
1377
+ card.innerHTML = `
1378
+ <div class="relative w-full cursor-pointer group">
1379
+ <div class="aspect-w-3 aspect-h-2"><img data-src="${imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
1380
+ <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>
1381
+ <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">
1382
+ <button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>
1383
+ <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>
1384
+ </div>
1385
+ </div>
1386
+ `;
1387
+
1388
+ const handlePlay = async (e) => {
1389
+ e.stopPropagation();
1390
+ if (isStrongdog) {
1391
+ const baseURL = sd_getBaseURLForPage(game.page);
1392
+ let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
1393
+ const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
1394
+ openZone({ name: game.name, url: embedPath, category: game.category, id: game.id });
1395
+ } else {
1396
+ // For GN-Math, url is absolute (handled in init). For Others/Gameboy, we might need to resolve
1397
+ let gUrl = game.url;
1398
+ if (game.category === 'Others' || game.category === 'Gameboy Games') {
1399
+ gUrl = resolveGameUrl(game.url);
1400
+ }
1401
+ openZone({ ...game, url: gUrl });
1402
+ }
1403
+ };
1404
+
1405
+ card.querySelector('.group').addEventListener('click', handlePlay);
1406
+ card.querySelector('.play-action').addEventListener('click', handlePlay);
1407
+ card.querySelector('.fav-action').addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
1408
+ imageObserver.observe(card.querySelector('img'));
1409
+ return card;
1410
+ }
1411
+
1412
+ // --- Game Viewer Logic ---
1413
+ function openZone(game) {
1414
+ if (!game || !game.url) return;
1415
+ updateURL(categories[currentCategoryIndex], game.id || null);
1416
+
1417
+ const zoneViewer = document.getElementById('zoneViewer');
1418
+ const zoneFrame = document.getElementById('zoneFrame');
1419
+ const zoneNameEl = document.getElementById('zoneNameEl');
1420
+ const downloadBtn = document.getElementById('downloadBtnZone');
1421
+
1422
+ // Setup Download Button (Specific to Eaglercraft)
1423
+ if (game.baseGameId === 'other-eaglercraft' || game.id === 'other-eaglercraft') {
1424
+ downloadBtn.href = game.url;
1425
+ downloadBtn.download = game.name.replace(/ /g, '_') + '.html';
1426
+ downloadBtn.removeAttribute('target');
1427
+ downloadBtn.classList.remove('hidden');
1428
+ } else {
1429
+ downloadBtn.classList.add('hidden');
1430
+ downloadBtn.href = '#';
1431
+ downloadBtn.removeAttribute('download');
1432
+ }
1433
+
1434
+ zoneNameEl.textContent = game.name;
1435
+
1436
+ // RESET FRAME BEFORE LOAD
1437
+ zoneFrame.src = 'about:blank';
1438
+
1439
+ // SECURITY: Set sandbox permissions
1440
+ const sandboxRules = 'allow-scripts allow-same-origin allow-forms allow-pointer-lock';
1441
+ zoneFrame.setAttribute('sandbox', sandboxRules);
1442
+ zoneFrame.setAttribute('allow', 'fullscreen; pointer-lock; autoplay; clipboard-write');
1443
+
1444
+ // --- REVISED LOGIC FOR LOADING GAMES ---
1445
+ // With raw.githack.com, we can treat GN-Math games as standard URL games again
1446
+ // because Githack serves correct Content-Type: text/html headers.
1447
+ const isStandardURLGame = game.category === 'StrongdogXP' || game.category === 'Others' || game.category === 'GN-Math';
1448
+
1449
+ if (isStandardURLGame) {
1450
+ // Load URL directly for standard games
1451
+ zoneFrame.src = game.url;
1452
+ zoneViewer.style.display = "flex";
1453
+ } else {
1454
+ // Fallback for other potential categories (same logic as before: fetch & write)
1455
+ fetch(`${game.url}?t=${Date.now()}`)
1456
+ .then(response => {
1457
+ if (!response.ok) throw new Error(`HTTP error ${response.status}`);
1458
+ return response.text();
1459
+ })
1460
+ .then(html => {
1461
+ const doc = zoneFrame.contentWindow.document;
1462
+ doc.open();
1463
+ doc.write(html);
1464
+ doc.close();
1465
+ zoneViewer.style.display = "flex";
1466
+ })
1467
+ .catch(error => {
1468
+ console.error(`Failed to load game "${game.name}": ${error.message}`);
1469
+ zoneFrame.src = game.url;
1470
+ zoneViewer.style.display = "flex";
1471
+ });
1472
+ }
1473
+ }
1474
+
1475
+ function closeZoneViewer() {
1476
+ updateURL(categories[currentCategoryIndex]);
1477
+ const downloadBtn = document.getElementById('downloadBtnZone');
1478
+ downloadBtn.classList.add('hidden');
1479
+ downloadBtn.href = '#';
1480
+ downloadBtn.removeAttribute('download');
1481
+ downloadBtn.removeAttribute('target');
1482
+ const zoneViewer = document.getElementById('zoneViewer');
1483
+ const zoneFrame = document.getElementById('zoneFrame');
1484
+ zoneViewer.style.display = "none";
1485
+ if (zoneFrame) {
1486
+ zoneFrame.src = 'about:blank';
1487
+ }
1488
+ }
1489
+
1490
+ // --- Render Lists ---
1491
+ function renderGames(gamesToRender, targetElement, forceSmallCard = false) {
1492
+ targetElement.innerHTML = '';
1493
+ 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';
1494
+ gamesToRender.forEach(game => targetElement.appendChild(createGameCard(game, forceSmallCard)));
1495
+ observeImages(targetElement);
1496
+ }
1497
+
1498
+ function renderFavorites() {
1499
+ const favoriteIds = getFavorites().map(String);
1500
+ if (favoriteIds.length > 0) {
1501
+ favoritesHeader.style.display = "block";
1502
+ favoritesGameList.style.display = "grid";
1503
+ const favoriteGames = [];
1504
+ allGames.forEach(game => {
1505
+ if (favoriteIds.includes(String(game.id)) && !game.versions) { favoriteGames.push(game); }
1506
+ else if (game.versions) {
1507
+ game.versions.forEach(v => {
1508
+ if (favoriteIds.includes(String(v.favoriteId))) {
1509
+ // Need to resolve URL here for favorites too!
1510
+ let vUrl = v.url;
1511
+ if (game.category === 'Others' || game.category === 'Gameboy Games') {
1512
+ vUrl = resolveGameUrl(v.url);
1513
+ } else if (game.category === 'StrongdogXP') {
1514
+ // Strongdog favorites usually just rely on the ID to regenerate logic, but for safety in this object:
1515
+ // Logic remains handled inside createGameCard via ID lookup generally, but let's leave as is
1516
+ // because strongdog logic inside createGameCard regenerates paths based on ID/Page props anyway.
1517
+ }
1518
+
1519
+ favoriteGames.push({ ...game, id: v.favoriteId, name: `${game.name} - ${v.name}`, url: vUrl, versions: undefined });
1520
+ }
1521
+ });
1522
+ }
1523
+ });
1524
+ renderGames(favoriteGames.sort((a, b) => a.name.localeCompare(b.name)), favoritesGameList, true);
1525
+ } else {
1526
+ favoritesHeader.style.display = "none";
1527
+ favoritesGameList.style.display = "none";
1528
+ }
1529
+ }
1530
+
1531
+ // --- Category Nav ---
1532
+ function updateArrowVisibility() {
1533
+ prevCategoryBtn.disabled = currentCategoryIndex === 0;
1534
+ nextCategoryBtn.disabled = currentCategoryIndex === categories.length - 1;
1535
+ }
1536
+
1537
+ function switchCategory(newIndex) {
1538
+ currentCategoryIndex = newIndex;
1539
+ categorySlider.style.transform = `translateX(-${currentCategoryIndex * (100 / categories.length)}%)`;
1540
+ renderGamesForCurrentCategory();
1541
+ updateArrowVisibility();
1542
+ updateURL(categories[currentCategoryIndex]);
1543
+ }
1544
+
1545
+ async function renderGamesForCurrentCategory() {
1546
+ const categoryName = categories[currentCategoryIndex];
1547
+ gamesGridContainer.classList.add('opacity-0');
1548
+ await new Promise(r => setTimeout(r, 150));
1549
+ renderGames(allGames.filter(g => g.category === categoryName), gamesGridContainer, false);
1550
+ gamesGridContainer.classList.remove('opacity-0');
1551
+ }
1552
+
1553
+ // --- Search ---
1554
+ function debounce(func, delay) {
1555
+ let timeout;
1556
+ return function(...args) {
1557
+ clearTimeout(timeout);
1558
+ timeout = setTimeout(() => func.apply(this, args), delay);
1559
+ };
1560
+ }
1561
+
1562
+ const handleSearch = debounce(() => {
1563
+ const text = searchInput.value.toLowerCase().trim();
1564
+ const filteredDropdown = text ? searchableGames.filter(g => g.name.toLowerCase().includes(text)).slice(0, 5) : [];
1565
+
1566
+ searchResults.innerHTML = "";
1567
+
1568
+ if (filteredDropdown.length === 0 || !text) {
1569
+ searchResults.style.display = "none";
1570
+ searchDivider.style.display = "none";
1571
+ return;
1572
+ }
1573
+
1574
+ const favorites = getFavorites().map(String);
1575
+ filteredDropdown.forEach(game => {
1576
+ const isStrongdog = game.category === 'StrongdogXP';
1577
+ let imgSrc;
1578
+ let finalGameData = { ...game }; // clone
1579
+
1580
+ if (isStrongdog) {
1581
+ const { adjustedImgSrc } = getAdjustedUrls(game.imgSrc, game.page);
1582
+ imgSrc = adjustedImgSrc;
1583
+ } else if (game.category === 'Others' || game.category === 'Gameboy Games') {
1584
+ imgSrc = resolveGameUrl(game.imgSrc);
1585
+ finalGameData.url = resolveGameUrl(game.url);
1586
+ } else {
1587
+ imgSrc = game.imgSrc;
1588
+ }
1589
+
1590
+ const linkEl = document.createElement("a");
1591
+ linkEl.href = '#';
1592
+ linkEl.className = 'search-item flex justify-between items-center';
1593
+ linkEl.dataset.gameId = game.id;
1594
+ let isFavorite = favorites.includes(String(game.id));
1595
+
1596
+ linkEl.innerHTML = `
1597
+ <div class="flex items-center truncate min-w-0">
1598
+ <img src="${imgSrc}" alt="${game.name}" class="w-10 h-10 rounded-lg object-cover flex-shrink-0">
1599
+ <span class="ml-3 font-medium truncate text-gray-200" title="${game.name}">${game.name}</span>
1600
+ </div>
1601
+ <div class="flex items-center flex-shrink-0">
1602
+ <div class="bg-black/80 backdrop-blur-md rounded-xl px-2 py-1 flex items-center space-x-2">
1603
+ <button class="btn-card-action play-action" title="Play Game">
1604
+ <i class="fa-solid fa-play transition-colors"></i>
1605
+ </button>
1606
+ <button class="btn-card-action fav-action ${isFavorite ? 'favorited' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
1607
+ <i class="fa-solid fa-star fa-solid-star transition-colors"></i>
1608
+ <i class="fa-regular fa-star fa-regular-star transition-colors"></i>
1609
+ </button>
1610
+ </div>
1611
+ </div>
1612
+ `;
1613
+
1614
+ const favBtn = linkEl.querySelector('.fav-action');
1615
+ favBtn.addEventListener('click', (e) => {
1616
+ e.preventDefault(); e.stopPropagation();
1617
+ toggleFavorite(game.id);
1618
+ });
1619
+
1620
+ linkEl.addEventListener('click', async (e) => {
1621
+ e.preventDefault();
1622
+ if (isStrongdog) {
1623
+ const baseURL = sd_getBaseURLForPage(game.page);
1624
+ let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
1625
+ const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
1626
+ const strongdogGameData = { name: game.name, url: embedPath, category: game.category, id: game.id };
1627
+ openZone(strongdogGameData);
1628
+ } else {
1629
+ openZone(finalGameData);
1630
+ }
1631
+ searchResults.style.display = "none";
1632
+ searchDivider.style.display = "none";
1633
+ });
1634
+ searchResults.appendChild(linkEl);
1635
+ });
1636
+ searchResults.style.display = "block";
1637
+ searchDivider.style.display = "block";
1638
+ }, 250);
1639
+
1640
+ // --- FETCHING DATA MANUALLY (NO MODULES) ---
1641
+ // This function fetches the JS file as text, transforms it to set a global variable, and evals it.
1642
+ // This bypasses module loading restrictions on file:// protocols.
1643
+ async function fetchGameData() {
1644
+ try {
1645
+ // Fetch the file
1646
+ const response = await fetch(CARDS_DATA_URL);
1647
+ if (!response.ok) throw new Error('Failed to fetch game data');
1648
+ let scriptText = await response.text();
1649
+
1650
+ // Transform 'export default' to 'window.loadedGameData ='
1651
+ // This assumes the file structure is simple "export default [...]"
1652
+ if (scriptText.includes('export default')) {
1653
+ scriptText = scriptText.replace('export default', 'window.loadedGameData =');
1654
+ }
1655
+
1656
+ // Execute the script
1657
+ // We use new Function instead of eval for slight isolation, though still unsafe for untrusted code
1658
+ // (But we trust this source).
1659
+ // However, new Function creates a local scope. We need global assignment.
1660
+ // Standard eval or appending a script tag is better.
1661
+ const scriptEl = document.createElement('script');
1662
+ scriptEl.textContent = scriptText;
1663
+ document.body.appendChild(scriptEl);
1664
+
1665
+ // Wait a tick for execution
1666
+ await new Promise(resolve => setTimeout(resolve, 0));
1667
+
1668
+ if (window.loadedGameData) {
1669
+ return window.loadedGameData;
1670
+ } else {
1671
+ console.warn("Script loaded but window.loadedGameData is undefined");
1672
+ return [];
1673
+ }
1674
+ } catch (e) {
1675
+ console.error("Error manual loading games:", e);
1676
+ return [];
1677
+ }
1678
+ }
1679
+
1680
+ // --- Init ---
1681
+ async function initializeApp() {
1682
+ // Load Game Data Manually
1683
+ let gamesData = [];
1684
+ try {
1685
+ gamesData = await fetchGameData();
1686
+ } catch (e) {
1687
+ console.error("Could not load external games data", e);
1688
+ }
1689
+
1690
+ const strongdogGames = gamesData.map(g => ({ ...g, category: 'StrongdogXP' }));
1691
+
1692
+ try {
1693
+ const res = await fetch(GN_ZONES_URL);
1694
+ const gnMathRaw = await res.json();
1695
+ 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) }));
1696
+ allGames = [...strongdogGames, ...gnMathGames, ...othersGames];
1697
+ } catch (error) {
1698
+ console.warn("Failed to load GN-Math, falling back to others.", error);
1699
+ allGames = [...strongdogGames, ...othersGames];
1700
+ }
1701
+
1702
+ allGames = allGames.filter(g => !['bitlife', 'soundboard'].some(term => g.name.toLowerCase().includes(term)));
1703
+
1704
+ searchableGames = [];
1705
+ allGames.forEach(game => {
1706
+ if (game.versions && game.versions.length > 0) {
1707
+ game.versions.forEach(version => {
1708
+ searchableGames.push({
1709
+ ...game,
1710
+ id: version.favoriteId,
1711
+ name: `${game.name} - ${version.name}`,
1712
+ url: version.url,
1713
+ wasm: version.wasm,
1714
+ versions: undefined,
1715
+ baseGameId: game.id
1716
+ });
1717
+ });
1718
+ } else {
1719
+ searchableGames.push(game);
1720
+ }
1721
+ });
1722
+
1723
+ categories = ['StrongdogXP', 'GN-Math', 'Gameboy Games', 'Others'].filter(cat => allGames.some(g => g.category === cat));
1724
+
1725
+ const { category: urlCat } = parseURL();
1726
+ if (urlCat) currentCategoryIndex = Math.max(0, categories.findIndex(c => c.replace(/\s+/g, '-').toLowerCase() === urlCat.toLowerCase()));
1727
+
1728
+ categorySlider.style.width = `${categories.length * 100}%`;
1729
+ 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('');
1730
+
1731
+ switchCategory(currentCategoryIndex);
1732
+ renderFavorites();
1733
+ setupInstructionOverlay();
1734
+
1735
+ searchInput.placeholder = `Search all games...`;
1736
+ searchInput.addEventListener("input", handleSearch);
1737
+ searchInput.addEventListener("focus", handleSearch);
1738
+
1739
+ document.addEventListener("click", ev => {
1740
+ if (!document.getElementById('bottom-fixed-bar').contains(ev.target)) {
1741
+ searchResults.style.display = "none";
1742
+ searchDivider.style.display = "none";
1743
+ }
1744
+ if (!ev.target.closest('.version-btn') && !ev.target.closest('.version-favorite-btn') && !ev.target.closest('.eagler-dropdown')) {
1745
+ document.querySelectorAll('.eagler-dropdown.show').forEach(d => {
1746
+ d.classList.remove('show');
1747
+ });
1748
+ }
1749
+ });
1750
+
1751
+ prevCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex - 1));
1752
+ nextCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex + 1));
1753
+ document.getElementById('closeBtnZone').addEventListener('click', closeZoneViewer);
1754
+
1755
+ // --- FULLSCREEN BUTTON LISTENER ---
1756
+ document.getElementById('fullscreenBtnZone').addEventListener('click', () => {
1757
+ const zoneFrame = document.getElementById('zoneFrame');
1758
+ if (zoneFrame && zoneFrame.requestFullscreen) {
1759
+ zoneFrame.requestFullscreen();
1760
+ } else if (zoneFrame && zoneFrame.webkitRequestFullscreen) {
1761
+ zoneFrame.webkitRequestFullscreen();
1762
+ } else if (zoneFrame && zoneFrame.msRequestFullscreen) {
1763
+ zoneFrame.msRequestFullscreen();
1764
+ }
1765
+ });
1766
+
1767
+ 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(''); });
1768
+ closeCreditsBtn.addEventListener('click', () => creditsModal.style.display = 'none');
1769
+ }
1770
+
1771
+ document.addEventListener('DOMContentLoaded', initializeApp);
1772
+ </script>
1773
+ </body>
1774
+ </html>
1775
+ </template>
1776
+
1777
+ <script type="module">
1778
+ import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js";
1779
+ import { getAuth, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/10.12.2/firebase-auth.js";
1780
+ import { getFirestore, doc, getDoc, updateDoc, setDoc, onSnapshot, collection, query, where, getDocs, serverTimestamp, arrayUnion, increment } from "https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js";
1781
+
1782
+ const firebaseConfig = {
1783
+ apiKey: "AIzaSyAZBKAckVa4IMvJGjcyndZx6Y1XD52lgro",
1784
+ authDomain: "project-zirconium.firebaseapp.com",
1785
+ projectId: "project-zirconium",
1786
+ storageBucket: "project-zirconium.firebasestorage.app",
1787
+ messagingSenderId: "1096564243475",
1788
+ appId: "1:1096564243475:web:6d0956a70125eeea1ad3e6",
1789
+ measurementId: "G-1D4F692C1Q"
1790
+ };
1791
+
1792
+ const app = initializeApp(firebaseConfig);
1793
+ const db = getFirestore(app);
1794
+ const auth = getAuth(app); // Added for admin keybinds
1795
+
1796
+ const lockScreen = document.getElementById('lock-screen');
1797
+ const navbar = document.getElementById('navbar-container');
1798
+ let appFrame = document.getElementById('app-frame');
1799
+ const codeInput = document.getElementById('codeInput');
1800
+ const unlockBtn = document.getElementById('unlockBtn');
1801
+ const lockMessage = document.getElementById('lockMessage');
1802
+ const loadingText = document.getElementById('loading-text');
1803
+
1804
+ // Navbar elements
1805
+ const tabsContainer = document.getElementById('tabs-container');
1806
+ const glideLeft = document.getElementById('glide-left');
1807
+ const glideRight = document.getElementById('glide-right');
1808
+
1809
+ // Pin Elements
1810
+ const pinWrapper = document.getElementById('pin-wrapper');
1811
+ const pinBtn = document.getElementById('pin-btn');
1812
+ const pinMenu = document.getElementById('pin-menu');
1813
+ const repinBtn = document.getElementById('repin-btn');
1814
+ const removePinBtn = document.getElementById('remove-pin-btn');
1815
+ const hidePinBtn = document.getElementById('hide-pin-btn');
1816
+ const showPinMenuItem = document.getElementById('show-pin-menu-item');
1817
+
1818
+ // Auth Elements
1819
+ const authBtn = document.getElementById('auth-btn');
1820
+ const authMenu = document.getElementById('auth-menu');
1821
+ const logoutBtn = document.getElementById('logout-btn');
1822
+ const displayUsername = document.getElementById('display-username');
1823
+ const displayEmail = document.getElementById('display-email');
1824
+
1825
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.15/logged-in/';
1826
+ const STORAGE_KEY = 'local_access_code';
1827
+ const PINNED_PAGE_KEY = 'local_pinned_page';
1828
+ const PIN_HIDDEN_KEY = 'local_pin_hidden';
1829
+ const USER_DATA_KEY = 'local_user_data';
1830
+ const LAST_PAGE_KEY = 'local_last_page';
1831
+ const OWNER_EMAIL = "4simpleproblems@gmail.com"; // For admin checks
1832
+
1833
+ const PAGE_DATA = {
1834
+ "dashboard": { "name": "Dashboard", "icon": "fa-solid fa-house-user", "url": "dashboard.html" },
1835
+ "soundboard": { "name": "Soundboard", "icon": "fa-solid fa-volume-up", "url": "soundboard.html" },
1836
+ "notes": { "name": "Notes", "icon": "fa-solid fa-sticky-note", "url": "notes.html" },
1837
+ "dailyphoto": { "name": "Dailyphoto", "icon": "fa-solid fa-image", "url": "dailyphoto.html" },
1838
+ "countdowns": { "name": "Countdowns", "icon": "fa-solid fa-clock", "url": "countdowns.html" },
1839
+ "weather": { "name": "Weather", "icon": "fa-solid fa-cloud-sun", "url": "weather.html" },
1840
+ "dictionary": { "name": "Dictionary", "icon": "fa-solid fa-book", "url": "dictionary.html" },
1841
+ "messenger-v2": { "name": "Messenger", "icon": "fa-solid fa-comments", "url": "messenger-v2.html" },
1842
+ "games": { "name": "GAMES", "icon": "fa-solid fa-gamepad", "url": "games.html" },
1843
+ "settings": { "name": "Settings", "icon": "fa-solid fa-gear", "url": "settings.html" }
1844
+ };
1845
+
1846
+ let currentUser = null;
1847
+
1848
+ // --- ADMIN KEYBINDS LOGIC (Integrated) ---
1849
+ function initAdminKeybinds(db, auth, user) {
1850
+ console.log("[Admin Keybinds] Initializing for", user.uid);
1851
+ let explicitEnabled = true;
1852
+ let lastEnablePressTime = 0;
1853
+ let configUnsubscribe = null;
1854
+
1855
+ // Visual Feedback Helper
1856
+ function showAdminToast(message, type = "neutral") {
1857
+ const existing = document.getElementById("admin-keybind-toast");
1858
+ if (existing) existing.remove();
1859
+
1860
+ const toast = document.createElement("div");
1861
+ toast.id = "admin-keybind-toast";
1862
+
1863
+ // Styles
1864
+ Object.assign(toast.style, {
1865
+ position: "fixed",
1866
+ bottom: "24px",
1867
+ right: "24px",
1868
+ display: "flex",
1869
+ alignItems: "center",
1870
+ gap: "12px",
1871
+ backgroundColor: "rgba(13, 13, 13, 0.95)",
1872
+ backdropFilter: "blur(5px)",
1873
+ color: "#c0c0c0",
1874
+ padding: "14px 20px",
1875
+ borderRadius: "12px",
1876
+ border: "1px solid #333",
1877
+ fontFamily: "'Geist', 'Roboto', sans-serif",
1878
+ fontSize: "14px",
1879
+ fontWeight: "500",
1880
+ boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.5)",
1881
+ zIndex: "9999999",
1882
+ opacity: "0",
1883
+ transform: "translateY(20px)",
1884
+ transition: "all 0.3s cubic-bezier(0.16, 1, 0.3, 1)"
1885
+ });
1886
+
1887
+ let iconHtml = '';
1888
+ if (type === "success" || type === "green") {
1889
+ toast.style.borderColor = "rgba(34, 197, 94, 0.5)";
1890
+ iconHtml = '<i class="fa-solid fa-check-circle" style="color: #4ade80;"></i>';
1891
+ } else if (type === "error" || type === "red") {
1892
+ toast.style.borderColor = "rgba(239, 68, 68, 0.5)";
1893
+ iconHtml = '<i class="fa-solid fa-circle-exclamation" style="color: #f87171;"></i>';
1894
+ } else {
1895
+ toast.style.borderColor = "rgba(59, 130, 246, 0.5)";
1896
+ iconHtml = '<i class="fa-solid fa-info-circle" style="color: #60a5fa;"></i>';
1897
+ }
1898
+
1899
+ toast.innerHTML = `${iconHtml}<span>${message}</span>`;
1900
+ document.body.appendChild(toast);
1901
+
1902
+ requestAnimationFrame(() => {
1903
+ toast.style.opacity = "1";
1904
+ toast.style.transform = "translateY(0)";
1905
+ });
1906
+
1907
+ setTimeout(() => {
1908
+ toast.style.opacity = "0";
1909
+ toast.style.transform = "translateY(10px)";
1910
+ setTimeout(() => toast.remove(), 300);
1911
+ }, 3000);
1912
+ }
1913
+
1914
+ function subscribeToConfig() {
1915
+ if (configUnsubscribe) return;
1916
+ const configRef = doc(db, 'config', 'soundboard');
1917
+ configUnsubscribe = onSnapshot(configRef, async (docSnap) => {
1918
+ if (docSnap.exists()) {
1919
+ const data = docSnap.data();
1920
+ if (data.explicitEnabled !== undefined) {
1921
+ explicitEnabled = data.explicitEnabled;
1922
+ }
1923
+ } else {
1924
+ explicitEnabled = true;
1925
+ }
1926
+ }, (error) => console.error("Config Listen Error:", error));
1927
+ }
1928
+
1929
+ // Init Listener
1930
+ subscribeToConfig();
1931
+
1932
+ // Key Listener
1933
+ document.addEventListener('keydown', async (e) => {
1934
+ if (e.ctrlKey && e.altKey && (e.key.toLowerCase() === 'e' || e.code === 'KeyE')) {
1935
+ e.preventDefault();
1936
+ console.log("[Admin Keybinds] Ctrl+Alt+E detected.");
1937
+
1938
+ if (explicitEnabled) {
1939
+ try {
1940
+ await setDoc(doc(db, 'config', 'soundboard'), { explicitEnabled: false }, { merge: true });
1941
+ showAdminToast("Explicit Sounds: DISABLED", "red");
1942
+ lastEnablePressTime = 0;
1943
+ } catch (err) {
1944
+ console.error("[Admin Keybinds] Failed to disable:", err);
1945
+ showAdminToast("Error: Check Console", "red");
1946
+ }
1947
+ } else {
1948
+ const now = Date.now();
1949
+ if (now - lastEnablePressTime < 1500) {
1950
+ try {
1951
+ await setDoc(doc(db, 'config', 'soundboard'), { explicitEnabled: true }, { merge: true });
1952
+ showAdminToast("Explicit Sounds: ENABLED", "green");
1953
+ lastEnablePressTime = 0;
1954
+ } catch (err) {
1955
+ console.error("[Admin Keybinds] Failed to enable:", err);
1956
+ showAdminToast("Error: Check Console", "red");
1957
+ }
1958
+ } else {
1959
+ showAdminToast("Press Ctrl+Alt+E again to ENABLE", "blue");
1960
+ lastEnablePressTime = now;
1961
+ }
1962
+ }
1963
+ }
1964
+ });
1965
+ }
1966
+
1967
+ // --- ANALYTICS LOGIC (Integrated) ---
1968
+ function initAnalytics(db, userUid) {
1969
+ console.log("Analytics: Initializing...");
1970
+
1971
+ function getSessionId() {
1972
+ let sid = sessionStorage.getItem('analytics_session_id');
1973
+ if (!sid) {
1974
+ sid = 'sess_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
1975
+ sessionStorage.setItem('analytics_session_id', sid);
1976
+ }
1977
+ return sid;
1978
+ }
1979
+
1980
+ const sessionId = getSessionId();
1981
+ let isExcluded = sessionStorage.getItem('analytics_is_admin') === 'true';
1982
+
1983
+ // We handle exclusion logic in the main Auth flow, so here we just assume the status passed is correct
1984
+ // But we can double check session storage
1985
+
1986
+ if (!sessionStorage.getItem('analytics_start_time')) {
1987
+ sessionStorage.setItem('analytics_start_time', Date.now());
1988
+ }
1989
+
1990
+ window.trackPageView = function() {
1991
+ if (isExcluded) return;
1992
+ const path = window.location.pathname.replace('/logged-in/', '') || window.lastLoadedPage || 'dashboard.html';
1993
+ const pageName = document.title || path;
1994
+
1995
+ // For iframe, get path from loaded page
1996
+ const finalPath = window.lastLoadedPage || path;
1997
+
1998
+ const docRef = doc(db, 'analytics', sessionId);
1999
+
2000
+ // Using Firestore functions imported at top
2001
+ setDoc(docRef, {
2002
+ sessionId: sessionId,
2003
+ userAgent: navigator.userAgent,
2004
+ lastActive: serverTimestamp(),
2005
+ visitedPages: arrayUnion({
2006
+ path: finalPath,
2007
+ title: pageName,
2008
+ timestamp: Date.now()
2009
+ }),
2010
+ pageCount: increment(1)
2011
+ }, { merge: true }).catch(err => console.error("Analytics Error:", err));
2012
+ };
2013
+
2014
+ function updateSession() {
2015
+ if (isExcluded) return;
2016
+ const docRef = doc(db, 'analytics', sessionId);
2017
+ let startTime = parseInt(sessionStorage.getItem('analytics_start_time') || Date.now());
2018
+ const now = Date.now();
2019
+ const duration = (now - startTime) / 1000;
2020
+
2021
+ setDoc(docRef, {
2022
+ userId: userUid || 'anonymous',
2023
+ lastActive: serverTimestamp(),
2024
+ duration: duration,
2025
+ startTime: startTime
2026
+ }, { merge: true });
2027
+ }
2028
+
2029
+ // Start
2030
+ window.trackPageView();
2031
+ setInterval(updateSession, 10000);
2032
+ document.addEventListener('visibilitychange', () => {
2033
+ if (document.visibilityState === 'hidden') updateSession();
2034
+ });
2035
+ window.addEventListener('beforeunload', () => updateSession());
2036
+ }
2037
+
2038
+ // --- Navbar Logic ---
2039
+ function renderNavbar(activePageUrl) {
2040
+ const tabsContainerEl = document.getElementById('tabs-container');
2041
+ const pinBtnEl = document.getElementById('pin-btn');
2042
+ const pinWrapperEl = document.getElementById('pin-wrapper');
2043
+ const showPinMenuItemEl = document.getElementById('show-pin-menu-item');
2044
+
2045
+ // Glide Elements
2046
+ const glideLeft = document.getElementById('glide-left');
2047
+ const glideRight = document.getElementById('glide-right');
2048
+
2049
+ if (!tabsContainerEl) return;
2050
+ tabsContainerEl.innerHTML = '';
2051
+
2052
+ let currentPath = activePageUrl.replace(BASE_URL, '');
2053
+
2054
+ Object.values(PAGE_DATA).forEach(page => {
2055
+ const isAuth = page.url === currentPath;
2056
+ const tab = document.createElement('a');
2057
+ tab.className = `nav-tab ${isAuth ? 'active' : ''}`;
2058
+ tab.innerHTML = `<i class="${page.icon}"></i> ${page.name}`;
2059
+ tab.onclick = () => loadPage(page.url);
2060
+ tabsContainerEl.appendChild(tab);
2061
+ });
2062
+
2063
+ // --- Scroll Glide Logic ---
2064
+ const updateScrollGilders = () => {
2065
+ const container = tabsContainerEl;
2066
+ const leftButton = glideLeft;
2067
+ const rightButton = glideRight;
2068
+
2069
+ // Only show gliders if scrolling is possible/needed
2070
+ if (container.scrollWidth <= container.offsetWidth + 2) {
2071
+ if (leftButton) leftButton.classList.add('hidden');
2072
+ if (rightButton) rightButton.classList.add('hidden');
2073
+ // Center content if few tabs
2074
+ container.style.justifyContent = 'center';
2075
+ return;
2076
+ } else {
2077
+ container.style.justifyContent = 'flex-start';
2078
+ }
2079
+
2080
+ if (!container || !leftButton || !rightButton) return;
2081
+
2082
+ const isScrolledToLeft = container.scrollLeft <= 5;
2083
+ const maxScrollLeft = container.scrollWidth - container.offsetWidth;
2084
+ const isScrolledToRight = (container.scrollLeft + 5) >= maxScrollLeft;
2085
+
2086
+ if (isScrolledToLeft) leftButton.classList.add('hidden');
2087
+ else leftButton.classList.remove('hidden');
2088
+
2089
+ if (isScrolledToRight) rightButton.classList.add('hidden');
2090
+ else rightButton.classList.remove('hidden');
2091
+ };
2092
+
2093
+ // Setup Listeners for Glide
2094
+ tabsContainerEl.removeEventListener('scroll', updateScrollGilders); // Clean up old
2095
+ tabsContainerEl.addEventListener('scroll', updateScrollGilders);
2096
+ window.removeEventListener('resize', updateScrollGilders);
2097
+ window.addEventListener('resize', updateScrollGilders);
2098
+
2099
+ if(glideLeft) {
2100
+ glideLeft.onclick = () => { tabsContainerEl.scrollLeft = 0; };
2101
+ }
2102
+ if(glideRight) {
2103
+ glideRight.onclick = () => { tabsContainerEl.scrollLeft = tabsContainerEl.scrollWidth; };
2104
+ }
2105
+
2106
+ // Initial check
2107
+ setTimeout(updateScrollGilders, 50);
2108
+
2109
+ // Update Pin Button State
2110
+ const pinned = localStorage.getItem(PINNED_PAGE_KEY);
2111
+ if (pinBtnEl) {
2112
+ if (pinned && pinned === currentPath) {
2113
+ pinBtnEl.style.color = '#4f46e5';
2114
+ } else {
2115
+ pinBtnEl.style.color = '#d1d5db';
2116
+ }
2117
+ }
2118
+
2119
+ // Update Pin Visibility
2120
+ const isHidden = localStorage.getItem(PIN_HIDDEN_KEY) === 'true';
2121
+ if (isHidden) {
2122
+ if (pinWrapperEl) pinWrapperEl.classList.add('hidden');
2123
+ if (showPinMenuItemEl) {
2124
+ showPinMenuItemEl.classList.remove('hidden');
2125
+ showPinMenuItemEl.onclick = () => {
2126
+ localStorage.setItem(PIN_HIDDEN_KEY, 'false');
2127
+ renderNavbar(activePageUrl);
2128
+ };
2129
+ }
2130
+ } else {
2131
+ if (pinWrapperEl) pinWrapperEl.classList.remove('hidden');
2132
+ if (showPinMenuItemEl) showPinMenuItemEl.classList.add('hidden');
2133
+ }
2134
+ }
2135
+
2136
+ // --- Pin Logic ---
2137
+ function getCurrentPage() {
2138
+ const currentFrameSrc = appFrame.contentWindow.location.href;
2139
+ let pageName = currentFrameSrc.replace(BASE_URL, '');
2140
+ if (!pageName || pageName === 'about:blank') pageName = window.lastLoadedPage || 'dashboard.html';
2141
+ return pageName;
2142
+ }
2143
+
2144
+ // Pin Button
2145
+ if (pinBtn) {
2146
+ pinBtn.addEventListener('click', () => {
2147
+ const pageName = getCurrentPage();
2148
+ const currentPinned = localStorage.getItem(PINNED_PAGE_KEY);
2149
+
2150
+ if (currentPinned === pageName) {
2151
+ localStorage.removeItem(PINNED_PAGE_KEY);
2152
+ } else {
2153
+ localStorage.setItem(PINNED_PAGE_KEY, pageName);
2154
+ }
2155
+ renderNavbar(pageName);
2156
+ });
2157
+
2158
+ // Context Menu
2159
+ pinBtn.addEventListener('contextmenu', (e) => {
2160
+ e.preventDefault();
2161
+ if (pinMenu) pinMenu.classList.toggle('open');
2162
+ if (authMenu) authMenu.classList.remove('open');
2163
+ });
2164
+ }
2165
+
2166
+ if (repinBtn) {
2167
+ repinBtn.addEventListener('click', () => {
2168
+ localStorage.setItem(PINNED_PAGE_KEY, getCurrentPage());
2169
+ renderNavbar(getCurrentPage());
2170
+ if (pinMenu) pinMenu.classList.remove('open');
2171
+ });
2172
+ }
2173
+
2174
+ if (removePinBtn) {
2175
+ removePinBtn.addEventListener('click', () => {
2176
+ localStorage.removeItem(PINNED_PAGE_KEY);
2177
+ renderNavbar(getCurrentPage());
2178
+ if (pinMenu) pinMenu.classList.remove('open');
2179
+ });
2180
+ }
2181
+
2182
+ if (hidePinBtn) {
2183
+ hidePinBtn.addEventListener('click', () => {
2184
+ localStorage.setItem(PIN_HIDDEN_KEY, 'true');
2185
+ renderNavbar(getCurrentPage());
2186
+ if (pinMenu) pinMenu.classList.remove('open');
2187
+ });
2188
+ }
2189
+
2190
+ // Auth Menu
2191
+ if (authBtn) {
2192
+ authBtn.addEventListener('click', (e) => {
2193
+ e.stopPropagation();
2194
+ if (authMenu) authMenu.classList.toggle('open');
2195
+ if (pinMenu) pinMenu.classList.remove('open');
2196
+ });
2197
+ }
2198
+
2199
+ document.addEventListener('click', (e) => {
2200
+ if (authMenu && authBtn && !authMenu.contains(e.target) && !authBtn.contains(e.target)) {
2201
+ authMenu.classList.remove('open');
2202
+ }
2203
+ if (pinMenu && pinBtn && !pinMenu.contains(e.target) && !pinBtn.contains(e.target)) {
2204
+ pinMenu.classList.remove('open');
2205
+ }
2206
+ });
2207
+
2208
+ if (logoutBtn) {
2209
+ logoutBtn.addEventListener('click', () => {
2210
+ localStorage.removeItem(STORAGE_KEY);
2211
+ localStorage.removeItem(USER_DATA_KEY);
2212
+ location.reload();
2213
+ });
2214
+ }
2215
+
2216
+ async function fetchUserData(ownerUid) {
2217
+ try {
2218
+ // Parallel check for User Data and Admin Status
2219
+ const [userDoc, adminDoc] = await Promise.all([
2220
+ getDoc(doc(db, "users", ownerUid)),
2221
+ getDoc(doc(db, "admins", ownerUid))
2222
+ ]);
2223
+
2224
+ if (userDoc.exists()) {
2225
+ const data = userDoc.data();
2226
+ const userData = {
2227
+ uid: ownerUid,
2228
+ username: data.username || "User",
2229
+ email: data.email || "No email"
2230
+ };
2231
+ localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
2232
+ updateUIWithUser(userData);
2233
+ currentUser = userData; // Set global
2234
+ }
2235
+
2236
+ // Check Admin Status
2237
+ let isAdmin = false;
2238
+ if (userData && userData.email === OWNER_EMAIL) isAdmin = true;
2239
+ if (adminDoc.exists()) isAdmin = true;
2240
+
2241
+ if (isAdmin) {
2242
+ console.log("User is Admin/Superadmin");
2243
+ sessionStorage.setItem('analytics_is_admin', 'true');
2244
+ initAdminKeybinds(db, auth, { uid: ownerUid, email: currentUser?.email });
2245
+ } else {
2246
+ sessionStorage.removeItem('analytics_is_admin');
2247
+ }
2248
+
2249
+ // Initialize Analytics (After admin check to set exclusion flag)
2250
+ initAnalytics(db, ownerUid);
2251
+
2252
+ } catch (e) {
2253
+ console.error("Error fetching user data:", e);
2254
+ // Even on error, try init analytics as anon/fallback
2255
+ initAnalytics(db, ownerUid);
2256
+ }
2257
+ }
2258
+
2259
+ function updateUIWithUser(userData) {
2260
+ if (!userData) return;
2261
+ displayUsername.textContent = userData.username;
2262
+ displayEmail.textContent = userData.email;
2263
+
2264
+ // Profile Picture Logic
2265
+ const pfpType = userData.pfpType || 'letter';
2266
+ let pfpHtml = '';
2267
+
2268
+ if (pfpType === 'custom' && userData.customPfp) {
2269
+ pfpHtml = `<img src="${userData.customPfp}" class="w-full h-full object-cover rounded-full" alt="Profile">`;
2270
+ } else {
2271
+ // Letter Avatar
2272
+ const letter = (userData.username || 'U').charAt(0).toUpperCase();
2273
+ const bgColor = userData.pfpLetterBg || '#3B82F6'; // Default blue
2274
+ 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>`;
2275
+ }
2276
+
2277
+ authBtn.innerHTML = pfpHtml;
2278
+ authBtn.style.padding = '0'; // Remove padding to let avatar fill
2279
+ authBtn.style.overflow = 'hidden'; // Ensure roundness
2280
+ authBtn.style.border = '1px solid #4b5563'; // Maintain border
2281
+ }
2282
+
2283
+ // --- Auto-Login Logic ---
2284
+ async function checkAutoLogin() {
2285
+ const savedCode = localStorage.getItem(STORAGE_KEY);
2286
+ if (savedCode) {
2287
+ loadingText.classList.remove('hidden');
2288
+ unlockBtn.classList.add('hidden');
2289
+ codeInput.classList.add('hidden');
2290
+
2291
+ try {
2292
+ const docRef = doc(db, "access_codes", savedCode);
2293
+ const docSnap = await getDoc(docRef);
2294
+
2295
+ if (docSnap.exists() && docSnap.data().active) {
2296
+ const codeData = docSnap.data();
2297
+
2298
+ // Check for cached user data first
2299
+ let userData = null;
2300
+ try {
2301
+ userData = JSON.parse(localStorage.getItem(USER_DATA_KEY));
2302
+ } catch(e){}
2303
+
2304
+ if (userData && userData.uid === codeData.ownerUid) {
2305
+ updateUIWithUser(userData);
2306
+ currentUser = userData;
2307
+ }
2308
+
2309
+ // Always fetch fresh to re-verify admin status
2310
+ if (codeData.ownerUid) {
2311
+ await fetchUserData(codeData.ownerUid);
2312
+ }
2313
+
2314
+ launchApp();
2315
+ return;
2316
+ } else {
2317
+ localStorage.removeItem(STORAGE_KEY);
2318
+ resetLockScreen("Session expired. Please enter code again.");
2319
+ }
2320
+ } catch(e) {
2321
+ console.error("Auto-login error:", e);
2322
+ resetLockScreen("Connection failed. Retrying...");
2323
+ }
2324
+ }
2325
+ }
2326
+
2327
+ function resetLockScreen(msg) {
2328
+ loadingText.classList.add('hidden');
2329
+ unlockBtn.classList.remove('hidden');
2330
+ codeInput.classList.remove('hidden');
2331
+ lockMessage.innerText = msg || "";
2332
+ }
2333
+
2334
+ // --- Unlock Logic ---
2335
+ unlockBtn.addEventListener('click', async () => {
2336
+ let code = codeInput.value.trim().toUpperCase();
2337
+ code = code.replace(/-/g, '');
2338
+
2339
+ if (code.length !== 12) {
2340
+ lockMessage.innerText = "Invalid code format (12 characters).";
2341
+ return;
2342
+ }
2343
+
2344
+ lockMessage.innerText = "";
2345
+ unlockBtn.disabled = true;
2346
+ unlockBtn.innerText = "Checking...";
2347
+
2348
+ try {
2349
+ const docRef = doc(db, "access_codes", code);
2350
+ const docSnap = await getDoc(docRef);
2351
+
2352
+ if (!docSnap.exists()) {
2353
+ throw new Error("Code not found.");
2354
+ }
2355
+
2356
+ const data = docSnap.data();
2357
+
2358
+ if (data.claimed) {
2359
+ if (localStorage.getItem(STORAGE_KEY) === code) {
2360
+ launchApp();
2361
+ return;
2362
+ }
2363
+ throw new Error("This code is already in use.");
2364
+ }
2365
+
2366
+ if (!data.active) {
2367
+ throw new Error("Code is inactive.");
2368
+ }
2369
+
2370
+ // Claim it!
2371
+ await updateDoc(docRef, { claimed: true });
2372
+ localStorage.setItem(STORAGE_KEY, code);
2373
+
2374
+ // Fetch User Data immediately
2375
+ if (data.ownerUid) {
2376
+ await fetchUserData(data.ownerUid);
2377
+ }
2378
+
2379
+ launchApp();
2380
+
2381
+ } catch (e) {
2382
+ lockMessage.innerText = e.message;
2383
+ unlockBtn.disabled = false;
2384
+ unlockBtn.innerText = "Login";
2385
+ }
2386
+ });
2387
+
2388
+ function launchApp() {
2389
+ const lockScreenEl = document.getElementById('lock-screen');
2390
+ const navbarEl = document.getElementById('navbar-container');
2391
+ const appFrameEl = document.getElementById('app-frame');
2392
+
2393
+ if (lockScreenEl) lockScreenEl.classList.add('hidden');
2394
+ if (navbarEl) navbarEl.style.display = 'flex';
2395
+ if (appFrameEl) appFrameEl.style.display = 'block';
2396
+
2397
+ // Determine start page
2398
+ let startPage = 'dashboard.html';
2399
+ const lastPage = localStorage.getItem(LAST_PAGE_KEY);
2400
+ const pinned = localStorage.getItem(PINNED_PAGE_KEY);
2401
+
2402
+ // Priority: Last Page > Pinned > Default
2403
+ if (lastPage) {
2404
+ const isValid = Object.values(PAGE_DATA).some(p => p.url === lastPage);
2405
+ if (isValid) startPage = lastPage;
2406
+ } else if (pinned) {
2407
+ const isValid = Object.values(PAGE_DATA).some(p => p.url === pinned);
2408
+ if (isValid) startPage = pinned;
2409
+ }
2410
+
2411
+ loadPage(startPage);
2412
+
2413
+ // --- Apply Saved Theme on Startup ---
2414
+ try {
2415
+ const savedThemeRaw = localStorage.getItem('user-navbar-theme');
2416
+ if (savedThemeRaw) {
2417
+ const savedTheme = JSON.parse(savedThemeRaw);
2418
+ applyTheme(savedTheme);
2419
+ }
2420
+ } catch(e) { console.error("Error applying saved theme:", e); }
2421
+ }
2422
+
2423
+ // --- Page Loader ---
2424
+ window.loadPage = function(pageName) {
2425
+
2426
+ // Trigger Analytics Track on Page Load
2427
+ if (window.trackPageView) {
2428
+ window.trackPageView();
2429
+ }
2430
+
2431
+ // Intercept Settings
2432
+ if (pageName === 'settings.html' || pageName === 'settings') {
2433
+ window.lastLoadedPage = 'settings.html';
2434
+ localStorage.setItem(LAST_PAGE_KEY, 'settings.html');
2435
+ renderNavbar(BASE_URL + 'settings.html');
2436
+
2437
+ // Ensure overlay mode is OFF for settings (per request)
2438
+ document.body.classList.remove('nav-overlay-mode');
2439
+
2440
+ openSettings();
2441
+ return;
2442
+ }
2443
+
2444
+ // Close Settings if open
2445
+ const settingsOverlay = document.getElementById('settings-overlay');
2446
+ if (settingsOverlay) settingsOverlay.classList.add('hidden');
2447
+
2448
+ // Normalize path
2449
+ if (pageName.startsWith('./')) pageName = pageName.substring(2);
2450
+ if (pageName.startsWith('../')) pageName = pageName.substring(3);
2451
+ if (pageName.startsWith(BASE_URL)) pageName = pageName.substring(BASE_URL.length);
2452
+
2453
+ // --- OVERLAY LOGIC SWITCH ---
2454
+ // If page is 'games.html', 'settings.html', or 'messenger-v2.html', do NOT use overlay mode.
2455
+ if (pageName.includes('games') || pageName.includes('settings') || pageName.includes('messenger')) {
2456
+ document.body.classList.remove('nav-overlay-mode');
2457
+ } else {
2458
+ document.body.classList.add('nav-overlay-mode');
2459
+ }
2460
+ // ---------------------------
2461
+
2462
+ window.lastLoadedPage = pageName;
2463
+ localStorage.setItem(LAST_PAGE_KEY, pageName);
2464
+ renderNavbar(pageName);
2465
+
2466
+ // --- SPECIAL HANDLING FOR GAMES.HTML ---
2467
+ if (pageName === 'games.html') {
2468
+ const template = document.getElementById('games-page-template');
2469
+ if (template) {
2470
+ // Create new iframe
2471
+ const oldFrame = document.getElementById('app-frame');
2472
+ const newFrame = document.createElement('iframe');
2473
+ newFrame.id = 'app-frame';
2474
+
2475
+ if (oldFrame) {
2476
+ newFrame.style.cssText = oldFrame.style.cssText;
2477
+ if (oldFrame.parentNode) oldFrame.parentNode.replaceChild(newFrame, oldFrame);
2478
+ } else {
2479
+ newFrame.style.width = '100%';
2480
+ newFrame.style.height = '100%';
2481
+ newFrame.style.border = 'none';
2482
+ newFrame.style.display = 'block';
2483
+ newFrame.style.flexGrow = '1';
2484
+ document.body.appendChild(newFrame);
2485
+ }
2486
+ appFrame = newFrame;
2487
+
2488
+ const doc = newFrame.contentWindow.document;
2489
+ doc.open();
2490
+
2491
+ // We still need the base tag for certain resources, but imports inside template use absolute
2492
+ const baseTag = '<base href="' + BASE_URL + '">';
2493
+
2494
+ // Client bridge script
2495
+ const patchScript = `
2496
+ <script>
2497
+ window._LOCAL_MODE = true;
2498
+ window._CURRENT_PAGE_NAME = "${pageName}";
2499
+
2500
+ window.currentUser = {
2501
+ uid: '${currentUser ? currentUser.uid : "client-user"}',
2502
+ displayName: '${currentUser ? currentUser.username : "Client User"}',
2503
+ email: '${currentUser ? currentUser.email : "local@client"}',
2504
+ photoURL: null,
2505
+ providerData: []
2506
+ };
2507
+
2508
+ window.openSettings = function() { window.parent.openSettings(); };
2509
+
2510
+ // Forward keydown to parent for panic key
2511
+ window.addEventListener('keydown', function(e) {
2512
+ if(window.parent.handlePanic) window.parent.handlePanic(e);
2513
+ });
2514
+ <\/script>
2515
+ `;
2516
+
2517
+ // Inject template content
2518
+ let templateContent = template.innerHTML;
2519
+ let modifiedHtml = templateContent.replace('<head>', '<head>' + baseTag + patchScript);
2520
+ doc.write(modifiedHtml);
2521
+ doc.close();
2522
+ return;
2523
+ }
2524
+ }
2525
+
2526
+ // Normal fetch flow for other pages
2527
+ const url = BASE_URL + pageName;
2528
+
2529
+ fetch(url)
2530
+ .then(res => res.text())
2531
+ .then(html => {
2532
+ const oldFrame = document.getElementById('app-frame');
2533
+ const newFrame = document.createElement('iframe');
2534
+ newFrame.id = 'app-frame';
2535
+
2536
+ if (oldFrame) {
2537
+ newFrame.style.cssText = oldFrame.style.cssText;
2538
+ if (oldFrame.parentNode) {
2539
+ oldFrame.parentNode.replaceChild(newFrame, oldFrame);
2540
+ }
2541
+ } else {
2542
+ newFrame.style.width = '100%';
2543
+ newFrame.style.height = '100%';
2544
+ newFrame.style.border = 'none';
2545
+ newFrame.style.display = 'block';
2546
+ newFrame.style.flexGrow = '1';
2547
+ document.body.appendChild(newFrame);
2548
+ }
2549
+
2550
+ appFrame = newFrame;
2551
+
2552
+ const doc = newFrame.contentWindow.document;
2553
+ doc.open();
2554
+
2555
+ const baseTag = '<base href="' + BASE_URL + '">';
2556
+
2557
+ const patchScript = `
2558
+ <script>
2559
+ window._LOCAL_MODE = true;
2560
+ window._CURRENT_PAGE_NAME = "${pageName}";
2561
+
2562
+ window.currentUser = {
2563
+ uid: '${currentUser ? currentUser.uid : "client-user"}',
2564
+ displayName: '${currentUser ? currentUser.username : "Client User"}',
2565
+ email: '${currentUser ? currentUser.email : "local@client"}',
2566
+ photoURL: null,
2567
+ providerData: []
2568
+ };
2569
+
2570
+ // --- Parent Communication ---
2571
+ window.openSettings = function() {
2572
+ window.parent.openSettings();
2573
+ };
2574
+
2575
+ // Forward keydown to parent for panic key
2576
+ window.addEventListener('keydown', function(e) {
2577
+ if(window.parent.handlePanic) window.parent.handlePanic(e);
2578
+ });
2579
+
2580
+ document.addEventListener('click', e => {
2581
+ const link = e.target.closest('a');
2582
+ if (link && link.href) {
2583
+ const href = link.getAttribute('href');
2584
+ // Check if it's settings
2585
+ if (href.includes('settings.html') || href === '#settings') {
2586
+ e.preventDefault();
2587
+ window.parent.openSettings();
2588
+ return;
2589
+ }
2590
+
2591
+ e.preventDefault();
2592
+ window.parent.loadPage(href);
2593
+ }
2594
+ });
2595
+ <\/script>
2596
+ `;
2597
+
2598
+ let modifiedHtml = html.replace('<head>', '<head>' + baseTag + patchScript);
2599
+ doc.write(modifiedHtml);
2600
+ doc.close();
2601
+ })
2602
+ .catch(err => {
2603
+ console.error(err);
2604
+ alert("Failed to load page: " + pageName);
2605
+ });
2606
+ };
2607
+
2608
+ // --- Settings Logic ---
2609
+ const settingsOverlay = document.getElementById('settings-overlay');
2610
+ const settingsTabs = document.querySelectorAll('.settings-tab');
2611
+ const settingsSections = document.querySelectorAll('.settings-section');
2612
+
2613
+ window.openSettings = function() {
2614
+ settingsOverlay.classList.remove('hidden');
2615
+ loadSettingsData();
2616
+ };
2617
+
2618
+ // Tab Switching
2619
+ settingsTabs.forEach(btn => {
2620
+ btn.addEventListener('click', () => {
2621
+ // Update buttons
2622
+ settingsTabs.forEach(b => {
2623
+ b.classList.remove('active', 'text-white', 'bg-[#1a1a1a]');
2624
+ b.classList.add('text-gray-300');
2625
+ });
2626
+ btn.classList.add('active', 'text-white', 'bg-[#1a1a1a]');
2627
+ btn.classList.remove('text-gray-300');
2628
+
2629
+ // Show Content
2630
+ const tabId = btn.dataset.tab;
2631
+ settingsSections.forEach(sec => {
2632
+ if (sec.id === `tab-${tabId}`) sec.classList.remove('hidden');
2633
+ else sec.classList.add('hidden');
2634
+ });
2635
+ });
2636
+ });
2637
+
2638
+ // --- Settings Data & Actions ---
2639
+
2640
+ // --- 1. General: Username ---
2641
+ const settingsUsernameInput = document.getElementById('settings-username-input');
2642
+ const saveUsernameBtn = document.getElementById('save-username-btn');
2643
+ const usernameMsg = document.getElementById('username-msg');
2644
+
2645
+ async function checkProfanity(text) {
2646
+ try {
2647
+ const res = await fetch(`https://www.purgomalum.com/service/containsprofanity?text=${encodeURIComponent(text)}`);
2648
+ return (await res.text()) === 'true';
2649
+ } catch { return false; }
2650
+ }
2651
+
2652
+ saveUsernameBtn.addEventListener('click', async () => {
2653
+ if (!currentUser) return;
2654
+ const newName = settingsUsernameInput.value.trim();
2655
+ usernameMsg.innerText = "Checking...";
2656
+ usernameMsg.className = "text-gray-500 text-xs mt-2 min-h-[20px]";
2657
+
2658
+ if (newName.length < 3 || newName.length > 20) {
2659
+ usernameMsg.innerText = "Username must be 3-20 characters.";
2660
+ usernameMsg.className = "text-red-400 text-xs mt-2 min-h-[20px]";
2661
+ return;
2662
+ }
2663
+
2664
+ if (await checkProfanity(newName)) {
2665
+ usernameMsg.innerText = "Username not allowed.";
2666
+ usernameMsg.className = "text-red-400 text-xs mt-2 min-h-[20px]";
2667
+ return;
2668
+ }
2669
+
2670
+ try {
2671
+ // Check if taken
2672
+ const q = query(collection(db, "users"), where("username", "==", newName));
2673
+ const snap = await getDocs(q);
2674
+ if (!snap.empty && snap.docs[0].id !== currentUser.uid) {
2675
+ usernameMsg.innerText = "Username taken.";
2676
+ usernameMsg.className = "text-red-400 text-xs mt-2 min-h-[20px]";
2677
+ return;
2678
+ }
2679
+
2680
+ // Save
2681
+ await updateDoc(doc(db, "users", currentUser.uid), { username: newName });
2682
+ currentUser.username = newName;
2683
+ localStorage.setItem(USER_DATA_KEY, JSON.stringify(currentUser));
2684
+ updateUIWithUser(currentUser);
2685
+ usernameMsg.innerText = "Saved!";
2686
+ usernameMsg.className = "text-green-400 text-xs mt-2 min-h-[20px]";
2687
+
2688
+ } catch (e) {
2689
+ console.error(e);
2690
+ usernameMsg.innerText = "Error saving.";
2691
+ usernameMsg.className = "text-red-400 text-xs mt-2 min-h-[20px]";
2692
+ }
2693
+ });
2694
+
2695
+ // --- 2. Personalization ---
2696
+ // Letter Avatar Setup
2697
+ const letterColors = ['EF4444', 'F97316', 'F59E0B', '84CC16', '10B981', '06B6D4', '3B82F6', '6366F1', '8B5CF6', 'EC4899', '6B7280'];
2698
+ const pfpModeBtns = document.querySelectorAll('.pfp-mode-btn');
2699
+ const pfpLetterOptions = document.getElementById('pfp-letter-options');
2700
+ const pfpUploadOptions = document.getElementById('pfp-upload-options');
2701
+ const pfpPreview = document.getElementById('pfp-preview');
2702
+ const saveLetterPfpBtn = document.getElementById('save-letter-pfp-btn');
2703
+
2704
+ let pfpState = { mode: 'letter', letterColor: '#3B82F6' };
2705
+
2706
+ pfpModeBtns.forEach(btn => {
2707
+ btn.addEventListener('click', () => {
2708
+ pfpModeBtns.forEach(b => b.classList.remove('active', 'bg-[#222]'));
2709
+ btn.classList.add('active', 'bg-[#222]');
2710
+
2711
+ const mode = btn.dataset.mode;
2712
+ pfpState.mode = mode;
2713
+
2714
+ if (mode === 'letter') {
2715
+ pfpLetterOptions.classList.remove('hidden');
2716
+ pfpUploadOptions.classList.add('hidden');
2717
+ updatePfpPreview();
2718
+ } else {
2719
+ pfpLetterOptions.classList.add('hidden');
2720
+ pfpUploadOptions.classList.remove('hidden');
2721
+ }
2722
+ });
2723
+ });
2724
+
2725
+ // Render Color Grid
2726
+ const gridContainer = pfpLetterOptions.querySelector('.grid');
2727
+ gridContainer.innerHTML = ''; // Clear prev to avoid dups on re-run
2728
+ letterColors.forEach(color => {
2729
+ const div = document.createElement('div');
2730
+ div.className = 'w-8 h-8 rounded-full cursor-pointer hover:scale-110 transition border-2 border-transparent';
2731
+ div.style.backgroundColor = '#' + color;
2732
+ div.onclick = () => {
2733
+ pfpState.letterColor = '#' + color;
2734
+ updatePfpPreview();
2735
+ };
2736
+ gridContainer.appendChild(div);
2737
+ });
2738
+
2739
+ function updatePfpPreview() {
2740
+ if (pfpState.mode === 'letter') {
2741
+ const initial = currentUser ? (currentUser.username || "U").charAt(0).toUpperCase() : "U";
2742
+ pfpPreview.style.backgroundImage = 'none';
2743
+ pfpPreview.style.backgroundColor = pfpState.letterColor;
2744
+ pfpPreview.innerText = initial;
2745
+ }
2746
+ }
2747
+
2748
+ saveLetterPfpBtn.addEventListener('click', async () => {
2749
+ if (!currentUser) return;
2750
+ saveLetterPfpBtn.innerText = "Saving...";
2751
+ try {
2752
+ await updateDoc(doc(db, "users", currentUser.uid), {
2753
+ pfpType: 'letter',
2754
+ pfpLetterBg: pfpState.letterColor,
2755
+ letterAvatarColor: pfpState.letterColor // Legacy support
2756
+ });
2757
+ // Update local logic if needed
2758
+ currentUser.pfpType = 'letter';
2759
+ currentUser.pfpLetterBg = pfpState.letterColor;
2760
+ localStorage.setItem(USER_DATA_KEY, JSON.stringify(currentUser));
2761
+ saveLetterPfpBtn.innerText = "Saved!";
2762
+ setTimeout(() => saveLetterPfpBtn.innerText = "Set Letter Avatar", 2000);
2763
+ } catch(e) {
2764
+ console.error(e);
2765
+ saveLetterPfpBtn.innerText = "Error";
2766
+ }
2767
+ });
2768
+
2769
+ // --- CROPPER LOGIC ---
2770
+ const pfpUploadInput = document.getElementById('pfp-upload-input');
2771
+ const saveUploadPfpBtn = document.getElementById('save-upload-pfp-btn');
2772
+ const cropperModal = document.getElementById('cropperModal');
2773
+ const cropperCanvas = document.getElementById('cropperCanvas');
2774
+ const cancelCropBtn = document.getElementById('cancelCropBtn');
2775
+ const submitCropBtn = document.getElementById('submitCropBtn');
2776
+ const ctx = cropperCanvas.getContext('2d');
2777
+
2778
+ let cropperImage = null;
2779
+ let cropState = { x: 0, y: 0, radius: 100 };
2780
+ let isDragging = false;
2781
+ let dragStart = { x: 0, y: 0 };
2782
+
2783
+ pfpUploadInput.addEventListener('change', (e) => {
2784
+ const file = e.target.files[0];
2785
+ if (!file) return;
2786
+
2787
+ if (file.size > 2 * 1024 * 1024) {
2788
+ alert('File is too large (max 2MB).');
2789
+ return;
2790
+ }
2791
+
2792
+ const reader = new FileReader();
2793
+ reader.onload = (evt) => {
2794
+ cropperImage = new Image();
2795
+ cropperImage.onload = () => {
2796
+ // Setup canvas with fixed height for consistency
2797
+ const fixedHeight = 400;
2798
+ const scale = fixedHeight / cropperImage.height;
2799
+ cropperCanvas.height = fixedHeight;
2800
+ cropperCanvas.width = cropperImage.width * scale;
2801
+
2802
+ // Initial crop state: center, 1/3 min dim
2803
+ cropState = {
2804
+ x: cropperCanvas.width / 2,
2805
+ y: cropperCanvas.height / 2,
2806
+ radius: Math.min(cropperCanvas.width, cropperCanvas.height) / 3
2807
+ };
2808
+
2809
+ cropperModal.style.display = 'flex';
2810
+ requestAnimationFrame(drawCropper);
2811
+ };
2812
+ cropperImage.src = evt.target.result;
2813
+ };
2814
+ reader.readAsDataURL(file);
2815
+ });
2816
+
2817
+ const drawCropper = () => {
2818
+ if (!cropperImage) return;
2819
+ const w = cropperCanvas.width;
2820
+ const h = cropperCanvas.height;
2821
+ ctx.clearRect(0, 0, w, h);
2822
+
2823
+ // Draw image filling canvas
2824
+ ctx.drawImage(cropperImage, 0, 0, w, h);
2825
+
2826
+ // Draw overlay
2827
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
2828
+ ctx.beginPath();
2829
+ ctx.rect(0, 0, w, h);
2830
+ // Cut hole
2831
+ ctx.arc(cropState.x, cropState.y, cropState.radius, 0, 2 * Math.PI, true);
2832
+ ctx.fill();
2833
+
2834
+ // Draw dashed border
2835
+ ctx.strokeStyle = '#fff';
2836
+ ctx.lineWidth = 2;
2837
+ ctx.setLineDash([6, 4]); // Dashed pattern
2838
+ ctx.beginPath();
2839
+ ctx.arc(cropState.x, cropState.y, cropState.radius, 0, 2 * Math.PI);
2840
+ ctx.stroke();
2841
+ ctx.setLineDash([]); // Reset
2842
+ };
2843
+
2844
+ const handleStart = (x, y) => {
2845
+ const dx = x - cropState.x;
2846
+ const dy = y - cropState.y;
2847
+ if (dx*dx + dy*dy < cropState.radius * cropState.radius) {
2848
+ isDragging = true;
2849
+ dragStart = { x, y };
2850
+ }
2851
+ };
2852
+
2853
+ const handleMove = (x, y) => {
2854
+ if (isDragging) {
2855
+ const dx = x - dragStart.x;
2856
+ const dy = y - dragStart.y;
2857
+
2858
+ let newX = cropState.x + dx;
2859
+ let newY = cropState.y + dy;
2860
+
2861
+ const r = cropState.radius;
2862
+ const w = cropperCanvas.width;
2863
+ const h = cropperCanvas.height;
2864
+
2865
+ newX = Math.max(r, Math.min(newX, w - r));
2866
+ newY = Math.max(r, Math.min(newY, h - r));
2867
+
2868
+ cropState.x = newX;
2869
+ cropState.y = newY;
2870
+
2871
+ dragStart = { x, y };
2872
+ requestAnimationFrame(drawCropper);
2873
+ }
2874
+ };
2875
+
2876
+ const handleEnd = () => { isDragging = false; };
2877
+
2878
+ const handleScroll = (e) => {
2879
+ e.preventDefault();
2880
+ const delta = e.deltaY > 0 ? -5 : 5;
2881
+ let newRadius = cropState.radius + delta;
2882
+ const w = cropperCanvas.width;
2883
+ const h = cropperCanvas.height;
2884
+ const maxPossibleRadius = Math.min(w, h) / 2;
2885
+ newRadius = Math.max(20, Math.min(newRadius, maxPossibleRadius));
2886
+ const minX = newRadius;
2887
+ const maxX = w - newRadius;
2888
+ const minY = newRadius;
2889
+ const maxY = h - newRadius;
2890
+ cropState.x = Math.max(minX, Math.min(cropState.x, maxX));
2891
+ cropState.y = Math.max(minY, Math.min(cropState.y, maxY));
2892
+ cropState.radius = newRadius;
2893
+ requestAnimationFrame(drawCropper);
2894
+ };
2895
+
2896
+ cropperCanvas.addEventListener('mousedown', e => handleStart(e.offsetX, e.offsetY));
2897
+ cropperCanvas.addEventListener('mousemove', e => handleMove(e.offsetX, e.offsetY));
2898
+ cropperCanvas.addEventListener('mouseup', handleEnd);
2899
+ cropperCanvas.addEventListener('mouseleave', handleEnd);
2900
+ cropperCanvas.addEventListener('wheel', handleScroll);
2901
+
2902
+ cancelCropBtn.addEventListener('click', () => {
2903
+ cropperModal.style.display = 'none';
2904
+ pfpUploadInput.value = '';
2905
+ });
2906
+
2907
+ submitCropBtn.addEventListener('click', async () => {
2908
+ const tempCanvas = document.createElement('canvas');
2909
+ const size = 128; // Output size
2910
+ tempCanvas.width = size;
2911
+ tempCanvas.height = size;
2912
+ const tCtx = tempCanvas.getContext('2d');
2913
+
2914
+ const scale = cropperCanvas.height / cropperImage.height;
2915
+ const sourceX = (cropState.x - cropState.radius) / scale;
2916
+ const sourceY = (cropState.y - cropState.radius) / scale;
2917
+ const sourceSize = (cropState.radius * 2) / scale;
2918
+
2919
+ tCtx.drawImage(cropperImage, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
2920
+ const base64 = tempCanvas.toDataURL('image/jpeg', 0.8);
2921
+
2922
+ // Save to Firestore
2923
+ try {
2924
+ submitCropBtn.disabled = true;
2925
+ submitCropBtn.textContent = "Saving...";
2926
+ await updateDoc(doc(db, "users", currentUser.uid), {
2927
+ customPfp: base64,
2928
+ pfpType: 'custom'
2929
+ });
2930
+
2931
+ currentUser.customPfp = base64;
2932
+ currentUser.pfpType = 'custom';
2933
+ localStorage.setItem(USER_DATA_KEY, JSON.stringify(currentUser));
2934
+
2935
+ // Update UI
2936
+ pfpPreview.innerText = "";
2937
+ pfpPreview.style.backgroundImage = `url('${base64}')`;
2938
+ pfpPreview.style.backgroundColor = "transparent";
2939
+ pfpPreview.style.backgroundSize = "cover";
2940
+
2941
+ cropperModal.style.display = 'none';
2942
+ saveUploadPfpBtn.innerText = "Saved!"; // Feedback on the main btn too
2943
+ } catch (e) {
2944
+ console.error(e);
2945
+ alert('Failed to save image.');
2946
+ } finally {
2947
+ submitCropBtn.disabled = false;
2948
+ submitCropBtn.innerHTML = '<i class="fa-solid fa-check mr-2"></i> Submit';
2949
+ }
2950
+ });
2951
+
2952
+ saveUploadPfpBtn.addEventListener('click', () => {
2953
+ // Just triggers the file input again if clicked after logic
2954
+ pfpUploadInput.click();
2955
+ });
2956
+
2957
+
2958
+ // Theme Logic
2959
+ const themeGrid = document.getElementById('theme-picker-container');
2960
+
2961
+ async function loadThemes() {
2962
+ try {
2963
+ const res = await fetch('https://cdn.jsdelivr.net/npm/4sp-dv@latest/themes.json');
2964
+ if (!res.ok) return;
2965
+ let themes = await res.json();
2966
+
2967
+ // --- Sorting Logic from settings.js ---
2968
+ const orderedThemeNames = ['Dark', 'Light', 'Christmas'];
2969
+ const sortedThemes = [];
2970
+
2971
+ orderedThemeNames.forEach(name => {
2972
+ const theme = themes.find(t => t.name === name);
2973
+ if (theme) {
2974
+ sortedThemes.push(theme);
2975
+ themes = themes.filter(t => t.name !== name);
2976
+ }
2977
+ });
2978
+
2979
+ const colorMap = {
2980
+ 'Crimson': 1, 'Fire': 1, 'Orange': 2, 'Sunset': 2, 'Rust': 2, 'Ember': 2, 'Copper': 2,
2981
+ 'Gold': 3, 'Green': 4, 'Forest': 4, 'Matrix': 4, 'Mint': 5, 'Ocean': 6, 'Deep Blue': 6,
2982
+ 'Purple': 7, 'Royal': 7, 'Haze': 7, 'Lavender': 7, 'Pink': 8, 'Coral': 8, 'Rose Gold': 8,
2983
+ 'Clanker': 9, 'Monochrome': 9, 'Silver': 9, 'Slate': 9
2984
+ };
2985
+
2986
+ const getThemeType = (t) => (t['logo-src'] && t['logo-src'].includes('logo-dark.png')) ? 1 : 0;
2987
+
2988
+ themes.sort((a, b) => {
2989
+ const colorA = colorMap[a.name] || 100;
2990
+ const colorB = colorMap[b.name] || 100;
2991
+ if (colorA !== colorB) return colorA - colorB;
2992
+ const typeA = getThemeType(a);
2993
+ const typeB = getThemeType(b);
2994
+ if (typeA !== typeB) return typeA - typeB;
2995
+ return a.name.localeCompare(b.name);
2996
+ });
2997
+
2998
+ themes = [...sortedThemes, ...themes];
2999
+ // --- End Sorting Logic ---
3000
+
3001
+ const savedThemeRaw = localStorage.getItem('user-navbar-theme');
3002
+ let savedTheme = null;
3003
+ try { if(savedThemeRaw) savedTheme = JSON.parse(savedThemeRaw); } catch(e){}
3004
+
3005
+ themeGrid.innerHTML = '';
3006
+ themes.forEach(theme => {
3007
+ const isActive = savedTheme && savedTheme.name === theme.name;
3008
+ const previewBg = theme['navbar-bg'] || '#000000';
3009
+ const previewText = theme['navbar-text'] || '#c0c0c0';
3010
+ const previewBorder = theme['tab-active-border'] || '#4f46e5';
3011
+
3012
+ const btn = document.createElement('button');
3013
+ btn.className = `theme-button ${isActive ? 'active' : ''}`;
3014
+ btn.style.backgroundColor = previewBg;
3015
+ btn.style.borderColor = previewBorder;
3016
+ btn.innerHTML = `<div class="theme-button-name" style="color: ${previewText};">${theme.name}</div>`;
3017
+
3018
+ btn.onclick = () => {
3019
+ applyTheme(theme);
3020
+ document.querySelectorAll('.theme-button').forEach(b => b.classList.remove('active'));
3021
+ btn.classList.add('active');
3022
+ };
3023
+ themeGrid.appendChild(btn);
3024
+ });
3025
+ } catch(e) { console.error("Themes error", e); }
3026
+ }
3027
+
3028
+ const lightThemeNames = ['Light', 'Lavender', 'Rose Gold', 'Mint', 'Pink'];
3029
+
3030
+ function applyTheme(theme) {
3031
+ const root = document.documentElement;
3032
+
3033
+ // --- Logo Path Modification ---
3034
+ let logoSrc;
3035
+ if (lightThemeNames.includes(theme.name)) {
3036
+ logoSrc = 'https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo-dark.png';
3037
+ } else if (theme.name === 'Christmas') {
3038
+ logoSrc = 'https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo-christmas.png';
3039
+ } else {
3040
+ logoSrc = 'https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png';
3041
+ }
3042
+
3043
+ // Apply CSS Variables
3044
+ for (const [key, value] of Object.entries(theme)) {
3045
+ if (key !== 'logo-src' && key !== 'name') {
3046
+ root.style.setProperty(`--${key}`, value);
3047
+ }
3048
+ }
3049
+
3050
+ // Apply Logo & Tinting
3051
+ const logoImg = document.getElementById('navbar-logo');
3052
+ if (logoImg) {
3053
+ // Update Source (Use absolute path comparison or specific logic)
3054
+ // We just set it. Browser handles cache.
3055
+ // Note: The original navigation.js checked src endsWith to avoid reload flicker, but simple assignment is okay.
3056
+ if (!logoImg.src.includes(logoSrc)) {
3057
+ logoImg.src = logoSrc;
3058
+ }
3059
+
3060
+ // Apply Tinting
3061
+ const noFilterThemes = ['Dark', 'Light', 'Christmas'];
3062
+ if (noFilterThemes.includes(theme.name)) {
3063
+ logoImg.style.filter = '';
3064
+ logoImg.style.transform = '';
3065
+ } else {
3066
+ const tintColor = theme['tab-active-text'] || '#ffffff';
3067
+ // The original navigation.js used this trick for SVG coloring via filter
3068
+ logoImg.style.filter = `drop-shadow(100px 0 0 ${tintColor})`;
3069
+ logoImg.style.transform = 'translateX(-100px)';
3070
+ }
3071
+ }
3072
+
3073
+ // Save to local storage for persistence on reload
3074
+ localStorage.setItem('user-navbar-theme', JSON.stringify(theme));
3075
+
3076
+ // Also update user doc if logged in
3077
+ if (currentUser) {
3078
+ updateDoc(doc(db, "users", currentUser.uid), { navbarTheme: theme }).catch(console.error);
3079
+ }
3080
+ }
3081
+
3082
+ // --- 3. Privacy: Panic Key Logic ---
3083
+
3084
+ const DB_NAME = 'userLocalSettingsDB';
3085
+ const STORE_NAME = 'panicKeyStore';
3086
+
3087
+ // --- IndexedDB Helpers ---
3088
+ function openDB() {
3089
+ return new Promise((resolve, reject) => {
3090
+ const request = indexedDB.open(DB_NAME);
3091
+ request.onupgradeneeded = event => {
3092
+ const db = event.target.result;
3093
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
3094
+ db.createObjectStore(STORE_NAME, { keyPath: 'id' });
3095
+ }
3096
+ };
3097
+ request.onsuccess = () => resolve(request.result);
3098
+ request.onerror = () => reject(request.error);
3099
+ });
3100
+ }
3101
+
3102
+ async function getPanicKeySettings() {
3103
+ const db = await openDB();
3104
+ return new Promise((resolve, reject) => {
3105
+ const transaction = db.transaction(STORE_NAME, 'readonly');
3106
+ const store = transaction.objectStore(STORE_NAME);
3107
+ const request = store.getAll();
3108
+ request.onsuccess = () => {
3109
+ const settingsMap = new Map(request.result.map(item => [item.id, item]));
3110
+ db.close();
3111
+ resolve(settingsMap);
3112
+ };
3113
+ request.onerror = () => {
3114
+ db.close();
3115
+ reject(request.error);
3116
+ };
3117
+ });
3118
+ }
3119
+
3120
+ async function savePanicKeySettings(settingsArray) {
3121
+ const db = await openDB();
3122
+ return new Promise((resolve, reject) => {
3123
+ const transaction = db.transaction(STORE_NAME, 'readwrite');
3124
+ const store = transaction.objectStore(STORE_NAME);
3125
+
3126
+ let completed = 0;
3127
+ const total = settingsArray.length;
3128
+
3129
+ settingsArray.forEach(setting => {
3130
+ const request = store.put(setting);
3131
+ request.onsuccess = () => {
3132
+ completed++;
3133
+ if (completed === total) {
3134
+ db.close();
3135
+ resolve();
3136
+ }
3137
+ };
3138
+ request.onerror = () => {
3139
+ db.close();
3140
+ reject(request.error);
3141
+ };
3142
+ });
3143
+
3144
+ if (total === 0) {
3145
+ db.close();
3146
+ resolve();
3147
+ }
3148
+ });
3149
+ }
3150
+
3151
+ // Global store for active keys (in-memory)
3152
+ window.activePanicKeys = [];
3153
+
3154
+ async function loadPanicKeys() {
3155
+ try {
3156
+ const settingsMap = await getPanicKeySettings();
3157
+ window.activePanicKeys = []; // Reset
3158
+
3159
+ // Populate UI if settings panel is open (though this runs globally too)
3160
+ const panicKey1 = document.getElementById('panicKey1');
3161
+ const panicUrl1 = document.getElementById('panicUrl1');
3162
+ const panicKey2 = document.getElementById('panicKey2');
3163
+ const panicUrl2 = document.getElementById('panicUrl2');
3164
+ const panicKey3 = document.getElementById('panicKey3');
3165
+ const panicUrl3 = document.getElementById('panicUrl3');
3166
+
3167
+ const key1 = settingsMap.get('panicKey1');
3168
+ const key2 = settingsMap.get('panicKey2');
3169
+ const key3 = settingsMap.get('panicKey3');
3170
+
3171
+ if (key1) {
3172
+ if (panicKey1) panicKey1.value = key1.key || '';
3173
+ if (panicUrl1) panicUrl1.value = key1.url || '';
3174
+ if (key1.key && key1.url) window.activePanicKeys.push(key1);
3175
+ }
3176
+ if (key2) {
3177
+ if (panicKey2) panicKey2.value = key2.key || '';
3178
+ if (panicUrl2) panicUrl2.value = key2.url || '';
3179
+ if (key2.key && key2.url) window.activePanicKeys.push(key2);
3180
+ }
3181
+ if (key3) {
3182
+ if (panicKey3) panicKey3.value = key3.key || '';
3183
+ if (panicUrl3) panicUrl3.value = key3.url || '';
3184
+ if (key3.key && key3.url) window.activePanicKeys.push(key3);
3185
+ }
3186
+ } catch (e) {
3187
+ console.error("Error loading panic keys:", e);
3188
+ }
3189
+ }
3190
+
3191
+ // Panic Key UI Logic
3192
+ const applyPanicKeyBtn = document.getElementById('applyPanicKeyBtn');
3193
+ const panicKeyMessage = document.getElementById('panicKeyMessage');
3194
+ const panicKeyInputs = document.querySelectorAll('.panic-key-input');
3195
+
3196
+ // Input Validation
3197
+ const validKeyRegex = /^[a-z0-9`\-=\[\]\\;',\.\/]$/;
3198
+ panicKeyInputs.forEach(input => {
3199
+ input.addEventListener('keydown', e => {
3200
+ if (e.key.length > 1 && e.key !== 'Backspace' && e.key !== 'Delete' && e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Tab') {
3201
+ e.preventDefault(); return;
3202
+ }
3203
+ const lowerKey = e.key.toLowerCase();
3204
+ if (lowerKey.length === 1 && !validKeyRegex.test(lowerKey)) {
3205
+ e.preventDefault();
3206
+ if(panicKeyMessage) panicKeyMessage.innerHTML = `<span class="text-yellow-400">Invalid key. Only a-z, 0-9, and \` - = [ ] \\ ; ' , . / are allowed.</span>`;
3207
+ } else if (lowerKey.length === 1) {
3208
+ e.preventDefault(); input.value = lowerKey;
3209
+ if(panicKeyMessage) panicKeyMessage.innerText = '';
3210
+ }
3211
+ });
3212
+ });
3213
+
3214
+ if (applyPanicKeyBtn) {
3215
+ applyPanicKeyBtn.addEventListener('click', async () => {
3216
+ if(panicKeyMessage) panicKeyMessage.innerText = '';
3217
+ const keys = [
3218
+ document.getElementById('panicKey1').value,
3219
+ document.getElementById('panicKey2').value,
3220
+ document.getElementById('panicKey3').value
3221
+ ];
3222
+ const urls = [
3223
+ document.getElementById('panicUrl1').value,
3224
+ document.getElementById('panicUrl2').value,
3225
+ document.getElementById('panicUrl3').value
3226
+ ];
3227
+
3228
+ const settingsToSave = [];
3229
+ let hasError = false;
3230
+
3231
+ for (let i = 0; i < 3; i++) {
3232
+ const key = keys[i];
3233
+ let url = urls[i];
3234
+ const id = `panicKey${i + 1}`;
3235
+
3236
+ if (!key && !url) { settingsToSave.push({ id, key: '', url: '' }); continue; }
3237
+
3238
+ if ( (key && !url) || (!key && url) ) {
3239
+ panicKeyMessage.innerHTML = `<span class="text-red-400">Panic Key ${i+1} is incomplete. Both key and URL must be filled, or both empty.</span>`;
3240
+ hasError = true; break;
3241
+ }
3242
+
3243
+ if (url.startsWith('/') || url.startsWith('../') || url.startsWith('./')) {
3244
+ panicKeyMessage.innerHTML = `<span class="text-red-400">Panic Key ${i+1} must be a full web address (e.g., google.com).</span>`;
3245
+ hasError = true; break;
3246
+ }
3247
+
3248
+ if (!url.startsWith('https://') && !url.startsWith('http://')) { url = 'https://' + url; }
3249
+
3250
+ try { new URL(url); } catch (e) {
3251
+ panicKeyMessage.innerHTML = `<span class="text-red-400">Panic Key ${i+1} has an invalid URL.</span>`;
3252
+ hasError = true; break;
3253
+ }
3254
+ settingsToSave.push({ id, key, url });
3255
+ }
3256
+
3257
+ if (hasError) return;
3258
+
3259
+ const filledKeys = settingsToSave.map(s => s.key).filter(k => k);
3260
+ if (new Set(filledKeys).size !== filledKeys.length) {
3261
+ panicKeyMessage.innerHTML = `<span class="text-red-400">Duplicate panic keys found. Keys must be unique.</span>`;
3262
+ return;
3263
+ }
3264
+
3265
+ try {
3266
+ applyPanicKeyBtn.disabled = true;
3267
+ panicKeyMessage.innerHTML = `<span class="text-yellow-400"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Saving...</span>`;
3268
+ await savePanicKeySettings(settingsToSave);
3269
+ panicKeyMessage.innerHTML = `<span class="text-green-400">Panic key settings saved successfully! Reloading...</span>`;
3270
+ setTimeout(() => location.reload(), 1000);
3271
+ } catch (e) {
3272
+ console.error("Error saving panic key settings:", e);
3273
+ panicKeyMessage.innerHTML = `<span class="text-red-400">An error occurred while saving.</span>`;
3274
+ } finally {
3275
+ applyPanicKeyBtn.disabled = false;
3276
+ }
3277
+ });
3278
+ }
3279
+
3280
+ // --- Global Key Listener for Panic ---
3281
+ window.handlePanic = function(e) {
3282
+ if (!window.activePanicKeys || window.activePanicKeys.length === 0) return;
3283
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
3284
+ if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
3285
+
3286
+ const pressedKey = e.key.toLowerCase();
3287
+ const match = window.activePanicKeys.find(s => s.key.toLowerCase() === pressedKey);
3288
+
3289
+ if (match && match.url) {
3290
+ e.preventDefault();
3291
+ e.stopPropagation();
3292
+ window.location.href = match.url;
3293
+ }
3294
+ };
3295
+ window.addEventListener('keydown', window.handlePanic);
3296
+
3297
+
3298
+ // --- Helper: Load Data into Settings ---
3299
+ function loadSettingsData() {
3300
+ if (currentUser) {
3301
+ settingsUsernameInput.value = currentUser.username || "";
3302
+ pfpState.letterColor = currentUser.pfpLetterBg || '#3B82F6';
3303
+ updatePfpPreview();
3304
+ }
3305
+ loadThemes();
3306
+ loadPanicKeys(); // Refresh inputs when settings open
3307
+ }
3308
+
3309
+ checkAutoLogin();
3310
+ loadPanicKeys(); // Initial load for global listener
3311
+
3312
+ </script>
3313
+ </body>
3314
+ </html>