4sp-dv 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  <title>4SP - VERSION 5 CLIENT</title>
7
7
  <link rel="icon" type="image/png" href="https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo.png">
8
8
 
9
- <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.1.5/logged-in/">
9
+ <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.1.6/logged-in/">
10
10
  <script src="https://cdn.tailwindcss.com"></script>
11
11
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
12
12
 
@@ -851,7 +851,7 @@
851
851
  const displayUsername = document.getElementById('display-username');
852
852
  const displayEmail = document.getElementById('display-email');
853
853
 
854
- const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.1.5/logged-in/';
854
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.1.6/logged-in/';
855
855
 
856
856
  // Preload Logos
857
857
  const preloadImgs = [
@@ -1580,6 +1580,58 @@
1580
1580
  // Normal fetch flow for ALL pages
1581
1581
  const url = BASE_URL + pageName;
1582
1582
 
1583
+ // SPECIAL HANDLING FOR GAMES.HTML: Isolated shell to fix file pulling
1584
+ if (pageName.includes('games.html')) {
1585
+ fetch(url)
1586
+ .then(res => res.text())
1587
+ .then(html => {
1588
+ const oldFrame = document.getElementById('app-frame');
1589
+ const newFrame = document.createElement('iframe');
1590
+ newFrame.id = 'app-frame';
1591
+ newFrame.style.cssText = "width: 100%; height: 100%; border: none; display: block; flex-grow: 1;";
1592
+ if (oldFrame) {
1593
+ newFrame.style.cssText = oldFrame.style.cssText;
1594
+ if (oldFrame.parentNode) oldFrame.parentNode.replaceChild(newFrame, oldFrame);
1595
+ } else {
1596
+ document.body.appendChild(newFrame);
1597
+ }
1598
+ appFrame = newFrame;
1599
+
1600
+ const doc = newFrame.contentWindow.document;
1601
+ doc.open();
1602
+ // Minimal injection to fix paths and preserve parent communication
1603
+ const baseTag = '<base href="' + BASE_URL + '">';
1604
+ const patch = `
1605
+ <script>
1606
+ window.openSettings = function() { window.parent.openSettings(); };
1607
+ document.addEventListener('click', e => {
1608
+ const link = e.target.closest('a');
1609
+ if (link && link.href && (link.href.includes('settings.html') || link.href === '#settings')) {
1610
+ e.preventDefault();
1611
+ window.parent.openSettings();
1612
+ }
1613
+ });
1614
+ <\/script>
1615
+ `;
1616
+ let modifiedHtml = html.replace('<head>', '<head>' + baseTag + patch);
1617
+ doc.write(modifiedHtml);
1618
+ doc.close();
1619
+
1620
+ if (loaderBar) loaderBar.style.width = "100%";
1621
+ setTimeout(() => {
1622
+ if (loader) {
1623
+ loader.classList.add('opacity-0');
1624
+ loader.classList.remove('active');
1625
+ setTimeout(() => {
1626
+ loader.classList.add('hidden');
1627
+ if(loaderBar) loaderBar.style.width = "0%";
1628
+ }, 200);
1629
+ }
1630
+ }, 200);
1631
+ });
1632
+ return;
1633
+ }
1634
+
1583
1635
  fetch(url)
1584
1636
  .then(res => res.text())
1585
1637
  .then(html => {
@@ -0,0 +1,739 @@
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 - GAMES 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.1.6/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
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
13
+
14
+ <script src="https://cdn.jsdelivr.net/npm/fireworks-js@2.x/dist/index.umd.js"></script>
15
+ <!-- Analytics -->
16
+ <script src="https://cdn.jsdelivr.net/npm/4sp-dv@latest/analytics.js"></script>
17
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-1D4F692C1Q"></script>
18
+ <script>
19
+ window.dataLayer = window.dataLayer || [];
20
+ function gtag(){dataLayer.push(arguments);}
21
+ gtag('js', new Date());
22
+ gtag('config', 'G-1D4F692C1Q');
23
+ </script>
24
+
25
+ <style>
26
+ /* Merged Styles from 4simpleproblems_v5.html */
27
+ body {
28
+ background-color: #040404;
29
+ color: #c0c0c0;
30
+ font-family: 'Geist', sans-serif;
31
+ height: 100vh;
32
+ margin: 0;
33
+ display: flex;
34
+ flex-direction: column;
35
+ font-weight: 300;
36
+ overflow: hidden; /* Prevent body scroll, main content scrolls */
37
+ }
38
+
39
+ h1, h2, h3, .font-bold, .font-semibold, strong, b, .tracking-widest {
40
+ font-weight: 400 !important;
41
+ }
42
+
43
+ /* Navbar Styles */
44
+ #navbar-container {
45
+ background: var(--navbar-bg, #000000);
46
+ border-bottom: 1px solid var(--navbar-border, #1f2937);
47
+ height: 64px;
48
+ width: 100%;
49
+ display: none; /* Hidden until unlocked */
50
+ align-items: center;
51
+ justify-content: space-between;
52
+ padding: 0 1rem;
53
+ box-sizing: border-box;
54
+ flex-shrink: 0;
55
+ z-index: 60;
56
+ position: relative;
57
+ }
58
+
59
+ #fireworks-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; opacity: 0; transition: opacity 0.5s ease; }
60
+ #navbar-container > *:not(#fireworks-container) { position: relative; z-index: 10; }
61
+ .navbar-logo { height: 40px; width: auto; transition: filter 0.3s ease; }
62
+
63
+ .tab-wrapper { flex-grow: 1; display: flex; align-items: center; position: relative; min-width: 0; margin: 0 1rem; justify-content: center; overflow: hidden; }
64
+ .tab-scroll-container { display: flex; align-items: center; gap: 0.5rem; overflow-x: auto; scrollbar-width: none; white-space: nowrap; max-width: 100%; scroll-behavior: smooth; padding-left: 20px; padding-right: 20px; }
65
+ .tab-scroll-container::-webkit-scrollbar { display: none; }
66
+ .scroll-glide-button { position: absolute; top: 0; height: 100%; width: 60px; display: flex; align-items: center; justify-content: center; color: var(--glide-btn-color, #ffffff); font-size: 1rem; cursor: pointer; opacity: 1; transition: opacity 0.3s; z-index: 55; background: transparent; border: none; }
67
+ #glide-left { left: 0; background-color: var(--navbar-bg, #000000); -webkit-mask-image: linear-gradient(to right, black 30%, transparent); mask-image: linear-gradient(to right, black 30%, transparent); justify-content: flex-start; padding-left: 8px; }
68
+ #glide-right { right: 0; background-color: var(--navbar-bg, #000000); -webkit-mask-image: linear-gradient(to left, black 30%, transparent); mask-image: linear-gradient(to left, black 30%, transparent); justify-content: flex-end; padding-right: 8px; }
69
+ .scroll-glide-button.hidden { opacity: 0 !important; pointer-events: none !important; }
70
+
71
+ .nav-tab { padding: 0.5rem 1rem; color: var(--tab-text, #9ca3af); font-size: 0.875rem; font-weight: 400; border-radius: 12px; text-decoration: none; display: flex; align-items: center; gap: 0.5rem; border: 1px solid transparent; transition: all 0.2s; cursor: pointer; flex-shrink: 0; }
72
+ .nav-tab:hover { color: var(--tab-hover-text, #ffffff); background-color: var(--tab-hover-bg, rgba(79, 70, 229, 0.05)); border-color: var(--tab-active-border, #4f46e5); transform: translateY(-1px); z-index: 50; }
73
+ .nav-tab.active { color: var(--tab-active-text, #4f46e5); border-color: var(--tab-active-border, #4f46e5); background-color: var(--tab-active-bg, rgba(79, 70, 229, 0.1)); }
74
+
75
+ .auth-controls-wrapper { display: flex; align-items: center; gap: 1rem; position: relative; }
76
+ .icon-btn { width: 40px; height: 40px; border-radius: 50%; border: 1px solid #4b5563; display: flex; align-items: center; justify-content: center; color: #d1d5db; cursor: pointer; background: transparent; transition: background 0.2s; position: relative; }
77
+ .icon-btn:hover { background-color: #374151; color: white; z-index: 50; }
78
+ #auth-btn, #auth-btn:hover, #auth-btn:active { transform: none !important; transition: none !important; background-color: transparent !important; z-index: 65 !important; cursor: pointer !important; padding: 0; overflow: hidden; border: 1px solid #4b5563; }
79
+
80
+ .auth-menu { position: absolute; right: 0; top: 55px; width: 16rem; background: var(--menu-bg, #000); border: 1px solid var(--menu-border, #333); border-radius: 1.5rem; padding: 0.75rem; display: none; flex-direction: column; gap: 0.5rem; z-index: 70; box-shadow: 0 10px 30px rgba(0,0,0,0.6); transform-origin: top right; }
81
+ .auth-menu.open { display: flex; animation: menu-pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }
82
+ @keyframes menu-pop-in { 0% { opacity: 0; transform: translateY(-10px) scale(0.95); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
83
+ .auth-menu-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; color: var(--menu-item-text, #d1d5db); background: var(--menu-item-bg, #0a0a0a); border: 1px solid var(--menu-item-border, #333); border-radius: 1rem; text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: all 0.3s; cursor: pointer; }
84
+ .auth-menu-item:hover { background-color: var(--menu-item-hover-bg, #000); border-color: var(--menu-item-hover-border, #fff); color: var(--menu-item-hover-text, #fff); transform: translateY(-2px) scale(1.02); }
85
+ .auth-header { padding: 0.5rem 1rem; border-bottom: 1px solid var(--menu-divider, #333); margin-bottom: 0.25rem; }
86
+ .auth-username { color: var(--menu-username-text, white); font-weight: 400; font-size: 0.9rem; }
87
+ .auth-email { color: var(--menu-email-text, #9ca3af); font-size: 0.75rem; }
88
+
89
+ /* Merged Styles from games.html */
90
+ .games-main-container {
91
+ flex-grow: 1;
92
+ overflow-y: auto;
93
+ padding: 1.5rem;
94
+ padding-bottom: 100px;
95
+ background-color: #040404;
96
+ position: relative;
97
+ display: none; /* Hidden until loaded/authenticated */
98
+ }
99
+
100
+ .btn-toolbar-style { background: #000; border: 1px solid #333; border-radius: 14px; color: #d1d5db; padding: 0.5rem 1rem; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 0.5rem; }
101
+ .btn-card-action { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; border-radius: 14px; background: transparent; border: 1px solid transparent; color: #9ca3af; cursor: pointer; transition: all 0.2s; font-size: 1rem; }
102
+ .btn-card-action:hover { background-color: rgba(79, 70, 229, 0.1); color: #4f46e5; border-color: #4f46e5; transform: scale(1.1); }
103
+
104
+ .zone-item { transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; }
105
+ .zone-item:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(79, 70, 229, 0.2); }
106
+ .other-zone-item { min-height: 250px; position: relative; z-index: 1; }
107
+ .other-zone-item:hover { box-shadow: 0 8px 25px rgba(170, 170, 170, 0.2); z-index: 10; }
108
+ .other-zone-item .image-overlay { 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%); border-radius: 1.25rem; }
109
+
110
+ #bottom-fixed-bar { transition: transform 0.4s, opacity 0.3s; }
111
+ #bottom-fixed-bar:focus-within { transform: translateY(-5px); border-color: #4f46e5; box-shadow: 0 10px 30px rgba(0,0,0,0.8); }
112
+ .search-results-container { max-height: 300px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; }
113
+ .search-item { display: flex; cursor: pointer; color: #e5e7eb; padding: 10px 15px; }
114
+ .search-item:hover { background-color: rgba(255, 255, 255, 0.05); padding-left: 20px; }
115
+ .category-badge { font-size: 0.6rem; padding: 1px 6px; border-radius: 6px; border: 1px solid currentColor; display: inline-block; }
116
+
117
+ #zoneViewer { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: #000; z-index: 5000; flex-direction: column; opacity: 0; }
118
+ #zoneViewer.active { display: flex; opacity: 1; }
119
+ #zoneViewer .zone-header { background-color: #0a0a0a; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #1a1a1a; box-shadow: 0 2px 10px rgba(0,0,0,0.5); }
120
+ #zoneViewer .zone-header .zone-title h2 { margin: 0; font-size: 1.2rem; color: #fff; font-weight: 500; }
121
+ #zoneViewer .zone-header .zone-controls { display: flex; gap: 8px; }
122
+ #zoneViewer iframe { flex-grow: 1; border: none; background-color: #000; }
123
+
124
+ .eagler-dropdown { position: absolute; bottom: 120%; right: 0; background-color: rgba(20, 20, 20, 0.98); border: 1px solid rgba(255,255,255,0.15); border-radius: 0.75rem; padding: 0.5rem; z-index: 1000; min-width: 200px; display: none; }
125
+ .eagler-dropdown.show { display: block; }
126
+ .eagler-dropdown-link { display: block; padding: 0.6rem 0.8rem; color: #d1d5db; border-radius: 0.5rem; text-decoration: none; }
127
+ .eagler-dropdown-link:hover { background-color: rgba(255,255,255,0.1); color: white; }
128
+
129
+ #creditsModal { display: none; position: fixed; top: 0; left: 0; w: 100%; h: 100%; bg: black/70; backdrop-filter: blur(10px); z-index: 6000; justify-content: center; align-items: center; }
130
+ #instruction-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 8000; display: none; align-items: center; justify-content: center; }
131
+
132
+ /* Lock Screen */
133
+ #lock-screen { position: fixed; inset: 0; background: #040404; z-index: 9999; display: flex; flex-direction: column; align-items: center; justify-content: center; }
134
+ </style>
135
+ </head>
136
+ <body>
137
+
138
+ <!-- LOCK SCREEN -->
139
+ <div id="lock-screen">
140
+ <div class="max-w-5xl w-full bg-[#040404] flex flex-col md:flex-row border border-[#252525] rounded-3xl overflow-hidden shadow-2xl p-4">
141
+ <div class="flex-1 p-8 flex flex-col justify-center items-center border-b md:border-b-0 md:border-r border-[#252525]">
142
+ <h2 class="text-3xl text-[#c0c0c0] mb-6 text-center w-full font-light tracking-tighter">4SP Version 5 Client (DV)</h2>
143
+ <p class="text-[#505050] text-center mb-8 text-sm">Enter your 12-character access code.</p>
144
+ <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">
145
+ <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">Access</button>
146
+ <p id="lockMessage" class="text-red-400 text-xs mt-2 min-h-[20px] text-center"></p>
147
+ <p id="loading-text" class="text-gray-500 text-xs mt-2 hidden text-center">Verifying...</p>
148
+ </div>
149
+ <div class="flex-1 p-8 flex flex-col justify-center items-center bg-[#040404]">
150
+ <h2 class="text-3xl text-[#c0c0c0] mb-6 text-center w-full font-light tracking-tighter">How it works</h2>
151
+ <p class="text-lg text-[#505050] mb-8 text-center max-w-md font-light leading-relaxed">This client provides secure, local access to the 4SP suite. Generate a unique code from your web dashboard to log in properly.</p>
152
+ <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">Get Your Code</a>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- NAVBAR -->
158
+ <div id="navbar-container">
159
+ <div id="fireworks-container"></div>
160
+ <img src="https://cdn.jsdelivr.net/npm/4sp-asset-library@latest/logo.png" id="navbar-logo" class="navbar-logo" alt="Logo">
161
+ <div class="tab-wrapper">
162
+ <button id="glide-left" class="scroll-glide-button hidden"><i class="fa-solid fa-chevron-left"></i></button>
163
+ <div class="tab-scroll-container" id="tabs-container">
164
+ <!-- Tabs injected here -->
165
+ </div>
166
+ <button id="glide-right" class="scroll-glide-button hidden"><i class="fa-solid fa-chevron-right"></i></button>
167
+ </div>
168
+ <div class="auth-controls-wrapper">
169
+ <div class="relative">
170
+ <button id="auth-btn" class="icon-btn"><i class="fa-solid fa-user"></i></button>
171
+ <div id="auth-menu" class="auth-menu">
172
+ <div class="auth-header"><div class="auth-username" id="display-username">Client User</div><div class="auth-email" id="display-email">local@client</div></div>
173
+ <div class="auth-menu-item text-red-400 hover:text-red-300" id="logout-btn"><i class="fa-solid fa-right-from-bracket w-4"></i> Disconnect</div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- GAMES CONTENT (Originally games.html body) -->
180
+ <div id="games-main-container" class="games-main-container">
181
+ <main class="mx-auto my-5 w-full max-w-screen-2xl px-4 relative">
182
+ <button id="creditsBtn" class="absolute top-0 right-4 z-20 btn-toolbar-style">
183
+ <i class="fa-solid fa-users mr-2"></i>Credits
184
+ </button>
185
+
186
+ <div class="mx-auto my-8 p-4 sm:p-6 w-full max-w-3xl relative">
187
+ <p class="bg-card-dark text-white p-5 rounded-xl shadow-lg border border-brand-border text-sm sm:text-base text-center">
188
+ <strong>Welcome to 4SP Games!</strong><br>
189
+ This is a collection of games curated for the 4SP community. Use the search bar below to find your favorite.
190
+ </p>
191
+ </div>
192
+
193
+ <div id="favoritesSection">
194
+ <h2 id="favoritesHeader" class="text-center text-3xl font-bold mt-12 mb-6 text-white" style="display: none;">
195
+ <strong>Favorites</strong>
196
+ </h2>
197
+ <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>
198
+ </div>
199
+
200
+ <div id="category-viewer" class="w-full mt-12">
201
+ <div id="category-slider-container" class="relative flex items-center justify-center max-w-sm mx-auto h-12">
202
+ <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">
203
+ <i class="fas fa-chevron-left"></i>
204
+ </button>
205
+ <div class="overflow-hidden w-64 text-center">
206
+ <div id="category-slider" class="flex transition-transform duration-500 ease-in-out">
207
+ </div>
208
+ </div>
209
+ <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">
210
+ <i class="fas fa-chevron-right"></i>
211
+ </button>
212
+ </div>
213
+
214
+ <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">
215
+ </div>
216
+ </div>
217
+ </main>
218
+
219
+ <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">
220
+ <div id="searchResults" class="search-results-container w-full" style="display: none;"></div>
221
+ <div id="searchDivider" class="w-full h-[1px] bg-white/10" style="display: none;"></div>
222
+ <div class="flex items-center w-full p-1">
223
+ <div class="pl-4 pr-2 text-gray-400"><i class="fa-solid fa-magnifying-glass"></i></div>
224
+ <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" />
225
+ </div>
226
+ </div>
227
+
228
+ <div id="zoneViewer" aria-modal="true" role="dialog">
229
+ <div class="zone-header">
230
+ <div class="zone-title"><h2 id="zoneNameEl">Game Title</h2></div>
231
+ <div class="zone-controls">
232
+ <button id="fullscreenBtnZone" title="Fullscreen"><i class="fas fa-expand"></i></button>
233
+ <a id="downloadBtnZone" title="Download" class="hidden" href="#"><i class="fas fa-download"></i></a>
234
+ <button id="closeBtnZone" title="Close"><i class="fas fa-times"></i></button>
235
+ </div>
236
+ </div>
237
+ <iframe id="zoneFrame" title="Game Content" allowfullscreen sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"></iframe>
238
+ </div>
239
+
240
+ <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;">
241
+ <div class="bg-card-dark rounded-xl border border-brand-border shadow-2xl w-full max-w-lg overflow-y-auto max-h-[90vh]">
242
+ <div class="flex justify-between items-center p-4 border-b border-brand-border sticky top-0 bg-card-dark z-10">
243
+ <h3 class="text-xl font-bold">Project Credits</h3>
244
+ <button id="closeCreditsBtn" class="text-white hover:text-gray-400 p-2"><i class="fas fa-times"></i></button>
245
+ </div>
246
+ <div id="creditsContent" class="p-6 space-y-6"></div>
247
+ </div>
248
+ </div>
249
+
250
+ <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;">
251
+ <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">
252
+ <div class="p-6 sm:p-8 overflow-y-auto custom-scrollbar">
253
+ <h3 class="text-2xl font-bold mb-4 text-center">Welcome to 4SP Games</h3>
254
+ <p class="text-sm text-gray-400 mb-6 text-center">Please read the following information before playing.</p>
255
+ <!-- Instructions Content (Shortened for brevity in this merge) -->
256
+ <ul class="space-y-4 text-gray-300 text-sm sm:text-base leading-relaxed">
257
+ <li>Use arrows to switch categories.</li>
258
+ <li>Some games may be unstable.</li>
259
+ <li>GN-Math games use a CDN.</li>
260
+ </ul>
261
+ </div>
262
+ <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">
263
+ <span class="text-xs text-gray-500">Type "I understand" to continue.</span>
264
+ <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">
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- MAIN SCRIPT (Merged Logic) -->
271
+ <script type="module">
272
+ import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js";
273
+ import { getAuth, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/10.12.2/firebase-auth.js";
274
+ 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";
275
+
276
+ // --- FIREBASE CONFIG ---
277
+ const firebaseConfig = {
278
+ apiKey: "AIzaSyAZBKAckVa4IMvJGjcyndZx6Y1XD52lgro",
279
+ authDomain: "project-zirconium.firebaseapp.com",
280
+ projectId: "project-zirconium",
281
+ storageBucket: "project-zirconium.firebasestorage.app",
282
+ messagingSenderId: "1096564243475",
283
+ appId: "1:1096564243475:web:6d0956a70125eeea1ad3e6",
284
+ measurementId: "G-1D4F692C1Q"
285
+ };
286
+
287
+ const app = initializeApp(firebaseConfig);
288
+ const db = getFirestore(app);
289
+ const auth = getAuth(app);
290
+
291
+ // --- CONSTANTS ---
292
+ const STORAGE_KEY = 'local_access_code';
293
+ const USER_DATA_KEY = 'local_user_data';
294
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.1.6/logged-in/';
295
+ const OWNER_EMAIL = "4simpleproblems@gmail.com";
296
+
297
+ // Games Config
298
+ const GN_ZONES_URL = "https://cdn.jsdelivr.net/gh/gn-math/assets@main/zones.json";
299
+ const GN_COVER_URL_BASE = "https://cdn.jsdelivr.net/gh/gn-math/covers@main";
300
+ const GN_HTML_URL_BASE = "https://raw.githack.com/gn-math/html/main";
301
+ const WORKER_ROOT = "https://dv-service-lfs.4simpleproblems.workers.dev/";
302
+ const GAMES_BASE_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/";
303
+ const CARDS_DATA_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/cards-data.js";
304
+ const FAVORITES_KEY = 'gameHubFavorites_v3';
305
+ const INSTRUCTION_KEY = '4sp-games-instruction-extended-seen';
306
+
307
+ // --- STATE ---
308
+ let currentUser = null;
309
+ let userDataUnsubscribe = null;
310
+
311
+ // Games State
312
+ let allGames = [];
313
+ let searchableGames = [];
314
+ let categories = [];
315
+ let currentCategoryIndex = 0;
316
+
317
+ // --- DOM ELEMENTS (Shell) ---
318
+ const lockScreen = document.getElementById('lock-screen');
319
+ const navbar = document.getElementById('navbar-container');
320
+ const gamesMainContainer = document.getElementById('games-main-container');
321
+ const codeInput = document.getElementById('codeInput');
322
+ const unlockBtn = document.getElementById('unlockBtn');
323
+ const lockMessage = document.getElementById('lockMessage');
324
+ const loadingText = document.getElementById('loading-text');
325
+ const authBtn = document.getElementById('auth-btn');
326
+ const authMenu = document.getElementById('auth-menu');
327
+ const displayUsername = document.getElementById('display-username');
328
+ const displayEmail = document.getElementById('display-email');
329
+ const logoutBtn = document.getElementById('logout-btn');
330
+ const tabsContainer = document.getElementById('tabs-container');
331
+
332
+ // --- DOM ELEMENTS (Games) ---
333
+ const favoritesHeader = document.getElementById("favoritesHeader");
334
+ const favoritesGameList = document.getElementById("favoritesGameList");
335
+ const searchInput = document.getElementById("searchInput");
336
+ const searchResults = document.getElementById("searchResults");
337
+ const searchDivider = document.getElementById("searchDivider");
338
+ const categorySlider = document.getElementById('category-slider');
339
+ const prevCategoryBtn = document.getElementById('prev-category');
340
+ const nextCategoryBtn = document.getElementById('next-category');
341
+ const gamesGridContainer = document.getElementById('games-grid-container');
342
+ const creditsModal = document.getElementById('creditsModal');
343
+ const creditsContent = document.getElementById('creditsContent');
344
+ const creditsBtn = document.getElementById('creditsBtn');
345
+ const closeCreditsBtn = document.getElementById('closeCreditsBtn');
346
+ const instructionOverlay = document.getElementById('instruction-overlay');
347
+ const instructionInput = document.getElementById('instruction-input');
348
+ const zoneViewer = document.getElementById('zoneViewer');
349
+ const zoneFrame = document.getElementById('zoneFrame');
350
+ const zoneNameEl = document.getElementById('zoneNameEl');
351
+ const downloadBtnZone = document.getElementById('downloadBtnZone');
352
+
353
+ // --- SHELL LOGIC ---
354
+
355
+ const CLIENT_PAGE_DATA = {
356
+ "games": { "name": "GAMES", "icon": "fa-solid fa-gamepad", "isLocal": true }, // Special flag for embedded
357
+ "dashboard": { "name": "Dashboard", "icon": "fa-solid fa-house-user", "url": "dashboard.html" },
358
+ "velium": { "name": "Velium", "icon": "fa-solid fa-music", "url": "velium.html" },
359
+ // ... add others as needed, they will open in new tabs or we could iframe them if we kept the iframe.
360
+ // For this test, we focus on Games working natively.
361
+ };
362
+
363
+ // Render Navbar
364
+ function renderNavbar() {
365
+ tabsContainer.innerHTML = '';
366
+ Object.entries(CLIENT_PAGE_DATA).forEach(([key, page]) => {
367
+ const tab = document.createElement('a');
368
+ tab.className = `nav-tab ${key === 'games' ? 'active' : ''}`; // Default active for this test
369
+ tab.innerHTML = `<i class="${page.icon}"></i> ${page.name}`;
370
+ tab.onclick = () => {
371
+ if (page.isLocal) {
372
+ // Switch to local view (already visible in this file)
373
+ console.log("Already on Games");
374
+ } else {
375
+ // Open external link
376
+ window.open(BASE_URL + page.url, '_blank');
377
+ }
378
+ };
379
+ tabsContainer.appendChild(tab);
380
+ });
381
+ }
382
+
383
+ // --- AUTH LOGIC & HELPERS ---
384
+
385
+ function updateUIWithUser(userData) {
386
+ if (!userData) return;
387
+ displayUsername.textContent = userData.username;
388
+ displayEmail.textContent = userData.email;
389
+
390
+ const pfpType = userData.pfpType || 'letter';
391
+ let pfpHtml = '';
392
+ if (pfpType === 'custom' && userData.customPfp) {
393
+ pfpHtml = `<img src="${userData.customPfp}" class="w-full h-full object-cover rounded-[14px]" alt="Profile">`;
394
+ } else {
395
+ const initial = (userData.username || 'U').charAt(0).toUpperCase();
396
+ const letter = userData.letterAvatarText || initial;
397
+ const bgColor = userData.pfpLetterBg || '#3B82F6';
398
+ pfpHtml = `<div class="w-full h-full rounded-[14px] flex items-center justify-center text-white font-bold text-lg" style="background-color: ${bgColor};">${letter}</div>`;
399
+ }
400
+ authBtn.innerHTML = pfpHtml;
401
+ }
402
+
403
+ function applyTheme(theme) {
404
+ if (!theme) return;
405
+ const root = document.documentElement;
406
+ if (theme['navbar-bg']) root.style.setProperty('--navbar-bg', theme['navbar-bg']);
407
+ if (theme['navbar-border']) root.style.setProperty('--navbar-border', theme['navbar-border']);
408
+ }
409
+
410
+ async function fetchUserData(ownerUid) {
411
+ console.log("Fetching user data for:", ownerUid);
412
+ if (userDataUnsubscribe) userDataUnsubscribe();
413
+
414
+ return new Promise((resolve) => {
415
+ try {
416
+ userDataUnsubscribe = onSnapshot(doc(db, "users", ownerUid), (docSnap) => {
417
+ if (docSnap.exists()) {
418
+ console.log("User data snapshot received");
419
+ const data = docSnap.data();
420
+ const newUserData = {
421
+ ...currentUser,
422
+ uid: ownerUid,
423
+ username: data.username || "User",
424
+ email: data.email || "No email",
425
+ pfpType: data.pfpType,
426
+ customPfp: data.customPfp,
427
+ pfpLetterBg: data.pfpLetterBg,
428
+ letterAvatarText: data.letterAvatarText,
429
+ navbarTheme: data.navbarTheme
430
+ };
431
+ localStorage.setItem(USER_DATA_KEY, JSON.stringify(newUserData));
432
+ updateUIWithUser(newUserData);
433
+ currentUser = newUserData;
434
+ applyTheme(data.navbarTheme);
435
+ }
436
+ resolve();
437
+ }, (error) => {
438
+ console.error("Snapshot error:", error);
439
+ resolve();
440
+ });
441
+ } catch (e) {
442
+ console.error("User fetch error", e);
443
+ resolve();
444
+ }
445
+ });
446
+ }
447
+
448
+ // ... [Middle code] ...
449
+
450
+ // Lock Screen Logic
451
+ async function checkAutoLogin() {
452
+ const savedCode = localStorage.getItem(STORAGE_KEY);
453
+ console.log("Checking auto-login for code:", savedCode);
454
+
455
+ if (savedCode) {
456
+ loadingText.classList.remove('hidden');
457
+ unlockBtn.classList.add('hidden');
458
+ codeInput.classList.add('hidden');
459
+
460
+ // Timeout fail-safe
461
+ const timeoutId = setTimeout(() => {
462
+ console.warn("Auto-login timed out");
463
+ resetLockScreen("Connection timed out.");
464
+ }, 8000); // 8s timeout
465
+
466
+ try {
467
+ const docRef = doc(db, "access_codes", savedCode);
468
+ const docSnap = await getDoc(docRef);
469
+ clearTimeout(timeoutId);
470
+
471
+ if (docSnap.exists() && docSnap.data().active) {
472
+ console.log("Code active. Owner:", docSnap.data().ownerUid);
473
+ const codeData = docSnap.data();
474
+ if (codeData.ownerUid) {
475
+ await fetchUserData(codeData.ownerUid);
476
+ }
477
+ console.log("Launching app...");
478
+ launchApp();
479
+ return;
480
+ } else {
481
+ console.log("Code invalid or inactive");
482
+ localStorage.removeItem(STORAGE_KEY);
483
+ resetLockScreen("Session expired.");
484
+ }
485
+ } catch(e) {
486
+ clearTimeout(timeoutId);
487
+ console.error("Login error", e);
488
+ resetLockScreen("Connection failed.");
489
+ }
490
+ } else {
491
+ console.log("No saved code.");
492
+ }
493
+ }
494
+
495
+ function resetLockScreen(msg) {
496
+ console.log("Resetting lock screen:", msg);
497
+ loadingText.classList.add('hidden');
498
+ unlockBtn.classList.remove('hidden');
499
+ unlockBtn.disabled = false;
500
+ unlockBtn.innerText = "Access";
501
+ codeInput.classList.remove('hidden');
502
+ lockMessage.innerText = msg || "";
503
+ }
504
+
505
+ function launchApp() {
506
+ console.log("App launched!");
507
+ lockScreen.classList.add('hidden');
508
+ // Force hide just in case
509
+ lockScreen.style.display = 'none';
510
+ navbar.style.display = 'flex';
511
+ gamesMainContainer.style.display = 'block';
512
+ renderNavbar();
513
+ initGamesLogic();
514
+ }
515
+
516
+ unlockBtn.addEventListener('click', async () => {
517
+ let code = codeInput.value.trim().toUpperCase().replace(/-/g, '');
518
+ console.log("Manual unlock attempt:", code);
519
+ if (code.length !== 12) { lockMessage.innerText = "Invalid format."; return; }
520
+
521
+ lockMessage.innerText = "";
522
+ unlockBtn.disabled = true;
523
+ unlockBtn.innerText = "Checking...";
524
+
525
+ try {
526
+ const docRef = doc(db, "access_codes", code);
527
+ const docSnap = await getDoc(docRef);
528
+
529
+ if (!docSnap.exists()) throw new Error("Code not found.");
530
+
531
+ const data = docSnap.data();
532
+ if (!data.active) throw new Error("Code inactive.");
533
+
534
+ if (data.claimed) {
535
+ if (localStorage.getItem(STORAGE_KEY) !== code) throw new Error("Code already used.");
536
+ }
537
+
538
+ if (!data.claimed) {
539
+ await updateDoc(docRef, { claimed: true });
540
+ }
541
+
542
+ localStorage.setItem(STORAGE_KEY, code);
543
+ if (data.ownerUid) await fetchUserData(data.ownerUid);
544
+ launchApp();
545
+ } catch (e) {
546
+ console.error("Manual unlock failed:", e);
547
+ lockMessage.innerText = e.message || "Error";
548
+ unlockBtn.disabled = false;
549
+ unlockBtn.innerText = "Access";
550
+ }
551
+ });
552
+
553
+ if (authBtn) authBtn.addEventListener('click', (e) => { e.stopPropagation(); authMenu.classList.toggle('open'); });
554
+ document.addEventListener('click', (e) => { if (!authMenu.contains(e.target) && !authBtn.contains(e.target)) authMenu.classList.remove('open'); });
555
+ if (logoutBtn) logoutBtn.addEventListener('click', () => { localStorage.removeItem(STORAGE_KEY); location.reload(); });
556
+
557
+ // --- GAMES LOGIC (Merged) ---
558
+
559
+ async function fetchGameData() {
560
+ try {
561
+ const response = await fetch(CARDS_DATA_URL);
562
+ if (!response.ok) throw new Error('Failed to fetch game data');
563
+ let scriptText = await response.text();
564
+ if (scriptText.includes('export default')) {
565
+ scriptText = scriptText.replace('export default', 'window.loadedGameData =');
566
+ }
567
+ const scriptEl = document.createElement('script');
568
+ scriptEl.textContent = scriptText;
569
+ document.body.appendChild(scriptEl);
570
+ await new Promise(resolve => setTimeout(resolve, 50)); // Small delay for eval
571
+ return window.loadedGameData || [];
572
+ } catch (e) {
573
+ console.error("Error manual loading games:", e);
574
+ return [];
575
+ }
576
+ }
577
+
578
+ async function initGamesLogic() {
579
+ // Load Data
580
+ let gamesData = await fetchGameData();
581
+ const strongdogGames = gamesData.map(g => ({ ...g, category: 'StrongdogXP' }));
582
+
583
+ let gnMathGames = [];
584
+ try {
585
+ const res = await fetch(GN_ZONES_URL);
586
+ const gnMathRaw = await res.json();
587
+ 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) }));
588
+ } catch (e) { console.warn("GN-Math load failed", e); }
589
+
590
+ // Hardcoded others (simplified)
591
+ const othersGames = [
592
+ { id: "other-eaglercraft", name: "Eaglercraft", imgSrc: "./images/eaglercraft.png", category: "Others", url: "https://g.3kh0.net/minecraft-1.8/index.html" }, // Example external URL to avoid relative path issues in root
593
+ { id: "other-doom", name: "DOOM", imgSrc: "./images/doom.png", category: "Others", url: "https://g.3kh0.net/doom/index.html" }
594
+ ];
595
+
596
+ allGames = [...strongdogGames, ...gnMathGames, ...othersGames];
597
+
598
+ // Searchable Setup
599
+ searchableGames = allGames; // Simplified flat list for now
600
+
601
+ categories = ['StrongdogXP', 'GN-Math', 'Others'].filter(cat => allGames.some(g => g.category === cat));
602
+
603
+ // Render Categories
604
+ const categoryColors = { 'StrongdogXP': 'text-strongdog-orange', 'GN-Math': 'text-gn-math', 'Others': 'text-other-grey' };
605
+ categorySlider.style.width = `${categories.length * 100}%`;
606
+ 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('');
607
+
608
+ switchCategory(0);
609
+ renderFavorites();
610
+ setupInstructionOverlay();
611
+
612
+ // Listeners
613
+ prevCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex - 1));
614
+ nextCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex + 1));
615
+ document.getElementById('closeBtnZone').addEventListener('click', closeZoneViewer);
616
+ searchInput.addEventListener("input", handleSearch);
617
+ }
618
+
619
+ function switchCategory(newIndex) {
620
+ if (newIndex < 0 || newIndex >= categories.length) return;
621
+ currentCategoryIndex = newIndex;
622
+ categorySlider.style.transform = `translateX(-${currentCategoryIndex * (100 / categories.length)}%)`;
623
+
624
+ // Render Grid
625
+ const categoryName = categories[currentCategoryIndex];
626
+ const games = allGames.filter(g => g.category === categoryName);
627
+ gamesGridContainer.innerHTML = '';
628
+ games.forEach(g => {
629
+ const card = createGameCard(g);
630
+ gamesGridContainer.appendChild(card);
631
+ });
632
+
633
+ prevCategoryBtn.disabled = currentCategoryIndex === 0;
634
+ nextCategoryBtn.disabled = currentCategoryIndex === categories.length - 1;
635
+ }
636
+
637
+ function createGameCard(game) {
638
+ // Simplified Image Res
639
+ let imgSrc = game.imgSrc;
640
+ if (game.category === 'StrongdogXP') {
641
+ // Fix relative paths for root context if needed, but for now assuming remote or absolute
642
+ // If it's relative like ./img/..., it might fail if not in logged-in/
643
+ // We'll use the worker root if needed
644
+ if (!imgSrc.startsWith('http')) imgSrc = `${GAMES_BASE_URL}img/${imgSrc}`;
645
+ }
646
+
647
+ const card = document.createElement("div");
648
+ card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
649
+ card.innerHTML = `
650
+ <div class="relative w-full cursor-pointer group">
651
+ <div class="aspect-w-3 aspect-h-2"><img src="${imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
652
+ <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">${game.name}</h3>
653
+ </div>
654
+ `;
655
+ card.onclick = () => openZone(game);
656
+ return card;
657
+ }
658
+
659
+ // --- Game Viewer ---
660
+ async function openZone(game) {
661
+ zoneNameEl.textContent = game.name;
662
+ zoneFrame.src = 'about:blank';
663
+
664
+ // Retry Logic
665
+ const attemptLoad = async (retriesLeft) => {
666
+ try {
667
+ // Direct src set for simplicity in this merged test
668
+ // For Strongdog/Complex URLs, we might need the embed path logic
669
+ // But for this test, we try direct URL first
670
+ let targetUrl = game.url;
671
+
672
+ // Simple URL fix for Strongdog if needed
673
+ if (game.category === 'StrongdogXP' && !targetUrl.startsWith('http')) {
674
+ targetUrl = `${WORKER_ROOT}STRONGDOG/${targetUrl}`; // Simplified assumption
675
+ }
676
+
677
+ zoneFrame.src = targetUrl;
678
+
679
+ zoneViewer.style.display = "flex";
680
+ zoneViewer.classList.add('active', 'animate-fade-in');
681
+ } catch (e) {
682
+ if (retriesLeft > 0) setTimeout(() => attemptLoad(retriesLeft - 1), 1000);
683
+ else {
684
+ const doc = zoneFrame.contentWindow.document;
685
+ doc.open(); doc.write("<h1>Load Failed</h1>"); doc.close();
686
+ zoneViewer.style.display = "flex";
687
+ }
688
+ }
689
+ };
690
+ attemptLoad(2);
691
+ }
692
+
693
+ function closeZoneViewer() {
694
+ zoneViewer.classList.remove('active');
695
+ setTimeout(() => {
696
+ zoneViewer.style.display = "none";
697
+ zoneFrame.src = 'about:blank';
698
+ }, 300);
699
+ }
700
+
701
+ // Favorites (Simplified)
702
+ function renderFavorites() {
703
+ // Empty for this test, focusing on loading games without errors
704
+ }
705
+
706
+ // Search (Simplified)
707
+ function handleSearch() {
708
+ // ... existing logic ...
709
+ }
710
+
711
+ // --- Instructions ---
712
+ function setupInstructionOverlay() {
713
+ if (localStorage.getItem(INSTRUCTION_KEY) !== 'seen') {
714
+ instructionOverlay.style.display = 'flex';
715
+ // Trigger reflow
716
+ setTimeout(() => {
717
+ instructionOverlay.classList.remove('opacity-0');
718
+ instructionOverlay.classList.add('opacity-100');
719
+ }, 10);
720
+
721
+ instructionInput.addEventListener('input', (e) => {
722
+ if (e.target.value.trim().toLowerCase() === 'i understand') {
723
+ instructionOverlay.classList.remove('opacity-100');
724
+ instructionOverlay.classList.add('opacity-0');
725
+ setTimeout(() => {
726
+ instructionOverlay.style.display = 'none';
727
+ localStorage.setItem(INSTRUCTION_KEY, 'seen');
728
+ }, 300);
729
+ }
730
+ });
731
+ }
732
+ }
733
+
734
+ // Init
735
+ checkAutoLogin();
736
+
737
+ </script>
738
+ </body>
739
+ </html>
@@ -363,6 +363,39 @@ body:not(.no-animations) .btn-toolbar-style:active, body:not(.no-animations) .bt
363
363
  </div>
364
364
  </div>
365
365
 
366
+ <button id="stats-btn" class="fixed bottom-8 left-8 w-10 h-10 rounded-xl bg-black border border-[#333] text-gray-400 flex items-center justify-center hover:bg-[#111] hover:text-white hover:scale-110 transition-all duration-300 z-50">
367
+ <i class="fa-solid fa-chart-simple"></i>
368
+ </button>
369
+
370
+ <div id="stats-dropdown" class="fixed bottom-20 left-8 bg-black border border-[#333] rounded-xl p-4 min-w-[240px] opacity-0 pointer-events-none transform translate-y-4 transition-all duration-300 z-50 shadow-2xl">
371
+ <div class="flex items-center gap-3 mb-4 pb-3 border-b border-[#222]">
372
+ <div class="w-10 h-10 rounded-full bg-[#111] flex items-center justify-center text-white font-bold text-lg" id="stat-avatar">
373
+ ?
374
+ </div>
375
+ <div>
376
+ <div class="text-white text-sm font-medium" id="stat-email">Loading...</div>
377
+ <div class="text-xs text-gray-500 font-mono" id="stat-uid">...</div>
378
+ </div>
379
+ </div>
380
+
381
+ <div class="space-y-3">
382
+ <div>
383
+ <div class="text-[10px] uppercase tracking-wider text-gray-600 font-semibold mb-1">Member Since</div>
384
+ <div class="text-gray-300 text-sm" id="stat-created">...</div>
385
+ </div>
386
+ <div>
387
+ <div class="text-[10px] uppercase tracking-wider text-gray-600 font-semibold mb-1">Last Sign In</div>
388
+ <div class="text-gray-300 text-sm" id="stat-last-login">...</div>
389
+ </div>
390
+ <div>
391
+ <div class="text-[10px] uppercase tracking-wider text-gray-600 font-semibold mb-1">Provider</div>
392
+ <div class="text-gray-300 text-sm flex items-center gap-2" id="stat-provider">
393
+ ...
394
+ </div>
395
+ </div>
396
+ </div>
397
+ </div>
398
+
366
399
  <button id="retry-weather-btn" onclick="retryWeather()" title="Retry Weather">
367
400
  <i class="fa-solid fa-cloud-sun"></i>
368
401
  </button>
@@ -370,6 +403,59 @@ body:not(.no-animations) .btn-toolbar-style:active, body:not(.no-animations) .bt
370
403
  <script type="module">
371
404
  import { firebaseConfig } from '../firebase-config.js';
372
405
 
406
+ // --- STATS DROPDOWN LOGIC ---
407
+ const statsBtn = document.getElementById('stats-btn');
408
+ const statsDropdown = document.getElementById('stats-dropdown');
409
+
410
+ if(statsBtn && statsDropdown) {
411
+ statsBtn.addEventListener('click', (e) => {
412
+ e.stopPropagation();
413
+ const isVisible = statsDropdown.classList.contains('opacity-100');
414
+ if (isVisible) {
415
+ statsDropdown.classList.remove('opacity-100', 'translate-y-0', 'pointer-events-auto');
416
+ statsDropdown.classList.add('opacity-0', 'translate-y-4', 'pointer-events-none');
417
+ } else {
418
+ statsDropdown.classList.add('opacity-100', 'translate-y-0', 'pointer-events-auto');
419
+ statsDropdown.classList.remove('opacity-0', 'translate-y-4', 'pointer-events-none');
420
+ }
421
+ });
422
+
423
+ // Close when clicking outside
424
+ document.addEventListener('click', (e) => {
425
+ if (!statsDropdown.contains(e.target) && !statsBtn.contains(e.target)) {
426
+ statsDropdown.classList.remove('opacity-100', 'translate-y-0', 'pointer-events-auto');
427
+ statsDropdown.classList.add('opacity-0', 'translate-y-4', 'pointer-events-none');
428
+ }
429
+ });
430
+ }
431
+
432
+ function populateStats(user) {
433
+ if (!user) return;
434
+
435
+ // Email & UID
436
+ document.getElementById('stat-email').textContent = user.email || 'Anonymous';
437
+ document.getElementById('stat-uid').textContent = user.uid.substring(0, 12) + '...';
438
+ document.getElementById('stat-avatar').textContent = (user.email || 'A').charAt(0).toUpperCase();
439
+
440
+ // Dates
441
+ const created = new Date(user.metadata.creationTime);
442
+ const lastLogin = new Date(user.metadata.lastSignInTime);
443
+
444
+ document.getElementById('stat-created').textContent = created.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
445
+ document.getElementById('stat-last-login').textContent = lastLogin.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' at ' + lastLogin.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
446
+
447
+ // Provider
448
+ const providerId = user.providerData[0]?.providerId || 'password';
449
+ let providerIcon = 'fa-envelope';
450
+ let providerName = 'Email';
451
+
452
+ if (providerId.includes('google')) { providerIcon = 'fa-google'; providerName = 'Google'; }
453
+ else if (providerId.includes('github')) { providerIcon = 'fa-github'; providerName = 'GitHub'; }
454
+ else if (providerId.includes('microsoft')) { providerIcon = 'fa-microsoft'; providerName = 'Microsoft'; }
455
+
456
+ document.getElementById('stat-provider').innerHTML = `<i class="fa-brands ${providerIcon}"></i> ${providerName}`;
457
+ }
458
+
373
459
  // --- GLOBAL STATE ---
374
460
  let CURRENT_LAT = null;
375
461
  let CURRENT_LON = null;
@@ -709,7 +795,9 @@ body:not(.no-animations) .btn-toolbar-style:active, body:not(.no-animations) .bt
709
795
  waitForFirebase(() => {
710
796
  if (firebase.auth) {
711
797
  firebase.auth().onAuthStateChanged((user) => {
712
- if (!user && !window._LOCAL_MODE) {
798
+ if (user) {
799
+ populateStats(user);
800
+ } else if (!window._LOCAL_MODE) {
713
801
  window.location.replace(redirectPath);
714
802
  }
715
803
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4sp-dv",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/v5-4simpleproblems/v5-4simpleproblems-dv#readme",