stem-lab-toolkit 1.0.1 → 1.0.2
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 +58 -13
- package/api.js +134 -85
- package/assets/favicon.png +0 -0
- package/assets/logo.png +0 -0
- package/browse.html +23 -5
- package/bump.sh +5 -1
- package/control-panel.html +884 -0
- package/css/style.css +3 -1
- package/index.html +240 -98
- package/js/admin.js +120 -29
- package/js/games.js +2 -2
- package/js/main.js +14 -95
- package/js/pin-modal.js +10 -95
- package/js/theme.js +20 -0
- package/package.json +7 -2
- package/{game.html → tool.html} +28 -18
- package/users.json +17 -0
- package/version.txt +1 -1
- package/pins.json +0 -1
package/admin.html
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<script>
|
|
6
|
+
(function(){
|
|
7
|
+
var n=performance.getEntriesByType('navigation')[0];
|
|
8
|
+
if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}
|
|
9
|
+
if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}
|
|
10
|
+
var rank=sessionStorage.getItem('mm_rank');
|
|
11
|
+
if(rank==='admin'){window.location.replace('./control-panel.html');return;}
|
|
12
|
+
if(rank!=='administrator'){window.location.replace('./browse.html');}
|
|
13
|
+
})();
|
|
14
|
+
</script>
|
|
15
|
+
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
|
5
16
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
17
|
<title>Admin — MM Games</title>
|
|
7
18
|
<link rel="stylesheet" href="./css/style.css">
|
|
@@ -12,6 +23,11 @@
|
|
|
12
23
|
.overflow-x { overflow-x: auto; }
|
|
13
24
|
.thumb-preview { width: 56px; height: 42px; object-fit: cover; border-radius: 4px; background: var(--surface); }
|
|
14
25
|
.thumb-ph { width: 56px; height: 42px; border-radius: 4px; background: #e0e7ff; display:flex; align-items:center; justify-content:center; font-size:.65rem; font-weight:600; padding:4px; line-height:1.2; overflow:hidden; }
|
|
26
|
+
.rank-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 20px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
|
27
|
+
.rank-administrator { background: #ede9fe; color: #6d28d9; }
|
|
28
|
+
.rank-admin { background: #fef3c7; color: #92400e; }
|
|
29
|
+
.rank-user { background: #ecfdf5; color: #065f46; }
|
|
30
|
+
.rank-select { font-size: 0.8rem; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; }
|
|
15
31
|
</style>
|
|
16
32
|
</head>
|
|
17
33
|
<body>
|
|
@@ -19,20 +35,21 @@
|
|
|
19
35
|
<header class="site-header">
|
|
20
36
|
<div class="header-inner">
|
|
21
37
|
<a href="./browse.html" class="logo-link">
|
|
22
|
-
<img src="./assets/logo.png" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
23
|
-
<span>MM Games</span
|
|
38
|
+
<img src="./assets/logo.png?v=new" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
39
|
+
<span>MM Games</span>
|
|
24
40
|
</a>
|
|
25
41
|
<nav class="site-nav">
|
|
26
42
|
<a href="./browse.html">Browse</a>
|
|
27
43
|
<a href="./admin.html" class="active">Admin</a>
|
|
28
44
|
<a href="./index.html" class="nav-calc-btn" title="Calculator"><svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="10" x2="10" y2="10"/><line x1="8" y1="14" x2="10" y2="14"/><line x1="8" y1="18" x2="10" y2="18"/><line x1="14" y1="10" x2="16" y2="10"/><line x1="14" y1="14" x2="16" y2="14"/><line x1="14" y1="18" x2="16" y2="18"/></svg></a>
|
|
45
|
+
<button onclick="sessionStorage.clear();window.location.href='./index.html';" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:.8rem;padding:5px 12px;border-radius:8px;cursor:pointer;margin-left:4px;">Logout</button>
|
|
29
46
|
</nav>
|
|
30
47
|
</div>
|
|
31
48
|
</header>
|
|
32
49
|
|
|
33
50
|
<div class="admin-wrap">
|
|
34
51
|
<div class="page-title">Admin Panel</div>
|
|
35
|
-
<div class="page-sub">Manage games and site settings</div>
|
|
52
|
+
<div class="page-sub">Manage games, users, and site settings</div>
|
|
36
53
|
|
|
37
54
|
<!-- Stats -->
|
|
38
55
|
<div class="card">
|
|
@@ -49,23 +66,50 @@
|
|
|
49
66
|
</div>
|
|
50
67
|
</div>
|
|
51
68
|
|
|
52
|
-
<!--
|
|
69
|
+
<!-- User Management -->
|
|
53
70
|
<div class="card">
|
|
54
|
-
<div class="card-title">
|
|
71
|
+
<div class="card-title">User Management</div>
|
|
72
|
+
|
|
73
|
+
<!-- Add user -->
|
|
55
74
|
<div class="form-grid">
|
|
56
75
|
<div class="form-group">
|
|
57
|
-
<label>
|
|
58
|
-
<input type="
|
|
76
|
+
<label>Username</label>
|
|
77
|
+
<input type="text" id="new-username" placeholder="New username" autocomplete="off">
|
|
78
|
+
</div>
|
|
79
|
+
<div class="form-group">
|
|
80
|
+
<label>Password</label>
|
|
81
|
+
<input type="password" id="new-password" placeholder="Password">
|
|
59
82
|
</div>
|
|
60
83
|
<div class="form-group">
|
|
61
|
-
<label>
|
|
62
|
-
<
|
|
84
|
+
<label>Rank</label>
|
|
85
|
+
<select id="new-rank">
|
|
86
|
+
<option value="user">user</option>
|
|
87
|
+
<option value="admin">admin</option>
|
|
88
|
+
<option value="administrator">administrator</option>
|
|
89
|
+
</select>
|
|
63
90
|
</div>
|
|
64
91
|
</div>
|
|
65
92
|
<div style="margin-top:16px; display:flex; gap:10px; align-items:center;">
|
|
66
|
-
<button class="btn btn-primary" id="
|
|
67
|
-
<span class="feedback" id="
|
|
93
|
+
<button class="btn btn-primary" id="add-user-btn">Add User</button>
|
|
94
|
+
<span class="feedback" id="add-user-feedback"></span>
|
|
68
95
|
</div>
|
|
96
|
+
|
|
97
|
+
<!-- Users table -->
|
|
98
|
+
<div class="overflow-x" style="margin-top:28px;">
|
|
99
|
+
<table class="admin-table" id="users-table">
|
|
100
|
+
<thead>
|
|
101
|
+
<tr>
|
|
102
|
+
<th>Username</th>
|
|
103
|
+
<th>Rank</th>
|
|
104
|
+
<th>Actions</th>
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody id="users-tbody">
|
|
108
|
+
<tr><td colspan="3" style="color:var(--muted);text-align:center;padding:24px;">Loading…</td></tr>
|
|
109
|
+
</tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="feedback" id="users-feedback" style="margin-top:12px;"></div>
|
|
69
113
|
</div>
|
|
70
114
|
|
|
71
115
|
<!-- Games table -->
|
|
@@ -93,6 +137,7 @@
|
|
|
93
137
|
</div>
|
|
94
138
|
</div>
|
|
95
139
|
|
|
96
|
-
<script src="./js/admin.js?v=
|
|
140
|
+
<script src="./js/admin.js?v=1.0.5"></script>
|
|
141
|
+
|
|
97
142
|
</body>
|
|
98
143
|
</html>
|
package/api.js
CHANGED
|
@@ -1,106 +1,155 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
const
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const bcrypt = require('bcrypt');
|
|
3
|
+
const rateLimit = require('express-rate-limit');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
4
7
|
|
|
5
|
-
const
|
|
6
|
-
const PINS_FILE = path.join(__dirname, 'pins.json');
|
|
8
|
+
const app = express();
|
|
7
9
|
const PORT = 3001;
|
|
10
|
+
const USERS_FILE = path.join(__dirname, 'users.json');
|
|
11
|
+
const GAMES_FILE = path.join(__dirname, 'games.json');
|
|
12
|
+
|
|
13
|
+
// In-memory sessions: token -> { username, rank, expires }
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
|
|
16
|
+
app.use(express.json({ limit: '1mb' }));
|
|
17
|
+
app.use((req, res, next) => {
|
|
18
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
19
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
20
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
21
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
22
|
+
next();
|
|
23
|
+
});
|
|
8
24
|
|
|
25
|
+
function readUsers() {
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); }
|
|
27
|
+
catch { return []; }
|
|
28
|
+
}
|
|
29
|
+
function writeUsers(users) {
|
|
30
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
31
|
+
}
|
|
9
32
|
function readGames() {
|
|
10
33
|
try { return JSON.parse(fs.readFileSync(GAMES_FILE, 'utf8')); }
|
|
11
34
|
catch { return []; }
|
|
12
35
|
}
|
|
13
|
-
|
|
14
36
|
function writeGames(games) {
|
|
15
37
|
fs.writeFileSync(GAMES_FILE, JSON.stringify(games, null, 2));
|
|
16
38
|
}
|
|
17
39
|
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
function getSession(req) {
|
|
41
|
+
const auth = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
|
42
|
+
if (!auth) return null;
|
|
43
|
+
const sess = sessions.get(auth);
|
|
44
|
+
if (!sess || sess.expires < Date.now()) { sessions.delete(auth); return null; }
|
|
45
|
+
return sess;
|
|
21
46
|
}
|
|
22
47
|
|
|
23
|
-
function
|
|
24
|
-
|
|
48
|
+
function requireAdmin(req, res, next) {
|
|
49
|
+
const sess = getSession(req);
|
|
50
|
+
if (!sess || sess.rank !== 'administrator') return res.status(403).json({ error: 'Forbidden.' });
|
|
51
|
+
req.sess = sess;
|
|
52
|
+
next();
|
|
25
53
|
}
|
|
26
54
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
const loginLimiter = rateLimit({
|
|
56
|
+
windowMs: 60 * 1000,
|
|
57
|
+
max: 5,
|
|
58
|
+
standardHeaders: true,
|
|
59
|
+
legacyHeaders: false,
|
|
60
|
+
handler: (_req, res) =>
|
|
61
|
+
res.status(429).json({ success: false, error: 'Too many attempts. Try again in 60 seconds.' }),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Auth ───────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
app.post('/api/login', loginLimiter, async (req, res) => {
|
|
67
|
+
const { username, password } = req.body || {};
|
|
68
|
+
if (!username || !password) return res.status(400).json({ success: false });
|
|
69
|
+
const users = readUsers();
|
|
70
|
+
const user = users.find(u => u.username === username);
|
|
71
|
+
if (!user) return res.status(401).json({ success: false });
|
|
72
|
+
const match = await bcrypt.compare(password, user.password);
|
|
73
|
+
if (!match) return res.status(401).json({ success: false });
|
|
74
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
75
|
+
sessions.set(token, {
|
|
76
|
+
username: user.username,
|
|
77
|
+
rank: user.rank,
|
|
78
|
+
expires: Date.now() + 8 * 3600 * 1000,
|
|
31
79
|
});
|
|
32
|
-
res.
|
|
33
|
-
}
|
|
80
|
+
res.json({ success: true, rank: user.rank, token });
|
|
81
|
+
});
|
|
34
82
|
|
|
35
|
-
|
|
36
|
-
let body = '';
|
|
37
|
-
req.on('data', d => { body += d; if (body.length > 1e6) req.destroy(); });
|
|
38
|
-
req.on('end', () => { try { cb(JSON.parse(body)); } catch { cb(null); } });
|
|
39
|
-
}
|
|
83
|
+
// ── Games ──────────────────────────────────────────────────────────────────
|
|
40
84
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
res.writeHead(204, {
|
|
44
|
-
'Access-Control-Allow-Origin': '*',
|
|
45
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
46
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
47
|
-
});
|
|
48
|
-
return res.end();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// GET /api/games — visible games for browse page
|
|
52
|
-
if (req.method === 'GET' && req.url === '/api/games') {
|
|
53
|
-
return json(res, 200, readGames().filter(g => g.visible));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// GET /api/games/all — all games for admin
|
|
57
|
-
if (req.method === 'GET' && req.url === '/api/games/all') {
|
|
58
|
-
return json(res, 200, readGames());
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// POST /api/games — full save (admin only)
|
|
62
|
-
if (req.method === 'POST' && req.url === '/api/games') {
|
|
63
|
-
return parseBody(req, data => {
|
|
64
|
-
if (!data || data.adminPin !== readPins().admin)
|
|
65
|
-
return json(res, 403, { error: 'Unauthorized.' });
|
|
66
|
-
if (!Array.isArray(data.games))
|
|
67
|
-
return json(res, 400, { error: 'Invalid payload.' });
|
|
68
|
-
writeGames(data.games);
|
|
69
|
-
json(res, 200, { ok: true });
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// POST /api/pin — validate PIN, return role
|
|
74
|
-
if (req.method === 'POST' && req.url === '/api/pin') {
|
|
75
|
-
return parseBody(req, data => {
|
|
76
|
-
if (!data) return json(res, 400, { error: 'Bad request.' });
|
|
77
|
-
const pins = readPins();
|
|
78
|
-
if (data.pin === pins.admin) return json(res, 200, { role: 'admin' });
|
|
79
|
-
if (data.pin === pins.user) return json(res, 200, { role: 'user' });
|
|
80
|
-
return json(res, 403, { role: 'none' });
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// POST /api/pins — update PINs (admin only)
|
|
85
|
-
if (req.method === 'POST' && req.url === '/api/pins') {
|
|
86
|
-
return parseBody(req, data => {
|
|
87
|
-
if (!data) return json(res, 400, { error: 'Bad request.' });
|
|
88
|
-
const pins = readPins();
|
|
89
|
-
if (data.adminPin !== pins.admin) return json(res, 403, { error: 'Unauthorized.' });
|
|
90
|
-
const newAdmin = String(data.newAdmin || '').trim();
|
|
91
|
-
const newUser = String(data.newUser || '').trim();
|
|
92
|
-
if (newAdmin && !/^\d{4,8}$/.test(newAdmin)) return json(res, 400, { error: 'Admin PIN must be 4–8 digits.' });
|
|
93
|
-
if (newUser && !/^\d{4,8}$/.test(newUser)) return json(res, 400, { error: 'User PIN must be 4–8 digits.' });
|
|
94
|
-
if (newAdmin) pins.admin = newAdmin;
|
|
95
|
-
if (newUser) pins.user = newUser;
|
|
96
|
-
writePins(pins);
|
|
97
|
-
json(res, 200, { ok: true, pins: { admin: !!newAdmin, user: !!newUser } });
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
json(res, 404, { error: 'Not found.' });
|
|
85
|
+
app.get('/api/games', (_req, res) => {
|
|
86
|
+
res.json(readGames().filter(g => g.visible));
|
|
102
87
|
});
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
|
|
89
|
+
app.get('/api/games/all', requireAdmin, (_req, res) => {
|
|
90
|
+
res.json(readGames());
|
|
106
91
|
});
|
|
92
|
+
|
|
93
|
+
app.post('/api/games', requireAdmin, (req, res) => {
|
|
94
|
+
if (!Array.isArray(req.body.games)) return res.status(400).json({ error: 'Invalid payload.' });
|
|
95
|
+
writeGames(req.body.games);
|
|
96
|
+
res.json({ ok: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── Users ──────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const VALID_RANKS = ['administrator', 'admin', 'user'];
|
|
102
|
+
|
|
103
|
+
app.get('/api/users', requireAdmin, (_req, res) => {
|
|
104
|
+
const users = readUsers().map(u => ({ username: u.username, rank: u.rank }));
|
|
105
|
+
res.json(users);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post('/api/users/add', requireAdmin, async (req, res) => {
|
|
109
|
+
const { username, password, rank } = req.body || {};
|
|
110
|
+
if (!username || !password || !VALID_RANKS.includes(rank))
|
|
111
|
+
return res.status(400).json({ error: 'username, password and valid rank required.' });
|
|
112
|
+
const users = readUsers();
|
|
113
|
+
if (users.find(u => u.username === username))
|
|
114
|
+
return res.status(409).json({ error: 'Username already exists.' });
|
|
115
|
+
const hashed = await bcrypt.hash(password, 12);
|
|
116
|
+
users.push({ username, password: hashed, rank });
|
|
117
|
+
writeUsers(users);
|
|
118
|
+
res.json({ ok: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
app.post('/api/users/update', requireAdmin, (req, res) => {
|
|
122
|
+
const { username, rank } = req.body || {};
|
|
123
|
+
if (!username || !VALID_RANKS.includes(rank))
|
|
124
|
+
return res.status(400).json({ error: 'username and valid rank required.' });
|
|
125
|
+
const users = readUsers();
|
|
126
|
+
const user = users.find(u => u.username === username);
|
|
127
|
+
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
128
|
+
user.rank = rank;
|
|
129
|
+
writeUsers(users);
|
|
130
|
+
res.json({ ok: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
app.post('/api/users/delete', requireAdmin, (req, res) => {
|
|
134
|
+
const { username } = req.body || {};
|
|
135
|
+
if (!username) return res.status(400).json({ error: 'username required.' });
|
|
136
|
+
const users = readUsers();
|
|
137
|
+
const idx = users.findIndex(u => u.username === username);
|
|
138
|
+
if (idx === -1) return res.status(404).json({ error: 'User not found.' });
|
|
139
|
+
users.splice(idx, 1);
|
|
140
|
+
writeUsers(users);
|
|
141
|
+
res.json({ ok: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
app.post('/api/users/reset-password', requireAdmin, async (req, res) => {
|
|
145
|
+
const { username, password } = req.body || {};
|
|
146
|
+
if (!username || !password) return res.status(400).json({ error: 'username and password required.' });
|
|
147
|
+
const users = readUsers();
|
|
148
|
+
const user = users.find(u => u.username === username);
|
|
149
|
+
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
150
|
+
user.password = await bcrypt.hash(password, 12);
|
|
151
|
+
writeUsers(users);
|
|
152
|
+
res.json({ ok: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
app.listen(PORT, '127.0.0.1', () => console.log(`API listening on 127.0.0.1:${PORT}`));
|
|
Binary file
|
package/assets/logo.png
CHANGED
|
Binary file
|
package/browse.html
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
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;}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">
|
|
@@ -11,8 +13,9 @@
|
|
|
11
13
|
<header class="site-header">
|
|
12
14
|
<div class="header-inner">
|
|
13
15
|
<a href="./browse.html" class="logo-link">
|
|
14
|
-
<img src="./assets/logo.png" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
15
|
-
<span>MM Games</span
|
|
16
|
+
<img src="./assets/logo.png?v=new" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
17
|
+
<span>MM Games</span>
|
|
18
|
+
|
|
16
19
|
</a>
|
|
17
20
|
<nav class="site-nav">
|
|
18
21
|
<a href="./browse.html" class="active">Browse</a>
|
|
@@ -53,10 +56,25 @@
|
|
|
53
56
|
<div class="game-grid" id="game-grid"></div>
|
|
54
57
|
</div>
|
|
55
58
|
|
|
59
|
+
|
|
56
60
|
</div>
|
|
57
61
|
</main>
|
|
58
62
|
|
|
59
|
-
<script src="./js/games.js?v=
|
|
60
|
-
<script src="./js/pin-modal.js?v=
|
|
63
|
+
<script src="./js/games.js?v=1.0.5"></script>
|
|
64
|
+
<script src="./js/pin-modal.js?v=1.0.5c"></script>
|
|
65
|
+
|
|
66
|
+
<button id="scroll-top" onclick="window.scrollTo({top:0,behavior:'smooth'})" aria-label="Back to top" style="position:fixed;bottom:28px;right:28px;width:40px;height:40px;border-radius:50%;background:#2563eb;color:#fff;border:none;font-size:18px;cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,.18);opacity:0;transform:translateY(8px);transition:opacity .25s,transform .25s;pointer-events:none;z-index:200;display:flex;align-items:center;justify-content:center;">↑</button>
|
|
67
|
+
<script>
|
|
68
|
+
(function(){
|
|
69
|
+
var btn=document.getElementById('scroll-top');
|
|
70
|
+
window.addEventListener('scroll',function(){
|
|
71
|
+
var show=window.scrollY>300;
|
|
72
|
+
btn.style.opacity=show?'1':'0';
|
|
73
|
+
btn.style.transform=show?'translateY(0)':'translateY(8px)';
|
|
74
|
+
btn.style.pointerEvents=show?'auto':'none';
|
|
75
|
+
},{passive:true});
|
|
76
|
+
})();
|
|
77
|
+
</script>
|
|
78
|
+
|
|
61
79
|
</body>
|
|
62
80
|
</html>
|
package/bump.sh
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
VER=$(cat /var/www/moshelab/version.txt | tr -d '[:space:]')
|
|
3
|
-
|
|
3
|
+
VERNUM="${VER#v}"
|
|
4
|
+
# Update visible version badges (vX.X.X)
|
|
5
|
+
sudo sed -i "s/v[0-9][0-9.]*/$VER/g" /var/www/moshelab/*.html
|
|
6
|
+
# Update script/link cache-buster query params (?v=X.X.X)
|
|
7
|
+
sudo sed -i "s/?v=[0-9][0-9.]*/?v=$VERNUM/g" /var/www/moshelab/*.html
|
|
4
8
|
echo "Version set to $VER"
|