more QOL improvements
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
This commit is contained in:
@@ -180,21 +180,75 @@ const Hunts = {
|
||||
return new Date(hunt.expiry_date) < new Date();
|
||||
},
|
||||
|
||||
getLeaderboard(huntId) {
|
||||
return db.prepare(`
|
||||
getLeaderboard(huntId, limit = null, offset = 0) {
|
||||
let sql = `
|
||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||
GROUP BY u.id
|
||||
ORDER BY total_points DESC
|
||||
`).all(huntId);
|
||||
ORDER BY total_points DESC`;
|
||||
if (limit) {
|
||||
sql += ` LIMIT ${parseInt(limit)} OFFSET ${parseInt(offset)}`;
|
||||
}
|
||||
return db.prepare(sql).all(huntId);
|
||||
},
|
||||
|
||||
getLeaderboardCount(huntId) {
|
||||
return db.prepare(`
|
||||
SELECT COUNT(DISTINCT s.user_id) as count
|
||||
FROM scans s
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||
`).get(huntId).count;
|
||||
},
|
||||
|
||||
shortNameExists(shortName) {
|
||||
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
||||
return !!row;
|
||||
},
|
||||
|
||||
update(id, name, description, expiryDate) {
|
||||
db.prepare('UPDATE hunts SET name = ?, description = ?, expiry_date = ? WHERE id = ?')
|
||||
.run(name, description, expiryDate || null, id);
|
||||
},
|
||||
|
||||
delete(id) {
|
||||
const doDelete = db.transaction(() => {
|
||||
// Delete scans for all packages in this hunt
|
||||
db.prepare('DELETE FROM scans WHERE package_id IN (SELECT id FROM packages WHERE hunt_id = ?)').run(id);
|
||||
// Delete packages
|
||||
db.prepare('DELETE FROM packages WHERE hunt_id = ?').run(id);
|
||||
// Delete hunt
|
||||
db.prepare('DELETE FROM hunts WHERE id = ?').run(id);
|
||||
});
|
||||
doDelete();
|
||||
},
|
||||
|
||||
getStats(huntId) {
|
||||
const totalScans = db.prepare('SELECT COUNT(*) as count FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ? AND s.points_awarded > 0').get(huntId).count;
|
||||
const uniquePlayers = db.prepare('SELECT COUNT(DISTINCT s.user_id) as count FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ? AND s.points_awarded > 0').get(huntId).count;
|
||||
const discoveredCount = db.prepare('SELECT COUNT(*) as count FROM packages WHERE hunt_id = ? AND scan_count > 0').get(huntId).count;
|
||||
const totalPackages = db.prepare('SELECT package_count FROM hunts WHERE id = ?').get(huntId).package_count;
|
||||
const totalPoints = db.prepare('SELECT COALESCE(SUM(s.points_awarded), 0) as total FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ?').get(huntId).total;
|
||||
const topFinders = db.prepare(`
|
||||
SELECT u.username, SUM(s.points_awarded) as points, COUNT(s.id) as finds
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||
GROUP BY u.id ORDER BY points DESC LIMIT 5
|
||||
`).all(huntId);
|
||||
const recentScans = db.prepare(`
|
||||
SELECT s.scanned_at, s.points_awarded, u.username, p.card_number
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||
ORDER BY s.scanned_at DESC LIMIT 10
|
||||
`).all(huntId);
|
||||
return { totalScans, uniquePlayers, discoveredCount, totalPackages, totalPoints, topFinders, recentScans, discoveryRate: totalPackages > 0 ? Math.round((discoveredCount / totalPackages) * 100) : 0 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -320,15 +374,22 @@ const Scans = {
|
||||
return doScan();
|
||||
},
|
||||
|
||||
getGlobalLeaderboard() {
|
||||
return db.prepare(`
|
||||
getGlobalLeaderboard(limit = null, offset = 0) {
|
||||
let sql = `
|
||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.points_awarded > 0
|
||||
GROUP BY u.id
|
||||
ORDER BY total_points DESC
|
||||
`).all();
|
||||
ORDER BY total_points DESC`;
|
||||
if (limit) {
|
||||
sql += ` LIMIT ${parseInt(limit)} OFFSET ${parseInt(offset)}`;
|
||||
}
|
||||
return db.prepare(sql).all();
|
||||
},
|
||||
|
||||
getGlobalLeaderboardCount() {
|
||||
return db.prepare('SELECT COUNT(DISTINCT user_id) as count FROM scans WHERE points_awarded > 0').get().count;
|
||||
},
|
||||
|
||||
getRecentActivity(limit = 5) {
|
||||
|
||||
@@ -56,6 +56,7 @@ router.post('/hunts', (req, res) => {
|
||||
|
||||
try {
|
||||
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId);
|
||||
req.session.flash = { type: 'success', message: `Hunt "${name}" created with ${count} packages.` };
|
||||
res.redirect(`/admin/hunts/${huntId}`);
|
||||
} catch (err) {
|
||||
console.error('Hunt creation error:', err);
|
||||
@@ -73,7 +74,40 @@ router.get('/hunts/:id', (req, res) => {
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl });
|
||||
const stats = Hunts.getStats(hunt.id);
|
||||
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl, stats });
|
||||
});
|
||||
|
||||
// Edit hunt form
|
||||
router.get('/hunts/:id/edit', (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
res.render('admin/edit-hunt', { title: `Edit: ${hunt.name}`, hunt, error: null });
|
||||
});
|
||||
|
||||
// Update hunt
|
||||
router.post('/hunts/:id/edit', (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
|
||||
const { name, description, expiry_date } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.render('admin/edit-hunt', { title: `Edit: ${hunt.name}`, hunt: { ...hunt, ...req.body }, error: 'Hunt name is required.' });
|
||||
}
|
||||
|
||||
Hunts.update(hunt.id, name.trim(), (description || '').trim(), expiry_date);
|
||||
req.session.flash = { type: 'success', message: 'Hunt updated successfully.' };
|
||||
res.redirect(`/admin/hunts/${hunt.id}`);
|
||||
});
|
||||
|
||||
// Delete hunt
|
||||
router.post('/hunts/:id/delete', (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
|
||||
Hunts.delete(hunt.id);
|
||||
req.session.flash = { type: 'success', message: `Hunt "${hunt.name}" deleted.` };
|
||||
res.redirect('/admin');
|
||||
});
|
||||
|
||||
// Download PDF of QR codes
|
||||
|
||||
@@ -63,6 +63,7 @@ router.post('/register', (req, res) => {
|
||||
req.session.userId = userId;
|
||||
req.session.username = username;
|
||||
req.session.isAdmin = false;
|
||||
req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' };
|
||||
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
|
||||
@@ -22,14 +22,22 @@ router.get('/hunt/:shortName/leaderboard', (req, res) => {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
}
|
||||
|
||||
const leaderboard = Hunts.getLeaderboard(hunt.id);
|
||||
res.render('hunt/leaderboard', { title: `${hunt.name} - Leaderboard`, hunt, leaderboard });
|
||||
const perPage = 25;
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const totalCount = Hunts.getLeaderboardCount(hunt.id);
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / perPage));
|
||||
const leaderboard = Hunts.getLeaderboard(hunt.id, perPage, (page - 1) * perPage);
|
||||
res.render('hunt/leaderboard', { title: `${hunt.name} - Leaderboard`, hunt, leaderboard, page, totalPages, offset: (page - 1) * perPage });
|
||||
});
|
||||
|
||||
// ─── Global leaderboard ──────────────────────────────────
|
||||
router.get('/leaderboard', (req, res) => {
|
||||
const leaderboard = Scans.getGlobalLeaderboard();
|
||||
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
|
||||
const perPage = 25;
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const totalCount = Scans.getGlobalLeaderboardCount();
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / perPage));
|
||||
const leaderboard = Scans.getGlobalLeaderboard(perPage, (page - 1) * perPage);
|
||||
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard, page, totalPages, offset: (page - 1) * perPage });
|
||||
});
|
||||
|
||||
// ─── Package profile (by card number — no secret code exposed) ────
|
||||
@@ -74,13 +82,16 @@ router.get('/player/:username', (req, res) => {
|
||||
const rank = Users.getRank(user.id);
|
||||
const totalPlayers = Users.getTotalPlayerCount();
|
||||
|
||||
const isOwnProfile = req.session && req.session.userId === user.id;
|
||||
|
||||
res.render('player/profile', {
|
||||
title: `${user.username}'s Profile`,
|
||||
profile,
|
||||
recentScans,
|
||||
huntBreakdown,
|
||||
rank,
|
||||
totalPlayers
|
||||
totalPlayers,
|
||||
isOwnProfile
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req
|
||||
|
||||
const imagePath = `/uploads/${req.file.filename}`;
|
||||
Packages.updateFirstScanImage(pkg.id, imagePath);
|
||||
req.session.flash = { type: 'success', message: 'Photo uploaded successfully.' };
|
||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||
});
|
||||
|
||||
@@ -139,6 +140,7 @@ router.post('/:shortName/:code/image/delete', requireAuth, (req, res) => {
|
||||
}
|
||||
|
||||
Packages.removeFirstScanImage(pkg.id);
|
||||
req.session.flash = { type: 'success', message: 'Image removed.' };
|
||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||
});
|
||||
|
||||
@@ -157,6 +159,7 @@ router.post('/:shortName/:code/hint', requireAuth, (req, res) => {
|
||||
|
||||
const hint = (req.body.hint || '').trim().substring(0, 500);
|
||||
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
||||
req.session.flash = { type: 'success', message: 'Hint saved.' };
|
||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||
});
|
||||
|
||||
@@ -171,6 +174,7 @@ router.post('/:shortName/:code/hint/delete', requireAuth, (req, res) => {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||
}
|
||||
Packages.clearHint(pkg.id);
|
||||
req.session.flash = { type: 'success', message: 'Hint cleared.' };
|
||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||
});
|
||||
|
||||
|
||||
52
src/views/admin/edit-hunt.ejs
Normal file
52
src/views/admin/edit-hunt.ejs
Normal file
@@ -0,0 +1,52 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 2rem;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="/admin/hunts/<%= hunt.id %>" style="color: var(--muted); text-decoration: none;">← Back to <%= hunt.name %></a>
|
||||
</div>
|
||||
|
||||
<h1 style="margin-bottom: 1.5rem;">Edit Hunt</h1>
|
||||
|
||||
<div class="card">
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/admin/hunts/<%= hunt.id %>/edit">
|
||||
<div class="form-group">
|
||||
<label for="name">Hunt Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required value="<%= hunt.name %>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Short Name</label>
|
||||
<input type="text" class="form-control" value="<%= hunt.short_name %>" disabled style="font-family: monospace; opacity: 0.6;">
|
||||
<div class="form-hint">Short name cannot be changed (used in QR codes).</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3"><%= hunt.description || '' %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiry_date">Expiry Date (optional)</label>
|
||||
<input type="datetime-local" id="expiry_date" name="expiry_date" class="form-control"
|
||||
value="<%= hunt.expiry_date ? new Date(hunt.expiry_date).toISOString().slice(0, 16) : '' %>">
|
||||
<div class="form-hint">Leave blank for no expiry.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border: 2px solid var(--danger); margin-top: 1.5rem;">
|
||||
<div class="card-header" style="color: var(--danger);">Danger Zone</div>
|
||||
<p style="color: var(--muted); font-size: 0.9rem;">Permanently delete this hunt, all its packages, and all scan data. This cannot be undone.</p>
|
||||
<form method="POST" action="/admin/hunts/<%= hunt.id %>/delete" onsubmit="return confirm('Are you sure you want to permanently delete "<%= hunt.name %>"? This will delete ALL packages and scan data. This cannot be undone.')">
|
||||
<button type="submit" class="btn btn-danger">Delete Hunt</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
@@ -7,6 +7,7 @@
|
||||
<span style="color: var(--muted); font-family: monospace; font-size: 1rem;"><%= hunt.short_name %></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<a href="/admin/hunts/<%= hunt.id %>/edit" class="btn btn-outline">✏️ Edit</a>
|
||||
<a href="/admin/hunts/<%= hunt.id %>/pdf" class="btn btn-success">📥 Download PDF</a>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="btn btn-outline">View Public Page</a>
|
||||
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
@@ -32,12 +33,62 @@
|
||||
<div class="value"><%= packages.reduce((sum, p) => sum + p.scan_count, 0) %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= typeof stats !== 'undefined' ? stats.uniquePlayers : 0 %></div>
|
||||
<div class="label">Players</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= typeof stats !== 'undefined' ? stats.discoveryRate + '%' : '0%' %></div>
|
||||
<div class="label">Discovery Rate</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : '—' %></div>
|
||||
<div class="label">Expires</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (typeof stats !== 'undefined' && stats.topFinders.length > 0) { %>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
<div class="card" style="flex: 1; min-width: 280px;">
|
||||
<div class="card-header">Top Finders</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead><tr><th>Player</th><th>Finds</th><th>Points</th></tr></thead>
|
||||
<tbody>
|
||||
<% stats.topFinders.forEach(f => { %>
|
||||
<tr>
|
||||
<td><a href="/player/<%= f.username %>"><%= f.username %></a></td>
|
||||
<td><%= f.finds %></td>
|
||||
<td><span class="points-badge">+<%= f.points %></span></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% if (stats.recentScans.length > 0) { %>
|
||||
<div class="card" style="flex: 1; min-width: 280px;">
|
||||
<div class="card-header">Recent Scans</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Player</th><th>Points</th><th>When</th></tr></thead>
|
||||
<tbody>
|
||||
<% stats.recentScans.forEach(s => { %>
|
||||
<tr>
|
||||
<td><%= s.card_number %></td>
|
||||
<td><a href="/player/<%= s.username %>"><%= s.username %></a></td>
|
||||
<td><span class="points-badge">+<%= s.points_awarded %></span></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= s.scanned_at %>"><%= new Date(s.scanned_at).toLocaleString() %></time></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
|
||||
|
||||
<div class="table-wrapper">
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<span class="points-badge" style="font-size: 0.85rem; padding: 0.25rem 0.6rem;">+<%= a.points_awarded %></span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 600;"><a href="/player/<%= a.username %>" style="text-decoration: none; color: inherit;"><%= a.username %></a> found <a href="/hunt/<%= a.hunt_short_name %>/<%= a.card_number %>" style="color: var(--primary);">#<%= a.card_number %></a> in <a href="/hunt/<%= a.hunt_short_name %>" style="color: var(--primary);"><%= a.hunt_name %></a></div>
|
||||
<div style="font-size: 0.8rem; color: var(--muted);"><%= new Date(a.scanned_at).toLocaleString() %></div>
|
||||
<div style="font-size: 0.8rem; color: var(--muted);"><time datetime="<%= a.scanned_at %>"><%= new Date(a.scanned_at).toLocaleString() %></time></div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<% const rank = (typeof offset !== 'undefined' ? offset : 0) + i + 1; %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>"><%= i + 1 %></td>
|
||||
<td class="rank-cell rank-<%= rank %>"><%= rank %></td>
|
||||
<td><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
|
||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||
<td><%= entry.scans %></td>
|
||||
@@ -36,6 +37,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('../partials/pagination') %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<% const rank = (typeof offset !== 'undefined' ? offset : 0) + i + 1; %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>">
|
||||
<% if (i === 0) { %>🥇<% } else if (i === 1) { %>🥈<% } else if (i === 2) { %>🥉<% } else { %><%= i + 1 %><% } %>
|
||||
<td class="rank-cell rank-<%= rank %>">
|
||||
<% if (rank === 1) { %>🥇<% } else if (rank === 2) { %>🥈<% } else if (rank === 3) { %>🥉<% } else { %><%= rank %><% } %>
|
||||
</td>
|
||||
<td><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
|
||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||
@@ -34,6 +35,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('../partials/pagination') %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<td><%= i + 1 %></td>
|
||||
<td><a href="/player/<%= scan.username %>"><%= scan.username %></a></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= scan.scanned_at %>"><%= new Date(scan.scanned_at).toLocaleString() %></time></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<td><%= i + 1 %></td>
|
||||
<td><a href="/player/<%= scan.username %>"><%= scan.username %></a></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= scan.scanned_at %>"><%= new Date(scan.scanned_at).toLocaleString() %></time></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
<footer class="footer">
|
||||
© <%= new Date().getFullYear() %> Loot Hunt — Find. Scan. Conquer.
|
||||
</footer>
|
||||
<script src="/js/timeago.js"></script>
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
var html = document.documentElement;
|
||||
var current = html.getAttribute('data-theme');
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
html.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
// Update toggle icon
|
||||
var btn = document.querySelector('.theme-toggle');
|
||||
if (btn) btn.textContent = next === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
|
||||
}
|
||||
// Set correct icon on load
|
||||
(function() {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
var btn = document.querySelector('.theme-toggle');
|
||||
if (btn) btn.textContent = isDark ? '\u2600\uFE0F' : '\uD83C\uDF19';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,13 +5,25 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= typeof title !== 'undefined' ? title + ' | Loot Hunt' : 'Loot Hunt' %></title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script>
|
||||
// Apply theme before render to prevent flash
|
||||
(function() {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="navbar-brand">🎯 Loot Hunt</a>
|
||||
<button class="nav-toggle" aria-label="Toggle menu" onclick="document.querySelector('.navbar-nav').classList.toggle('open')">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode" title="Toggle dark mode">🌙</button>
|
||||
<button class="nav-toggle" aria-label="Toggle menu" onclick="document.querySelector('.navbar-nav').classList.toggle('open')">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="navbar-nav">
|
||||
<li><a href="/hunts">Hunts</a></li>
|
||||
<li><a href="/leaderboard">Leaderboard</a></li>
|
||||
|
||||
19
src/views/partials/pagination.ejs
Normal file
19
src/views/partials/pagination.ejs
Normal file
@@ -0,0 +1,19 @@
|
||||
<% if (typeof totalPages !== 'undefined' && totalPages > 1) { %>
|
||||
<div style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1.5rem; flex-wrap: wrap;">
|
||||
<% if (page > 1) { %>
|
||||
<a href="?page=<%= page - 1 %>" class="btn btn-sm btn-outline">← Prev</a>
|
||||
<% } %>
|
||||
<% for (let p = 1; p <= totalPages; p++) { %>
|
||||
<% if (p === page) { %>
|
||||
<span class="btn btn-sm btn-primary" style="pointer-events: none;"><%= p %></span>
|
||||
<% } else if (Math.abs(p - page) <= 2 || p === 1 || p === totalPages) { %>
|
||||
<a href="?page=<%= p %>" class="btn btn-sm btn-outline"><%= p %></a>
|
||||
<% } else if (Math.abs(p - page) === 3) { %>
|
||||
<span style="color: var(--muted);">…</span>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if (page < totalPages) { %>
|
||||
<a href="?page=<%= page + 1 %>" class="btn btn-sm btn-outline">Next →</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
@@ -66,7 +66,7 @@
|
||||
<td><a href="/hunt/<%= scan.hunt_short_name %>/<%= scan.card_number %>"><%= scan.card_number %> of <%= scan.package_count %></a></td>
|
||||
<td><a href="/hunt/<%= scan.hunt_short_name %>"><%= scan.hunt_name %></a></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= scan.scanned_at %>"><%= new Date(scan.scanned_at).toLocaleString() %></time></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
@@ -75,7 +75,11 @@
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="card" style="text-align: center; color: var(--muted); padding: 2rem;">
|
||||
No scans yet. Get out there and find some loot!
|
||||
<% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %>
|
||||
No scans yet. Get out there and find some loot!
|
||||
<% } else { %>
|
||||
This player hasn't found any loot yet.
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user