more QOL improvements
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s

This commit is contained in:
2026-02-28 01:37:32 -05:00
parent b9981d0e70
commit 4f9e92bda7
18 changed files with 426 additions and 32 deletions

View File

@@ -10,6 +10,28 @@
--muted: #636e72;
--card-bg: #ffffff;
--body-bg: #f0f2f5;
--text: #2d3436;
--border: #dfe6e9;
--table-hover: rgba(108, 92, 231, 0.03);
--table-border: #eee;
}
[data-theme="dark"] {
--primary: #a29bfe;
--primary-dark: #6c5ce7;
--accent: #fdcb6e;
--dark: #0d1117;
--darker: #161b22;
--light: #c9d1d9;
--success: #2ecc71;
--danger: #e74c3c;
--muted: #8b949e;
--card-bg: #161b22;
--body-bg: #0d1117;
--text: #c9d1d9;
--border: #30363d;
--table-hover: rgba(162, 155, 254, 0.06);
--table-border: #21262d;
}
* {
@@ -19,7 +41,7 @@
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--body-bg);
color: #2d3436;
color: var(--text);
margin: 0;
min-height: 100vh;
display: flex;
@@ -63,6 +85,22 @@ body {
gap: 5px;
}
/* Theme toggle */
.theme-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 6px;
line-height: 1;
border-radius: 6px;
transition: background 0.2s;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.nav-toggle span {
display: block;
width: 24px;
@@ -125,7 +163,7 @@ body {
font-weight: 700;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--body-bg);
border-bottom: 2px solid var(--border);
}
/* ─── Buttons ─────────────────────────────────────────── */
@@ -167,6 +205,10 @@ body {
color: var(--primary);
}
.btn-outline:visited {
color: var(--primary);
}
.btn-outline:hover {
background: var(--primary);
color: #fff;
@@ -192,17 +234,19 @@ body {
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
color: #555;
color: var(--muted);
}
.form-control {
width: 100%;
padding: 0.6rem 0.8rem;
border: 2px solid #dfe6e9;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 0.95rem;
transition: border-color 0.2s;
font-family: inherit;
background: var(--card-bg);
color: var(--text);
}
.form-control:focus {
@@ -284,7 +328,7 @@ table {
th, td {
text-align: left;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--table-border);
}
th {
@@ -296,7 +340,7 @@ th {
}
tr:hover {
background: rgba(108, 92, 231, 0.03);
background: var(--table-hover);
}
.rank-cell {
@@ -497,13 +541,60 @@ tr:hover {
background: var(--danger);
}
/* ─── Dark Mode Refinements ───────────────────────────── */
[data-theme="dark"] .alert-danger {
background: #3d2e00;
border-color: #fdcb6e;
color: #fdcb6e;
}
[data-theme="dark"] .alert-danger.error {
background: #3d1515;
border-color: #e74c3c;
color: #fab1a0;
}
[data-theme="dark"] .alert-success {
background: #0d3320;
border-color: var(--success);
color: var(--success);
}
[data-theme="dark"] .alert-info {
background: #0d2137;
border-color: #74b9ff;
color: #74b9ff;
}
[data-theme="dark"] .package-card.scanned {
border-color: var(--success);
background: linear-gradient(135deg, var(--card-bg) 0%, #0d2618 100%);
}
[data-theme="dark"] a {
color: var(--primary);
}
[data-theme="dark"] .hunt-card {
color: var(--text);
}
[data-theme="dark"] .points-badge {
color: var(--dark);
}
[data-theme="dark"] select.form-control {
background: var(--card-bg);
color: var(--text);
}
/* ─── Footer ──────────────────────────────────────────── */
.footer {
text-align: center;
padding: 1.5rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid #dfe6e9;
border-top: 1px solid var(--border);
margin-top: auto;
}

31
public/js/timeago.js Normal file
View File

@@ -0,0 +1,31 @@
// Relative timestamp ("time ago") for <time> elements
(function () {
function timeAgo(date) {
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
const days = Math.floor(hours / 24);
if (days < 7) return days + 'd ago';
if (days < 30) return Math.floor(days / 7) + 'w ago';
if (days < 365) return Math.floor(days / 30) + 'mo ago';
return Math.floor(days / 365) + 'y ago';
}
function updateTimes() {
document.querySelectorAll('time[datetime]').forEach(function (el) {
var d = new Date(el.getAttribute('datetime'));
if (!isNaN(d)) {
el.textContent = timeAgo(d);
el.title = d.toLocaleString();
}
});
}
updateTimes();
setInterval(updateTimes, 30000); // refresh every 30s
})();

View File

@@ -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) {

View File

@@ -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

View File

@@ -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;

View File

@@ -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
});
});

View File

@@ -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}`);
});

View 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;">&larr; 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 &quot;<%= hunt.name %>&quot;? 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') %>

View File

@@ -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">&#x270F;&#xFE0F; Edit</a>
<a href="/admin/hunts/<%= hunt.id %>/pdf" class="btn btn-success">&#x1F4E5; 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() : '&mdash;' %></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">

View File

@@ -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>
<% }) %>

View File

@@ -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>

View File

@@ -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) { %>&#x1F947;<% } else if (i === 1) { %>&#x1F948;<% } else if (i === 2) { %>&#x1F949;<% } else { %><%= i + 1 %><% } %>
<td class="rank-cell rank-<%= rank %>">
<% if (rank === 1) { %>&#x1F947;<% } else if (rank === 2) { %>&#x1F948;<% } else if (rank === 3) { %>&#x1F949;<% } 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,5 +1,24 @@
<footer class="footer">
&copy; <%= new Date().getFullYear() %> Loot Hunt &mdash; 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>

View File

@@ -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">&#x1F3AF; 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">&#x1F319;</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>

View 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">&larr; 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);">&hellip;</span>
<% } %>
<% } %>
<% if (page < totalPages) { %>
<a href="?page=<%= page + 1 %>" class="btn btn-sm btn-outline">Next &rarr;</a>
<% } %>
</div>
<% } %>

View File

@@ -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>
<% } %>