stem-lab-toolkit 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/admin.js CHANGED
@@ -1,16 +1,16 @@
1
- if (!localStorage.getItem('mm_admin_session')) {
2
- window.location.replace('./browse.html');
3
- }
4
-
5
1
  let games = [];
6
2
 
7
- function getAdminPin() {
8
- return sessionStorage.getItem('mm_admin_pin') || '1234';
3
+ function getToken() {
4
+ return sessionStorage.getItem('mm_token') || '';
5
+ }
6
+
7
+ function authHeaders() {
8
+ return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
9
9
  }
10
10
 
11
11
  async function loadGames() {
12
12
  try {
13
- const res = await fetch('./api/games/all');
13
+ const res = await fetch('./api/games/all', { headers: authHeaders() });
14
14
  if (!res.ok) throw new Error();
15
15
  games = await res.json();
16
16
  } catch {
@@ -27,8 +27,8 @@ async function saveGames() {
27
27
  try {
28
28
  const res = await fetch('./api/games', {
29
29
  method: 'POST',
30
- headers: { 'Content-Type': 'application/json' },
31
- body: JSON.stringify({ adminPin: getAdminPin(), games }),
30
+ headers: authHeaders(),
31
+ body: JSON.stringify({ games }),
32
32
  });
33
33
  if (!res.ok) throw new Error();
34
34
  return true;
@@ -135,34 +135,149 @@ document.getElementById('table-body').addEventListener('change', async e => {
135
135
  showFeedback('table-feedback', ok ? 'Visibility updated.' : 'Updated locally (API unavailable).', ok ? 'ok' : 'error');
136
136
  });
137
137
 
138
- // Change PINs
139
- document.getElementById('pin-save-btn').addEventListener('click', async () => {
140
- const newAdmin = document.getElementById('pin-admin').value.trim();
141
- const newUser = document.getElementById('pin-user').value.trim();
138
+ // ── User Management ───────────────────────────────────────────────────────────
139
+
140
+ async function loadUsers() {
141
+ try {
142
+ const res = await fetch('./api/users', { headers: authHeaders() });
143
+ if (!res.ok) throw new Error();
144
+ const users = await res.json();
145
+ renderUsers(users);
146
+ } catch {
147
+ document.getElementById('users-tbody').innerHTML =
148
+ '<tr><td colspan="3" style="color:var(--muted);text-align:center;padding:24px;">Unable to load users.</td></tr>';
149
+ }
150
+ }
151
+
152
+ const RANK_CLASS = { administrator: 'rank-administrator', admin: 'rank-admin', user: 'rank-user' };
153
+
154
+ function renderUsers(users) {
155
+ const tbody = document.getElementById('users-tbody');
156
+ if (!users.length) {
157
+ tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--muted);">No users.</td></tr>';
158
+ return;
159
+ }
160
+ tbody.innerHTML = users.map(u => `
161
+ <tr>
162
+ <td style="font-weight:500;">${u.username}</td>
163
+ <td>
164
+ <select class="rank-select" data-user="${u.username}" data-orig="${u.rank}">
165
+ <option value="user" ${u.rank==='user'?'selected':''}>user</option>
166
+ <option value="admin" ${u.rank==='admin'?'selected':''}>admin</option>
167
+ <option value="administrator" ${u.rank==='administrator'?'selected':''}>administrator</option>
168
+ </select>
169
+ </td>
170
+ <td style="text-align:center;">
171
+ <label class="toggle">
172
+ <input type="checkbox" data-action="force-change" data-user="${u.username}" ${u.forcePasswordChange ? 'checked' : ''}>
173
+ <span class="toggle-slider"></span>
174
+ </label>
175
+ </td>
176
+ <td style="display:flex;gap:6px;flex-wrap:wrap;padding:6px 8px;">
177
+ <button class="btn btn-sm btn-primary" data-action="update-rank" data-user="${u.username}">Save Rank</button>
178
+ <button class="btn btn-sm btn-ghost" data-action="reset-pass" data-user="${u.username}">Reset Password</button>
179
+ <button class="btn btn-sm btn-danger" data-action="del-user" data-user="${u.username}">Delete</button>
180
+ </td>
181
+ </tr>
182
+ `).join('');
183
+ }
184
+
185
+ document.getElementById('users-tbody').addEventListener('click', async e => {
186
+ const btn = e.target.closest('[data-action]');
187
+ if (!btn) return;
188
+ const action = btn.dataset.action;
189
+ const username = btn.dataset.user;
190
+
191
+ if (action === 'update-rank') {
192
+ const row = btn.closest('tr');
193
+ const sel = row.querySelector('select.rank-select');
194
+ const rank = sel.value;
195
+ try {
196
+ const res = await fetch('./api/users/update', {
197
+ method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, rank }),
198
+ });
199
+ const data = await res.json();
200
+ if (!res.ok) throw new Error(data.error);
201
+ showFeedback('users-feedback', `${username} rank updated to ${rank}.`, 'ok');
202
+ loadUsers();
203
+ } catch (err) {
204
+ showFeedback('users-feedback', err.message || 'Failed.', 'error');
205
+ }
206
+ }
207
+
208
+ if (action === 'reset-pass') {
209
+ const newPass = prompt(`New password for "${username}":`);
210
+ if (!newPass) return;
211
+ try {
212
+ const res = await fetch('./api/users/reset-password', {
213
+ method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, password: newPass }),
214
+ });
215
+ const data = await res.json();
216
+ if (!res.ok) throw new Error(data.error);
217
+ showFeedback('users-feedback', `Password reset for ${username}.`, 'ok');
218
+ } catch (err) {
219
+ showFeedback('users-feedback', err.message || 'Failed.', 'error');
220
+ }
221
+ }
222
+
223
+ if (action === 'del-user') {
224
+ if (!confirm(`Delete user "${username}"?`)) return;
225
+ try {
226
+ const res = await fetch('./api/users/delete', {
227
+ method: 'POST', headers: authHeaders(), body: JSON.stringify({ username }),
228
+ });
229
+ const data = await res.json();
230
+ if (!res.ok) throw new Error(data.error);
231
+ showFeedback('users-feedback', `${username} deleted.`, 'ok');
232
+ loadUsers();
233
+ } catch (err) {
234
+ showFeedback('users-feedback', err.message || 'Failed.', 'error');
235
+ }
236
+ }
237
+ });
238
+
239
+ document.getElementById('add-user-btn').addEventListener('click', async () => {
240
+ const username = document.getElementById('new-username').value.trim();
241
+ const password = document.getElementById('new-password').value;
242
+ const rank = document.getElementById('new-rank').value;
142
243
 
143
- if (!newAdmin && !newUser) {
144
- showFeedback('pin-feedback', 'Enter at least one new PIN.', 'error');
244
+ if (!username || !password) {
245
+ showFeedback('add-user-feedback', 'Username and password are required.', 'error');
145
246
  return;
146
247
  }
147
248
 
148
249
  try {
149
- const res = await fetch('./api/pins', {
150
- method: 'POST',
151
- headers: { 'Content-Type': 'application/json' },
152
- body: JSON.stringify({ adminPin: getAdminPin(), newAdmin, newUser }),
250
+ const res = await fetch('./api/users/add', {
251
+ method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, password, rank }),
153
252
  });
154
253
  const data = await res.json();
155
- if (!res.ok) {
156
- showFeedback('pin-feedback', data.error || 'Failed.', 'error');
157
- return;
158
- }
159
- if (newAdmin) sessionStorage.setItem('mm_admin_pin', newAdmin);
160
- document.getElementById('pin-admin').value = '';
161
- document.getElementById('pin-user').value = '';
162
- showFeedback('pin-feedback', 'PINs updated.', 'ok');
163
- } catch {
164
- showFeedback('pin-feedback', 'API unavailable.', 'error');
254
+ if (!res.ok) throw new Error(data.error);
255
+ document.getElementById('new-username').value = '';
256
+ document.getElementById('new-password').value = '';
257
+ showFeedback('add-user-feedback', `User "${username}" added.`, 'ok');
258
+ loadUsers();
259
+ } catch (err) {
260
+ showFeedback('add-user-feedback', err.message || 'Failed.', 'error');
261
+ }
262
+ });
263
+
264
+ document.getElementById('users-tbody').addEventListener('change', async e => {
265
+ const input = e.target.closest('input[data-action="force-change"]');
266
+ if (!input) return;
267
+ const username = input.dataset.user;
268
+ const force = input.checked;
269
+ try {
270
+ const res = await fetch('./api/users/set-force-change', {
271
+ method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, force }),
272
+ });
273
+ const data = await res.json();
274
+ if (!res.ok) throw new Error(data.error);
275
+ showFeedback('users-feedback', `Force password change ${force ? 'enabled' : 'disabled'} for ${username}.`, 'ok');
276
+ } catch (err) {
277
+ input.checked = !force;
278
+ showFeedback('users-feedback', err.message || 'Failed.', 'error');
165
279
  }
166
280
  });
167
281
 
168
282
  loadGames();
283
+ loadUsers();
package/js/games.js CHANGED
@@ -131,7 +131,7 @@ function renderGotd() {
131
131
 
132
132
  section.innerHTML = `
133
133
  <div class="section-title">Game of the Day</div>
134
- <a href="./game.html?slug=${gotd.slug}" class="gotd-card">
134
+ <a href="./tool.html?slug=${gotd.slug}" class="gotd-card">
135
135
  ${thumbHtml}
136
136
  <div>
137
137
  <div class="gotd-label">⭐ Featured Today</div>
@@ -159,7 +159,7 @@ function renderGrid() {
159
159
  }
160
160
 
161
161
  grid.innerHTML = games.map(g => `
162
- <a href="./game.html?slug=${g.slug}" class="game-card">
162
+ <a href="./tool.html?slug=${g.slug}" class="game-card">
163
163
  ${cardThumb(g)}
164
164
  <div class="game-card-body">
165
165
  <div class="game-card-name">${g.name}</div>
package/js/main.js CHANGED
@@ -81,7 +81,7 @@ function pressEquals() {
81
81
  const entry = currentInput || displayVal;
82
82
 
83
83
  if (operand === null) {
84
- if (entry === '5555') { window.location.href = './browse.html'; return; }
84
+ if (entry === '8008') { triggerMatrixEgg(); return; }
85
85
  justEvaled = true;
86
86
  return;
87
87
  }
@@ -174,99 +174,18 @@ document.querySelector('.sci-rows').addEventListener('click', e => {
174
174
  });
175
175
 
176
176
  document.addEventListener('keydown', e => {
177
- if (pinOverlay.classList.contains('hidden')) {
178
- if (e.key >= '0' && e.key <= '9') pressDigit(e.key);
179
- else if (e.key === '.') pressDot();
180
- else if (e.key === '+') pressOp('+');
181
- else if (e.key === '-') pressOp('');
182
- else if (e.key === '*') pressOp('×');
183
- else if (e.key === '/') { e.preventDefault(); pressOp('÷'); }
184
- else if (e.key === 'Enter' || e.key === '=') pressEquals();
185
- else if (e.key === 'Escape') pressClear();
186
- else if (e.key === 'Backspace') {
187
- if (currentInput.length > 1) { currentInput = currentInput.slice(0, -1); setDisplay(currentInput); }
188
- else { currentInput = ''; setDisplay('0'); }
189
- }
190
- } else {
191
- if (e.key >= '0' && e.key <= '9') addPinDigit(e.key);
192
- else if (e.key === 'Backspace') delPinDigit();
193
- else if (e.key === 'Escape') closePinOverlay();
194
- }
195
- });
196
-
197
- // ── PIN overlay ──────────────────────────────────────────────────────────────
198
- const pinOverlay = document.getElementById('pin-overlay');
199
- const pinError = document.getElementById('pin-error');
200
- let pinEntry = '';
201
-
202
- function openPinOverlay() {
203
- pinEntry = '';
204
- pinError.textContent = '';
205
- updatePinDots();
206
- pinOverlay.classList.remove('hidden');
207
- }
208
-
209
- function closePinOverlay() {
210
- pinOverlay.classList.add('hidden');
211
- pinEntry = '';
212
- }
213
-
214
- function updatePinDots(state) {
215
- for (let i = 0; i < 4; i++) {
216
- const dot = document.getElementById('d' + i);
217
- dot.className = 'pin-dot';
218
- if (state === 'error') { dot.classList.add('error'); }
219
- else if (i < pinEntry.length) { dot.classList.add('filled'); }
177
+ const loginOverlay = document.getElementById('login-overlay');
178
+ if (loginOverlay && !loginOverlay.classList.contains('hidden')) return;
179
+ if (e.key >= '0' && e.key <= '9') pressDigit(e.key);
180
+ else if (e.key === '.') pressDot();
181
+ else if (e.key === '+') pressOp('+');
182
+ else if (e.key === '-') pressOp('');
183
+ else if (e.key === '*') pressOp('×');
184
+ else if (e.key === '/') { e.preventDefault(); pressOp('÷'); }
185
+ else if (e.key === 'Enter' || e.key === '=') pressEquals();
186
+ else if (e.key === 'Escape') pressClear();
187
+ else if (e.key === 'Backspace') {
188
+ if (currentInput.length > 1) { currentInput = currentInput.slice(0, -1); setDisplay(currentInput); }
189
+ else { currentInput = ''; setDisplay('0'); }
220
190
  }
221
- }
222
-
223
- function addPinDigit(d) {
224
- if (pinEntry.length >= 4) return;
225
- pinEntry += d;
226
- updatePinDots();
227
- if (pinEntry.length === 4) submitPin();
228
- }
229
-
230
- function delPinDigit() {
231
- pinEntry = pinEntry.slice(0, -1);
232
- updatePinDots();
233
- pinError.textContent = '';
234
- }
235
-
236
- async function submitPin() {
237
- const pin = pinEntry;
238
- let isUser = false;
239
- try {
240
- const res = await fetch('./api/pin', {
241
- method: 'POST',
242
- headers: { 'Content-Type': 'application/json' },
243
- body: JSON.stringify({ pin }),
244
- });
245
- const data = await res.json();
246
- isUser = data.role === 'user';
247
- } catch {
248
- isUser = pin === '5555';
249
- }
250
- if (isUser) { window.location.href = './browse.html'; }
251
- else { showPinError(); }
252
- }
253
-
254
- function showPinError() {
255
- updatePinDots('error');
256
- pinError.textContent = 'Incorrect PIN';
257
- setTimeout(() => {
258
- pinEntry = '';
259
- updatePinDots();
260
- pinError.textContent = '';
261
- }, 1200);
262
- }
263
-
264
-
265
- document.querySelector('.pin-keypad').addEventListener('click', e => {
266
- const btn = e.target.closest('button');
267
- if (!btn) return;
268
- const k = btn.dataset.k;
269
- if (k === 'del') delPinDigit();
270
- else if (k === 'cancel') closePinOverlay();
271
- else addPinDigit(k);
272
191
  });
package/js/pin-modal.js CHANGED
@@ -1,98 +1,13 @@
1
- // Shared PIN modal for light-themed pages (browse, game)
2
- // Intercepts clicks on any <a href="./admin.html"> and prompts for PIN.
3
- (() => {
4
- const el = document.createElement('div');
5
- el.innerHTML = `
6
- <div class="pin-overlay hidden" id="pm-overlay">
7
- <div class="pin-box">
8
- <h2>Admin Access</h2>
9
- <p>Enter your PIN to continue</p>
10
- <div class="pin-dots">
11
- <div class="pin-dot" id="pm-d0"></div>
12
- <div class="pin-dot" id="pm-d1"></div>
13
- <div class="pin-dot" id="pm-d2"></div>
14
- <div class="pin-dot" id="pm-d3"></div>
15
- </div>
16
- <div class="pin-error" id="pm-error"></div>
17
- <div class="pin-keypad" id="pm-keypad">
18
- <button class="pin-key" data-k="1">1</button>
19
- <button class="pin-key" data-k="2">2</button>
20
- <button class="pin-key" data-k="3">3</button>
21
- <button class="pin-key" data-k="4">4</button>
22
- <button class="pin-key" data-k="5">5</button>
23
- <button class="pin-key" data-k="6">6</button>
24
- <button class="pin-key" data-k="7">7</button>
25
- <button class="pin-key" data-k="8">8</button>
26
- <button class="pin-key" data-k="9">9</button>
27
- <button class="pin-key delete" data-k="del">⌫</button>
28
- <button class="pin-key" data-k="0">0</button>
29
- <button class="pin-cancel" data-k="cancel">Cancel</button>
30
- </div>
31
- </div>
32
- </div>`;
33
- document.body.appendChild(el.firstElementChild);
34
-
35
- const overlay = document.getElementById('pm-overlay');
36
- const errEl = document.getElementById('pm-error');
37
- let entry = '';
38
-
39
- function dots(state) {
40
- for (let i = 0; i < 4; i++) {
41
- const d = document.getElementById('pm-d' + i);
42
- d.className = 'pin-dot' + (state === 'error' ? ' error' : i < entry.length ? ' filled' : '');
43
- }
44
- }
45
-
46
- function open() { entry = ''; errEl.textContent = ''; dots(); overlay.classList.remove('hidden'); }
47
- function close() { overlay.classList.add('hidden'); entry = ''; }
48
-
49
- function addDigit(d) {
50
- if (entry.length >= 4) return;
51
- entry += d; dots();
52
- if (entry.length === 4) submit();
53
- }
54
-
55
- function del() { entry = entry.slice(0, -1); dots(); errEl.textContent = ''; }
56
-
57
- async function submit() {
58
- const pin = entry;
59
- let ok = false;
60
- try {
61
- const res = await fetch('./api/pin', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ pin }) });
62
- const data = await res.json();
63
- ok = data.role === 'admin';
64
- } catch { ok = pin === '1234'; }
65
-
66
- if (ok) {
67
- localStorage.setItem('mm_admin_session', '1');
68
- sessionStorage.setItem('mm_admin_pin', pin);
69
- window.location.href = './admin.html';
70
- } else {
71
- dots('error');
72
- errEl.textContent = 'Incorrect PIN';
73
- setTimeout(() => { entry = ''; dots(); errEl.textContent = ''; }, 1200);
74
- }
75
- }
76
-
77
- document.getElementById('pm-keypad').addEventListener('click', e => {
78
- const btn = e.target.closest('[data-k]');
79
- if (!btn) return;
80
- const k = btn.dataset.k;
81
- if (k === 'del') del(); else if (k === 'cancel') close(); else addDigit(k);
82
- });
83
-
84
- document.addEventListener('keydown', e => {
85
- if (overlay.classList.contains('hidden')) return;
86
- if (e.key >= '0' && e.key <= '9') addDigit(e.key);
87
- else if (e.key === 'Backspace') del();
88
- else if (e.key === 'Escape') close();
89
- });
90
-
91
- // Intercept every Admin nav link on this page
92
- document.addEventListener('click', e => {
93
- const a = e.target.closest('a[href="./admin.html"]');
1
+ // Redirect the Admin nav link based on the authenticated user's rank.
2
+ (function () {
3
+ document.addEventListener('click', function (e) {
4
+ var a = e.target.closest('a[href="./admin.html"]');
94
5
  if (!a) return;
95
- e.preventDefault();
96
- open();
6
+ var rank = sessionStorage.getItem('mm_rank');
7
+ if (rank === 'admin') {
8
+ e.preventDefault();
9
+ window.location.href = './control-panel.html';
10
+ }
11
+ // 'administrator' follows the href naturally; 'user' hits admin.html which redirects them
97
12
  });
98
13
  })();
package/js/theme.js ADDED
@@ -0,0 +1,20 @@
1
+ const moonSVG = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>`;
2
+ const sunSVG = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`;
3
+
4
+ function toggleTheme() {
5
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
6
+ const next = isDark ? 'light' : 'dark';
7
+ document.documentElement.setAttribute('data-theme', next);
8
+ localStorage.setItem('mm_theme', next);
9
+ updateIcon();
10
+ }
11
+
12
+ function updateIcon() {
13
+ const btn = document.getElementById('theme-toggle');
14
+ if (!btn) return;
15
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
16
+ btn.innerHTML = isDark ? sunSVG : moonSVG;
17
+ btn.title = isDark ? 'Light mode' : 'Dark mode';
18
+ }
19
+
20
+ updateIcon();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stem-lab-toolkit",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "STEM educational toolkit",
5
5
  "main": "index.html",
6
6
  "scripts": {
@@ -13,5 +13,10 @@
13
13
  "math"
14
14
  ],
15
15
  "license": "MIT",
16
- "author": ""
16
+ "author": "",
17
+ "dependencies": {
18
+ "bcrypt": "^6.0.0",
19
+ "express": "^5.2.1",
20
+ "express-rate-limit": "^8.5.1"
21
+ }
17
22
  }
@@ -1,7 +1,9 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <meta charset="UTF-8">
4
+ <meta charset="UTF-8">
5
+ <script>(function(){var n=performance.getEntriesByType('navigation')[0];if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_force_change')==='true'){window.location.replace('./change-password.html');return;}var r=sessionStorage.getItem('mm_rank');if(!r){window.location.replace('./index.html');}})();</script>
6
+ <link rel="icon" type="image/png" href="/assets/favicon.png">
5
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
8
  <title>MM Games</title>
7
9
  <link rel="stylesheet" href="./css/style.css">
@@ -115,8 +117,9 @@
115
117
  <header class="site-header">
116
118
  <div class="header-inner">
117
119
  <a href="./browse.html" class="logo-link">
118
- <img src="./assets/logo.png" alt="" class="logo-img" onerror="this.style.display='none'">
119
- <span>MM Games</span><span style="font-size:0.65rem;color:var(--muted);margin-left:6px;">v1.0.0</span>
120
+ <img src="./assets/logo.png?v=new" alt="" class="logo-img" onerror="this.style.display='none'">
121
+ <span>MM Games</span>
122
+
120
123
  </a>
121
124
  <nav class="site-nav">
122
125
  <a href="./browse.html">Browse</a>
@@ -162,37 +165,33 @@
162
165
  document.title = game.name + ' — MM Games';
163
166
 
164
167
  const SITE = 'https://calc.moshelab.com';
165
- let src;
166
- if (game.embedType === 'local') {
167
- src = `./local-games/${game.localFile || game.slug + '.html'}`;
168
- } else if (game.embedType === 'gamepix') {
169
- src = game.embedUrl;
170
- } else {
171
- src = `https://html5.gamedistribution.com/${game.gameId}/?gd_sdk_referrer_url=${SITE}/games/${game.slug}`;
172
- }
168
+ const isLocal = game.embedType === 'local';
169
+ const src = isLocal ? '' :
170
+ game.embedType === 'gamepix' ? game.embedUrl :
171
+ `https://html5.gamedistribution.com/${game.gameId}/?gd_sdk_referrer_url=${SITE}/games/${game.slug}`;
173
172
 
174
173
  wrap.innerHTML = `
175
174
  <div class="game-title-bar">
176
175
  <div class="game-name">${game.name}</div>
177
176
  <span class="cat-badge ${game.category}">${game.category}</span>
178
177
  </div>
179
- <div class="game-frame-wrap" id="frame-wrap" style="${game.embedType === 'local' ? 'height:80vh;' : ''}">
180
- <div class="${game.embedType === 'local' ? '' : 'game-frame-ratio'}" style="${game.embedType === 'local' ? 'height:100%;' : ''}">
178
+ <div class="game-frame-wrap" id="frame-wrap" style="${isLocal ? 'height:80vh;' : ''}">
179
+ <div class="${isLocal ? '' : 'game-frame-ratio'}" style="${isLocal ? 'height:100%;' : ''}">
181
180
  <iframe
182
181
  class="game-iframe"
183
182
  id="game-iframe"
184
- src="${src}"
183
+ ${src ? `src="${src}"` : ''}
185
184
  allowfullscreen
186
185
  allow="fullscreen; autoplay; encrypted-media"
187
- scrolling="${game.embedType === 'local' ? 'yes' : 'no'}"
188
- style="${game.embedType === 'local' ? 'position:static;height:100%;' : ''}"
186
+ scrolling="${isLocal ? 'yes' : 'no'}"
187
+ style="${isLocal ? 'position:static;height:100%;' : ''}"
189
188
  ></iframe>
190
189
  </div>
191
190
  </div>
192
191
  <div class="game-actions">
193
192
  <button class="fullscreen-btn" id="fs-btn">
194
193
  <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
195
- <path d="M8 3H5a2 2 0 00-2 2v1.0.0m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v1.0.0a2 2 0 002 2h3"/>
194
+ <path d="M8 3H5a2 2 0 00-2 2v1.0.3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v1.0.3a2 2 0 002 2h3"/>
196
195
  </svg>
197
196
  Fullscreen
198
197
  </button>
@@ -202,6 +201,17 @@
202
201
  </div>
203
202
  `;
204
203
 
204
+ if (isLocal) {
205
+ try {
206
+ const localPath = `./local-games/${game.localFile || game.slug + '.html'}`;
207
+ const res = await fetch(localPath);
208
+ const html = await res.text();
209
+ document.getElementById('game-iframe').srcdoc = html;
210
+ } catch {
211
+ error('Could not load game file.');
212
+ }
213
+ }
214
+
205
215
  document.getElementById('fs-btn').addEventListener('click', () => {
206
216
  const el = document.getElementById('frame-wrap');
207
217
  if (el.requestFullscreen) el.requestFullscreen();
@@ -209,6 +219,9 @@
209
219
  });
210
220
  })();
211
221
  </script>
212
- <script src="./js/pin-modal.js?v=2"></script>
222
+ <script src="./js/pin-modal.js?v=1.0.3c"></script>
223
+
224
+ <a href="https://forms.gle/59RMvCkURByui1747" target="_blank" rel="noopener" style="position:fixed;bottom:20px;right:20px;background:rgba(255,255,255,.08);color:rgba(255,255,255,.4);font-size:0.72rem;font-weight:500;padding:6px 14px;border-radius:999px;text-decoration:none;z-index:199;border:1px solid rgba(255,255,255,.1);transition:all .15s;" onmouseover="this.style.background='rgba(255,255,255,.16)';this.style.color='rgba(255,255,255,.8)'" onmouseout="this.style.background='rgba(255,255,255,.08)';this.style.color='rgba(255,255,255,.4)'">Feedback</a>
225
+
213
226
  </body>
214
227
  </html>
package/users.json ADDED
@@ -0,0 +1,62 @@
1
+ [
2
+ {
3
+ "username": "owner",
4
+ "password": "$2b$12$pv3OKADBaxJdCPEni5KHJexzvuSxQZQU0wdZ03OogqGbg1/UbPjc2",
5
+ "rank": "administrator",
6
+ "forcePasswordChange": false
7
+ },
8
+ {
9
+ "username": "Zach",
10
+ "password": "$2b$12$Mmz/l6N5rbwGOzYVoOMHbeVjQtpn.dSFQxoDhM2EJF9PJXesASxDC",
11
+ "rank": "admin",
12
+ "forcePasswordChange": true
13
+ },
14
+ {
15
+ "username": "Anthony",
16
+ "password": "$2b$12$GOdPip91LXzin0tm405YR.czj4zVDHcKNugvBDASfuyeNs20g0OkS",
17
+ "rank": "admin",
18
+ "forcePasswordChange": true
19
+ },
20
+ {
21
+ "username": "Viraj",
22
+ "password": "$2b$12$Tq60W3D7ti8l3LOApZP1keKy8aulE9zv.es0dbOxyQ61BF/g1iFJO",
23
+ "rank": "user",
24
+ "forcePasswordChange": true
25
+ },
26
+ {
27
+ "username": "Blake",
28
+ "password": "$2b$12$5QX1SvmJVDQuP/Ek3IOmVu85Fh5Gp3rgKNUREZGuONIc5zp7quIb.",
29
+ "rank": "admin",
30
+ "forcePasswordChange": true
31
+ },
32
+ {
33
+ "username": "Niko",
34
+ "password": "$2b$12$orOTbzLeA.Dzw7E.fRuPd.IE52xQahOF1oX57xVlGmUeNSRvB8sLi",
35
+ "rank": "admin",
36
+ "forcePasswordChange": true
37
+ },
38
+ {
39
+ "username": "Sebastian",
40
+ "password": "$2b$12$gA9e471aAPqqtkxMkiUsou2SWwMEPHF1.rYTEqarwvFWPHiavJxfW",
41
+ "rank": "administrator",
42
+ "forcePasswordChange": true
43
+ },
44
+ {
45
+ "username": "Logan",
46
+ "password": "$2b$12$ivEJU6D/nOXXg3WP93TuUusjOJaLFc2uEDvWm9IDRzINMFhoxm5cq",
47
+ "rank": "user",
48
+ "forcePasswordChange": true
49
+ },
50
+ {
51
+ "username": "Spencer",
52
+ "password": "$2b$12$Ru3Vz/.XeOO.xJRD3NW6rOK5LIpXH4nOFGKLf6W36nU5inUM0s27m",
53
+ "rank": "admin",
54
+ "forcePasswordChange": true
55
+ },
56
+ {
57
+ "username": "Finn",
58
+ "password": "$2b$12$UKA3fZnBItyMSME0y9Jmsu0eN4iXZmqUAY0gVPvB0bonBvWtCo3Gy",
59
+ "rank": "admin",
60
+ "forcePasswordChange": true
61
+ }
62
+ ]