This commit is contained in:
+98
-7
@@ -10,6 +10,28 @@
|
|||||||
--muted: #636e72;
|
--muted: #636e72;
|
||||||
--card-bg: #ffffff;
|
--card-bg: #ffffff;
|
||||||
--body-bg: #f0f2f5;
|
--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 {
|
body {
|
||||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
background: var(--body-bg);
|
background: var(--body-bg);
|
||||||
color: #2d3436;
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -63,6 +85,22 @@ body {
|
|||||||
gap: 5px;
|
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 {
|
.nav-toggle span {
|
||||||
display: block;
|
display: block;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -125,7 +163,7 @@ body {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 2px solid var(--body-bg);
|
border-bottom: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Buttons ─────────────────────────────────────────── */
|
/* ─── Buttons ─────────────────────────────────────────── */
|
||||||
@@ -167,6 +205,10 @@ body {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline:visited {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -192,17 +234,19 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #555;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.6rem 0.8rem;
|
||||||
border: 2px solid #dfe6e9;
|
border: 2px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
@@ -284,7 +328,7 @@ table {
|
|||||||
th, td {
|
th, td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid var(--table-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@@ -296,7 +340,7 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tr:hover {
|
tr:hover {
|
||||||
background: rgba(108, 92, 231, 0.03);
|
background: var(--table-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-cell {
|
.rank-cell {
|
||||||
@@ -497,13 +541,60 @@ tr:hover {
|
|||||||
background: var(--danger);
|
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 ──────────────────────────────────────────── */
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
border-top: 1px solid #dfe6e9;
|
border-top: 1px solid var(--border);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})();
|
||||||
+69
-8
@@ -180,21 +180,75 @@ const Hunts = {
|
|||||||
return new Date(hunt.expiry_date) < new Date();
|
return new Date(hunt.expiry_date) < new Date();
|
||||||
},
|
},
|
||||||
|
|
||||||
getLeaderboard(huntId) {
|
getLeaderboard(huntId, limit = null, offset = 0) {
|
||||||
return db.prepare(`
|
let sql = `
|
||||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||||
FROM scans s
|
FROM scans s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
JOIN packages p ON s.package_id = p.id
|
JOIN packages p ON s.package_id = p.id
|
||||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||||
GROUP BY u.id
|
GROUP BY u.id
|
||||||
ORDER BY total_points DESC
|
ORDER BY total_points DESC`;
|
||||||
`).all(huntId);
|
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) {
|
shortNameExists(shortName) {
|
||||||
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
||||||
return !!row;
|
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();
|
return doScan();
|
||||||
},
|
},
|
||||||
|
|
||||||
getGlobalLeaderboard() {
|
getGlobalLeaderboard(limit = null, offset = 0) {
|
||||||
return db.prepare(`
|
let sql = `
|
||||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||||
FROM scans s
|
FROM scans s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.points_awarded > 0
|
WHERE s.points_awarded > 0
|
||||||
GROUP BY u.id
|
GROUP BY u.id
|
||||||
ORDER BY total_points DESC
|
ORDER BY total_points DESC`;
|
||||||
`).all();
|
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) {
|
getRecentActivity(limit = 5) {
|
||||||
|
|||||||
+35
-1
@@ -56,6 +56,7 @@ router.post('/hunts', (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId);
|
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}`);
|
res.redirect(`/admin/hunts/${huntId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Hunt creation error:', err);
|
console.error('Hunt creation error:', err);
|
||||||
@@ -73,7 +74,40 @@ router.get('/hunts/:id', (req, res) => {
|
|||||||
|
|
||||||
const packages = Packages.getByHunt(hunt.id);
|
const packages = Packages.getByHunt(hunt.id);
|
||||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
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
|
// Download PDF of QR codes
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ router.post('/register', (req, res) => {
|
|||||||
req.session.userId = userId;
|
req.session.userId = userId;
|
||||||
req.session.username = username;
|
req.session.username = username;
|
||||||
req.session.isAdmin = false;
|
req.session.isAdmin = false;
|
||||||
|
req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' };
|
||||||
|
|
||||||
const returnTo = req.session.returnTo || '/';
|
const returnTo = req.session.returnTo || '/';
|
||||||
delete req.session.returnTo;
|
delete req.session.returnTo;
|
||||||
|
|||||||
+16
-5
@@ -22,14 +22,22 @@ router.get('/hunt/:shortName/leaderboard', (req, res) => {
|
|||||||
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const leaderboard = Hunts.getLeaderboard(hunt.id);
|
const perPage = 25;
|
||||||
res.render('hunt/leaderboard', { title: `${hunt.name} - Leaderboard`, hunt, leaderboard });
|
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 ──────────────────────────────────
|
// ─── Global leaderboard ──────────────────────────────────
|
||||||
router.get('/leaderboard', (req, res) => {
|
router.get('/leaderboard', (req, res) => {
|
||||||
const leaderboard = Scans.getGlobalLeaderboard();
|
const perPage = 25;
|
||||||
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
|
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) ────
|
// ─── 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 rank = Users.getRank(user.id);
|
||||||
const totalPlayers = Users.getTotalPlayerCount();
|
const totalPlayers = Users.getTotalPlayerCount();
|
||||||
|
|
||||||
|
const isOwnProfile = req.session && req.session.userId === user.id;
|
||||||
|
|
||||||
res.render('player/profile', {
|
res.render('player/profile', {
|
||||||
title: `${user.username}'s Profile`,
|
title: `${user.username}'s Profile`,
|
||||||
profile,
|
profile,
|
||||||
recentScans,
|
recentScans,
|
||||||
huntBreakdown,
|
huntBreakdown,
|
||||||
rank,
|
rank,
|
||||||
totalPlayers
|
totalPlayers,
|
||||||
|
isOwnProfile
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req
|
|||||||
|
|
||||||
const imagePath = `/uploads/${req.file.filename}`;
|
const imagePath = `/uploads/${req.file.filename}`;
|
||||||
Packages.updateFirstScanImage(pkg.id, imagePath);
|
Packages.updateFirstScanImage(pkg.id, imagePath);
|
||||||
|
req.session.flash = { type: 'success', message: 'Photo uploaded successfully.' };
|
||||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ router.post('/:shortName/:code/image/delete', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Packages.removeFirstScanImage(pkg.id);
|
Packages.removeFirstScanImage(pkg.id);
|
||||||
|
req.session.flash = { type: 'success', message: 'Image removed.' };
|
||||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
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);
|
const hint = (req.body.hint || '').trim().substring(0, 500);
|
||||||
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
||||||
|
req.session.flash = { type: 'success', message: 'Hint saved.' };
|
||||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
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.' });
|
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||||
}
|
}
|
||||||
Packages.clearHint(pkg.id);
|
Packages.clearHint(pkg.id);
|
||||||
|
req.session.flash = { type: 'success', message: 'Hint cleared.' };
|
||||||
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<span style="color: var(--muted); font-family: monospace; font-size: 1rem;"><%= hunt.short_name %></span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
<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="/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 %>" class="btn btn-outline">View Public Page</a>
|
||||||
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-outline">Leaderboard</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="value"><%= packages.reduce((sum, p) => sum + p.scan_count, 0) %></div>
|
||||||
<div class="label">Total Scans</div>
|
<div class="label">Total Scans</div>
|
||||||
</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="stat-box">
|
||||||
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : '—' %></div>
|
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : '—' %></div>
|
||||||
<div class="label">Expires</div>
|
<div class="label">Expires</div>
|
||||||
</div>
|
</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>
|
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
|
||||||
|
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@
|
|||||||
<span class="points-badge" style="font-size: 0.85rem; padding: 0.25rem 0.6rem;">+<%= a.points_awarded %></span>
|
<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="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-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>
|
||||||
</div>
|
</div>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% leaderboard.forEach((entry, i) => { %>
|
<% leaderboard.forEach((entry, i) => { %>
|
||||||
|
<% const rank = (typeof offset !== 'undefined' ? offset : 0) + i + 1; %>
|
||||||
<tr>
|
<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><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
|
||||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||||
<td><%= entry.scans %></td>
|
<td><%= entry.scans %></td>
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<%- include('../partials/pagination') %>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,10 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% leaderboard.forEach((entry, i) => { %>
|
<% leaderboard.forEach((entry, i) => { %>
|
||||||
|
<% const rank = (typeof offset !== 'undefined' ? offset : 0) + i + 1; %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="rank-cell rank-<%= i + 1 %>">
|
<td class="rank-cell rank-<%= rank %>">
|
||||||
<% if (i === 0) { %>🥇<% } else if (i === 1) { %>🥈<% } else if (i === 2) { %>🥉<% } else { %><%= i + 1 %><% } %>
|
<% if (rank === 1) { %>🥇<% } else if (rank === 2) { %>🥈<% } else if (rank === 3) { %>🥉<% } else { %><%= rank %><% } %>
|
||||||
</td>
|
</td>
|
||||||
<td><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
|
<td><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
|
||||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<%- include('../partials/pagination') %>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<td><%= i + 1 %></td>
|
<td><%= i + 1 %></td>
|
||||||
<td><a href="/player/<%= scan.username %>"><%= scan.username %></a></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><% 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>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<td><%= i + 1 %></td>
|
<td><%= i + 1 %></td>
|
||||||
<td><a href="/player/<%= scan.username %>"><%= scan.username %></a></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><% 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>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
© <%= new Date().getFullYear() %> Loot Hunt — Find. Scan. Conquer.
|
© <%= new Date().getFullYear() %> Loot Hunt — Find. Scan. Conquer.
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,13 +5,25 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><%= typeof title !== 'undefined' ? title + ' | Loot Hunt' : 'Loot Hunt' %></title>
|
<title><%= typeof title !== 'undefined' ? title + ' | Loot Hunt' : 'Loot Hunt' %></title>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<a href="/" class="navbar-brand">🎯 Loot Hunt</a>
|
<a href="/" class="navbar-brand">🎯 Loot Hunt</a>
|
||||||
<button class="nav-toggle" aria-label="Toggle menu" onclick="document.querySelector('.navbar-nav').classList.toggle('open')">
|
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||||
<span></span><span></span><span></span>
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode" title="Toggle dark mode">🌙</button>
|
||||||
</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">
|
<ul class="navbar-nav">
|
||||||
<li><a href="/hunts">Hunts</a></li>
|
<li><a href="/hunts">Hunts</a></li>
|
||||||
<li><a href="/leaderboard">Leaderboard</a></li>
|
<li><a href="/leaderboard">Leaderboard</a></li>
|
||||||
|
|||||||
@@ -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.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><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><% 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>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -75,7 +75,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="card" style="text-align: center; color: var(--muted); padding: 2rem;">
|
<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>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user