mm-math 0.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.
- package/admin.html +145 -0
- package/ads.txt +880 -0
- package/api.js +179 -0
- package/assets/favicon.png +0 -0
- package/assets/logo.png +0 -0
- package/browse.html +178 -0
- package/change-password.html +168 -0
- package/control-panel.html +885 -0
- package/css/style.css +615 -0
- package/index.html +577 -0
- package/js/admin.js +285 -0
- package/js/games.js +192 -0
- package/js/main.js +191 -0
- package/js/pin-modal.js +13 -0
- package/js/theme.js +20 -0
- package/package.json +36 -0
- package/tool.html +227 -0
- package/version.txt +1 -0
package/js/admin.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
const API_BASE = (window.location.hostname === 'unpkg.com' || window.location.hostname === 'cdn.jsdelivr.net') ? 'https://calc.moshelab.com' : '';
|
|
2
|
+
|
|
3
|
+
let games = [];
|
|
4
|
+
|
|
5
|
+
function getToken() {
|
|
6
|
+
return sessionStorage.getItem('mm_token') || '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function authHeaders() {
|
|
10
|
+
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function loadGames() {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${API_BASE}/api/games/all`, { headers: authHeaders() });
|
|
16
|
+
if (!res.ok) throw new Error();
|
|
17
|
+
games = await res.json();
|
|
18
|
+
} catch {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('./games.json');
|
|
21
|
+
games = await res.json();
|
|
22
|
+
} catch { games = []; }
|
|
23
|
+
}
|
|
24
|
+
renderStats();
|
|
25
|
+
renderTable();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function saveGames() {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${API_BASE}/api/games`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: authHeaders(),
|
|
33
|
+
body: JSON.stringify({ games }),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) throw new Error();
|
|
36
|
+
return true;
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function showFeedback(id, msg, type) {
|
|
41
|
+
const el = document.getElementById(id);
|
|
42
|
+
el.textContent = msg;
|
|
43
|
+
el.className = 'feedback ' + type;
|
|
44
|
+
setTimeout(() => { el.textContent = ''; el.className = 'feedback'; }, 3000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function thumbHtml(game) {
|
|
48
|
+
if (game.thumbUrl && !game.thumbUrl.includes('REPLACE')) {
|
|
49
|
+
return `<img class="thumb-preview" src="${game.thumbUrl}" alt="${game.name}" onerror="this.outerHTML=fallbackThumbHtml(this.alt)">`;
|
|
50
|
+
}
|
|
51
|
+
return fallbackThumbHtml(game.name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fallbackThumbHtml(name) {
|
|
55
|
+
return `<div class="thumb-ph">${name}</div>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderStats() {
|
|
59
|
+
const counts = { Action:0, Sports:0, Racing:0, Puzzle:0, Multiplayer:0, Casual:0 };
|
|
60
|
+
let visible = 0;
|
|
61
|
+
games.forEach(g => {
|
|
62
|
+
if (g.visible) visible++;
|
|
63
|
+
if (counts[g.category] !== undefined) counts[g.category]++;
|
|
64
|
+
});
|
|
65
|
+
document.getElementById('stat-total').textContent = games.length;
|
|
66
|
+
document.getElementById('stat-visible').textContent = visible;
|
|
67
|
+
document.getElementById('stat-action').textContent = counts.Action;
|
|
68
|
+
document.getElementById('stat-sports').textContent = counts.Sports;
|
|
69
|
+
document.getElementById('stat-racing').textContent = counts.Racing;
|
|
70
|
+
document.getElementById('stat-puzzle').textContent = counts.Puzzle;
|
|
71
|
+
document.getElementById('stat-multi').textContent = counts.Multiplayer;
|
|
72
|
+
document.getElementById('stat-casual').textContent = counts.Casual;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderTable() {
|
|
76
|
+
const tbody = document.getElementById('table-body');
|
|
77
|
+
if (!games.length) {
|
|
78
|
+
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--muted)">No games yet.</td></tr>';
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
tbody.innerHTML = games.map((g, i) => `
|
|
83
|
+
<tr data-i="${i}">
|
|
84
|
+
<td>${thumbHtml(g)}</td>
|
|
85
|
+
<td style="font-weight:500;">${g.name}</td>
|
|
86
|
+
<td><span class="cat-badge ${g.category}">${g.category}</span></td>
|
|
87
|
+
<td style="font-size:.8rem;color:var(--muted);">${g.embedType}</td>
|
|
88
|
+
<td>
|
|
89
|
+
<button class="btn btn-sm ${g.gameOfDay ? 'btn-primary' : 'btn-ghost'}" data-action="gotd" data-i="${i}" title="Set as Game of the Day">
|
|
90
|
+
${g.gameOfDay ? '⭐' : '☆'}
|
|
91
|
+
</button>
|
|
92
|
+
</td>
|
|
93
|
+
<td>
|
|
94
|
+
<label class="toggle">
|
|
95
|
+
<input type="checkbox" data-action="toggle" data-i="${i}" ${g.visible ? 'checked' : ''}>
|
|
96
|
+
<span class="toggle-slider"></span>
|
|
97
|
+
</label>
|
|
98
|
+
</td>
|
|
99
|
+
<td>
|
|
100
|
+
<button class="btn btn-sm btn-danger" data-action="delete" data-i="${i}">Delete</button>
|
|
101
|
+
</td>
|
|
102
|
+
</tr>
|
|
103
|
+
`).join('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
document.getElementById('table-body').addEventListener('click', async e => {
|
|
107
|
+
const btn = e.target.closest('[data-action]');
|
|
108
|
+
if (!btn) return;
|
|
109
|
+
const i = parseInt(btn.dataset.i);
|
|
110
|
+
const action = btn.dataset.action;
|
|
111
|
+
|
|
112
|
+
if (action === 'delete') {
|
|
113
|
+
if (!confirm(`Delete "${games[i].name}"?`)) return;
|
|
114
|
+
games.splice(i, 1);
|
|
115
|
+
const ok = await saveGames();
|
|
116
|
+
renderStats();
|
|
117
|
+
renderTable();
|
|
118
|
+
showFeedback('table-feedback', ok ? 'Game deleted.' : 'Deleted locally (API unavailable).', ok ? 'ok' : 'error');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (action === 'gotd') {
|
|
122
|
+
games.forEach(g => g.gameOfDay = false);
|
|
123
|
+
games[i].gameOfDay = true;
|
|
124
|
+
const ok = await saveGames();
|
|
125
|
+
renderTable();
|
|
126
|
+
showFeedback('table-feedback', ok ? `"${games[i].name}" set as Game of the Day.` : 'Saved locally only.', ok ? 'ok' : 'error');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
document.getElementById('table-body').addEventListener('change', async e => {
|
|
131
|
+
const input = e.target.closest('input[data-action="toggle"]');
|
|
132
|
+
if (!input) return;
|
|
133
|
+
const i = parseInt(input.dataset.i);
|
|
134
|
+
games[i].visible = input.checked;
|
|
135
|
+
const ok = await saveGames();
|
|
136
|
+
renderStats();
|
|
137
|
+
showFeedback('table-feedback', ok ? 'Visibility updated.' : 'Updated locally (API unavailable).', ok ? 'ok' : 'error');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── User Management ───────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async function loadUsers() {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${API_BASE}/api/users`, { headers: authHeaders() });
|
|
145
|
+
if (!res.ok) throw new Error();
|
|
146
|
+
const users = await res.json();
|
|
147
|
+
renderUsers(users);
|
|
148
|
+
} catch {
|
|
149
|
+
document.getElementById('users-tbody').innerHTML =
|
|
150
|
+
'<tr><td colspan="3" style="color:var(--muted);text-align:center;padding:24px;">Unable to load users.</td></tr>';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const RANK_CLASS = { administrator: 'rank-administrator', admin: 'rank-admin', user: 'rank-user' };
|
|
155
|
+
|
|
156
|
+
function renderUsers(users) {
|
|
157
|
+
const tbody = document.getElementById('users-tbody');
|
|
158
|
+
if (!users.length) {
|
|
159
|
+
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--muted);">No users.</td></tr>';
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
tbody.innerHTML = users.map(u => `
|
|
163
|
+
<tr>
|
|
164
|
+
<td style="font-weight:500;">${u.username}</td>
|
|
165
|
+
<td>
|
|
166
|
+
<select class="rank-select" data-user="${u.username}" data-orig="${u.rank}">
|
|
167
|
+
<option value="user" ${u.rank==='user'?'selected':''}>user</option>
|
|
168
|
+
<option value="admin" ${u.rank==='admin'?'selected':''}>admin</option>
|
|
169
|
+
<option value="administrator" ${u.rank==='administrator'?'selected':''}>administrator</option>
|
|
170
|
+
</select>
|
|
171
|
+
</td>
|
|
172
|
+
<td style="text-align:center;">
|
|
173
|
+
<label class="toggle">
|
|
174
|
+
<input type="checkbox" data-action="force-change" data-user="${u.username}" ${u.forcePasswordChange ? 'checked' : ''}>
|
|
175
|
+
<span class="toggle-slider"></span>
|
|
176
|
+
</label>
|
|
177
|
+
</td>
|
|
178
|
+
<td style="display:flex;gap:6px;flex-wrap:wrap;padding:6px 8px;">
|
|
179
|
+
<button class="btn btn-sm btn-primary" data-action="update-rank" data-user="${u.username}">Save Rank</button>
|
|
180
|
+
<button class="btn btn-sm btn-ghost" data-action="reset-pass" data-user="${u.username}">Reset Password</button>
|
|
181
|
+
<button class="btn btn-sm btn-danger" data-action="del-user" data-user="${u.username}">Delete</button>
|
|
182
|
+
</td>
|
|
183
|
+
</tr>
|
|
184
|
+
`).join('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
document.getElementById('users-tbody').addEventListener('click', async e => {
|
|
188
|
+
const btn = e.target.closest('[data-action]');
|
|
189
|
+
if (!btn) return;
|
|
190
|
+
const action = btn.dataset.action;
|
|
191
|
+
const username = btn.dataset.user;
|
|
192
|
+
|
|
193
|
+
if (action === 'update-rank') {
|
|
194
|
+
const row = btn.closest('tr');
|
|
195
|
+
const sel = row.querySelector('select.rank-select');
|
|
196
|
+
const rank = sel.value;
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetch(`${API_BASE}/api/users/update`, {
|
|
199
|
+
method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, rank }),
|
|
200
|
+
});
|
|
201
|
+
const data = await res.json();
|
|
202
|
+
if (!res.ok) throw new Error(data.error);
|
|
203
|
+
showFeedback('users-feedback', `${username} rank updated to ${rank}.`, 'ok');
|
|
204
|
+
loadUsers();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
showFeedback('users-feedback', err.message || 'Failed.', 'error');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (action === 'reset-pass') {
|
|
211
|
+
const newPass = prompt(`New password for "${username}":`);
|
|
212
|
+
if (!newPass) return;
|
|
213
|
+
try {
|
|
214
|
+
const res = await fetch(`${API_BASE}/api/users/reset-password`, {
|
|
215
|
+
method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, password: newPass }),
|
|
216
|
+
});
|
|
217
|
+
const data = await res.json();
|
|
218
|
+
if (!res.ok) throw new Error(data.error);
|
|
219
|
+
showFeedback('users-feedback', `Password reset for ${username}.`, 'ok');
|
|
220
|
+
} catch (err) {
|
|
221
|
+
showFeedback('users-feedback', err.message || 'Failed.', 'error');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (action === 'del-user') {
|
|
226
|
+
if (!confirm(`Delete user "${username}"?`)) return;
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch(`${API_BASE}/api/users/delete`, {
|
|
229
|
+
method: 'POST', headers: authHeaders(), body: JSON.stringify({ username }),
|
|
230
|
+
});
|
|
231
|
+
const data = await res.json();
|
|
232
|
+
if (!res.ok) throw new Error(data.error);
|
|
233
|
+
showFeedback('users-feedback', `${username} deleted.`, 'ok');
|
|
234
|
+
loadUsers();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
showFeedback('users-feedback', err.message || 'Failed.', 'error');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
document.getElementById('add-user-btn').addEventListener('click', async () => {
|
|
242
|
+
const username = document.getElementById('new-username').value.trim();
|
|
243
|
+
const password = document.getElementById('new-password').value;
|
|
244
|
+
const rank = document.getElementById('new-rank').value;
|
|
245
|
+
|
|
246
|
+
if (!username || !password) {
|
|
247
|
+
showFeedback('add-user-feedback', 'Username and password are required.', 'error');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const res = await fetch(`${API_BASE}/api/users/add`, {
|
|
253
|
+
method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, password, rank }),
|
|
254
|
+
});
|
|
255
|
+
const data = await res.json();
|
|
256
|
+
if (!res.ok) throw new Error(data.error);
|
|
257
|
+
document.getElementById('new-username').value = '';
|
|
258
|
+
document.getElementById('new-password').value = '';
|
|
259
|
+
showFeedback('add-user-feedback', `User "${username}" added.`, 'ok');
|
|
260
|
+
loadUsers();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
showFeedback('add-user-feedback', err.message || 'Failed.', 'error');
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
document.getElementById('users-tbody').addEventListener('change', async e => {
|
|
267
|
+
const input = e.target.closest('input[data-action="force-change"]');
|
|
268
|
+
if (!input) return;
|
|
269
|
+
const username = input.dataset.user;
|
|
270
|
+
const force = input.checked;
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(`${API_BASE}/api/users/set-force-change`, {
|
|
273
|
+
method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, force }),
|
|
274
|
+
});
|
|
275
|
+
const data = await res.json();
|
|
276
|
+
if (!res.ok) throw new Error(data.error);
|
|
277
|
+
showFeedback('users-feedback', `Force password change ${force ? 'enabled' : 'disabled'} for ${username}.`, 'ok');
|
|
278
|
+
} catch (err) {
|
|
279
|
+
input.checked = !force;
|
|
280
|
+
showFeedback('users-feedback', err.message || 'Failed.', 'error');
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
loadGames();
|
|
285
|
+
loadUsers();
|
package/js/games.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
const API_BASE = (window.location.hostname === 'unpkg.com' || window.location.hostname === 'cdn.jsdelivr.net') ? 'https://calc.moshelab.com' : '';
|
|
2
|
+
|
|
3
|
+
let allGames = [];
|
|
4
|
+
let activeCategory = 'All';
|
|
5
|
+
let searchQuery = '';
|
|
6
|
+
|
|
7
|
+
async function loadGames() {
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(`${API_BASE}/api/games`);
|
|
10
|
+
if (!res.ok) throw new Error();
|
|
11
|
+
allGames = await res.json();
|
|
12
|
+
} catch {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch('./games.json');
|
|
15
|
+
const data = await res.json();
|
|
16
|
+
allGames = data.filter(g => g.visible);
|
|
17
|
+
} catch { allGames = []; }
|
|
18
|
+
}
|
|
19
|
+
render();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Canvas thumbnail generation ─────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const thumbCache = new Map();
|
|
25
|
+
|
|
26
|
+
function nameHash(name) {
|
|
27
|
+
let h = 0;
|
|
28
|
+
for (let i = 0; i < name.length; i++) h = (Math.imul(31, h) + name.charCodeAt(i)) | 0;
|
|
29
|
+
return Math.abs(h);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function wrapText(ctx, text, maxW) {
|
|
33
|
+
const words = text.split(' ');
|
|
34
|
+
const lines = [];
|
|
35
|
+
let cur = '';
|
|
36
|
+
for (const w of words) {
|
|
37
|
+
const test = cur ? cur + ' ' + w : w;
|
|
38
|
+
if (cur && ctx.measureText(test).width > maxW) { lines.push(cur); cur = w; }
|
|
39
|
+
else cur = test;
|
|
40
|
+
}
|
|
41
|
+
if (cur) lines.push(cur);
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeCanvasThumb(name) {
|
|
46
|
+
if (thumbCache.has(name)) return thumbCache.get(name);
|
|
47
|
+
|
|
48
|
+
const canvas = document.createElement('canvas');
|
|
49
|
+
canvas.width = 512;
|
|
50
|
+
canvas.height = 384;
|
|
51
|
+
const ctx = canvas.getContext('2d');
|
|
52
|
+
|
|
53
|
+
const hue = nameHash(name) % 360;
|
|
54
|
+
ctx.fillStyle = `hsl(${hue},55%,28%)`;
|
|
55
|
+
ctx.fillRect(0, 0, 512, 384);
|
|
56
|
+
|
|
57
|
+
ctx.fillStyle = `hsl(${hue},20%,90%)`;
|
|
58
|
+
ctx.textAlign = 'center';
|
|
59
|
+
ctx.textBaseline = 'middle';
|
|
60
|
+
|
|
61
|
+
let fontSize = 18, lines = [name];
|
|
62
|
+
for (const sz of [60, 50, 40, 32, 26, 22, 18]) {
|
|
63
|
+
ctx.font = `bold ${sz}px sans-serif`;
|
|
64
|
+
const wrapped = wrapText(ctx, name, 440);
|
|
65
|
+
if (wrapped.length * sz * 1.35 <= 310) { fontSize = sz; lines = wrapped; break; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ctx.font = `bold ${fontSize}px sans-serif`;
|
|
69
|
+
const lineH = fontSize * 1.35;
|
|
70
|
+
const startY = 192 - (lines.length * lineH) / 2 + lineH / 2;
|
|
71
|
+
lines.forEach((line, i) => ctx.fillText(line, 256, startY + i * lineH));
|
|
72
|
+
|
|
73
|
+
const url = canvas.toDataURL('image/jpeg', 0.7);
|
|
74
|
+
thumbCache.set(name, url);
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Lazy observer: generate canvas only when card enters viewport
|
|
79
|
+
const canvasObserver = new IntersectionObserver(entries => {
|
|
80
|
+
entries.forEach(entry => {
|
|
81
|
+
if (!entry.isIntersecting) return;
|
|
82
|
+
const img = entry.target;
|
|
83
|
+
img.src = makeCanvasThumb(img.dataset.canvasName);
|
|
84
|
+
canvasObserver.unobserve(img);
|
|
85
|
+
});
|
|
86
|
+
}, { rootMargin: '200px' });
|
|
87
|
+
|
|
88
|
+
// ── Thumb helpers ───────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function getRealThumbUrl(game) {
|
|
91
|
+
const u = game.thumbUrl;
|
|
92
|
+
return u ? u : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cardThumb(game) {
|
|
96
|
+
const url = getRealThumbUrl(game);
|
|
97
|
+
if (url) {
|
|
98
|
+
const esc = game.name.replace(/'/g, "\\'");
|
|
99
|
+
return `<img class="game-card-thumb" src="${url}" alt="${game.name}" loading="lazy" onerror="this.src=makeCanvasThumb('${esc}')">`;
|
|
100
|
+
}
|
|
101
|
+
// Canvas generated lazily via observer
|
|
102
|
+
return `<img class="game-card-thumb" data-canvas-name="${game.name.replace(/"/g, '"')}" alt="${game.name}" style="background:hsl(${nameHash(game.name)%360},55%,28%)">`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function observeCanvasThumbs() {
|
|
106
|
+
document.querySelectorAll('img[data-canvas-name]').forEach(img => canvasObserver.observe(img));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Filtering ───────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function filtered() {
|
|
112
|
+
return allGames.filter(g => {
|
|
113
|
+
const matchCat = activeCategory === 'All' || g.category === activeCategory;
|
|
114
|
+
const matchQ = !searchQuery || g.name.toLowerCase().includes(searchQuery);
|
|
115
|
+
return matchCat && matchQ;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function render() { renderGotd(); renderGrid(); }
|
|
122
|
+
|
|
123
|
+
function renderGotd() {
|
|
124
|
+
const section = document.getElementById('gotd-section');
|
|
125
|
+
const gotd = allGames.find(g => g.gameOfDay && g.visible);
|
|
126
|
+
if (!gotd) { section.innerHTML = ''; return; }
|
|
127
|
+
|
|
128
|
+
const url = getRealThumbUrl(gotd);
|
|
129
|
+
const hue = nameHash(gotd.name) % 360;
|
|
130
|
+
const thumbHtml = url
|
|
131
|
+
? `<img class="gotd-thumb" src="${url}" alt="${gotd.name}" onerror="this.src=makeCanvasThumb('${gotd.name.replace(/'/g, "\\'")}')">`
|
|
132
|
+
: `<img class="gotd-thumb" data-canvas-name="${gotd.name.replace(/"/g, '"')}" alt="${gotd.name}" style="background:hsl(${hue},55%,28%)">`;
|
|
133
|
+
|
|
134
|
+
section.innerHTML = `
|
|
135
|
+
<div class="section-title">Game of the Day</div>
|
|
136
|
+
<a href="./tool.html?slug=${gotd.slug}" class="gotd-card">
|
|
137
|
+
${thumbHtml}
|
|
138
|
+
<div>
|
|
139
|
+
<div class="gotd-label">⭐ Featured Today</div>
|
|
140
|
+
<div class="gotd-name">${gotd.name}</div>
|
|
141
|
+
<span class="cat-badge ${gotd.category}">${gotd.category}</span>
|
|
142
|
+
<div class="gotd-play-btn">▶ Play Now</div>
|
|
143
|
+
</div>
|
|
144
|
+
</a>
|
|
145
|
+
`;
|
|
146
|
+
observeCanvasThumbs();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderGrid() {
|
|
150
|
+
const grid = document.getElementById('game-grid');
|
|
151
|
+
const label = document.getElementById('grid-label');
|
|
152
|
+
const games = filtered();
|
|
153
|
+
|
|
154
|
+
label.textContent = activeCategory === 'All'
|
|
155
|
+
? `All Games (${games.length})`
|
|
156
|
+
: `${activeCategory} (${games.length})`;
|
|
157
|
+
|
|
158
|
+
if (!games.length) {
|
|
159
|
+
grid.innerHTML = '<div class="no-results">No games found.</div>';
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
grid.innerHTML = games.map(g => `
|
|
164
|
+
<a href="./tool.html?slug=${g.slug}" class="game-card">
|
|
165
|
+
${cardThumb(g)}
|
|
166
|
+
<div class="game-card-body">
|
|
167
|
+
<div class="game-card-name">${g.name}</div>
|
|
168
|
+
<span class="cat-badge ${g.category}">${g.category}</span>
|
|
169
|
+
</div>
|
|
170
|
+
</a>
|
|
171
|
+
`).join('');
|
|
172
|
+
|
|
173
|
+
observeCanvasThumbs();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Events ──────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
document.getElementById('cat-filters').addEventListener('click', e => {
|
|
179
|
+
const btn = e.target.closest('.cat-btn');
|
|
180
|
+
if (!btn) return;
|
|
181
|
+
document.querySelectorAll('.cat-btn').forEach(b => b.classList.remove('active'));
|
|
182
|
+
btn.classList.add('active');
|
|
183
|
+
activeCategory = btn.dataset.cat;
|
|
184
|
+
render();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
document.getElementById('search').addEventListener('input', e => {
|
|
188
|
+
searchQuery = e.target.value.trim().toLowerCase();
|
|
189
|
+
render();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
loadGames();
|
package/js/main.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// ── Calculator state ────────────────────────────────────────────────────────
|
|
2
|
+
let displayVal = '0';
|
|
3
|
+
let operand = null;
|
|
4
|
+
let operator = null;
|
|
5
|
+
let currentInput = '';
|
|
6
|
+
let justEvaled = false;
|
|
7
|
+
let parenDepth = 0;
|
|
8
|
+
let memory = 0;
|
|
9
|
+
let isInverse = false;
|
|
10
|
+
let isDeg = true;
|
|
11
|
+
|
|
12
|
+
const mainEl = document.getElementById('main');
|
|
13
|
+
const exprEl = document.getElementById('expr');
|
|
14
|
+
|
|
15
|
+
function toRad(n) { return isDeg ? n * Math.PI / 180 : n; }
|
|
16
|
+
function fromRad(n) { return isDeg ? n * 180 / Math.PI : n; }
|
|
17
|
+
|
|
18
|
+
function setDisplay(val) {
|
|
19
|
+
displayVal = String(val);
|
|
20
|
+
const len = displayVal.length;
|
|
21
|
+
mainEl.style.fontSize = len > 12 ? '1.6rem' : len > 9 ? '2.2rem' : len > 6 ? '2.6rem' : '3rem';
|
|
22
|
+
mainEl.textContent = displayVal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fmt(n) {
|
|
26
|
+
if (!isFinite(n)) return 'Error';
|
|
27
|
+
const s = parseFloat(n.toPrecision(12)).toString();
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function applyOp(a, op, b) {
|
|
32
|
+
switch (op) {
|
|
33
|
+
case '+': return a + b;
|
|
34
|
+
case '−': return a - b;
|
|
35
|
+
case '×': return a * b;
|
|
36
|
+
case '÷': return b === 0 ? NaN : a / b;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pressDigit(d) {
|
|
41
|
+
if (justEvaled) { currentInput = d; setDisplay(d); justEvaled = false; return; }
|
|
42
|
+
if (operator && operand !== null && currentInput === '') {
|
|
43
|
+
currentInput = d; setDisplay(d); return;
|
|
44
|
+
}
|
|
45
|
+
if (displayVal === '0' && d !== '.') {
|
|
46
|
+
currentInput = d; setDisplay(d);
|
|
47
|
+
} else {
|
|
48
|
+
if (currentInput.replace(/[-.]/g, '').length >= 12) return;
|
|
49
|
+
currentInput += d;
|
|
50
|
+
setDisplay(displayVal + d);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pressDot() {
|
|
55
|
+
if (justEvaled) { currentInput = '0.'; setDisplay('0.'); justEvaled = false; return; }
|
|
56
|
+
if (displayVal.includes('.')) return;
|
|
57
|
+
currentInput += '.';
|
|
58
|
+
setDisplay(displayVal + '.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pressOp(op) {
|
|
62
|
+
document.querySelectorAll('.btn-op').forEach(b => b.classList.remove('lit'));
|
|
63
|
+
document.querySelector(`[data-op="${op}"]`)?.classList.add('lit');
|
|
64
|
+
const val = parseFloat(displayVal) || 0;
|
|
65
|
+
if (operand !== null && !justEvaled && currentInput !== '') {
|
|
66
|
+
const result = applyOp(operand, operator, val);
|
|
67
|
+
const rs = fmt(result);
|
|
68
|
+
exprEl.textContent = `${operand} ${operator} ${val} =`;
|
|
69
|
+
setDisplay(rs);
|
|
70
|
+
operand = parseFloat(rs);
|
|
71
|
+
} else {
|
|
72
|
+
operand = val;
|
|
73
|
+
}
|
|
74
|
+
operator = op;
|
|
75
|
+
currentInput = '';
|
|
76
|
+
justEvaled = false;
|
|
77
|
+
exprEl.textContent = `${operand} ${op}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pressEquals() {
|
|
81
|
+
const entry = currentInput || displayVal;
|
|
82
|
+
|
|
83
|
+
if (operand === null) {
|
|
84
|
+
if (entry === '8008') { triggerMatrixEgg(); return; }
|
|
85
|
+
justEvaled = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (operator === null) { justEvaled = true; return; }
|
|
89
|
+
document.querySelectorAll('.btn-op').forEach(b => b.classList.remove('lit'));
|
|
90
|
+
const val = parseFloat(displayVal) || 0;
|
|
91
|
+
const result = applyOp(operand, operator, val);
|
|
92
|
+
const rs = fmt(result);
|
|
93
|
+
exprEl.textContent = `${operand} ${operator} ${val} =`;
|
|
94
|
+
setDisplay(rs);
|
|
95
|
+
operand = null;
|
|
96
|
+
operator = null;
|
|
97
|
+
currentInput = '';
|
|
98
|
+
justEvaled = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pressClear() {
|
|
102
|
+
document.querySelectorAll('.btn-op').forEach(b => b.classList.remove('lit'));
|
|
103
|
+
setDisplay('0');
|
|
104
|
+
exprEl.textContent = '';
|
|
105
|
+
operand = null; operator = null; currentInput = ''; justEvaled = false; parenDepth = 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pressSign() {
|
|
109
|
+
if (displayVal === '0') return;
|
|
110
|
+
const n = parseFloat(displayVal) * -1;
|
|
111
|
+
currentInput = fmt(n);
|
|
112
|
+
setDisplay(currentInput);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pressPercent() {
|
|
116
|
+
const n = parseFloat(displayVal) / 100;
|
|
117
|
+
currentInput = fmt(n);
|
|
118
|
+
setDisplay(currentInput);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Scientific
|
|
122
|
+
function pressSci(fn) {
|
|
123
|
+
const val = parseFloat(displayVal) || 0;
|
|
124
|
+
let result;
|
|
125
|
+
switch (fn) {
|
|
126
|
+
case 'sin': result = isInverse ? fromRad(Math.asin(val)) : Math.sin(toRad(val)); break;
|
|
127
|
+
case 'cos': result = isInverse ? fromRad(Math.acos(val)) : Math.cos(toRad(val)); break;
|
|
128
|
+
case 'tan': result = isInverse ? fromRad(Math.atan(val)) : Math.tan(toRad(val)); break;
|
|
129
|
+
case 'log': result = isInverse ? Math.pow(10, val) : Math.log10(val); break;
|
|
130
|
+
case 'ln': result = isInverse ? Math.exp(val) : Math.log(val); break;
|
|
131
|
+
case 'sqrt': result = isInverse ? val * val : Math.sqrt(val); break;
|
|
132
|
+
case 'sq': result = val * val; break;
|
|
133
|
+
case 'pi': result = Math.PI; break;
|
|
134
|
+
case 'e': result = Math.E; break;
|
|
135
|
+
case 'open':
|
|
136
|
+
exprEl.textContent += '(';
|
|
137
|
+
parenDepth++;
|
|
138
|
+
return;
|
|
139
|
+
case 'close':
|
|
140
|
+
if (parenDepth > 0) { exprEl.textContent += ')'; parenDepth--; }
|
|
141
|
+
return;
|
|
142
|
+
case 'inv':
|
|
143
|
+
isInverse = !isInverse;
|
|
144
|
+
document.getElementById('inv-btn').classList.toggle('inv-active', isInverse);
|
|
145
|
+
return;
|
|
146
|
+
default: return;
|
|
147
|
+
}
|
|
148
|
+
const rs = fmt(result);
|
|
149
|
+
exprEl.textContent = `${fn}(${val}) =`;
|
|
150
|
+
setDisplay(rs);
|
|
151
|
+
currentInput = rs;
|
|
152
|
+
justEvaled = true;
|
|
153
|
+
if (fn !== 'inv') { isInverse = false; document.getElementById('inv-btn').classList.remove('inv-active'); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Events
|
|
157
|
+
document.querySelector('.calc-buttons').addEventListener('click', e => {
|
|
158
|
+
const btn = e.target.closest('button');
|
|
159
|
+
if (!btn) return;
|
|
160
|
+
const action = btn.dataset.action;
|
|
161
|
+
if (action === 'digit') pressDigit(btn.dataset.digit);
|
|
162
|
+
if (action === 'dot') pressDot();
|
|
163
|
+
if (action === 'op') pressOp(btn.dataset.op);
|
|
164
|
+
if (action === 'equals') pressEquals();
|
|
165
|
+
if (action === 'clear') pressClear();
|
|
166
|
+
if (action === 'sign') pressSign();
|
|
167
|
+
if (action === 'percent') pressPercent();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
document.querySelector('.sci-rows').addEventListener('click', e => {
|
|
171
|
+
const btn = e.target.closest('button');
|
|
172
|
+
if (!btn) return;
|
|
173
|
+
pressSci(btn.dataset.sci);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
document.addEventListener('keydown', e => {
|
|
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'); }
|
|
190
|
+
}
|
|
191
|
+
});
|
package/js/pin-modal.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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"]');
|
|
5
|
+
if (!a) return;
|
|
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
|
|
12
|
+
});
|
|
13
|
+
})();
|